Skip to content

Commit bbc79b8

Browse files
authored
Merge pull request #14 from CoolSpring8/replace-elk-with-custom
refactor: implement custom diagram layout algorithm and remove elkjs dependency
2 parents 7fa1c80 + 42036af commit bbc79b8

4 files changed

Lines changed: 107 additions & 124 deletions

File tree

bun.lock

Lines changed: 0 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
"@mantine/core": "^7.8.1",
1414
"@mantine/hooks": "^7.8.1",
1515
"@xyflow/react": "^12.9.1",
16-
"elkjs": "^0.11.0",
1716
"idb-keyval": "^6.2.1",
1817
"immer": "^10.0.4",
1918
"openai": "^4.38.2",

src/components/DiagramView.tsx

Lines changed: 107 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
} from "@xyflow/react";
1111
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1212
import "@xyflow/react/dist/style.css";
13-
import ELK from "elkjs/lib/elk.bundled.js";
1413
import { twJoin } from "tailwind-merge";
1514
import { useShallow } from "zustand/react/shallow";
1615
import { useConversationTree } from "../tree/useConversationTree";
@@ -22,14 +21,12 @@ interface DiagramViewProps {
2221
onDuplicateFromNode?: (nodeId: string) => void;
2322
}
2423

25-
const elk = new ELK();
26-
2724
const boxSize = {
2825
width: 320,
2926
height: 80,
3027
};
3128

32-
const columnGap = 64;
29+
const columnGap = 80;
3330
const rowGap = 80;
3431

