roc/crates/compiler/checkmate/www/src/components/Graph/VariablesGraph.tsx
2023-08-02 17:48:32 -05:00

567 lines
14 KiB
TypeScript

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<GraphMessage>;
eventListEe: TypedEmitter<EventListMessage>;
}
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<LayoutedElements> {
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<VariableNodeData>;
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<void>;
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<React.SetStateAction<LayoutConfiguration>>;
graphEe: TypedEmitter<GraphMessage>;
}) {
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<VariableMessage>());
// 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<LayoutConfiguration>(LAYOUT_CONFIG_DOWN);
const [elements, setElements] = useState<LayoutedElements>({
nodes: [],
edges: [],
});
const [variablesNeedingFocus, setVariablesNeedingFocus] = useState<
Set<Variable>
>(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<Variable>();
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 (
<ReactFlow
nodes={elements.nodes}
edges={elements.edges}
onNodesChange={(e) => 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"
)}
>
<Panel position="top-left">
<HashLink
smooth
to={`#events-${subs.epoch}`}
onClick={() => {
eventListEe.emit("focusEpoch", subs.epoch);
}}
>
<EpochCell>Epoch {subs.epoch}</EpochCell>
</HashLink>
</Panel>
<Panel position="top-right">
<LayoutPanel
layoutConfig={layoutConfig}
setLayoutConfig={setLayoutConfig}
/>
</Panel>
<Background variant={BackgroundVariant.Dots} />
</ReactFlow>
);
}
interface LayoutPanelProps {
layoutConfig: LayoutConfiguration;
setLayoutConfig: React.Dispatch<React.SetStateAction<LayoutConfiguration>>;
}
function LayoutPanel({
layoutConfig,
setLayoutConfig,
}: LayoutPanelProps): JSX.Element {
const commonStyle = "rounded cursor-pointer text-2xl select-none";
return (
<>
{LAYOUT_CONFIGURATIONS.map((config, i) => (
<span
key={i}
className={clsx(
commonStyle,
i !== 0 ? "ml-2" : "",
config !== layoutConfig ? "opacity-50" : ""
)}
onClick={() => {
setLayoutConfig(config);
}}
>
{config.emoji}
</span>
))}
</>
);
}
export default function VariablesGraph({
subs,
graphEe,
eventListEe,
}: VariablesGraphProps) {
return (
<ReactFlowProvider>
<Graph subs={subs} graphEe={graphEe} eventListEe={eventListEe} />
</ReactFlowProvider>
);
}