Skip to content

Commit 5e5301e

Browse files
Use custom edges
1 parent c801cb6 commit 5e5301e

File tree

3 files changed

+107
-125
lines changed

3 files changed

+107
-125
lines changed

src/Visualizer.tsx

+96-115
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import "@xyflow/react/dist/style.css";
44

55
import { scheme } from "vega-scale";
66
import { ErrorBoundary } from "react-error-boundary";
7-
import ELK, { ElkNode, ElkPrimitiveEdge } from "elkjs/lib/elk.bundled.js";
7+
import type { EdgeProps, EdgeTypes, NodeProps } from "@xyflow/react";
8+
import ELK, { ElkExtendedEdge, ElkNode } from "elkjs/lib/elk.bundled.js";
89
import { memo, startTransition, Suspense, use, useCallback, useEffect, useMemo, useState } from "react";
910
import {
1011
ReactFlow,
@@ -13,13 +14,14 @@ import {
1314
Panel,
1415
NodeTypes,
1516
NodeMouseHandler,
16-
Position,
17-
Handle,
1817
Background,
1918
MarkerType,
2019
Edge,
2120
useNodesInitialized,
2221
useReactFlow,
22+
BaseEdge,
23+
Handle,
24+
Position,
2325
} from "@xyflow/react";
2426

2527
import "@xyflow/react/dist/style.css";
@@ -36,7 +38,7 @@ const layoutOptions = {
3638
"elk.direction": "DOWN",
3739
"elk.portConstraints": "FIXED_SIDE",
3840
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
39-
// "elk.layered.mergeEdges": "True",
41+
"elk.layered.mergeEdges": "True",
4042
"elk.edgeRouting": "ORTHOGONAL",
4143
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
4244

@@ -70,19 +72,26 @@ type Color = string;
7072
// https://vega.github.io/vega/docs/schemes/#categorical
7173
const colorScheme: Color[] = [...scheme("pastel1"), ...scheme("pastel2")];
7274

75+
type FlowClass = Node<{ color: string | null; id: string }, "class">;
76+
type FlowNode = Node<{ label: string; id: string }, "node">;
77+
type FlowEdge = Edge<{ points: { x: number; y: number }[] }, "edge">;
78+
79+
type MyELKEdge = ElkExtendedEdge & { data: { sourceNode: string } };
7380
/// ELK Node but with additional data added to be later used when converting to react flow nodes
74-
type MyELKNode = Omit<ElkNode, "children"> & {
81+
type MyELKNode = Omit<ElkNode, "children" | "edges"> & {
82+
edges: MyELKEdge[];
7583
children: ({
76-
type: string;
77-
data: { color: string | null; port: string; id: string };
84+
type: NonNullable<FlowClass["type"]>;
85+
data: FlowClass["data"];
86+
edges: MyELKEdge[];
7887
children: {
79-
type: string;
88+
type: NonNullable<FlowNode["type"]>;
8089
width: number;
8190
height: number;
82-
data: { label: string; ports: { id: string }[]; id: string };
91+
data: FlowNode["data"];
8392
}[] &
8493
ElkNode[];
85-
} & Omit<ElkNode, "children" | "position">)[];
94+
} & Omit<ElkNode, "children" | "position" | "edges">)[];
8695
};
8796

8897
/// ELK node but with layout information added
@@ -157,87 +166,45 @@ function toELKNode(
157166
return {
158167
id: `class-${id}`,
159168
data: { color: type_to_color.get(egraph.class_data[id]?.type) || null, port: `port-${id}`, id },
160-
ports: [
161-
// {
162-
// id: `port-${id}`,
163-
// layoutOptions: {
164-
// "port.side": "NORTH",
165-
// },
166-
// },
167-
],
168-
type: "class",
169+
type: "class" as const,
169170
children: nodes.map(([id, node]) => {
170-
const ports = Object.keys(node.children).map((index) => ({
171-
id: `port-${id}-${index}`,
172-
layoutOptions: {
173-
"port.side": "SOUTH",
174-
},
175-
}));
176171
// compute the size of the text by setting a dummy node element then measureing it
177172
innerElem.innerText = node.op;
178173
const size = outerElem.getBoundingClientRect();
179174
return {
180175
id: `node-${id}`,
181-
type: "node",
182-
data: { label: node.op, ports, id },
176+
type: "node" as const,
177+
data: { label: node.op, id },
183178
width: size.width,
184179
height: size.height,
185-
// one port for every index
186-
ports,
180+
ports: Object.keys(node.children).map((index) => ({
181+
id: `port-${id}-${index}`,
182+
layoutOptions: {
183+
"port.side": "SOUTH",
184+
},
185+
})),
187186
};
188187
}),
189188
edges: nodes.flatMap(([id, node]) =>
190-
[...node.children.entries()].flatMap(([index, childNode]) => {
191-
const sourcePort = `port-${id}-${index}`;
192-
const class_ = nodeToClass.get(childNode)!;
193-
// only include if this is a self loop
194-
if (class_ != id) {
195-
return [];
196-
}
197-
// If the target or source class is not in the selected nodes, don't draw the edge
198-
const targetPort = `port-${class_}`;
199-
return [
200-
{
201-
id: `edge-${id}-${index}`,
202-
source: `node-${id}`,
203-
sourcePort,
204-
sourceHandle: sourcePort,
205-
target: `class-${class_}`,
206-
targetPort,
207-
targetHandle: targetPort,
208-
},
209-
];
210-
})
189+
[...node.children.entries()].map(([index, childNode]) => ({
190+
id: `edge-${id}-${index}`,
191+
data: { sourceNode: `node-${id}` },
192+
sources: [`port-${id}-${index}`],
193+
targets: [`class-${nodeToClass.get(childNode)!}`],
194+
}))
211195
),
212196
};
213197
});
214198