3532
const nodeStyleBase = {
@@ -202,121 +199,115 @@ const DiagramView = ({
202199
);
203200

204201
useEffect(() => {
205-
const children = Object.values(treeNodes).map((node) => ({
206-
id: node.id,
207-
width: boxSize.width,
208-
height: boxSize.height,
209-
}));
210-
const edges = Object.values(treeEdges).map((edge) => ({
211-
id: edge.id,
212-
sources: [edge.from],
213-
targets: [edge.to],
214-
}));
215-
216-
let cancelled = false;
217-
void elk
218-
.layout({
219-
id: "root",
220-
layoutOptions: {
221-
"elk.algorithm": "layered",
222-
"elk.direction": "DOWN",
223-
"elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
224-
"elk.spacing.nodeNode": "40",
225-
"elk.layered.spacing.nodeNodeBetweenLayers": "80",
226-
"elk.edgeRouting": "ORTHOGONAL",
202+
// No conversation = no graph
203+
const internalNodes = Object.values(treeNodes);
204+
if (internalNodes.length === 0) {
205+
setLayoutNodes([]);
206+
setLayoutEdges([]);
207+
return;
208+
}
209+
210+
const laneById = new Map(laneAssignments);
211+
let nextLane = laneAssignments.size;
212+
for (const lane of laneAssignments.values()) {
213+
nextLane = Math.max(nextLane, lane + 1);
214+
}
215+
216+
const getLane = (nodeId: string): number => {
217+
const lane = laneById.get(nodeId);
218+
if (lane !== undefined) {
219+
return lane;
220+
}
221+
// @ts-expect-error rsbuild v0.5.2 doesn't support env index access or import.meta.env
222+
if (process.env.NODE_ENV !== "production") {
223+
// eslint-disable-next-line no-console
224+
console.warn(`lane missing for node ${nodeId}; allocating new lane`);
225+
}
226+
const assigned = nextLane;
227+
nextLane += 1;
228+
laneById.set(nodeId, assigned);
229+
return assigned;
230+
};
231+
232+
const laneLookup = new Map<string, number>();
233+
for (const node of internalNodes) {
234+
laneLookup.set(node.id, getLane(node.id));
235+
}
236+
237+
// Sort by (depth, lane, createdAt) for stable ordering
238+
const sortedNodes = [...internalNodes].sort((a, b) => {
239+
const depthA = depthByNode.get(a.id) ?? 0;
240+
const depthB = depthByNode.get(b.id) ?? 0;
241+
if (depthA !== depthB) {
242+
return depthA - depthB;
243+
}
244+
245+
const laneA = laneLookup.get(a.id) ?? 0;
246+
const laneB = laneLookup.get(b.id) ?? 0;
247+
if (laneA !== laneB) {
248+
return laneA - laneB;
249+
}
250+
251+
const createdDiff = a.createdAt - b.createdAt;
252+
if (createdDiff !== 0) {
253+
return createdDiff;
254+
}
255+
256+
return a.id.localeCompare(b.id);
257+
});
258+
259+
const nodes: Node[] = sortedNodes.map((dataNode) => {
260+
const label = (
261+
<div className="flex items-start gap-2">
262+
<div className="min-w-0">
263+
<p className="text-xs font-mono uppercase text-slate-500">
264+
{dataNode.role}
265+
</p>
266+
<p className="mt-1 text-sm font-medium leading-snug text-slate-800 line-clamp-3">
267+
{dataNode.text}
268+
</p>
269+
</div>
270+
</div>
271+
);
272+
273+
const isActive = activePathIds.nodes.has(dataNode.id);
274+
const style = {
275+
...nodeStyleBase,
276+
border: isActive ? "2px solid #2563eb" : "1px solid #e2e8f0",
277+
opacity: isActive ? 1 : 0.7,
278+
};
279+
280+
const lane = laneLookup.get(dataNode.id) ?? 0;
281+
const depth = depthByNode.get(dataNode.id) ?? 0;
282+
283+
return {
284+
id: dataNode.id,
285+
position: {
286+
x: lane * (boxSize.width + columnGap),
287+
y: depth * (boxSize.height + rowGap),
227288
},
228-
children,
229-
edges,
230-
})
231-
.then(
232-
(result: {
233-
children?: Array<{ id: string; x?: number; y?: number }>;
234-
edges?: Array<{ id: string; sources?: string[]; targets?: string[] }>;
235-
}) => {
236-
if (cancelled) {
237-
return;
238-
}
239-
const nodes: Node[] = (result.children ?? []).map((child) => {
240-
const dataNode = treeNodes[child.id];
241-
if (!dataNode) {
242-
return {
243-
id: child.id,
244-
position: { x: child.x ?? 0, y: child.y ?? 0 },
245-
data: { label: child.id },
246-
} satisfies Node;
247-
}
248-
249-
const label = (
250-
<div className="flex items-start gap-2">
251-
<div className="min-w-0">
252-
<p className="text-xs font-mono uppercase text-slate-500">
253-
{dataNode.role}
254-
</p>
255-
<p className="mt-1 text-sm font-medium leading-snug text-slate-800 line-clamp-3">
256-
{dataNode.text}
257-
</p>
258-
</div>
259-
</div>
260-
);
261-
262-
const isActive = activePathIds.nodes.has(child.id);
263-
const style = {
264-
...nodeStyleBase,
265-
border: isActive ? "2px solid #2563eb" : "1px solid #e2e8f0",
266-
opacity: isActive ? 1 : 0.7,
267-
};
268-
269-
return {
270-
id: child.id,
271-
position: {
272-
x:
273-
(laneAssignments.get(child.id) ?? laneAssignments.size) *
274-
(boxSize.width + columnGap),
275-
y:
276-
child.y ??
277-
(depthByNode.get(child.id) ?? 0) * (boxSize.height + rowGap),
278-
},
279-
data: { label },
280-
style,
281-
} satisfies Node;
282-
});
283-
284-
const edgesStyled: Edge[] = (result.edges ?? []).flatMap((edge) => {
285-
const source = edge.sources?.[0];
286-
const target = edge.targets?.[0];
287-
if (!source || !target) {
288-
return [];
289-
}
290-
const isActive = activePathIds.edges.has(edge.id);
291-
return [
292-
{
293-
id: edge.id,
294-
source,
295-
target,
296-
type: "smoothstep",
297-
animated: false,
298-
style: {
299-
strokeWidth: isActive ? 2 : 1.5,
300-
stroke: isActive ? "#2563eb" : "#94a3b8",
301-
},
302-
} as Edge,
303-
];
304-
});
305-
306-
setLayoutNodes(nodes);
307-
setLayoutEdges(edgesStyled);
289+
data: { label },
290+
style,
291+
} satisfies Node;
292+
});
293+
294+
const edgesStyled: Edge[] = Object.values(treeEdges).map((edge) => {
295+
const isActive = activePathIds.edges.has(edge.id);
296+
return {
297+
id: edge.id,
298+
source: edge.from,
299+
target: edge.to,
300+
type: "smoothstep",
301+
animated: false,
302+
style: {
303+
strokeWidth: isActive ? 2 : 1.5,
304+
stroke: isActive ? "#2563eb" : "#94a3b8",
308305
},
309-
)
310-
.catch(() => {
311-
if (!cancelled) {
312-
setLayoutNodes([]);
313-
setLayoutEdges([]);
314-
}
315-
});
306+
} satisfies Edge;
307+
});
316308

317-
return () => {
318-
cancelled = true;
319-
};
309+
setLayoutNodes(nodes);
310+
setLayoutEdges(edgesStyled);
320311
}, [
321312
treeNodes,
322313
treeEdges,

src/types/elk.d.ts

Lines changed: 0 additions & 4 deletions
This file was deleted.

0 commit comments

Comments
 (0)