From 89dd9b1d05ab0558b12a8dce51895e3d6ddd3237 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Thu, 30 Apr 2026 23:25:00 -0400 Subject: [PATCH 1/2] feat: Add Elk layout component --- .changeset/silent-lines-hide.md | 5 + docs/src/content/components/Elk.md | 70 ++ docs/src/examples/catalog/Chart.json | 114 ++- docs/src/examples/catalog/Circle.json | 44 +- docs/src/examples/catalog/Elk.json | 802 ++++++++++++++++++ docs/src/examples/catalog/Group.json | 121 ++- docs/src/examples/catalog/Layer.json | 114 ++- docs/src/examples/catalog/Line.json | 9 +- docs/src/examples/catalog/Rect.json | 100 ++- docs/src/examples/catalog/Spline.json | 114 ++- docs/src/examples/catalog/Text.json | 177 +++- .../components/Elk/architecture.svelte | 144 ++++ docs/src/examples/components/Elk/basic.svelte | 98 +++ .../examples/components/Elk/cluster.svelte | 108 +++ docs/src/examples/components/Elk/erd.svelte | 172 ++++ .../examples/components/Elk/flowchart.svelte | 150 ++++ docs/src/examples/components/Elk/force.svelte | 94 ++ .../examples/components/Elk/layered.svelte | 111 +++ .../src/examples/components/Elk/mrtree.svelte | 97 +++ .../examples/components/Elk/oauth-flow.svelte | 167 ++++ .../examples/components/Elk/playground.svelte | 119 +++ .../src/examples/components/Elk/radial.svelte | 110 +++ .../components/Elk/sequence-diagram.svelte | 131 +++ .../components/Elk/state-composite.svelte | 156 ++++ .../components/Elk/state-machine.svelte | 138 +++ .../src/examples/components/Elk/stress.svelte | 94 ++ .../components/Elk/tcp-state-diagram.svelte | 115 +++ .../components/controls/ElkControls.svelte | 145 ++++ .../controls/ElkPlaygroundControls.svelte | 42 + .../controls/ElkSettingsControls.svelte | 20 + packages/layerchart/package.json | 1 + .../src/lib/components/graph/Elk.svelte | 272 ++++++ .../src/lib/components/graph/index.ts | 2 + .../layerchart/src/lib/utils/graph/elk.ts | 463 ++++++++++ pnpm-lock.yaml | 8 + 35 files changed, 4619 insertions(+), 8 deletions(-) create mode 100644 .changeset/silent-lines-hide.md create mode 100644 docs/src/content/components/Elk.md create mode 100644 docs/src/examples/catalog/Elk.json create mode 100644 docs/src/examples/components/Elk/architecture.svelte create mode 100644 docs/src/examples/components/Elk/basic.svelte create mode 100644 docs/src/examples/components/Elk/cluster.svelte create mode 100644 docs/src/examples/components/Elk/erd.svelte create mode 100644 docs/src/examples/components/Elk/flowchart.svelte create mode 100644 docs/src/examples/components/Elk/force.svelte create mode 100644 docs/src/examples/components/Elk/layered.svelte create mode 100644 docs/src/examples/components/Elk/mrtree.svelte create mode 100644 docs/src/examples/components/Elk/oauth-flow.svelte create mode 100644 docs/src/examples/components/Elk/playground.svelte create mode 100644 docs/src/examples/components/Elk/radial.svelte create mode 100644 docs/src/examples/components/Elk/sequence-diagram.svelte create mode 100644 docs/src/examples/components/Elk/state-composite.svelte create mode 100644 docs/src/examples/components/Elk/state-machine.svelte create mode 100644 docs/src/examples/components/Elk/stress.svelte create mode 100644 docs/src/examples/components/Elk/tcp-state-diagram.svelte create mode 100644 docs/src/lib/components/controls/ElkControls.svelte create mode 100644 docs/src/lib/components/controls/ElkPlaygroundControls.svelte create mode 100644 docs/src/lib/components/controls/ElkSettingsControls.svelte create mode 100644 packages/layerchart/src/lib/components/graph/Elk.svelte create mode 100644 packages/layerchart/src/lib/utils/graph/elk.ts diff --git a/.changeset/silent-lines-hide.md b/.changeset/silent-lines-hide.md new file mode 100644 index 000000000..0ff9a6518 --- /dev/null +++ b/.changeset/silent-lines-hide.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat: Add Elk layout component diff --git a/docs/src/content/components/Elk.md b/docs/src/content/components/Elk.md new file mode 100644 index 000000000..a5ad0d7fe --- /dev/null +++ b/docs/src/content/components/Elk.md @@ -0,0 +1,70 @@ +--- +description: Layout component which arranges directed graphs using the Eclipse Layout Kernel (elkjs), supporting layered, tree, force, stress, radial, and other algorithms with extensive routing and spacing options. +category: layout +layers: [svg, canvas] +related: [Dagre] +--- + +## Usage + +:example{name="basic"} + +## Algorithms + +### Layered + +Sugiyama-style layered layout — the default and most flexible. + +:example{name="layered"} + +### Mr. Tree + +Compact tree layout for strictly hierarchical graphs. + +:example{name="mrtree"} + +### Force + +Force-directed layout for general undirected graphs. + +:example{name="force"} + +### Stress + +Stress-majorization layout that preserves graph-theoretic distances. + +:example{name="stress"} + +### Radial + +Radial tree layout that arranges nodes on concentric circles. + +:example{name="radial"} + +## Compound (hierarchical) graphs + +Nodes with a `parent` reference are nested as children of that parent. Combine with `hierarchyHandling="include-children"` to route edges across hierarchy levels. + +:example{name="cluster"} + +:example{name="architecture"} + +## Diagrams + +:example{name="flowchart"} + +:example{name="sequence-diagram"} + +:example{name="oauth-flow"} + +:example{name="state-machine"} + +:example{name="state-composite"} + +:example{name="erd"} + +:example{name="tcp-state-diagram"} + +## Playground + +:example{name="playground"} diff --git a/docs/src/examples/catalog/Chart.json b/docs/src/examples/catalog/Chart.json index fa6db086c..2a1783c67 100644 --- a/docs/src/examples/catalog/Chart.json +++ b/docs/src/examples/catalog/Chart.json @@ -1923,6 +1923,118 @@ "lineNumber": 18, "line": "" } ], - "updatedAt": "2026-04-30T15:36:23.412Z" + "updatedAt": "2026-05-01T03:17:47.509Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/Circle.json b/docs/src/examples/catalog/Circle.json index b1f149b96..c8223eea0 100644 --- a/docs/src/examples/catalog/Circle.json +++ b/docs/src/examples/catalog/Circle.json @@ -436,6 +436,48 @@ "lineNumber": 37, "line": "" + }, + { + "example": "radial", + "component": "Elk", + "path": "/docs/components/Elk/radial", + "lineNumber": 89, + "line": "" + }, + { + "example": "state-composite", + "component": "Elk", + "path": "/docs/components/Elk/state-composite", + "lineNumber": 108, + "line": "" + }, + { + "example": "state-composite", + "component": "Elk", + "path": "/docs/components/Elk/state-composite", + "lineNumber": 110, + "line": "" + }, + { + "example": "state-composite", + "component": "Elk", + "path": "/docs/components/Elk/state-composite", + "lineNumber": 111, + "line": "" + }, + { + "example": "stress", + "component": "Elk", + "path": "/docs/components/Elk/stress", + "lineNumber": 73, + "line": "" + }, { "example": "beeswarm", "component": "ForceSimulation", @@ -1004,5 +1046,5 @@ "line": "" } ], - "updatedAt": "2026-04-30T15:36:23.837Z" + "updatedAt": "2026-05-01T03:14:13.633Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/Elk.json b/docs/src/examples/catalog/Elk.json new file mode 100644 index 000000000..d79fdbf56 --- /dev/null +++ b/docs/src/examples/catalog/Elk.json @@ -0,0 +1,802 @@ +{ + "component": "Elk", + "examples": [ + { + "name": "architecture", + "title": "architecture", + "path": "/docs/components/Elk/architecture", + "components": [ + { + "component": "Chart", + "lineNumber": 74, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 86, + "line": "" + }, + { + "component": "Spline", + "lineNumber": 90, + "line": "" + }, + { + "component": "Rect", + "lineNumber": 105, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 50, + "line": " d.links} {...settings}>" + }, + { + "component": "Spline", + "lineNumber": 54, + "line": "" + }, + { + "component": "Rect", + "lineNumber": 69, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 50, + "line": " d.links} {...settings}>" + }, + { + "component": "Spline", + "lineNumber": 54, + "line": "" + }, + { + "component": "Rect", + "lineNumber": 69, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 93, + "line": "" + }, + { + "component": "Spline", + "lineNumber": 97, + "line": "" + }, + { + "component": "Rect", + "lineNumber": 125, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 78, + "line": "" + }, + { + "component": "Spline", + "lineNumber": 82, + "line": "" + }, + { + "component": "Rect", + "lineNumber": 114, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 49, + "line": "" + }, + { + "component": "Circle", + "lineNumber": 73, + "line": "" + }, + { + "component": "Text", + "lineNumber": 74, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 51, + "line": " d.links} {...settings}>" + }, + { + "component": "Spline", + "lineNumber": 55, + "line": "" + }, + { + "component": "Rect", + "lineNumber": 82, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 49, + "line": " d.links} {...settings}>" + }, + { + "component": "Spline", + "lineNumber": 53, + "line": "" + }, + { + "component": "Rect", + "lineNumber": 68, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 82, + "line": "" + }, + { + "component": "Line", + "lineNumber": 91, + "line": "" + }, + { + "component": "Rect", + "lineNumber": 139, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 52, + "line": "" + }, + { + "component": "Rect", + "lineNumber": 85, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 65, + "line": "" + }, + { + "component": "Circle", + "lineNumber": 89, + "line": "" + }, + { + "component": "Text", + "lineNumber": 90, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 71, + "line": "" + }, + { + "component": "Spline", + "lineNumber": 75, + "line": "" + }, + { + "component": "Rect", + "lineNumber": 102, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 74, + "line": "" + }, + { + "component": "Spline", + "lineNumber": 78, + "line": "" + }, + { + "component": "Circle", + "lineNumber": 108, + "line": "" + }, + { + "component": "Rect", + "lineNumber": 116, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 72, + "line": "" + }, + { + "component": "Spline", + "lineNumber": 76, + "line": "" + }, + { + "component": "Rect", + "lineNumber": 104, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 49, + "line": "" + }, + { + "component": "Circle", + "lineNumber": 73, + "line": "" + }, + { + "component": "Text", + "lineNumber": 74, + "line": "" + }, + { + "component": "Elk", + "lineNumber": 51, + "line": " d.links} {...settings}>" + }, + { + "component": "Spline", + "lineNumber": 55, + "line": "" + }, + { + "component": "Rect", + "lineNumber": 82, + "line": "" + }, + { + "example": "basic", + "component": "Elk", + "path": "/docs/components/Elk/basic", + "lineNumber": 50, + "line": " d.links} {...settings}>" + }, + { + "example": "cluster", + "component": "Elk", + "path": "/docs/components/Elk/cluster", + "lineNumber": 50, + "line": " d.links} {...settings}>" + }, + { + "example": "erd", + "component": "Elk", + "path": "/docs/components/Elk/erd", + "lineNumber": 93, + "line": "" + }, + { + "example": "flowchart", + "component": "Elk", + "path": "/docs/components/Elk/flowchart", + "lineNumber": 78, + "line": "" + }, + { + "example": "force", + "component": "Elk", + "path": "/docs/components/Elk/force", + "lineNumber": 49, + "line": " d.links} {...settings}>" + }, + { + "example": "mrtree", + "component": "Elk", + "path": "/docs/components/Elk/mrtree", + "lineNumber": 49, + "line": " d.links} {...settings}>" + }, + { + "example": "oauth-flow", + "component": "Elk", + "path": "/docs/components/Elk/oauth-flow", + "lineNumber": 82, + "line": "" + }, + { + "example": "playground", + "component": "Elk", + "path": "/docs/components/Elk/playground", + "lineNumber": 52, + "line": "" + }, + { + "example": "state-composite", + "component": "Elk", + "path": "/docs/components/Elk/state-composite", + "lineNumber": 74, + "line": "" + }, + { + "example": "state-machine", + "component": "Elk", + "path": "/docs/components/Elk/state-machine", + "lineNumber": 72, + "line": "" + }, + { + "example": "stress", + "component": "Elk", + "path": "/docs/components/Elk/stress", + "lineNumber": 49, + "line": " d.links} {...settings}>" + } + ], + "updatedAt": "2026-05-01T03:17:48.379Z" +} \ No newline at end of file diff --git a/docs/src/examples/catalog/Group.json b/docs/src/examples/catalog/Group.json index 6e73fb46f..b5a4e440d 100644 --- a/docs/src/examples/catalog/Group.json +++ b/docs/src/examples/catalog/Group.json @@ -189,6 +189,125 @@ "lineNumber": 79, "line": "" }, + { + "example": "architecture", + "component": "Elk", + "path": "/docs/components/Elk/architecture", + "lineNumber": 104, + "line": "" + }, + { + "example": "basic", + "component": "Elk", + "path": "/docs/components/Elk/basic", + "lineNumber": 68, + "line": "" + }, + { + "example": "cluster", + "component": "Elk", + "path": "/docs/components/Elk/cluster", + "lineNumber": 68, + "line": "" + }, + { + "example": "erd", + "component": "Elk", + "path": "/docs/components/Elk/erd", + "lineNumber": 124, + "line": "" + }, + { + "example": "flowchart", + "component": "Elk", + "path": "/docs/components/Elk/flowchart", + "lineNumber": 113, + "line": "" + }, + { + "example": "force", + "component": "Elk", + "path": "/docs/components/Elk/force", + "lineNumber": 72, + "line": "" + }, + { + "example": "layered", + "component": "Elk", + "path": "/docs/components/Elk/layered", + "lineNumber": 81, + "line": "" + }, + { + "example": "mrtree", + "component": "Elk", + "path": "/docs/components/Elk/mrtree", + "lineNumber": 67, + "line": "" + }, + { + "example": "oauth-flow", + "component": "Elk", + "path": "/docs/components/Elk/oauth-flow", + "lineNumber": 138, + "line": "" + }, + { + "example": "playground", + "component": "Elk", + "path": "/docs/components/Elk/playground", + "lineNumber": 84, + "line": "" + }, + { + "example": "radial", + "component": "Elk", + "path": "/docs/components/Elk/radial", + "lineNumber": 88, + "line": "" + }, + { + "example": "sequence-diagram", + "component": "Elk", + "path": "/docs/components/Elk/sequence-diagram", + "lineNumber": 101, + "line": "" + }, + { + "example": "state-composite", + "component": "Elk", + "path": "/docs/components/Elk/state-composite", + "lineNumber": 106, + "line": "" + }, + { + "example": "state-composite", + "component": "Elk", + "path": "/docs/components/Elk/state-composite", + "lineNumber": 115, + "line": "" + }, + { + "example": "state-machine", + "component": "Elk", + "path": "/docs/components/Elk/state-machine", + "lineNumber": 103, + "line": "" + }, + { + "example": "stress", + "component": "Elk", + "path": "/docs/components/Elk/stress", + "lineNumber": 72, + "line": "" + }, + { + "example": "tcp-state-diagram", + "component": "Elk", + "path": "/docs/components/Elk/tcp-state-diagram", + "lineNumber": 81, + "line": "" + }, { "example": "collision-detection", "component": "ForceSimulation", @@ -393,5 +512,5 @@ "line": "" }, + { + "example": "architecture", + "component": "Elk", + "path": "/docs/components/Elk/architecture", + "lineNumber": 85, + "line": "" + }, + { + "example": "basic", + "component": "Elk", + "path": "/docs/components/Elk/basic", + "lineNumber": 49, + "line": "" + }, + { + "example": "cluster", + "component": "Elk", + "path": "/docs/components/Elk/cluster", + "lineNumber": 49, + "line": "" + }, + { + "example": "erd", + "component": "Elk", + "path": "/docs/components/Elk/erd", + "lineNumber": 92, + "line": "" + }, + { + "example": "flowchart", + "component": "Elk", + "path": "/docs/components/Elk/flowchart", + "lineNumber": 77, + "line": "" + }, + { + "example": "force", + "component": "Elk", + "path": "/docs/components/Elk/force", + "lineNumber": 48, + "line": "" + }, + { + "example": "layered", + "component": "Elk", + "path": "/docs/components/Elk/layered", + "lineNumber": 50, + "line": "" + }, + { + "example": "mrtree", + "component": "Elk", + "path": "/docs/components/Elk/mrtree", + "lineNumber": 48, + "line": "" + }, + { + "example": "oauth-flow", + "component": "Elk", + "path": "/docs/components/Elk/oauth-flow", + "lineNumber": 81, + "line": "" + }, + { + "example": "playground", + "component": "Elk", + "path": "/docs/components/Elk/playground", + "lineNumber": 51, + "line": "" + }, + { + "example": "radial", + "component": "Elk", + "path": "/docs/components/Elk/radial", + "lineNumber": 64, + "line": "" + }, + { + "example": "sequence-diagram", + "component": "Elk", + "path": "/docs/components/Elk/sequence-diagram", + "lineNumber": 70, + "line": "" + }, + { + "example": "state-composite", + "component": "Elk", + "path": "/docs/components/Elk/state-composite", + "lineNumber": 73, + "line": "" + }, + { + "example": "state-machine", + "component": "Elk", + "path": "/docs/components/Elk/state-machine", + "lineNumber": 71, + "line": "" + }, + { + "example": "stress", + "component": "Elk", + "path": "/docs/components/Elk/stress", + "lineNumber": 48, + "line": "" + }, + { + "example": "tcp-state-diagram", + "component": "Elk", + "path": "/docs/components/Elk/tcp-state-diagram", + "lineNumber": 50, + "line": "" + }, { "example": "color-via-ordinal-scale", "component": "Ellipse", @@ -4217,5 +4329,5 @@ "line": "" } ], - "updatedAt": "2026-04-30T15:36:26.177Z" + "updatedAt": "2026-05-01T03:17:49.711Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/Line.json b/docs/src/examples/catalog/Line.json index 7b25a3a83..f7d1a1c15 100644 --- a/docs/src/examples/catalog/Line.json +++ b/docs/src/examples/catalog/Line.json @@ -283,6 +283,13 @@ "lineNumber": 67, "line": "" }, + { + "example": "oauth-flow", + "component": "Elk", + "path": "/docs/components/Elk/oauth-flow", + "lineNumber": 91, + "line": "" } ], - "updatedAt": "2026-04-30T15:36:26.239Z" + "updatedAt": "2026-05-01T03:17:49.836Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/Rect.json b/docs/src/examples/catalog/Rect.json index 691aa1d3f..f92f4d43f 100644 --- a/docs/src/examples/catalog/Rect.json +++ b/docs/src/examples/catalog/Rect.json @@ -275,6 +275,104 @@ "lineNumber": 80, "line": "" } ], - "updatedAt": "2026-04-30T15:36:28.237Z" + "updatedAt": "2026-05-01T03:17:51.151Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/Text.json b/docs/src/examples/catalog/Text.json index e16541d56..a01208c1e 100644 --- a/docs/src/examples/catalog/Text.json +++ b/docs/src/examples/catalog/Text.json @@ -577,6 +577,181 @@ "lineNumber": 91, "line": " + import type { ComponentProps } from 'svelte'; + import { curveLinear } from 'd3-shape'; + import { cubicOut } from 'svelte/easing'; + import { slide } from 'svelte/transition'; + import { cls } from '@layerstack/tailwind'; + import { Chart, Group, Layer, Rect, Spline, Text } from 'layerchart'; + import { Elk } from 'layerchart/graph'; + import ElkControls from '$lib/components/controls/ElkControls.svelte'; + import ElkSettingsControls from '$lib/components/controls/ElkSettingsControls.svelte'; + import TransformContextControls from '$lib/components/controls/TransformContextControls.svelte'; + + // System architecture grouped by tier (compound nodes) + const data = { + nodes: [ + { id: 'edge', label: 'Edge' }, + { id: 'cdn', parent: 'edge', label: 'CDN' }, + { id: 'waf', parent: 'edge', label: 'WAF' }, + + { id: 'web', label: 'Web tier' }, + { id: 'spa', parent: 'web', label: 'SPA' }, + { id: 'ssr', parent: 'web', label: 'SSR' }, + + { id: 'svc', label: 'Services' }, + { id: 'auth', parent: 'svc', label: 'Auth' }, + { id: 'orders', parent: 'svc', label: 'Orders' }, + { id: 'billing', parent: 'svc', label: 'Billing' }, + + { id: 'data', label: 'Data' }, + { id: 'pg', parent: 'data', label: 'Postgres' }, + { id: 'redis', parent: 'data', label: 'Redis' }, + { id: 's3', parent: 'data', label: 'Object store' } + ], + edges: [ + { source: 'cdn', target: 'spa' }, + { source: 'cdn', target: 'ssr' }, + { source: 'waf', target: 'spa' }, + { source: 'waf', target: 'ssr' }, + { source: 'spa', target: 'auth' }, + { source: 'spa', target: 'orders' }, + { source: 'ssr', target: 'auth' }, + { source: 'ssr', target: 'orders' }, + { source: 'orders', target: 'billing' }, + { source: 'auth', target: 'pg' }, + { source: 'auth', target: 'redis' }, + { source: 'orders', target: 'pg' }, + { source: 'billing', target: 'pg' }, + { source: 'billing', target: 's3' } + ] + }; + + let settings = $state({ + algorithm: 'layered', + direction: 'right', + edgeRouting: 'orthogonal', + nodePlacementStrategy: 'network-simplex', + hierarchyHandling: 'include-children', + nodeNodeSpacing: 20, + edgeEdgeSpacing: 10, + edgeNodeSpacing: 20, + layerSpacing: 90, + componentSpacing: 40, + separateConnectedComponents: true, + curve: curveLinear, + arrow: 'arrow' + }) satisfies ComponentProps['settings']; + + let showSettings = $state(false); + + + + +
+ + + + + + {#snippet children({ nodes, edges })} + + {#each edges as edge (edge.id)} + + {/each} + + + + {#each nodes as node (node.id)} + + + + + + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/examples/components/Elk/basic.svelte b/docs/src/examples/components/Elk/basic.svelte new file mode 100644 index 000000000..57f8e23e7 --- /dev/null +++ b/docs/src/examples/components/Elk/basic.svelte @@ -0,0 +1,98 @@ + + + + +
+ + + + + d.links} {...settings}> + {#snippet children({ nodes, edges })} + + {#each edges as edge (edge.id)} + + {/each} + + + + {#each nodes as node (node.id)} + + + + + + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/examples/components/Elk/cluster.svelte b/docs/src/examples/components/Elk/cluster.svelte new file mode 100644 index 000000000..d5cd4b42c --- /dev/null +++ b/docs/src/examples/components/Elk/cluster.svelte @@ -0,0 +1,108 @@ + + + + +
+ + + + + d.links} {...settings}> + {#snippet children({ nodes, edges })} + + {#each edges as edge (edge.id)} + + {/each} + + + + {#each nodes as node (node.id)} + + + + + + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/examples/components/Elk/erd.svelte b/docs/src/examples/components/Elk/erd.svelte new file mode 100644 index 000000000..b6df11054 --- /dev/null +++ b/docs/src/examples/components/Elk/erd.svelte @@ -0,0 +1,172 @@ + + + + +
+ + + + + + {#snippet children({ nodes, edges })} + + {#each edges as edge (edge.id)} + + + {#if edge.label} + + {/if} + {/each} + + + + {#each nodes as node (node.id)} + {@const entity = entities.find((e) => e.id === node.id)!} + + + + + + + + {#each entity.fields as field, i} + + {/each} + + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/examples/components/Elk/flowchart.svelte b/docs/src/examples/components/Elk/flowchart.svelte new file mode 100644 index 000000000..eb7ba2d8a --- /dev/null +++ b/docs/src/examples/components/Elk/flowchart.svelte @@ -0,0 +1,150 @@ + + + + +
+ + + + + + {#snippet children({ nodes, edges })} + + {#each edges as edge (edge.id)} + + + {#if edge.label} + + {/if} + {/each} + + + + {#each nodes as node (node.id)} + {@const meta = data.nodes.find((n) => n.id === node.id)} + + + + + + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/examples/components/Elk/force.svelte b/docs/src/examples/components/Elk/force.svelte new file mode 100644 index 000000000..15c37e50c --- /dev/null +++ b/docs/src/examples/components/Elk/force.svelte @@ -0,0 +1,94 @@ + + + + +
+ + + + + d.links} + nodeWidth={20} + nodeHeight={20} + {...settings} + > + {#snippet children({ nodes, edges })} + + {#each edges as edge (edge.id)} + + {/each} + + + + {#each nodes as node (node.id)} + + + + + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/examples/components/Elk/layered.svelte b/docs/src/examples/components/Elk/layered.svelte new file mode 100644 index 000000000..fe755c3fb --- /dev/null +++ b/docs/src/examples/components/Elk/layered.svelte @@ -0,0 +1,111 @@ + + + + +
+ + + + + d.links} {...settings}> + {#snippet children({ nodes, edges })} + + {#each edges as edge (edge.id)} + + + {#if edge.label} + + {/if} + {/each} + + + + {#each nodes as node (node.id)} + + + + + + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/examples/components/Elk/mrtree.svelte b/docs/src/examples/components/Elk/mrtree.svelte new file mode 100644 index 000000000..e2be9e0e5 --- /dev/null +++ b/docs/src/examples/components/Elk/mrtree.svelte @@ -0,0 +1,97 @@ + + + + +
+ + + + + d.links} {...settings}> + {#snippet children({ nodes, edges })} + + {#each edges as edge (edge.id)} + + {/each} + + + + {#each nodes as node (node.id)} + + + + + + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/examples/components/Elk/oauth-flow.svelte b/docs/src/examples/components/Elk/oauth-flow.svelte new file mode 100644 index 000000000..499dd0ae5 --- /dev/null +++ b/docs/src/examples/components/Elk/oauth-flow.svelte @@ -0,0 +1,167 @@ + + + + +
+ + + + + + {#snippet children({ nodes })} + {@const byId = new Map(nodes.map((n) => [n.id, n]))} + {@const headerBottom = nodes.length ? nodes[0].y0 + nodes[0].height : 0} + {@const firstMessageY = headerBottom + lifelineTopGap} + {@const lifelineEndY = firstMessageY + messages.length * messageGap + lifelineBottomGap} + + + {#each nodes as node (node.id)} + + {/each} + + + + {#each messages as msg, i (i)} + {@const from = byId.get(msg.from)} + {@const to = byId.get(msg.to)} + {#if from && to} + {@const y = firstMessageY + i * messageGap} + {@const isResponse = msg.kind === 'response'} + + + {/if} + {/each} + + + + {#each nodes as node (node.id)} + + + + + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/examples/components/Elk/playground.svelte b/docs/src/examples/components/Elk/playground.svelte new file mode 100644 index 000000000..120989ac3 --- /dev/null +++ b/docs/src/examples/components/Elk/playground.svelte @@ -0,0 +1,119 @@ + + + + +
+ + + + + d.links} + algorithm={settings.algorithm} + direction={settings.direction} + edgeRouting={settings.edgeRouting} + nodePlacementStrategy={settings.nodePlacementStrategy} + hierarchyHandling={settings.hierarchyHandling} + nodeNodeSpacing={settings.nodeNodeSpacing} + edgeEdgeSpacing={settings.edgeEdgeSpacing} + edgeNodeSpacing={settings.edgeNodeSpacing} + layerSpacing={settings.layerSpacing} + componentSpacing={settings.componentSpacing} + separateConnectedComponents={settings.separateConnectedComponents} + > + {#snippet children({ nodes, edges })} + + {#each edges as edge (edge.id)} + + {/each} + + + + {#each nodes as node (node.id)} + + + + + + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/examples/components/Elk/radial.svelte b/docs/src/examples/components/Elk/radial.svelte new file mode 100644 index 000000000..da3abc190 --- /dev/null +++ b/docs/src/examples/components/Elk/radial.svelte @@ -0,0 +1,110 @@ + + + + +
+ + + + + d.links} + nodeWidth={30} + nodeHeight={30} + {...settings} + > + {#snippet children({ nodes, edges })} + + {#each edges as edge (edge.id)} + + {/each} + + + + {#each nodes as node (node.id)} + + + + + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/examples/components/Elk/sequence-diagram.svelte b/docs/src/examples/components/Elk/sequence-diagram.svelte new file mode 100644 index 000000000..6d19d7dc9 --- /dev/null +++ b/docs/src/examples/components/Elk/sequence-diagram.svelte @@ -0,0 +1,131 @@ + + + + +
+ + + + + + {#snippet children({ nodes, edges })} + + {#each edges as edge (edge.id)} + + + {#if edge.label} + + {/if} + {/each} + + + + {#each nodes as node (node.id)} + + + + + + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/examples/components/Elk/state-composite.svelte b/docs/src/examples/components/Elk/state-composite.svelte new file mode 100644 index 000000000..db42d16ea --- /dev/null +++ b/docs/src/examples/components/Elk/state-composite.svelte @@ -0,0 +1,156 @@ + + + + +
+ + + + + + {#snippet children({ nodes, edges })} + + {#each edges as edge (edge.id)} + + + {#if edge.label} + + {/if} + {/each} + + + + {#each nodes as node (node.id)} + {@const meta = data.nodes.find((n) => n.id === node.id) as any} + {#if meta?.kind === 'marker'} + + {#if node.id === 'start'} + + {:else} + + + {/if} + + {:else} + + + + + + {/if} + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/examples/components/Elk/state-machine.svelte b/docs/src/examples/components/Elk/state-machine.svelte new file mode 100644 index 000000000..75c66172a --- /dev/null +++ b/docs/src/examples/components/Elk/state-machine.svelte @@ -0,0 +1,138 @@ + + + + +
+ + + + + + {#snippet children({ nodes, edges })} + + {#each edges as edge (edge.id)} + + + {#if edge.label} + + {/if} + {/each} + + + + {#each nodes as node (node.id)} + {@const meta = data.nodes.find((n) => n.id === node.id) as any} + + + + + + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/examples/components/Elk/stress.svelte b/docs/src/examples/components/Elk/stress.svelte new file mode 100644 index 000000000..ae8276be5 --- /dev/null +++ b/docs/src/examples/components/Elk/stress.svelte @@ -0,0 +1,94 @@ + + + + +
+ + + + + d.links} + nodeWidth={24} + nodeHeight={24} + {...settings} + > + {#snippet children({ nodes, edges })} + + {#each edges as edge (edge.id)} + + {/each} + + + + {#each nodes as node (node.id)} + + + + + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/examples/components/Elk/tcp-state-diagram.svelte b/docs/src/examples/components/Elk/tcp-state-diagram.svelte new file mode 100644 index 000000000..a24187d14 --- /dev/null +++ b/docs/src/examples/components/Elk/tcp-state-diagram.svelte @@ -0,0 +1,115 @@ + + + + +
+ + + + + d.links} {...settings}> + {#snippet children({ nodes, edges })} + + {#each edges as edge (edge.id)} + + + {#if edge.label} + + {/if} + {/each} + + + + {#each nodes as node (node.id)} + + + + + + {/each} + + {/snippet} + + + + + {#if showSettings} +
+ +
+ {/if} +
diff --git a/docs/src/lib/components/controls/ElkControls.svelte b/docs/src/lib/components/controls/ElkControls.svelte new file mode 100644 index 000000000..3a465367f --- /dev/null +++ b/docs/src/lib/components/controls/ElkControls.svelte @@ -0,0 +1,145 @@ + + +
+ + + + + + + + + + + + + + + + + + +
diff --git a/docs/src/lib/components/controls/ElkPlaygroundControls.svelte b/docs/src/lib/components/controls/ElkPlaygroundControls.svelte new file mode 100644 index 000000000..78d2401b6 --- /dev/null +++ b/docs/src/lib/components/controls/ElkPlaygroundControls.svelte @@ -0,0 +1,42 @@ + + +
+ + + + (showSettings = !showSettings)} + {id} + size="md" + /> + +
diff --git a/docs/src/lib/components/controls/ElkSettingsControls.svelte b/docs/src/lib/components/controls/ElkSettingsControls.svelte new file mode 100644 index 000000000..5b95779d6 --- /dev/null +++ b/docs/src/lib/components/controls/ElkSettingsControls.svelte @@ -0,0 +1,20 @@ + + +
+ + (showSettings = !showSettings)} + {id} + size="md" + /> + +
diff --git a/packages/layerchart/package.json b/packages/layerchart/package.json index 984e25522..5ea3d6a4f 100644 --- a/packages/layerchart/package.json +++ b/packages/layerchart/package.json @@ -101,6 +101,7 @@ "d3-shape": "^3.2.0", "d3-tile": "^1.0.0", "d3-time": "^3.1.0", + "elkjs": "^0.11.1", "memoize": "^10.2.0", "runed": "^0.37.1" }, diff --git a/packages/layerchart/src/lib/components/graph/Elk.svelte b/packages/layerchart/src/lib/components/graph/Elk.svelte new file mode 100644 index 000000000..1d21028b5 --- /dev/null +++ b/packages/layerchart/src/lib/components/graph/Elk.svelte @@ -0,0 +1,272 @@ + + + + +{@render children?.({ nodes: graphNodes, edges: graphEdges, graph, loading })} diff --git a/packages/layerchart/src/lib/components/graph/index.ts b/packages/layerchart/src/lib/components/graph/index.ts index fc97fbd22..ae6d8d449 100644 --- a/packages/layerchart/src/lib/components/graph/index.ts +++ b/packages/layerchart/src/lib/components/graph/index.ts @@ -2,6 +2,8 @@ export { default as Chord } from './Chord.svelte'; export * from './Chord.svelte'; export { default as Dagre } from './Dagre.svelte'; export * from './Dagre.svelte'; +export { default as Elk } from './Elk.svelte'; +export * from './Elk.svelte'; export { default as Ribbon } from './Ribbon/Ribbon.svelte'; export * from './Ribbon/Ribbon.svelte'; export { default as Sankey } from './Sankey.svelte'; diff --git a/packages/layerchart/src/lib/utils/graph/elk.ts b/packages/layerchart/src/lib/utils/graph/elk.ts new file mode 100644 index 000000000..fddf45210 --- /dev/null +++ b/packages/layerchart/src/lib/utils/graph/elk.ts @@ -0,0 +1,463 @@ +import * as ElkLib from 'elkjs/lib/elk.bundled.js'; +import type { + ELK as ElkInstance, + ElkNode, + ElkExtendedEdge, + ElkPoint, + LayoutOptions, + ELKConstructorArguments, +} from 'elkjs/lib/elk.bundled.js'; + +// elkjs exposes its constructor as a CJS default; with NodeNext typings +// the default may live under `.default` or be the module itself. +const ElkConstructor = (ElkLib as unknown as { + default?: { new (args?: ELKConstructorArguments): ElkInstance }; +}).default ?? (ElkLib as unknown as { new (args?: ELKConstructorArguments): ElkInstance }); + +import { + Algorithm, + Direction, + EdgeRouting, + HierarchyHandling, + NodePlacementStrategy, + type ElkProps, +} from '$lib/components/graph/Elk.svelte'; + +/** Lazy ELK instance (created on first use, must run client-side) */ +let _elk: ElkInstance | undefined; +function getElk(): ElkInstance { + if (!_elk) { + _elk = new ElkConstructor(); + } + return _elk; +} + +export type ElkInputNode = { + id: string; + parent?: string; + label?: string; + width?: number; + height?: number; + layoutOptions?: LayoutOptions; +}; + +export type ElkInputEdge = { + id?: string; + source: string; + target: string; + label?: string; +}; + +export type ElkInputData = { + nodes: ElkInputNode[]; + edges: ElkInputEdge[]; +}; + +/** Flat node returned from layout (absolute coordinates, x/y are center) */ +export type ElkLayoutNode = { + id: string; + label: string; + x: number; + y: number; + width: number; + height: number; + parent?: string; + /** True when this node has children (i.e. is a compound/cluster container) */ + isCompound: boolean; + /** absolute top-left x */ + x0: number; + /** absolute top-left y */ + y0: number; + /** raw ELK node */ + raw: ElkNode; +}; + +/** Flat edge returned from layout */ +export type ElkLayoutEdge = { + id: string; + /** source node id (matches `v` from Dagre for compatibility) */ + v: string; + /** target node id (matches `w` from Dagre for compatibility) */ + w: string; + source: string; + target: string; + label?: string; + /** Polyline points (absolute) — start, bend points, end. Empty if elk did not produce sections. */ + points: Array; + /** Optional label center (absolute) */ + x?: number; + y?: number; + /** raw ELK edge */ + raw: ElkExtendedEdge; +}; + +export type ElkLayoutResult = { + nodes: ElkLayoutNode[]; + edges: ElkLayoutEdge[]; + /** Raw root ELK graph after layout */ + graph: ElkNode; +}; + +/** + * Build the ELK input graph from a flat `{ nodes, edges }` representation. + * Nodes with a `parent` reference are nested under their parent (compound layout). + */ +export function buildElkInput( + data: ElkInputData, + { + nodes = (d: any) => d.nodes, + nodeId = (d: any) => d.id, + edges = (d: any) => d.edges, + nodeWidth = 100, + nodeHeight = 50, + layoutOptions = {}, + }: { + nodes?: ElkProps['nodes']; + nodeId?: ElkProps['nodeId']; + edges?: ElkProps['edges']; + nodeWidth?: number; + nodeHeight?: number; + layoutOptions?: LayoutOptions; + } +): ElkNode { + const dataNodes: ElkInputNode[] = nodes(data) ?? []; + const dataEdges: ElkInputEdge[] = edges(data) ?? []; + + const elkNodes = new Map(); + for (const n of dataNodes) { + const id = nodeId(n); + const label = typeof n.label === 'string' ? n.label : id; + elkNodes.set(id, { + id, + width: n.width ?? nodeWidth, + height: n.height ?? nodeHeight, + labels: [{ text: label }], + layoutOptions: n.layoutOptions, + children: [], + edges: [], + }); + } + + const root: ElkNode = { + id: 'root', + layoutOptions, + children: [], + edges: [], + }; + + // Nest nodes under their parent (or root) + for (const n of dataNodes) { + const id = nodeId(n); + const elkNode = elkNodes.get(id)!; + const parent = n.parent && elkNodes.has(n.parent) ? elkNodes.get(n.parent)! : root; + parent.children!.push(elkNode); + } + + // We deliberately do NOT auto-add padding or label-placement options on compound + // nodes — those interfere with ELK's edge routing for sibling/internal edges. + // Users render compound labels in their own snippet (typically above the box, + // using the `isCompound` flag we expose on each node). + + // Build a parent-id lookup so we can find the lowest common ancestor of an edge's + // source and target nodes. ELK requires each edge to live on its LCA when using + // `hierarchyHandling=INCLUDE_CHILDREN`; placing them all at the root produces + // garbage coordinates for sibling edges inside a compound. + const parentOf = new Map(); + for (const n of dataNodes) { + parentOf.set(nodeId(n), n.parent); + } + function ancestors(id: string): string[] { + const chain: string[] = []; + let cur: string | undefined = id; + while (cur) { + chain.push(cur); + cur = parentOf.get(cur); + } + return chain; + } + function lcaContainer(source: string, target: string): ElkNode { + const aChain = ancestors(source); + const bSet = new Set(ancestors(target)); + // Skip the source itself; its enclosing parent is the first candidate. + for (let i = 1; i < aChain.length; i++) { + if (bSet.has(aChain[i])) { + const node = elkNodes.get(aChain[i]); + if (node) return node; + } + } + return root; + } + + let edgeIdx = 0; + for (const e of dataEdges) { + const id = e.id ?? `e${edgeIdx++}`; + const edge: ElkExtendedEdge = { + id, + sources: [e.source], + targets: [e.target], + }; + if (e.label) { + edge.labels = [{ text: e.label, width: 60, height: 16 }]; + } + const container = lcaContainer(e.source, e.target); + container.edges!.push(edge); + } + + return root; +} + +/** + * Algorithms that only place nodes — they either return no edge `sections` or + * return sections whose endpoints don't actually connect source to target. + * For these we synthesize straight center-to-center polylines instead of + * trusting ELK's edge output. + */ +const NODE_ONLY_ALGORITHMS = new Set([ + 'org.eclipse.elk.random', + 'org.eclipse.elk.box', + 'org.eclipse.elk.rectpacking', + 'org.eclipse.elk.fixed', + 'org.eclipse.elk.sporeOverlap', + 'org.eclipse.elk.sporeCompaction', +]); + +/** + * Flatten ELK's hierarchical output into absolute-coordinate nodes/edges. + */ +export function processElkLayout(graph: ElkNode): ElkLayoutResult { + const nodes: ElkLayoutNode[] = []; + const edges: ElkLayoutEdge[] = []; + + const algorithm = graph.layoutOptions?.['elk.algorithm']; + const skipSections = algorithm ? NODE_ONLY_ALGORITHMS.has(algorithm) : false; + + function walkNodes(node: ElkNode, parentX: number, parentY: number, parentId?: string) { + for (const child of node.children ?? []) { + const x0 = parentX + (child.x ?? 0); + const y0 = parentY + (child.y ?? 0); + const width = child.width ?? 0; + const height = child.height ?? 0; + const label = child.labels?.[0]?.text ?? child.id; + + nodes.push({ + id: child.id, + label, + x: x0 + width / 2, + y: y0 + height / 2, + width, + height, + x0, + y0, + parent: parentId, + isCompound: (child.children?.length ?? 0) > 0, + raw: child, + }); + + walkNodes(child, x0, y0, child.id); + } + } + + function walkEdges(node: ElkNode, parentX: number, parentY: number) { + for (const edge of (node.edges ?? []) as ElkExtendedEdge[]) { + const points: ElkPoint[] = []; + const sections = skipSections ? [] : edge.sections ?? []; + for (const section of sections) { + if (points.length === 0) { + points.push({ + x: parentX + section.startPoint.x, + y: parentY + section.startPoint.y, + }); + } + for (const p of section.bendPoints ?? []) { + points.push({ x: parentX + p.x, y: parentY + p.y }); + } + points.push({ + x: parentX + section.endPoint.x, + y: parentY + section.endPoint.y, + }); + } + + const labelShape = edge.labels?.[0]; + const labelX = + labelShape?.x != null + ? parentX + labelShape.x + (labelShape.width ?? 0) / 2 + : undefined; + const labelY = + labelShape?.y != null + ? parentY + labelShape.y + (labelShape.height ?? 0) / 2 + : undefined; + + edges.push({ + id: edge.id, + v: edge.sources[0], + w: edge.targets[0], + source: edge.sources[0], + target: edge.targets[0], + label: labelShape?.text, + points, + x: labelX, + y: labelY, + raw: edge, + }); + } + for (const child of node.children ?? []) { + walkEdges(child, parentX + (child.x ?? 0), parentY + (child.y ?? 0)); + } + } + + walkNodes(graph, 0, 0, undefined); + walkEdges(graph, 0, 0); + + // For node-only algorithms (and any other case where ELK didn't return + // section data) synthesize a straight line between source and target centers. + let nodesById: Map | undefined; + for (const edge of edges) { + if (edge.points.length > 0) continue; + nodesById ??= new Map(nodes.map((n) => [n.id, n])); + const source = nodesById.get(edge.source); + const target = nodesById.get(edge.target); + if (source && target) { + edge.points = [ + { x: source.x, y: source.y }, + { x: target.x, y: target.y }, + ]; + } + } + + return { nodes, edges, graph }; +} + +/** + * Resolve all `ElkProps` knobs into an ELK `layoutOptions` object. + */ +export function elkLayoutOptions({ + algorithm = 'layered', + direction = 'right', + edgeRouting, + hierarchyHandling, + nodePlacementStrategy, + nodeNodeSpacing, + edgeEdgeSpacing, + edgeNodeSpacing, + layerSpacing, + componentSpacing, + aspectRatio, + separateConnectedComponents, + padding, + layoutOptions = {}, +}: { + algorithm?: ElkProps['algorithm']; + direction?: ElkProps['direction']; + edgeRouting?: ElkProps['edgeRouting']; + hierarchyHandling?: ElkProps['hierarchyHandling']; + nodePlacementStrategy?: ElkProps['nodePlacementStrategy']; + nodeNodeSpacing?: number; + edgeEdgeSpacing?: number; + edgeNodeSpacing?: number; + layerSpacing?: number; + componentSpacing?: number; + aspectRatio?: number; + separateConnectedComponents?: boolean; + padding?: number | string; + layoutOptions?: LayoutOptions; +} = {}): LayoutOptions { + const opts: LayoutOptions = { + 'elk.algorithm': Algorithm[algorithm ?? 'layered'] ?? Algorithm.layered, + 'elk.direction': Direction[direction ?? 'right'] ?? Direction.right, + }; + + if (edgeRouting) { + opts['elk.edgeRouting'] = EdgeRouting[edgeRouting]; + } + if (hierarchyHandling) { + opts['elk.hierarchyHandling'] = HierarchyHandling[hierarchyHandling]; + } + if (nodePlacementStrategy) { + opts['elk.layered.nodePlacement.strategy'] = NodePlacementStrategy[nodePlacementStrategy]; + } + if (nodeNodeSpacing != null) { + opts['elk.spacing.nodeNode'] = String(nodeNodeSpacing); + } + if (edgeEdgeSpacing != null) { + opts['elk.spacing.edgeEdge'] = String(edgeEdgeSpacing); + } + if (edgeNodeSpacing != null) { + opts['elk.spacing.edgeNode'] = String(edgeNodeSpacing); + } + if (layerSpacing != null) { + opts['elk.layered.spacing.nodeNodeBetweenLayers'] = String(layerSpacing); + } + if (componentSpacing != null) { + opts['elk.spacing.componentComponent'] = String(componentSpacing); + } + if (aspectRatio != null) { + opts['elk.aspectRatio'] = String(aspectRatio); + } + if (separateConnectedComponents != null) { + opts['elk.separateConnectedComponents'] = String(separateConnectedComponents); + } + if (padding != null) { + opts['elk.padding'] = + typeof padding === 'number' ? `[top=${padding},left=${padding},bottom=${padding},right=${padding}]` : padding; + } + + return { ...opts, ...layoutOptions }; +} + +/** + * Run ELK layout on the given data and return flattened nodes/edges. + */ +export async function elkLayout( + data: ElkInputData, + props: { + nodes?: ElkProps['nodes']; + nodeId?: ElkProps['nodeId']; + edges?: ElkProps['edges']; + nodeWidth?: number; + nodeHeight?: number; + } & Parameters[0] = {} +): Promise { + const { nodes, nodeId, edges, nodeWidth, nodeHeight, ...optsArgs } = props; + const layoutOptions = elkLayoutOptions(optsArgs); + const input = buildElkInput(data, { + nodes, + nodeId, + edges, + nodeWidth, + nodeHeight, + layoutOptions, + }); + const elk = getElk(); + const result = await elk.layout(input); + return processElkLayout(result); +} + +/** Get all upstream predecessor ids for a node */ +export function elkAncestors( + edges: ElkLayoutEdge[], + nodeId: string, + maxDepth = Infinity, + currentDepth = 0 +): string[] { + if (currentDepth === maxDepth) return []; + const predecessors = edges.filter((e) => e.w === nodeId).map((e) => e.v); + return [ + ...predecessors, + ...predecessors.flatMap((id) => elkAncestors(edges, id, maxDepth, currentDepth + 1)), + ]; +} + +/** Get all downstream descendant ids for a node */ +export function elkDescendants( + edges: ElkLayoutEdge[], + nodeId: string, + maxDepth = Infinity, + currentDepth = 0 +): string[] { + if (currentDepth === maxDepth) return []; + const successors = edges.filter((e) => e.v === nodeId).map((e) => e.w); + return [ + ...successors, + ...successors.flatMap((id) => elkDescendants(edges, id, maxDepth, currentDepth + 1)), + ]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbcf99253..02dde2945 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -831,6 +831,9 @@ importers: d3-time: specifier: ^3.1.0 version: 3.1.0 + elkjs: + specifier: ^0.11.1 + version: 0.11.1 memoize: specifier: ^10.2.0 version: 10.2.0 @@ -3781,6 +3784,9 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + emoji-regex-xs@2.0.1: resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} engines: {node: '>=10.0.0'} @@ -8904,6 +8910,8 @@ snapshots: duplexer@0.1.2: {} + elkjs@0.11.1: {} + emoji-regex-xs@2.0.1: {} emoji-regex@8.0.0: {} From 654d606c77329f12417be8aaf16746961bb5a2dc Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Fri, 1 May 2026 07:44:00 -0400 Subject: [PATCH 2/2] Add Elk bundle scenario --- bundle-analyzer/bundle-scenarios.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bundle-analyzer/bundle-scenarios.ts b/bundle-analyzer/bundle-scenarios.ts index da93cfcd8..c99c90747 100644 --- a/bundle-analyzer/bundle-scenarios.ts +++ b/bundle-analyzer/bundle-scenarios.ts @@ -409,6 +409,12 @@ export const scenarios: Scenario[] = [ description: 'Dagre directed graph layout', imports: ['Chart', 'Svg', 'Dagre', 'Link', 'Circle', 'Text'], }, + { + name: 'elk', + group: 'Graph / network', + description: 'Elk directed graph layout', + imports: ['Chart', 'Svg', 'Elk', 'Spline', 'Rect', 'Group', 'Text'], + }, { name: 'sankey', group: 'Graph / network', @@ -1776,6 +1782,7 @@ const INDIVIDUAL_COMPONENTS: string[] = [ 'Contour', 'Dagre', 'Density', + 'Elk', 'Ellipse', 'ForceSimulation', 'Frame',