You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hamilton.apache.org by sk...@apache.org on 2026/05/24 19:51:09 UTC
(hamilton) branch stefan/replace-elkjs-with-dagre created (now 660703d0)
This is an automated email from the ASF dual-hosted git repository.
skrawcz pushed a change to branch stefan/replace-elkjs-with-dagre
in repository https://gitbox.apache.org/repos/asf/hamilton.git
at 660703d0 Replace elkjs (EPL-2.0) with dagre (MIT) for DAG visualization layout
This branch includes the following new commits:
new 660703d0 Replace elkjs (EPL-2.0) with dagre (MIT) for DAG visualization layout
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(hamilton) 01/01: Replace elkjs (EPL-2.0) with dagre (MIT) for DAG visualization layout
Posted by sk...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
skrawcz pushed a commit to branch stefan/replace-elkjs-with-dagre
in repository https://gitbox.apache.org/repos/asf/hamilton.git
commit 660703d048339c6bbb2dc53f62cedecaeac07fdb
Author: Stefan Krawczyk <st...@cs.stanford.edu>
AuthorDate: Sun May 24 12:38:34 2026 -0700
Replace elkjs (EPL-2.0) with dagre (MIT) for DAG visualization layout
elkjs uses the Eclipse Public License 2.0 which is Category B under
Apache licensing policy and cannot be bundled in an Apache release.
dagre is MIT licensed (Category A) and supports compound/clustered
graphs natively via setParent(), which handles Hamilton's hierarchical
node grouping (by module, namespace, defining function).
The layout uses dagre's compound graph mode:
- Group nodes are added without explicit dimensions (dagre computes
bounding boxes from children automatically)
- Leaf nodes use their measured dimensions
- All edges are included regardless of group boundaries
- Positions are converted from dagre's center-based coordinates to
ReactFlow's top-left relative-to-parent model
---
ui/frontend/package-lock.json | 35 +++-
ui/frontend/package.json | 3 +-
.../src/components/dashboard/Visualize/layout.ts | 224 ++++++++++-----------
3 files changed, 137 insertions(+), 125 deletions(-)
diff --git a/ui/frontend/package-lock.json b/ui/frontend/package-lock.json
index d6228b54..4aba7a77 100644
--- a/ui/frontend/package-lock.json
+++ b/ui/frontend/package-lock.json
@@ -26,9 +26,9 @@
"chart.js": "^4.3.0",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-plugin-zoom": "^2.0.1",
+ "dagre": "^0.8.5",
"date-fns": "^2.30.0",
"dayjs": "^1.11.9",
- "elkjs": "^0.8.2",
"fuse.js": "^6.6.2",
"headlessui": "^0.0.0",
"http-proxy-middleware": "^2.0.9",
@@ -60,6 +60,7 @@
},
"devDependencies": {
"@eslint/js": "^9.17.0",
+ "@types/dagre": "^0.7.52",
"@types/node": "^22.10.2",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4",
@@ -2875,6 +2876,13 @@
"@types/d3-selection": "*"
}
},
+ "node_modules/@types/dagre": {
+ "version": "0.7.54",
+ "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz",
+ "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -4541,6 +4549,16 @@
"node": ">=12"
}
},
+ "node_modules/dagre": {
+ "version": "0.8.5",
+ "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
+ "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
+ "license": "MIT",
+ "dependencies": {
+ "graphlib": "^2.1.8",
+ "lodash": "^4.17.15"
+ }
+ },
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -4822,12 +4840,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/elkjs": {
- "version": "0.8.2",
- "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.8.2.tgz",
- "integrity": "sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ==",
- "license": "EPL-2.0"
- },
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
@@ -5848,6 +5860,15 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
+ "node_modules/graphlib": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
+ "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.15"
+ }
+ },
"node_modules/hammerjs": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz",
diff --git a/ui/frontend/package.json b/ui/frontend/package.json
index b4124613..2299a10c 100644
--- a/ui/frontend/package.json
+++ b/ui/frontend/package.json
@@ -23,7 +23,7 @@
"chartjs-plugin-zoom": "^2.0.1",
"date-fns": "^2.30.0",
"dayjs": "^1.11.9",
- "elkjs": "^0.8.2",
+ "dagre": "^0.8.5",
"fuse.js": "^6.6.2",
"headlessui": "^0.0.0",
"http-proxy-middleware": "^2.0.9",
@@ -84,6 +84,7 @@
},
"devDependencies": {
"@eslint/js": "^9.17.0",
+ "@types/dagre": "^0.7.52",
"@types/node": "^22.10.2",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4",
diff --git a/ui/frontend/src/components/dashboard/Visualize/layout.ts b/ui/frontend/src/components/dashboard/Visualize/layout.ts
index 133171fa..23e8960c 100644
--- a/ui/frontend/src/components/dashboard/Visualize/layout.ts
+++ b/ui/frontend/src/components/dashboard/Visualize/layout.ts
@@ -17,136 +17,126 @@
import { VizEdge, VizNode } from "./types";
-import ELK, { ElkNode } from "elkjs/lib/elk.bundled.js";
-
-const elk = new ELK();
-
-const convertGraphFromElk = (
- root: ElkNode,
- nodeNameMap: Map<string, VizNode>
-) => {
- const output = [] as VizNode[];
- const queue = [root];
- while (queue.length > 0) {
- const node = queue.shift() as ElkNode;
- const vizNode = nodeNameMap.get(node.id);
- if (vizNode !== undefined) {
- ((vizNode.position = {
- x: node.x as number, // + PARENT_PADDING.left,
- y: node.y as number, // + PARENT_PADDING.top,
- }),
- (vizNode.data.dimensions = {
- width: node.width as number, // + PARENT_PADDING.left + PARENT_PADDING.right,
- height: node.height as number,
- // PARENT_PADDING.bottom +
- // PARENT_PADDING.top,
- }));
- output.push(vizNode);
- }
- queue.push(...(node.children || []));
- }
- return output;
-};
+import dagre from "dagre";
+/**
+ * Lay out nodes using dagre's compound graph support.
+ *
+ * dagre natively supports compound (clustered) graphs via setParent().
+ * Group nodes are added without explicit dimensions — dagre computes
+ * their size from their children. Leaf nodes get their measured dimensions.
+ * All edges are included regardless of group boundaries.
+ *
+ * Positions from dagre are center-based; we convert to top-left for ReactFlow.
+ * Child positions are made relative to their parent for ReactFlow's nested
+ * node model.
+ */
export const getLayoutedElements = (
nodes: VizNode[],
edges: VizEdge[],
nodeDimensions: Map<string, { width: number; height: number }>,
vertical: boolean
) => {
- // Organize every node by its parents
- const nodesByParent = new Map<string, VizNode[]>([["root", []]]);
+ const g = new dagre.graphlib.Graph({ compound: true });
+ g.setDefaultEdgeLabel(() => ({}));
+ g.setGraph({
+ rankdir: vertical ? "TB" : "LR",
+ nodesep: 50,
+ ranksep: 80,
+ marginx: 20,
+ marginy: 20,
+ });
+
+ // Determine which nodes are groups (have children)
+ const childIds = new Set(
+ nodes.filter((n) => n.parentNode).map((n) => n.parentNode!)
+ );
+
+ // Add all nodes
nodes.forEach((node) => {
- const parent = node.parentNode || "root";
- if (!nodesByParent.has(parent)) {
- nodesByParent.set(parent, []);
+ if (childIds.has(node.id)) {
+ // Group node: set a minimal label so dagre knows it exists,
+ // but don't set width/height — dagre will compute from children.
+ // We set clusterLabelPos to push the label to the top.
+ g.setNode(node.id, { clusterLabelPos: "top" });
+ } else {
+ // Leaf node: use measured dimensions
+ const dims = nodeDimensions.get(node.id) || { width: 150, height: 50 };
+ g.setNode(node.id, { width: dims.width, height: dims.height });
}
- nodesByParent.get(parent)?.push(node);
});
- const buildGraph = (rootNode: VizNode): ElkNode => {
- const children = nodesByParent.get(rootNode.id) || [];
- const subGraph = children.map((child) => buildGraph(child));
- // let { width: minWidth, height: minHeight } = nodeDimensions.get(
- // rootNode.id
- // ) || { minWidth: 0, minHeight: 0 };
- // minWidth = minWidth || 0;
- // minHeight = minHeight || 0;
- const graph = {
- id: rootNode.id,
- children: subGraph,
- targetPosition: "right", // TODO -- use the direction above
- sourcePosition: "left",
- layoutOptions: {
- "elk.padding": "[top=40,left=25,bottom=25,right=25]",
- // "elk.hierarchyHandling": "INCLUDE_CHILDREN",
- // "elk.nodeSize.constraints": "MINIMUM_SIZE",
- // "elk.nodeSize.minimum": `(${minHeight-2},${minWidth-2})`,
- },
- // ? isHorizontal
- // ? "right"
- // : "bottom"
- // : "right",
- // width: 1000,
- // height: 100,
- width: rootNode
- ? nodeDimensions.get(rootNode.id)?.width || 10
- : undefined,
- height: rootNode
- ? nodeDimensions.get(rootNode.id)?.height || 10
- : undefined,
- };
- return graph;
- };
+ // Set parent relationships
+ nodes.forEach((node) => {
+ if (node.parentNode) {
+ g.setParent(node.id, node.parentNode);
+ }
+ });
- const elkOptions = {
- // "nodeLabels.padding": "[top=10,left=10,bottom=10,right=10]",
- "elk.hierarchyHandling": "INCLUDE_CHILDREN",
- // "elk.padding" : "[top=25,left=25,bottom=25,right=25]",
- // "elk.spacing.individual": "true",
- // "spacing.nodeNodeBetweenLayers": "150",
- "elk.algorithm": "layered",
- // "org.eclipse.elk.layered.layering.strategy": "STRETCH_WIDfasefasefsTH",
- // "elk.spacing.nodeNode": "20",
- // "elk.direction": direction == "LR" ? "RIGHT" : "DOWN",
- // "elk.layered.spacing.nodeNodeBetweenLayers": "100",
- // "elk.spacing.nodeNode": "80",
- // // "elk.algorithm": "mrtree",
- };
+ // Add all edges (dagre handles cross-cluster edges)
+ edges.forEach((edge) => {
+ if (g.hasNode(edge.source) && g.hasNode(edge.target)) {
+ g.setEdge(edge.source, edge.target);
+ }
+ });
- const nodeNameMap = new Map(nodes.map((node) => [node.id, node]));
- const vizEdgeNameMap = new Map(
- edges.map((edge) => [edge.id, edge])
- );
+ // Run layout
+ dagre.layout(g);
- const subGraph = (nodesByParent.get("root") || []).map((node) =>
- buildGraph(node)
- );
- const graph = {
- id: "root",
- layoutOptions: elkOptions,
- children: subGraph,
- width: 1000,
- height: 200,
- edges: edges.map((edge) => ({
- ...edge,
- sources: [edge.source],
- targets: [edge.target],
- })),
- };
- return elk
- .layout(graph, {
- layoutOptions: {
- "org.eclipse.elk.direction": vertical ? "DOWN" : "RIGHT",
+ // Collect absolute center positions from dagre
+ const absoluteCenters = new Map<
+ string,
+ { x: number; y: number; width: number; height: number }
+ >();
+ nodes.forEach((node) => {
+ const dagreNode = g.node(node.id);
+ if (dagreNode) {
+ absoluteCenters.set(node.id, {
+ x: dagreNode.x,
+ y: dagreNode.y,
+ width: dagreNode.width,
+ height: dagreNode.height,
+ });
+ }
+ });
+
+ // Convert to ReactFlow positions (top-left, relative to parent)
+ const layoutedNodes = nodes.map((node) => {
+ const center = absoluteCenters.get(node.id);
+ if (!center) {
+ return {
+ ...node,
+ position: { x: 0, y: 0 },
+ };
+ }
+
+ // Convert center to top-left
+ let x = center.x - center.width / 2;
+ let y = center.y - center.height / 2;
+
+ // Make relative to parent if nested
+ if (node.parentNode) {
+ const parentCenter = absoluteCenters.get(node.parentNode);
+ if (parentCenter) {
+ const parentTopLeftX = parentCenter.x - parentCenter.width / 2;
+ const parentTopLeftY = parentCenter.y - parentCenter.height / 2;
+ x -= parentTopLeftX;
+ y -= parentTopLeftY;
+ }
+ }
+
+ return {
+ ...node,
+ position: { x, y },
+ data: {
+ ...node.data,
+ dimensions: { width: center.width, height: center.height },
},
- })
- .then((layoutedGraph) => ({
- nodes: convertGraphFromElk(layoutedGraph, nodeNameMap),
- edges: (layoutedGraph.edges || []).map((edge) => {
- const edgeId = edge.id;
- const originalEdge = vizEdgeNameMap.get(edgeId) as VizEdge;
- return originalEdge;
- }) as VizEdge[],
- }))
- .catch(console.error);
+ };
+ });
+
+ return Promise.resolve({
+ nodes: layoutedNodes,
+ edges: edges,
+ });
};