215-
const edges: ElkPrimitiveEdge[] = Object.entries(egraph.nodes).flatMap(([id, node]) =>
216-
[...node.children.entries()].flatMap(([index, childNode]) => {
217-
const sourcePort = `port-${id}-${index}`;
218-
const class_ = nodeToClass.get(childNode)!;
219-
// If the target or source class is not in the selected nodes, don't draw the edge
220-
if (
221-
!classToNodes.get(node.eclass)?.find(([sourceID]) => sourceID == id) ||
222-
!classToNodes.has(class_) ||
223-
class_ == nodeToClass.get(id)
224-
) {
225-
return [];
226-
}
227-
const targetPort = `port-${class_}`;
228-
return [
229-
{
230-
id: `edge-${id}-${index}`,
231-
source: `node-${id}`,
232-
sourcePort,
233-
sourceHandle: sourcePort,
234-
target: `class-${class_}`,
235-
targetPort,
236-
targetHandle: targetPort,
237-
},
238-
];
239-
})
240-
);
199+
// move all edges that aren't self loops to the root
200+
// https://github.com/eclipse/elk/issues/1068
201+
const edges = [];
202+
for (const child of children) {
203+
const { loop, not } = Object.groupBy(child.edges, ({ targets }) => (targets[0] === child.id ? "loop" : "not"));
204+
child.edges = loop || [];
205+
edges.push(...(not || []));
206+
}
207+
241208
return {
242209
id: "--eclipse-layout-kernel-root",
243210
layoutOptions,
@@ -247,7 +214,7 @@ function toELKNode(
247214
}
248215

249216
// This function takes an EGraph and returns an ELK node that can be used to layout the graph.
250-
function toFlowNodes(layout: MyELKNodeLayedOut): Node[] {
217+
function toFlowNodes(layout: MyELKNodeLayedOut): (FlowClass | FlowNode)[] {
251218
return layout.children.flatMap(({ children, x, y, data, id: parentId, type, height, width }) => [
252219
{ position: { x, y }, data, id: parentId, type, height, width },
253220
...children!.map(({ x, y, height, width, data, id, type }) => ({
@@ -262,68 +229,81 @@ function toFlowNodes(layout: MyELKNodeLayedOut): Node[] {
262229
]);
263230
}
264231

265-
export function EClassNode({ data }: { data: { port: string; color: string; id: string } }) {
232+
function toFlowEdges(layout: MyELKNodeLayedOut): FlowEdge[] {
233+
const allEdges = [...layout.edges!, ...layout.children.flatMap(({ edges }) => edges!)];
234+
return allEdges.map(({ id, sections, data: { sourceNode } }) => {
235+
const [section] = sections!;
236+
return {
237+
type: "edge",
238+
id,
239+
source: sourceNode,
240+
target: section.outgoingShape!,
241+
data: {
242+
points: [section.startPoint, ...(section.bendPoints || []), section.endPoint],
243+
},
244+
};
245+
});
246+
}
247+
248+
export function EClassNode({ data }: NodeProps<FlowClass>) {
266249
return (
267-
<div className="rounded-md border border-dotted border-stone-400 h-full w-full" style={{ backgroundColor: data.color }}>
268-
<Handle className="top-0 bottom-0 opacity-0 translate-0" type="target" id={data.port} position={Position.Top} />
250+
<div className="rounded-md border border-dotted border-stone-400 h-full w-full" style={{ backgroundColor: data.color! }}>
251+
<Handle type="target" position={Position.Top} className="invisible" />
269252
</div>
270253
);
271254
}
272255

273-
export function ENode({
274-
data,
275-
...rest
276-
}: {
277-
data: { label: string; ports: { id: string }[]; id: string };
278-
outerRef?: React.Ref<HTMLDivElement>;
279-
innerRef?: React.Ref<HTMLDivElement>;
280-
}) {
256+
export function ENode(
257+
props: Partial<
258+
NodeProps<FlowNode> & {
259+
outerRef: React.Ref<HTMLDivElement>;
260+
innerRef: React.Ref<HTMLDivElement>;
261+
}
262+
>
263+
) {
281264
return (
282-
<div className="p-1 rounded-md border bg-white border-stone-400 h-full w-full" ref={rest?.outerRef}>
283-
<div className="font-mono truncate max-w-96" ref={rest?.innerRef}>
284-
{data.label}
285-
</div>
286-
{/* Place at bottom of parent so that handles just touch the node */}
287-
<div className="flex justify-around absolute bottom-0 w-full">
288-
{data.ports.map(({ id }) => (
289-
<Handle
290-
className="relative left-auto transform-none opacity-0 translate-0"
291-
key={id}
292-
type="source"
293-
position={Position.Bottom}
294-
id={id}
295-
/>
296-
))}
265+
<div className="p-1 rounded-md border bg-white border-stone-400 h-full w-full" ref={props?.outerRef}>
266+
<div className="font-mono truncate max-w-96" ref={props?.innerRef}>
267+
{props?.data?.label}
297268
</div>
269+
{/* Only show handle if we aren't rendering this to calculate size */}
270+
{props?.outerRef ? <></> : <Handle type="source" position={Position.Bottom} className="invisible" />}
298271
</div>
299272
);
300273
}
301274

275+
export function CustomEdge({ markerEnd, data }: EdgeProps<FlowEdge>) {
276+
const { points } = data!;
277+
const edgePath = points.map(({ x, y }, index) => `${index === 0 ? "M" : "L"} ${x} ${y}`).join(" ");
278+
return <BaseEdge path={edgePath} markerEnd={markerEnd} />;
279+
}
280+
302281
const nodeTypes: NodeTypes = {
303282
class: memo(EClassNode),
304283
node: memo(ENode),
305284
};
306285

286+
const edgeTypes: EdgeTypes = {
287+
edge: memo(CustomEdge),
288+
};
289+
307290
function LayoutFlow({ egraph, outerElem, innerElem }: { egraph: string; outerElem: HTMLDivElement; innerElem: HTMLDivElement }) {
308291
const parsedEGraph: EGraph = useMemo(() => JSON.parse(egraph), [egraph]);
309292
/// e-class ID we have currently selected
310293
const [selectedNode, setSelectedNode] = useState<{ type: "class" | "node"; id: string } | null>(null);
311-
const elkNode = useMemo(() => {
312-
const r = toELKNode(parsedEGraph, outerElem, innerElem, selectedNode);
313-
// console.log(JSON.parse(JSON.stringify(r, null, 2)));
314-
return r;
315-
}, [parsedEGraph, outerElem, innerElem, selectedNode]);
316-
const edges = useMemo(
317-
() => [...elkNode.children.flatMap((c) => c.edges!.map((e) => ({ ...e }))), ...elkNode.edges!.map((e) => ({ ...e }))],
318-
[elkNode]
294+
const elkNode = useMemo(
295+
() => toELKNode(parsedEGraph, outerElem, innerElem, selectedNode),
296+
[parsedEGraph, outerElem, innerElem, selectedNode]
319297
);
320298
const layoutPromise = useMemo(() => elk.layout(elkNode) as Promise<MyELKNodeLayedOut>, [elkNode]);
321299
const layout = use(layoutPromise);
300+
const edges = useMemo(() => toFlowEdges(layout), [layout]);
322301
const nodes = useMemo(() => toFlowNodes(layout), [layout]);
323302
const onNodeClick = useCallback(
324303
((_, node) => {
325-
startTransition(() => setSelectedNode({ type: node.type! as "class" | "node", id: node.data!.id! as string }));
326-
}) as NodeMouseHandler,
304+
// Use start transition so that the whole component doesn't re-render
305+
startTransition(() => setSelectedNode({ type: node.type!, id: node.data.id }));
306+
}) as NodeMouseHandler<FlowClass | FlowNode>,
327307
[setSelectedNode]
328308
);
329309

@@ -340,7 +320,8 @@ function LayoutFlow({ egraph, outerElem, innerElem }: { egraph: string; outerEle
340320
<ReactFlow
341321
nodes={nodes}
342322
nodeTypes={nodeTypes}
343-
edges={edges as unknown as Edge[]}
323+
edgeTypes={edgeTypes}
324+
edges={edges}
344325
minZoom={0.05}
345326
maxZoom={10}
346327
defaultEdgeOptions={{ markerEnd: { type: MarkerType.ArrowClosed } }}
@@ -360,7 +341,7 @@ function Visualizer({ egraph }: { egraph: string }) {
360341
<>
361342
{/* Hidden node to measure text size */}
362343
<div className="invisible absolute">
363-
<ENode data={{ label: "test", ports: [] }} outerRef={setOuterElem} innerRef={setInnerElem} />
344+
<ENode outerRef={setOuterElem} innerRef={setInnerElem} />
364345
</div>
365346
<ReactFlowProvider>
366347
<ErrorBoundary fallback={<p>⚠️Something went wrong</p>}>

src/anywidget.tsx

+2-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { StrictMode, useSyncExternalStore } from "react";
1+
import { useSyncExternalStore } from "react";
22
import { createRoot } from "react-dom/client";
33
import "./index.css";
44
import { DOMWidgetModel } from "@jupyter-widgets/base";
@@ -18,11 +18,7 @@ function ModelApp({ model }: { model: DOMWidgetModel }) {
1818

1919
function render({ model, el }: { el: HTMLElement; model: DOMWidgetModel }) {
2020
const root = createRoot(el);
21-
root.render(
22-
<StrictMode>
23-
<ModelApp model={model} />
24-
</StrictMode>
25-
);
21+
root.render(<ModelApp model={model} />);
2622
return () => root.unmount();
2723
}
2824

tsconfig.app.json

+9-4
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,28 @@
22
"compilerOptions": {
33
"target": "ES2020",
44
"useDefineForClassFields": true,
5-
"lib": ["ES2020", "DOM", "DOM.Iterable"],
5+
"lib": [
6+
"ES2020",
7+
"DOM",
8+
"DOM.Iterable",
9+
"ESNext"
10+
],
611
"module": "ESNext",
712
"skipLibCheck": true,
8-
913
/* Bundler mode */
1014
"moduleResolution": "bundler",
1115
"allowImportingTsExtensions": true,
1216
"isolatedModules": true,
1317
"moduleDetection": "force",
1418
"noEmit": true,
1519
"jsx": "react-jsx",
16-
1720
/* Linting */
1821
"strict": true,
1922
"noUnusedLocals": true,
2023
"noUnusedParameters": true,
2124
"noFallthroughCasesInSwitch": true
2225
},
23-
"include": ["src"]
26+
"include": [
27+
"src"
28+
]
2429
}

0 commit comments

Comments
 (0)