import ELK, { type ElkNode, type LayoutOptions, } from "elkjs/lib/elk.bundled.js"; import ReactFlow, { Node, Edge, Background, BackgroundVariant, useReactFlow, ReactFlowProvider, NodeChange, applyNodeChanges, EdgeChange, applyEdgeChanges, Panel, NodeTypes, useStore, ReactFlowState, Position, MarkerType, EdgeMarkerType, } from "reactflow"; import { useCallback, useEffect, useRef, useState } from "react"; import { Variable } from "../../schema"; import "reactflow/dist/style.css"; import clsx from "clsx"; import VariableNode, { VariableNodeProps } from "./VariableNode"; import { SubsSnapshot } from "../../engine/subs"; import { TypedEmitter } from "tiny-typed-emitter"; import EpochCell from "../Common/EpochCell"; import { HashLink } from "react-router-hash-link"; import { EventListMessage, GraphMessage, VariableMessage, } from "../../utils/events"; import { useFocusOutlineEvent } from "../../hooks/useFocusOutlineEvent"; export interface VariablesGraphProps { subs: SubsSnapshot; graphEe: TypedEmitter; eventListEe: TypedEmitter; } function horizontalityToPositions(isHorizontal: boolean): { targetPosition: Position; sourcePosition: Position; } { return { targetPosition: isHorizontal ? Position.Left : Position.Top, sourcePosition: isHorizontal ? Position.Right : Position.Bottom, }; } interface LayoutedElements { nodes: Node[]; edges: Edge[]; } const LAYOUT_CONFIG_DOWN = { 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 // tweak check out: // // - https://www.eclipse.org/elk/reference/algorithms.html // - https://www.eclipse.org/elk/reference/options.html const baseElkOptions: LayoutOptions = { "elk.layered.spacing.nodeNodeBetweenLayers": "100", "elk.spacing.nodeNode": "80", }; async function computeLayoutedElements({ nodes, edges, elkLayoutOptions, isHorizontal, }: ComputeLayoutedElementsProps): Promise { if (nodes.length === 0) { return Promise.resolve({ nodes: [], edges: [], }); } const elk = new ELK(); const graph: ElkNode = { id: "root", layoutOptions: { ...baseElkOptions, ...elkLayoutOptions, }, //@ts-ignore children: nodes.map((node) => ({ ...node, // Adjust the target and source handle positions based on the layout // direction. targetPosition: isHorizontal ? "left" : "top", sourcePosition: isHorizontal ? "right" : "bottom", // Hardcode a width and height for elk to use when layouting. //width: 150, //height: 50, })), //@ts-ignore edges: edges.map((edge) => ({ ...edge, })), }; const layoutedGraph = await elk.layout(graph); if (!layoutedGraph.children || !layoutedGraph.edges) { throw new Error("Elk did not return a valid graph"); } return { //@ts-ignore nodes: layoutedGraph.children.map((node) => ({ ...node, // React Flow expects a position property on the node instead of `x` // and `y` fields. position: { x: node.x, y: node.y }, })), //@ts-ignore edges: layoutedGraph.edges, }; } const NODE_TYPES: NodeTypes = { variable: VariableNode, }; type VariableNodeData = VariableNodeProps["data"]; type RFVariableNode = Node; function newVariable( id: string, data: VariableNodeData, isHorizontal: boolean ): RFVariableNode { return { id, position: { x: 0, y: 0 }, type: "variable", data, ...horizontalityToPositions(isHorizontal), }; } function canAddVariable(variableName: string, existingNodes: Node[]): boolean { return !existingNodes.some((n) => n.id === variableName); } function canAddEdge(edgeName: string, existingEdges: Edge[]): boolean { return !existingEdges.some((e) => e.id === edgeName); } function addNode(node: Node): NodeChange { return { type: "add", item: node, }; } function addEdge(edge: Edge): EdgeChange { return { type: "add", item: edge, }; } // Auto-layout logic due in part to the `feldera/dbsp` project, licensed under // the MIT license. // // The source code for the original project can be found at // https://github.com/feldera/dbsp/blob/585a1926a6d3a0f8176dc80db5e906ec7d095400/web-ui/src/streaming/builder/hooks/useAutoLayout.ts#L215 // and its license at // https://github.com/feldera/dbsp/blob/585a1926a6d3a0f8176dc80db5e906ec7d095400/LICENSE const nodeCountSelector = (state: ReactFlowState) => state.nodeInternals.size + state.edges.length; const nodesSetInViewSelector = (state: ReactFlowState) => Array.from(state.nodeInternals.values()).every( (node) => node.width && node.height ); type RedoLayoutFn = () => Promise; function useRedoLayout(options: ComputeElkLayoutOptions): RedoLayoutFn { const nodeCount = useStore(nodeCountSelector); const nodesInitialized = useStore(nodesSetInViewSelector); const { getNodes, setNodes, getEdges } = useReactFlow(); const instance = useReactFlow(); return useCallback(async () => { if (!nodeCount || !nodesInitialized) { return; } const { nodes } = await computeLayoutedElements({ nodes: getNodes(), edges: getEdges(), ...options, }); setNodes(nodes); window.requestAnimationFrame(() => { instance.fitView(); }); }, [ nodeCount, nodesInitialized, getNodes, getEdges, options, setNodes, instance, ]); } // Does positioning of the nodes in the graph. function useAutoLayout(options: ComputeElkLayoutOptions) { const redoLayout = useRedoLayout(options); useEffect(() => { // This wrapping is of course redundant, but exercised for the purpose of // explicitness. async function inner() { await redoLayout(); } inner(); }, [redoLayout]); } function useKeydown({ layoutConfig, setLayoutConfig, graphEe, }: { layoutConfig: LayoutConfiguration; setLayoutConfig: React.Dispatch>; graphEe: TypedEmitter; }) { const redoLayout = useRedoLayout(layoutConfig); const keyDownHandler = useCallback( async (key: string) => { switch (key) { case "c": { await redoLayout(); return; } default: { break; } } for (const config of LAYOUT_CONFIGURATIONS) { if (key === config.keypress) { setLayoutConfig(config); return; } } }, [redoLayout, setLayoutConfig] ); graphEe.on("keydown", async (key) => await keyDownHandler(key)); } function Graph({ subs, graphEe, eventListEe, }: VariablesGraphProps): JSX.Element { const instance = useReactFlow(); // We need to reset the graph when the subs snapshot changes. I'm not sure // why this isn't done by the existing state manager. useEffect(() => { instance.setNodes([]); instance.setEdges([]); }, [instance, subs.epoch]); const varEe = useRef(new TypedEmitter()); // Allow an unbounded number of listeners since we attach a listener for each // variable. varEe.current.setMaxListeners(Infinity); const isOutlined = useFocusOutlineEvent({ ee: graphEe, value: subs.epoch, event: "focusEpoch", }); const [layoutConfig, setLayoutConfig] = useState(LAYOUT_CONFIG_DOWN); const [elements, setElements] = useState({ nodes: [], edges: [], }); const [variablesNeedingFocus, setVariablesNeedingFocus] = useState< Set >(new Set()); useEffect(() => { if (variablesNeedingFocus.size === 0) { return; } for (const variable of variablesNeedingFocus) { varEe.current.emit("focus", variable); } setVariablesNeedingFocus(new Set()); }, [variablesNeedingFocus]); useAutoLayout(layoutConfig); useKeydown({ layoutConfig, setLayoutConfig, graphEe, }); const onNodesChange = useCallback((changes: NodeChange[]) => { setElements(({ nodes, edges }) => { return { nodes: applyNodeChanges(changes, nodes), edges, }; }); }, []); const onEdgesChange = useCallback((changes: EdgeChange[]) => { setElements(({ nodes, edges }) => { return { nodes, edges: applyEdgeChanges(changes, edges), }; }); }, []); interface AddNewVariableParams { from?: Variable; variable: Variable; } const addNewVariable = useCallback( ({ from, variable }: AddNewVariableParams) => { const variablesToFocus = new Set(); setElements(({ nodes, edges }) => { let fromVariable: Variable | undefined = from; let toVariable: Variable | undefined = variable; const nodeChanges: NodeChange[] = []; const edgeChanges: EdgeChange[] = []; while (toVariable !== undefined) { const toVariableName = toVariable.toString(); if (canAddVariable(toVariableName, nodes)) { const newVariableNode = newVariable( toVariable.toString(), { subs, rawVariable: toVariable, addSubVariableLink: addNewVariable, isOutlined: true, ee: varEe.current, }, layoutConfig.isHorizontal ); nodeChanges.push(addNode(newVariableNode)); } if (fromVariable !== undefined) { const edgeName = `${fromVariable}->${toVariable}`; if (canAddEdge(edgeName, edges)) { let markerEnd: EdgeMarkerType | undefined; if (subs.get_root_key(fromVariable) === toVariable) { markerEnd = { type: MarkerType.ArrowClosed, width: 20, height: 20, }; } const newEdge = addEdge({ id: `${fromVariable}->${toVariable}`, source: fromVariable.toString(), target: toVariableName, markerEnd, }); edgeChanges.push(newEdge); } } variablesToFocus.add(toVariable); fromVariable = toVariable; const rootToVariable = subs.get_root_key(toVariable); if (toVariable !== rootToVariable) { toVariable = rootToVariable; } else { toVariable = undefined; } } const newNodes = applyNodeChanges(nodeChanges, nodes); const newEdges = applyEdgeChanges(edgeChanges, edges); return { nodes: newNodes, edges: newEdges }; }); setVariablesNeedingFocus(variablesToFocus); }, [layoutConfig.isHorizontal, subs] ); const addNewVariableNode = useCallback( (variable: Variable) => { addNewVariable({ variable }); }, [addNewVariable] ); graphEe.on("focusVariable", addNewVariableNode); return ( onNodesChange(e)} onEdgesChange={(e) => onEdgesChange(e)} fitView nodesDraggable nodesConnectable={false} nodeTypes={NODE_TYPES} proOptions={{ // 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" )} > { eventListEe.emit("focusEpoch", subs.epoch); }} > Epoch {subs.epoch} ); } interface LayoutPanelProps { layoutConfig: LayoutConfiguration; setLayoutConfig: React.Dispatch>; } function LayoutPanel({ layoutConfig, setLayoutConfig, }: LayoutPanelProps): JSX.Element { const commonStyle = "rounded cursor-pointer text-2xl select-none"; return ( <> {LAYOUT_CONFIGURATIONS.map((config, i) => ( { setLayoutConfig(config); }} > {config.emoji} ))} ); } export default function VariablesGraph({ subs, graphEe, eventListEe, }: VariablesGraphProps) { return ( ); }