diff --git a/bun.lock b/bun.lock
index 7def5be..471ba9e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -9,7 +9,6 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
- "@dagrejs/dagre": "^3.1.1",
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-avatar": "^1.1.11",
@@ -38,6 +37,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
+ "elkjs": "^0.12.0",
"form-data": "4.0.5",
"lucide-react": "^0.562.0",
"monaco-editor": "^0.56.0",
@@ -131,10 +131,6 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.12", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="],
- "@dagrejs/dagre": ["@dagrejs/dagre@3.1.1", "", { "dependencies": { "@dagrejs/graphlib": "4.0.5" } }, "sha512-zroZB1dFOFiGgv4Xcrn1DckB1o4aOikPqD2NDQPV0WM//CXGcS6xiD0rNkqHmw6FEg4tabt4nxPLwgCWT+Vb2A=="],
-
- "@dagrejs/graphlib": ["@dagrejs/graphlib@4.0.5", "", {}, "sha512-7xrBTqIts3o+PMUZX97wSc+7TUbW+/rULzGNCTP6yooNVDXbzw4Wutg/H/xOutTB/c/k0YqOAavgPh4/Zk9PFA=="],
-
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
@@ -633,6 +629,8 @@
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
+ "elkjs": ["elkjs@0.12.0", "", {}, "sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ=="],
+
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
diff --git a/frontend/package.json b/frontend/package.json
index 749702f..2df65ad 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -15,7 +15,6 @@
"test:ui": "bunx playwright test --ui"
},
"dependencies": {
- "@dagrejs/dagre": "^3.1.1",
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-avatar": "^1.1.11",
@@ -44,6 +43,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
+ "elkjs": "^0.12.0",
"form-data": "4.0.5",
"lucide-react": "^0.562.0",
"monaco-editor": "^0.56.0",
diff --git a/frontend/src/components/Flow/EndpointNode.tsx b/frontend/src/components/Flow/EndpointNode.tsx
index fa4461c..849d866 100644
--- a/frontend/src/components/Flow/EndpointNode.tsx
+++ b/frontend/src/components/Flow/EndpointNode.tsx
@@ -5,6 +5,7 @@ import { memo } from "react"
import { useIsMobile } from "@/hooks/useMobile"
import { cn } from "@/lib/utils"
import type { EndpointNodeData } from "./endpoints"
+import { handleOffset, nodeHeight } from "./layout"
const KIND_ICONS = {
dashboard: LayoutDashboard,
@@ -28,8 +29,14 @@ function EndpointNodeComponent({ data, selected }: NodeProps) {
// Follows the graph's own direction; see DESIGN-GUIDELINES.md → Responsive.
const vertical = useIsMobile()
+ // A dashboard reading six messages has six edges, and stacking their dots on
+ // one point is what made them leave in a knot. Spread and sized the way a
+ // node's ports are, so the layout can reserve exactly this much.
+ const ports = Math.max(provides.length, requires.length)
+
return (
{/* Both sides always exist so an edge can attach; only one is used. */}
- {requires.map((message) => (
-
- ))}
- {provides.map((message) => (
-
- ))}
+ {requires.map((message, index) => {
+ const offset = handleOffset(index, requires.length)
+ return (
+
+ )
+ })}
+ {provides.map((message, index) => {
+ const offset = handleOffset(index, provides.length)
+ return (
+
+ )
+ })}
diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx
index c0b68d0..0ba0e06 100644
--- a/frontend/src/components/Flow/FlowEditor.tsx
+++ b/frontend/src/components/Flow/FlowEditor.tsx
@@ -2,6 +2,7 @@ import {
Background,
BackgroundVariant,
type Connection,
+ type Edge,
type EdgeChange,
type Node as FlowCanvasNode,
type NodeChange,
@@ -57,12 +58,17 @@ import {
} from "./deriveEdges"
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
import { EndpointNode } from "./EndpointNode"
-import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints"
+import {
+ deriveEndpoints,
+ ENDPOINT_TYPE,
+ type EndpointNodeData,
+ isEndpointNode,
+} from "./endpoints"
import { FIT_VIEW, FIT_VIEW_PANEL, FlowDock } from "./FlowDock"
import { FlowNode, type FlowNodeData } from "./FlowNode"
import { FlowPanel } from "./FlowPanel"
import { LiveEdge } from "./LiveEdge"
-import { type Direction, layoutGraph, nodeHeight } from "./layout"
+import { type Direction, layoutGraph, type NodeShape } from "./layout"
import { NodePanel } from "./NodePanel"
import { RunDialog } from "./RunDialog"
import "./flow.css"
@@ -80,6 +86,41 @@ import { useFlowSocket } from "./useFlowSocket"
const nodeTypes = { flow: FlowNode, [ENDPOINT_TYPE]: EndpointNode }
+/**
+ * Where every node sits, once the engine has said so.
+ *
+ * The layout is a chunk of its own and answers asynchronously, so a canvas has
+ * a moment before it knows where anything goes. It draws nothing at all until
+ * then — a route already shows a skeleton for that — and an *edit* keeps the
+ * arrangement it had, so only the node that changed lands a frame late rather
+ * than the whole graph flickering through the corner.
+ */
+function useLayout(
+ ids: string[],
+ edges: Edge[],
+ direction: Direction,
+ shapes: Map,
+ shapeKey: string,
+): Map | null {
+ const [placed, setPlaced] = useState