More layout options, and focus on variable on re-selection

This commit is contained in:
Ayaz Hafiz 2023-07-31 12:53:42 -05:00
parent 734111711d
commit bea445bafa
No known key found for this signature in database
GPG key ID: 0E2A37416A25EF58
2 changed files with 169 additions and 94 deletions

View file

@ -5,14 +5,22 @@ import { assertExhaustive } from "../../utils/exhaustive";
import { contentStyles } from "../Content"; import { contentStyles } from "../Content";
import { VariableElPretty } from "../Common/Variable"; import { VariableElPretty } from "../Common/Variable";
import { SubsSnapshot, TypeDescriptor } from "../../engine/subs"; import { SubsSnapshot, TypeDescriptor } from "../../engine/subs";
import { useEffect, useState } from "react";
import { TypedEmitter } from "tiny-typed-emitter";
type AddSubVariableLink = (from: Variable, subVariable: Variable) => void; type AddSubVariableLink = (from: Variable, subVariable: Variable) => void;
export interface VariableMessageEvents {
focus: (variable: Variable) => void;
}
export interface VariableNodeProps { export interface VariableNodeProps {
data: { data: {
subs: SubsSnapshot; subs: SubsSnapshot;
variable: Variable; variable: Variable;
addSubVariableLink: AddSubVariableLink; addSubVariableLink: AddSubVariableLink;
isOutlined: boolean;
ee: TypedEmitter<VariableMessageEvents>;
}; };
targetPosition?: Position; targetPosition?: Position;
sourcePosition?: Position; sourcePosition?: Position;
@ -23,7 +31,33 @@ export default function VariableNode({
targetPosition, targetPosition,
sourcePosition, sourcePosition,
}: VariableNodeProps): JSX.Element { }: VariableNodeProps): JSX.Element {
const { variable, subs, addSubVariableLink } = data; const {
variable,
subs,
addSubVariableLink,
isOutlined: isOutlinedProp,
ee: eeProp,
} = data;
const [isOutlined, setIsOutlined] = useState(isOutlinedProp);
useEffect(() => {
eeProp.on("focus", (focusVar: Variable) => {
if (focusVar !== variable) return;
setIsOutlined(true);
});
}, [eeProp, variable]);
useEffect(() => {
if (!isOutlined) return;
const timer = setTimeout(() => {
setIsOutlined(false);
}, 500);
return () => {
clearTimeout(timer);
};
}, [isOutlined]);
const desc = subs.get_root(variable); const desc = subs.get_root(variable);
const styles = contentStyles(desc); const styles = contentStyles(desc);
@ -54,7 +88,8 @@ export default function VariableNode({
<div <div
className={clsx( className={clsx(
styles.bg, styles.bg,
"bg-opacity-50 py-2 px-4 rounded-lg border", "bg-opacity-50 py-2 px-4 rounded-lg border transition ease-in-out duration-700",
isOutlined && "ring-2 ring-blue-500",
"text-center font-mono" "text-center font-mono"
)} )}
> >

View file

@ -19,14 +19,18 @@ import ReactFlow, {
ReactFlowState, ReactFlowState,
Position, Position,
} from "reactflow"; } from "reactflow";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Variable } from "../../schema"; import { Variable } from "../../schema";
import "reactflow/dist/style.css"; import "reactflow/dist/style.css";
import clsx from "clsx"; import clsx from "clsx";
import VariableNode, { VariableNodeProps } from "./VariableNode"; import VariableNode, {
VariableMessageEvents,
VariableNodeProps,
} from "./VariableNode";
import { SubsSnapshot } from "../../engine/subs"; import { SubsSnapshot } from "../../engine/subs";
import { KeydownHandler } from "../Events"; import { KeydownHandler } from "../Events";
import { TypedEmitter } from "tiny-typed-emitter";
export interface VariablesGraphProps { export interface VariablesGraphProps {
subs: SubsSnapshot; subs: SubsSnapshot;
@ -34,36 +38,10 @@ export interface VariablesGraphProps {
onKeydown: (handler: KeydownHandler) => void; onKeydown: (handler: KeydownHandler) => void;
} }
enum GraphDirection { function horizontalityToPositions(isHorizontal: boolean): {
LeftRight,
TopBottom,
}
const DEFAULT_DIRECTION: GraphDirection = GraphDirection.TopBottom;
function directionToElkDirection(direction: GraphDirection): string {
switch (direction) {
case GraphDirection.TopBottom:
return "DOWN";
case GraphDirection.LeftRight:
return "RIGHT";
}
}
function horizontalDirectionality(direction: GraphDirection): boolean {
switch (direction) {
case GraphDirection.TopBottom:
return false;
case GraphDirection.LeftRight:
return true;
}
}
function directionalityToPositions(direction: GraphDirection): {
targetPosition: Position; targetPosition: Position;
sourcePosition: Position; sourcePosition: Position;
} { } {
const isHorizontal = horizontalDirectionality(direction);
return { return {
targetPosition: isHorizontal ? Position.Left : Position.Top, targetPosition: isHorizontal ? Position.Left : Position.Top,
sourcePosition: isHorizontal ? Position.Right : Position.Bottom, sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
@ -75,17 +53,69 @@ interface LayoutedElements {
edges: Edge[]; edges: Edge[];
} }
interface ComputeLayoutedElementsProps extends LayoutedElements { const LAYOUT_CONFIG_DOWN = {
direction: GraphDirection; keypress: "j",
} emoji: "⬇️",
elkLayoutOptions: {
"elk.algorithm": "layered",
"elk.direction": "DOWN",
},
isHorizontal: false,
} as const;
const LAYOUT_CONFIG_RIGHT = {
keypress: "l",
emoji: "➡️",
elkLayoutOptions: {
"elk.algorithm": "layered",
"elk.direction": "RIGHT",
},
isHorizontal: true,
} as const;
const LAYOUT_CONFIG_RADIAL = {
keypress: "r",
emoji: "🌐",
elkLayoutOptions: {
"elk.algorithm": "radial",
},
isHorizontal: false,
} as const;
const LAYOUT_CONFIG_FORCE = {
keypress: "f",
emoji: "🧲",
elkLayoutOptions: {
"elk.algorithm": "force",
},
isHorizontal: false,
} as const;
type LayoutConfiguration =
| typeof LAYOUT_CONFIG_DOWN
| typeof LAYOUT_CONFIG_RIGHT
| typeof LAYOUT_CONFIG_RADIAL
| typeof LAYOUT_CONFIG_FORCE;
const LAYOUT_CONFIGURATIONS: LayoutConfiguration[] = [
LAYOUT_CONFIG_DOWN,
LAYOUT_CONFIG_RIGHT,
LAYOUT_CONFIG_RADIAL,
LAYOUT_CONFIG_FORCE,
];
type ComputeElkLayoutOptions = Pick<
LayoutConfiguration,
"elkLayoutOptions" | "isHorizontal"
>;
interface ComputeLayoutedElementsProps
extends LayoutedElements,
ComputeElkLayoutOptions {}
// Elk has a *huge* amount of options to configure. To see everything you can // Elk has a *huge* amount of options to configure. To see everything you can
// tweak check out: // tweak check out:
// //
// - https://www.eclipse.org/elk/reference/algorithms.html // - https://www.eclipse.org/elk/reference/algorithms.html
// - https://www.eclipse.org/elk/reference/options.html // - https://www.eclipse.org/elk/reference/options.html
const elkOptions: LayoutOptions = { const baseElkOptions: LayoutOptions = {
"elk.algorithm": "layered",
"elk.layered.spacing.nodeNodeBetweenLayers": "100", "elk.layered.spacing.nodeNodeBetweenLayers": "100",
"elk.spacing.nodeNode": "80", "elk.spacing.nodeNode": "80",
}; };
@ -93,7 +123,8 @@ const elkOptions: LayoutOptions = {
async function computeLayoutedElements({ async function computeLayoutedElements({
nodes, nodes,
edges, edges,
direction, elkLayoutOptions,
isHorizontal,
}: ComputeLayoutedElementsProps): Promise<LayoutedElements> { }: ComputeLayoutedElementsProps): Promise<LayoutedElements> {
if (nodes.length === 0) { if (nodes.length === 0) {
return Promise.resolve({ return Promise.resolve({
@ -102,14 +133,12 @@ async function computeLayoutedElements({
}); });
} }
const isHorizontal = horizontalDirectionality(direction);
const elk = new ELK(); const elk = new ELK();
const graph: ElkNode = { const graph: ElkNode = {
id: "root", id: "root",
layoutOptions: { layoutOptions: {
...elkOptions, ...baseElkOptions,
"elk.direction": directionToElkDirection(direction), ...elkLayoutOptions,
}, },
//@ts-ignore //@ts-ignore
children: nodes.map((node) => ({ children: nodes.map((node) => ({
@ -150,17 +179,20 @@ const NODE_TYPES: NodeTypes = {
variable: VariableNode, variable: VariableNode,
}; };
type VariableNodeData = VariableNodeProps["data"];
type RFVariableNode = Node<VariableNodeData>;
function newVariable( function newVariable(
id: string, id: string,
data: VariableNodeProps["data"], data: VariableNodeData,
direction: GraphDirection isHorizontal: boolean
): Node { ): RFVariableNode {
return { return {
id, id,
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
type: "variable", type: "variable",
data, data,
...directionalityToPositions(direction), ...horizontalityToPositions(isHorizontal),
}; };
} }
@ -199,11 +231,7 @@ const nodesSetInViewSelector = (state: ReactFlowState) =>
); );
type RedoLayoutFn = () => Promise<void>; type RedoLayoutFn = () => Promise<void>;
function useRedoLayout({ function useRedoLayout(options: ComputeElkLayoutOptions): RedoLayoutFn {
direction,
}: {
direction: GraphDirection;
}): RedoLayoutFn {
const nodeCount = useStore(nodeCountSelector); const nodeCount = useStore(nodeCountSelector);
const nodesInitialized = useStore(nodesSetInViewSelector); const nodesInitialized = useStore(nodesSetInViewSelector);
const { getNodes, setNodes, getEdges } = useReactFlow(); const { getNodes, setNodes, getEdges } = useReactFlow();
@ -216,7 +244,7 @@ function useRedoLayout({
const { nodes } = await computeLayoutedElements({ const { nodes } = await computeLayoutedElements({
nodes: getNodes(), nodes: getNodes(),
edges: getEdges(), edges: getEdges(),
direction, ...options,
}); });
setNodes(nodes); setNodes(nodes);
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
@ -227,15 +255,15 @@ function useRedoLayout({
nodesInitialized, nodesInitialized,
getNodes, getNodes,
getEdges, getEdges,
direction, options,
setNodes, setNodes,
instance, instance,
]); ]);
} }
// Does positioning of the nodes in the graph. // Does positioning of the nodes in the graph.
function useAutoLayout({ direction }: { direction: GraphDirection }) { function useAutoLayout(options: ComputeElkLayoutOptions) {
const redoLayout = useRedoLayout({ direction }); const redoLayout = useRedoLayout(options);
useEffect(() => { useEffect(() => {
// This wrapping is of course redundant, but exercised for the purpose of // This wrapping is of course redundant, but exercised for the purpose of
@ -244,38 +272,40 @@ function useAutoLayout({ direction }: { direction: GraphDirection }) {
await redoLayout(); await redoLayout();
} }
inner(); inner();
}, [direction, redoLayout]); }, [redoLayout]);
} }
function useKeydown({ function useKeydown({
direction, layoutConfig,
setLayoutConfig,
onKeydown, onKeydown,
setDirection,
}: { }: {
direction: GraphDirection; layoutConfig: LayoutConfiguration;
setDirection: React.Dispatch<React.SetStateAction<GraphDirection>>; setLayoutConfig: React.Dispatch<React.SetStateAction<LayoutConfiguration>>;
onKeydown: (handler: KeydownHandler) => void; onKeydown: (handler: KeydownHandler) => void;
}) { }) {
const redoLayout = useRedoLayout({ direction }); const redoLayout = useRedoLayout(layoutConfig);
const keyDownHandler = useCallback( const keyDownHandler = useCallback(
async (key: string) => { async (key: string) => {
switch (key) { switch (key) {
case "c": { case "c": {
await redoLayout(); await redoLayout();
break; return;
} }
case "j": { default: {
setDirection(GraphDirection.TopBottom);
break;
}
case "l": {
setDirection(GraphDirection.LeftRight);
break; break;
} }
} }
for (const config of LAYOUT_CONFIGURATIONS) {
if (key === config.keypress) {
setLayoutConfig(config);
return;
}
}
}, },
[redoLayout, setDirection] [redoLayout, setLayoutConfig]
); );
onKeydown(async (key) => { onKeydown(async (key) => {
await keyDownHandler(key); await keyDownHandler(key);
@ -290,14 +320,21 @@ function Graph({
const initialNodes: Node[] = []; const initialNodes: Node[] = [];
const initialEdges: Edge[] = []; const initialEdges: Edge[] = [];
const [direction, setDirection] = useState(DEFAULT_DIRECTION); const ee = useRef(new TypedEmitter<VariableMessageEvents>());
const [layoutConfig, setLayoutConfig] =
useState<LayoutConfiguration>(LAYOUT_CONFIG_DOWN);
const [elements, setElements] = useState<LayoutedElements>({ const [elements, setElements] = useState<LayoutedElements>({
nodes: initialNodes, nodes: initialNodes,
edges: initialEdges, edges: initialEdges,
}); });
useAutoLayout({ direction }); useAutoLayout(layoutConfig);
useKeydown({ direction, onKeydown, setDirection }); useKeydown({
layoutConfig,
setLayoutConfig,
onKeydown,
});
const onNodesChange = useCallback((changes: NodeChange[]) => { const onNodesChange = useCallback((changes: NodeChange[]) => {
setElements(({ nodes, edges }) => { setElements(({ nodes, edges }) => {
@ -332,8 +369,10 @@ function Graph({
subs, subs,
variable: subLinkN, variable: subLinkN,
addSubVariableLink, addSubVariableLink,
isOutlined: true,
ee: ee.current,
}, },
direction layoutConfig.isHorizontal
), ),
nodes nodes
); );
@ -351,8 +390,10 @@ function Graph({
return { nodes: newNodes, edges: newEdges }; return { nodes: newNodes, edges: newEdges };
}); });
ee.current.emit("focus", subLinkN);
}, },
[direction, subs] [layoutConfig, subs]
); );
const addNode = useCallback( const addNode = useCallback(
@ -368,8 +409,10 @@ function Graph({
subs, subs,
variable: variableN, variable: variableN,
addSubVariableLink, addSubVariableLink,
isOutlined: true,
ee: ee.current,
}, },
direction layoutConfig.isHorizontal
), ),
nodes nodes
); );
@ -379,8 +422,10 @@ function Graph({
return { nodes: newNodes, edges: edges }; return { nodes: newNodes, edges: edges };
}); });
ee.current.emit("focus", variableN);
}, },
[subs, addSubVariableLink, direction] [subs, addSubVariableLink, layoutConfig]
); );
onVariable(addNode); onVariable(addNode);
@ -401,9 +446,9 @@ function Graph({
}} }}
> >
<Panel position="top-right"> <Panel position="top-right">
<DirectionPanel <LayoutPanel
direction={direction} layoutConfig={layoutConfig}
onChange={(e) => setDirection(e)} setLayoutConfig={setLayoutConfig}
/> />
</Panel> </Panel>
@ -412,37 +457,32 @@ function Graph({
); );
} }
interface DirectionPanelProps { interface LayoutPanelProps {
direction: GraphDirection; layoutConfig: LayoutConfiguration;
onChange: (direction: GraphDirection) => void; setLayoutConfig: React.Dispatch<React.SetStateAction<LayoutConfiguration>>;
} }
function DirectionPanel({ function LayoutPanel({
direction, layoutConfig,
onChange, setLayoutConfig,
}: DirectionPanelProps): JSX.Element { }: LayoutPanelProps): JSX.Element {
const commonStyle = "rounded cursor-pointer text-2xl select-none"; const commonStyle = "rounded cursor-pointer text-2xl select-none";
const dirs: { dir: GraphDirection; text: string }[] = [
{ dir: GraphDirection.TopBottom, text: "⬇️" },
{ dir: GraphDirection.LeftRight, text: "➡️" },
];
return ( return (
<> <>
{dirs.map(({ dir, text }, i) => ( {LAYOUT_CONFIGURATIONS.map((config, i) => (
<span <span
key={i} key={i}
className={clsx( className={clsx(
commonStyle, commonStyle,
i !== 0 ? "ml-2" : "", i !== 0 ? "ml-2" : "",
dir !== direction ? "opacity-50" : "" config !== layoutConfig ? "opacity-50" : ""
)} )}
onClick={() => { onClick={() => {
onChange(dir); setLayoutConfig(config);
}} }}
> >
{text} {config.emoji}
</span> </span>
))} ))}
</> </>