Better epoch selection

This commit is contained in:
Ayaz Hafiz 2023-08-02 14:37:01 -05:00
parent a5eaba9ab3
commit 43e2f2f091
No known key found for this signature in database
GPG key ID: 0E2A37416A25EF58
10 changed files with 341 additions and 166 deletions

View file

@ -1,60 +1,28 @@
import clsx from "clsx";
import { EventEpoch } from "../../engine/engine";
import { HashLink } from "react-router-hash-link";
export enum EpochCellView {
Events,
Graph,
}
function invert(cell: EpochCellView): EpochCellView {
if (cell === EpochCellView.Events) {
return EpochCellView.Graph;
}
return EpochCellView.Events;
}
function asStr(cell: EpochCellView): string {
switch (cell) {
case EpochCellView.Events:
return "events";
case EpochCellView.Graph:
return "graph";
}
}
interface EpochCellProps {
view: EpochCellView;
epoch: EventEpoch;
className?: string;
children?: React.ReactNode;
focus?: boolean;
}
const EPOCH_STYLES_ARRAY = [
"text-slate-900",
"font-mono",
"bg-slate-200",
"p-1",
"py-0",
"rounded-sm",
"ring-1",
"ring-slate-500",
"text-sm",
];
export const EPOCH_STYLES = clsx(...EPOCH_STYLES_ARRAY);
export default function EpochCell({ epoch, className, view }: EpochCellProps) {
const invertedView = invert(view);
export const EPOCH_STYLES =
"text-slate-900 font-mono bg-slate-200 p-1 py-0 rounded-sm text-sm transition ease-in-out duration-700 mr-2";
export default function EpochCell({
className,
children,
focus,
}: EpochCellProps) {
return (
<HashLink smooth to={`#${asStr(invertedView)}-${epoch}`}>
<div
id={`${asStr(view)}-${epoch}`}
className={clsx(EPOCH_STYLES, className)}
>
{view === EpochCellView.Graph ? "Epoch " : ""}
{epoch}
</div>
</HashLink>
<span
className={clsx(
EPOCH_STYLES,
className,
focus === true ? "ring-2 ring-blue-500" : "ring-1 ring-slate-500"
)}
>
{children}
</span>
);
}

View file

@ -10,9 +10,9 @@ interface VariableProps extends CommonProps {
export function VariableEl({
engine,
toggleVariableVis,
epoch,
variable,
graphEe,
}: VariableProps): JSX.Element {
engine.stepTo(epoch);
return (
@ -20,7 +20,7 @@ export function VariableEl({
variable={variable}
subs={engine.subs}
onClick={(variable: Variable) => {
toggleVariableVis(variable);
graphEe.emit("focusVariable", variable);
}}
></VariableElPretty>
);

View file

@ -1,8 +1,9 @@
import { TypedEmitter } from "tiny-typed-emitter";
import type { Engine, EventEpoch } from "../../engine/engine";
import type { Variable } from "../../schema";
import { GraphMessage } from "../../utils/events";
export interface CommonProps {
currentEpoch: EventEpoch;
engine: Engine;
toggleVariableVis: (variable: Variable) => void;
graphEe: TypedEmitter<GraphMessage>;
}

View file

@ -1,27 +1,30 @@
import clsx from "clsx";
import React, { useCallback, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { TypedEmitter } from "tiny-typed-emitter";
import { EventEpoch } from "../engine/engine";
import { lastSubEvent } from "../engine/event_util";
import { UnificationMode, Event } from "../schema";
import { EventListMessage, GraphMessage } from "../utils/events";
import { Refine } from "../utils/refine";
import EpochCell, { EpochCellView } from "./Common/EpochCell";
import EpochCell from "./Common/EpochCell";
import { CommonProps } from "./EventItem/types";
import { VariableEl } from "./EventItem/Variable";
interface EventListProps extends CommonProps {
events: Event[];
eventListEe: TypedEmitter<EventListMessage>;
root?: boolean;
}
const MT = "mt-2.5";
const UNFOCUSED = "opacity-40";
const MT = "my-2.5";
const LOWER_OPACITY = "opacity-40";
export default function EventList(props: EventListProps): JSX.Element {
const { events, root } = props;
return (
<ul className={clsx(MT, root ? "ml-2" : "ml-[1.5em]")}>
<ul className={clsx(MT, "space-y-2.5", root ? "" : "ml-[1em]")}>
{events.map((event, i) => (
<li key={i} className={MT}>
<li key={i}>
<OneEvent {...props} event={event} />
</li>
))}
@ -31,6 +34,7 @@ export default function EventList(props: EventListProps): JSX.Element {
interface OneEventProps extends CommonProps {
event: Event;
eventListEe: TypedEmitter<EventListMessage>;
}
function OneEvent(props: OneEventProps): JSX.Element {
@ -61,10 +65,27 @@ function epochInRange(
interface UnificationProps extends CommonProps {
event: Refine<Event, "Unification">;
eventListEe: TypedEmitter<EventListMessage>;
}
const COL_1_P = "pl-1.5";
const COL_1_OUTLINE_STYLES = clsx(COL_1_P, "outline-event-col-1");
const COL_3_OUTLINE_STYLES = clsx("outline-event-col-3");
const COL_1_ROUNDED = "rounded-l-md";
const COL_3_ROUNDED = "rounded-r-md";
const UN_EXPANDED_OUTLINE_STYLES = clsx(
COL_1_P,
"ring-inset ring-2 ring-blue-500"
);
const TRANSITION_SHADOW = "transition-shadow ease-in-out duration-500";
const TRANSITION_OPACITY = "transition-opacity ease-in-out duration-150";
function Unification(props: UnificationProps): JSX.Element {
const { engine, event, currentEpoch } = props;
const { engine, event, currentEpoch, graphEe, eventListEe } = props;
const { mode, subevents, success } = event;
const beforeUnificationEpoch = engine.getEventIndex(event);
@ -87,6 +108,10 @@ function Unification(props: UnificationProps): JSX.Element {
);
const [isOpen, setIsOpen] = useState(false);
const isOutlined = useFocusOutlineEvent({
ee: eventListEe,
epoch: currentEpoch,
});
const modeIcon = useMemo(() => <UnificationModeIcon mode={mode} />, [mode]);
@ -95,14 +120,8 @@ function Unification(props: UnificationProps): JSX.Element {
const epochCell = useMemo(() => {
if (!containsCurrentEpoch) return null;
return (
<EpochCell
view={EpochCellView.Events}
epoch={currentEpoch}
className="inline-block align-middle mr-2"
></EpochCell>
);
}, [containsCurrentEpoch, currentEpoch]);
return <EventListEpochCell epoch={currentEpoch} graphEe={graphEe} />;
}, [containsCurrentEpoch, currentEpoch, graphEe]);
const getHeadline = useCallback(
({
@ -154,13 +173,34 @@ function Unification(props: UnificationProps): JSX.Element {
includeEpochIfInRange: true,
});
return (
<div className={clsx(!containsCurrentEpoch && UNFOCUSED)}>{headLine}</div>
<div
className={clsx(
TRANSITION_OPACITY,
!containsCurrentEpoch && LOWER_OPACITY
)}
>
<div
className={clsx(
"rounded-md",
TRANSITION_SHADOW,
containsCurrentEpoch && isOutlined
? UN_EXPANDED_OUTLINE_STYLES
: COL_1_P
)}
>
{headLine}
</div>
</div>
);
} else {
const optEpochCellAfter =
afterUnificationEpoch === currentEpoch && epochCell;
const optEpochCellBefore =
beforeUnificationEpoch === currentEpoch && epochCell;
const beforeIsCurrentEpoch = beforeUnificationEpoch === currentEpoch;
const afterIsCurrentEpoch = afterUnificationEpoch === currentEpoch;
const epochCellBefore = beforeIsCurrentEpoch && epochCell;
const epochCellAfter = afterIsCurrentEpoch && epochCell;
const outlineEpochCellAfter = afterIsCurrentEpoch && isOutlined;
const outlineEpochCellBefore = beforeIsCurrentEpoch && isOutlined;
const headlineBefore = getHeadline({
epoch: beforeUnificationEpoch,
@ -180,14 +220,37 @@ function Unification(props: UnificationProps): JSX.Element {
);
return (
<div className="grid gap-0 grid-cols-[min-content_min-content_auto]">
<div
className={clsx(
"grid gap-0 grid-cols-[min-content_min-content_auto] opacity-100",
TRANSITION_OPACITY
)}
>
{/* Row 1: unification start */}
<div className="row-start-1 col-start-1">{optEpochCellBefore}</div>
<div className="row-start-1 col-start-3">{headlineBefore}</div>
<div
className={clsx(
"row-start-1 col-start-1",
TRANSITION_SHADOW,
COL_1_ROUNDED,
outlineEpochCellBefore ? COL_1_OUTLINE_STYLES : COL_1_P
)}
>
{epochCellBefore}
</div>
<div
className={clsx(
"row-start-1 col-start-3",
TRANSITION_SHADOW,
COL_3_ROUNDED,
outlineEpochCellBefore && COL_3_OUTLINE_STYLES
)}
>
{headlineBefore}
</div>
{/* Row 2: inner traces */}
<div className="row-start-2 col-start-1"></div>
<div className="row-start-2 col-start-3">
<div className={clsx("row-start-2 col-start-1")}></div>
<div className={clsx("row-start-2 col-start-3")}>
<EventList
{...props}
root={false}
@ -197,8 +260,26 @@ function Unification(props: UnificationProps): JSX.Element {
</div>
{/* Row 3: inner traces */}
<div className="row-start-3 col-start-1">{optEpochCellAfter}</div>
<div className="row-start-3 col-start-3">{headlineAfter}</div>
<div
className={clsx(
"row-start-3 col-start-1",
TRANSITION_SHADOW,
COL_1_ROUNDED,
outlineEpochCellAfter ? COL_1_OUTLINE_STYLES : COL_1_P
)}
>
{epochCellAfter}
</div>
<div
className={clsx(
"row-start-3 col-start-3",
TRANSITION_SHADOW,
COL_3_ROUNDED,
outlineEpochCellAfter && COL_3_OUTLINE_STYLES
)}
>
{headlineAfter}
</div>
{/* Col 2: dropdown line */}
<div
@ -232,3 +313,56 @@ function UnificationModeIcon({ mode }: { mode: UnificationMode }): JSX.Element {
return <>|~|</>;
}
}
interface EventListEpochCellProps {
epoch: EventEpoch;
graphEe: TypedEmitter<GraphMessage>;
}
function EventListEpochCell({
epoch,
graphEe,
}: EventListEpochCellProps): JSX.Element {
return (
<span
id={`events-${epoch}`}
className={clsx("cursor-pointer rounded-md")}
onClick={(e) => {
e.stopPropagation();
graphEe.emit("focusEpoch", epoch);
}}
>
<EpochCell>{epoch}</EpochCell>
</span>
);
}
function useFocusOutlineEvent({
epoch,
ee,
}: {
epoch: EventEpoch;
ee: TypedEmitter<EventListMessage>;
}) {
const [isOutlined, setIsOutlined] = useState(false);
useEffect(() => {
ee.on("focusEpoch", (focusEpoch: EventEpoch) => {
if (focusEpoch !== epoch) return;
setIsOutlined(true);
});
}, [ee, epoch]);
useEffect(() => {
if (!isOutlined) return;
const timer = setTimeout(() => {
setIsOutlined(false);
}, 500);
return () => {
clearTimeout(timer);
};
}, [isOutlined]);
return isOutlined;
}

View file

@ -1,4 +0,0 @@
import { Variable } from "../../schema";
export type ToggleVariableHandler = (variable: Variable) => void;
export type KeydownHandler = (key: string) => Promise<void>;

View file

@ -8,6 +8,7 @@ import { SubsSnapshot, TypeDescriptor } from "../../engine/subs";
import { useEffect, useState } from "react";
import { TypedEmitter } from "tiny-typed-emitter";
import { VariableLink } from "../Common/VariableLink";
import { VariableMessage } from "../../utils/events";
type AddSubVariableLink = ({
from,
@ -17,17 +18,13 @@ type AddSubVariableLink = ({
variable: Variable;
}) => void;
export interface VariableMessageEvents {
focus: (variable: Variable) => void;
}
export interface VariableNodeProps {
data: {
subs: SubsSnapshot;
rawVariable: Variable;
addSubVariableLink: AddSubVariableLink;
isOutlined: boolean;
ee: TypedEmitter<VariableMessageEvents>;
ee: TypedEmitter<VariableMessage>;
};
targetPosition?: Position;
sourcePosition?: Position;
@ -46,25 +43,11 @@ export default function VariableNode({
ee: eeProp,
} = data;
const [isOutlined, setIsOutlined] = useState(isOutlinedProp);
useEffect(() => {
eeProp.on("focus", (focusVar: Variable) => {
if (focusVar !== rawVariable) return;
setIsOutlined(true);
});
}, [eeProp, rawVariable]);
useEffect(() => {
if (!isOutlined) return;
const timer = setTimeout(() => {
setIsOutlined(false);
}, 500);
return () => {
clearTimeout(timer);
};
}, [isOutlined]);
const isOutlined = useFocusOutlineEvent({
ee: eeProp,
isOutlinedProp,
variable: rawVariable,
});
const varType = subs.get(rawVariable);
if (!varType) throw new Error("VariableNode: no entry for variable");
@ -140,7 +123,7 @@ export default function VariableNode({
<div
className={clsx(
bgStyles,
"bg-opacity-50 rounded-lg transition ease-in-out duration-700",
"bg-opacity-50 rounded-md transition ease-in-out duration-700",
isContent ? "py-2 px-4 border" : "p-0",
isOutlined && "ring-2 ring-blue-500",
"text-center font-mono"
@ -163,6 +146,38 @@ export default function VariableNode({
);
}
function useFocusOutlineEvent({
variable,
isOutlinedProp,
ee,
}: {
variable: Variable;
isOutlinedProp: boolean;
ee: TypedEmitter<VariableMessage>;
}) {
const [isOutlined, setIsOutlined] = useState(isOutlinedProp);
useEffect(() => {
ee.on("focus", (focusVar: Variable) => {
if (focusVar !== variable) return;
setIsOutlined(true);
});
}, [ee, variable]);
useEffect(() => {
if (!isOutlined) return;
const timer = setTimeout(() => {
setIsOutlined(false);
}, 500);
return () => {
clearTimeout(timer);
};
}, [isOutlined]);
return isOutlined;
}
function VariableNodeContent(
variable: Variable,
desc: TypeDescriptor | undefined,

View file

@ -26,19 +26,22 @@ import { Variable } from "../../schema";
import "reactflow/dist/style.css";
import clsx from "clsx";
import VariableNode, {
VariableMessageEvents,
VariableNodeProps,
} from "./VariableNode";
import VariableNode, { VariableNodeProps } from "./VariableNode";
import { SubsSnapshot } from "../../engine/subs";
import { KeydownHandler } from "../Events";
import { TypedEmitter } from "tiny-typed-emitter";
import EpochCell, { EpochCellView } from "../Common/EpochCell";
import EpochCell from "../Common/EpochCell";
import { HashLink } from "react-router-hash-link";
import { EventEpoch } from "../../engine/engine";
import {
EventListMessage,
GraphMessage,
VariableMessage,
} from "../../utils/events";
export interface VariablesGraphProps {
subs: SubsSnapshot;
onVariable: (handler: (variable: Variable) => void) => void;
onKeydown: (handler: KeydownHandler) => void;
graphEe: TypedEmitter<GraphMessage>;
eventListEe: TypedEmitter<EventListMessage>;
}
function horizontalityToPositions(isHorizontal: boolean): {
@ -285,11 +288,11 @@ function useAutoLayout(options: ComputeElkLayoutOptions) {
function useKeydown({
layoutConfig,
setLayoutConfig,
onKeydown,
graphEe,
}: {
layoutConfig: LayoutConfiguration;
setLayoutConfig: React.Dispatch<React.SetStateAction<LayoutConfiguration>>;
onKeydown: (handler: KeydownHandler) => void;
graphEe: TypedEmitter<GraphMessage>;
}) {
const redoLayout = useRedoLayout(layoutConfig);
@ -314,23 +317,64 @@ function useKeydown({
},
[redoLayout, setLayoutConfig]
);
onKeydown(async (key) => {
await keyDownHandler(key);
});
graphEe.on("keydown", async (key) => await keyDownHandler(key));
}
function useFocusOutlineEvent({
epoch,
ee,
}: {
epoch: EventEpoch;
ee: TypedEmitter<GraphMessage>;
}) {
const [isOutlined, setIsOutlined] = useState(false);
useEffect(() => {
ee.on("focusEpoch", (focusEpoch: EventEpoch) => {
if (focusEpoch !== epoch) return;
setIsOutlined(true);
});
}, [ee, epoch]);
useEffect(() => {
if (!isOutlined) return;
const timer = setTimeout(() => {
setIsOutlined(false);
}, 500);
return () => {
clearTimeout(timer);
};
}, [isOutlined]);
return isOutlined;
}
function Graph({
subs,
onVariable,
onKeydown,
graphEe,
eventListEe,
}: VariablesGraphProps): JSX.Element {
const initialNodes: Node[] = [];
const initialEdges: Edge[] = [];
const ee = useRef(new TypedEmitter<VariableMessageEvents>());
const varEe = useRef(new TypedEmitter<VariableMessage>());
// Allow an unbounded number of listeners since we attach a listener for each
// variable.
ee.current.setMaxListeners(Infinity);
varEe.current.setMaxListeners(Infinity);
const isOutlined = useFocusOutlineEvent({
epoch: subs.epoch,
ee: graphEe,
});
const [layoutConfig, setLayoutConfig] =
useState<LayoutConfiguration>(LAYOUT_CONFIG_DOWN);
const [elements, setElements] = useState<LayoutedElements>({
nodes: initialNodes,
edges: initialEdges,
});
const [variablesNeedingFocus, setVariablesNeedingFocus] = useState<
Set<Variable>
@ -341,24 +385,16 @@ function Graph({
return;
}
for (const variable of variablesNeedingFocus) {
ee.current.emit("focus", variable);
varEe.current.emit("focus", variable);
}
setVariablesNeedingFocus(new Set());
}, [variablesNeedingFocus]);
const [layoutConfig, setLayoutConfig] =
useState<LayoutConfiguration>(LAYOUT_CONFIG_DOWN);
useAutoLayout(layoutConfig);
useKeydown({
layoutConfig,
setLayoutConfig,
onKeydown,
});
const [elements, setElements] = useState<LayoutedElements>({
nodes: initialNodes,
edges: initialEdges,
graphEe,
});
const onNodesChange = useCallback((changes: NodeChange[]) => {
@ -405,7 +441,7 @@ function Graph({
rawVariable: toVariable,
addSubVariableLink: addNewVariable,
isOutlined: true,
ee: ee.current,
ee: varEe.current,
},
layoutConfig.isHorizontal
);
@ -465,7 +501,7 @@ function Graph({
[addNewVariable]
);
onVariable(addNewVariableNode);
graphEe.on("focusVariable", addNewVariableNode);
return (
<ReactFlow
@ -481,9 +517,21 @@ function Graph({
// https://reactflow.dev/docs/guides/remove-attribution/
hideAttribution: true,
}}
className={clsx(
"ring-inset rounded-md transition ease-in-out duration-700",
isOutlined && "ring-2 ring-blue-500"
)}
>
<Panel position="top-left">
<EpochCell view={EpochCellView.Graph} epoch={subs.epoch}></EpochCell>
<HashLink
smooth
to={`#events-${subs.epoch}`}
onClick={() => {
eventListEe.emit("focusEpoch", subs.epoch);
}}
>
<EpochCell>Epoch {subs.epoch}</EpochCell>
</HashLink>
</Panel>
<Panel position="top-right">
<LayoutPanel

View file

@ -1,32 +1,20 @@
import React, { useState } from "react";
import { AllEvents, Variable } from "../schema";
import { AllEvents } from "../schema";
import { Engine } from "../engine/engine";
import EventList from "./EventList";
import VariablesGraph from "./Graph/VariablesGraph";
import { TypedEmitter } from "tiny-typed-emitter";
import { KeydownHandler, ToggleVariableHandler } from "./Events";
import { EventListMessage, GraphMessage } from "../utils/events";
interface UiProps {
events: AllEvents;
}
interface MessageEvents {
toggleVariable: ToggleVariableHandler;
keydown: KeydownHandler;
}
export default function Ui({ events }: UiProps): JSX.Element {
const engine = new Engine(events);
const ee = new TypedEmitter<MessageEvents>();
const toggleVariableHandlers: ToggleVariableHandler[] = [];
const keydownHandlers: KeydownHandler[] = [];
ee.on("toggleVariable", (variable: Variable) => {
toggleVariableHandlers.forEach((handler) => handler(variable));
});
ee.on("keydown", async (key: string) => {
await Promise.all(keydownHandlers.map((handler) => handler(key)));
});
const graphEe = React.useRef(new TypedEmitter<GraphMessage>());
const eventListEe = React.useRef(new TypedEmitter<EventListMessage>());
engine.stepTo(engine.lastEventIndex());
const subs = engine.subsSnapshot();
@ -38,7 +26,7 @@ export default function Ui({ events }: UiProps): JSX.Element {
<div
className="flex flex-col md:flex-row gap-0 w-full h-full"
onKeyDown={(e) => {
ee.emit("keydown", e.key);
graphEe.current.emit("keydown", e.key);
}}
>
<div className="font-mono mt-2 text-lg md:flex-1 overflow-scroll">
@ -46,21 +34,16 @@ export default function Ui({ events }: UiProps): JSX.Element {
engine={engine}
root
events={events}
toggleVariableVis={(variable: Variable) =>
ee.emit("toggleVariable", variable)
}
graphEe={graphEe.current}
eventListEe={eventListEe.current}
currentEpoch={epoch}
/>
</div>
<div className="flex-1 min-h-[50%] h-full">
<VariablesGraph
subs={subs}
onVariable={(handler) => {
toggleVariableHandlers.push(handler);
}}
onKeydown={(handler) => {
keydownHandlers.push(handler);
}}
graphEe={graphEe.current}
eventListEe={eventListEe.current}
/>
</div>
</div>

View file

@ -1,3 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.outline-event-col-1 {
box-shadow: inset 2px 0px 0px #3B82F6, /* Left */
inset -2px 0px 0px transparent, /* Right */
inset 0px 2px 0px #3B82F6, /* Bottom */
inset 0px -2px 0px #3B82F6; /* Top */
}
.outline-event-col-3 {
box-shadow: inset 2px 0px 0px transparent, /* Left */
inset -2px 0px 0px #3B82F6, /* Right */
inset 0px 2px 0px #3B82F6, /* Bottom */
inset 0px -2px 0px #3B82F6; /* Top */
}

View file

@ -0,0 +1,16 @@
import { EventEpoch } from "../engine/engine";
import { Variable } from "../schema";
export interface VariableMessage {
focus: (variable: Variable) => void;
}
export interface GraphMessage {
focusEpoch: (epoch: EventEpoch) => void;
focusVariable: (variable: Variable) => void;
keydown: (key: string) => void;
}
export interface EventListMessage {
focusEpoch: (epoch: EventEpoch) => void;
}