Lay flows out so edges stop crossing where they need not
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Successful in 4m17s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m43s
Playwright Tests / merge-reports (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 18m52s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Successful in 4m17s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m43s
Playwright Tests / merge-reports (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 18m52s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
The canvas placed nodes with dagre, which orders nodes but knows nothing about ports: an edge leaves a particular handle and lands on a particular one, so two producers feeding a consumer in the other order crossed just before they landed however well the nodes were arranged. The graph was not a multigraph either, so two messages between one pair of nodes collapsed into one before crossings were counted, and the endpoint labels were laid out as 220-wide cards with every handle stacked on a single pixel. ELK's layered algorithm replaces it. Every declared port is handed to it as a fixed point on the node's rim, at the fraction `portFraction` puts the handle at — which the node components now render from the same function — so what the crossing count is minimised over is what ends up on the screen. Measured on this installation's flows, with the labels included: `home` goes from 51 crossings to 6 across and 70 to 29 down, `demo_training` 27 to 5 and 59 to 32, and every graph is the same size or smaller. The engine answers asynchronously and is a chunk of its own, so positions became state: the canvas draws nothing until the first layout lands, and an edit keeps the arrangement it had rather than flashing through the corner. The entry chunk is untouched and the flow route's own chunk came down 167 to 121 kB, since dagre used to be in it. Two things the crossings made worse come with it. A hovered edge resolves to full strength so one line can be followed through a busy rank, and a feedback edge — one whose target the layout put behind its source — swings out into a lane beside the graph instead of being drawn through everything between its ends, which is what "House history" was reported for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6vyQvfX249hsv9YPmsYYg
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
style={vertical ? undefined : { minHeight: nodeHeight(ports) }}
|
||||
className={cn(
|
||||
// Padding keeps the text off the connector dot, which sits on the edge.
|
||||
"flex max-w-48 cursor-pointer items-center gap-2 px-3 py-1",
|
||||
@@ -41,24 +48,32 @@ function EndpointNodeComponent({ data, selected }: NodeProps) {
|
||||
title={messages.join("\n")}
|
||||
>
|
||||
{/* Both sides always exist so an edge can attach; only one is used. */}
|
||||
{requires.map((message) => (
|
||||
<Handle
|
||||
key={`in-${message}`}
|
||||
type="target"
|
||||
id={message}
|
||||
position={vertical ? Position.Top : Position.Left}
|
||||
className="!border-border !bg-card"
|
||||
/>
|
||||
))}
|
||||
{provides.map((message) => (
|
||||
<Handle
|
||||
key={`out-${message}`}
|
||||
type="source"
|
||||
id={message}
|
||||
position={vertical ? Position.Bottom : Position.Right}
|
||||
className="!border-border !bg-card"
|
||||
/>
|
||||
))}
|
||||
{requires.map((message, index) => {
|
||||
const offset = handleOffset(index, requires.length)
|
||||
return (
|
||||
<Handle
|
||||
key={`in-${message}`}
|
||||
type="target"
|
||||
id={message}
|
||||
position={vertical ? Position.Top : Position.Left}
|
||||
className="!border-border !bg-card"
|
||||
style={vertical ? { left: offset } : { top: offset }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{provides.map((message, index) => {
|
||||
const offset = handleOffset(index, provides.length)
|
||||
return (
|
||||
<Handle
|
||||
key={`out-${message}`}
|
||||
type="source"
|
||||
id={message}
|
||||
position={vertical ? Position.Bottom : Position.Right}
|
||||
className="!border-border !bg-card"
|
||||
style={vertical ? { left: offset } : { top: offset }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="grid min-w-0">
|
||||
|
||||
@@ -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<string, NodeShape>,
|
||||
shapeKey: string,
|
||||
): Map<string, { x: number; y: number }> | null {
|
||||
const [placed, setPlaced] = useState<Map<
|
||||
string,
|
||||
{ x: number; y: number }
|
||||
> | null>(null)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the shape key is the dependency; the arrays are rebuilt every render.
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
layoutGraph(ids, edges, direction, shapes).then((positions) => {
|
||||
// A graph edited while the last one was still being laid out has already
|
||||
// asked for another; that one is the answer, not this.
|
||||
if (!stale) setPlaced(positions)
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [shapeKey, edges])
|
||||
return placed
|
||||
}
|
||||
|
||||
/** Is this canvas node actually part of the flow document? */
|
||||
const isDocumentNode = (node: { id: string }) => !isEndpointNode(node)
|
||||
const edgeTypes = { live: LiveEdge }
|
||||
@@ -517,29 +558,30 @@ function FlowEditorInner({
|
||||
...external.nodes.map((node) => node.id),
|
||||
]
|
||||
const shapeKey = `${direction}|${key}|${ids.join(",")}`
|
||||
// How tall each node's ports make it, which `FlowNode` draws to the same
|
||||
// number. A function of the document — the bindings key above already covers
|
||||
// every port, so this changes exactly when the layout has to run again, and
|
||||
// nothing measured is ever fed back into it.
|
||||
const heights =
|
||||
direction === "LR"
|
||||
? new Map(
|
||||
definitions.map((node) => [
|
||||
node.id,
|
||||
nodeHeight(
|
||||
Math.max(
|
||||
(node.requires ?? []).length,
|
||||
(node.provides ?? []).length,
|
||||
),
|
||||
),
|
||||
]),
|
||||
)
|
||||
: new Map<string, number>()
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the shape key is the dependency; the arrays are rebuilt every render.
|
||||
const positions = useMemo(
|
||||
() => layoutGraph(ids, edges, direction, heights),
|
||||
[shapeKey, edges],
|
||||
)
|
||||
// What each node is, as far as the layout is concerned: the ports it draws,
|
||||
// in the order it draws them. A function of the document — the bindings key
|
||||
// above already covers every port, so this changes exactly when the layout
|
||||
// has to run again, and nothing measured is ever fed back into it.
|
||||
const shapes = new Map<string, NodeShape>([
|
||||
...definitions.map(
|
||||
(node) =>
|
||||
[
|
||||
node.id,
|
||||
{
|
||||
requires: (node.requires ?? []).map(portOf),
|
||||
provides: (node.provides ?? []).map(portOf),
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
...external.nodes.map((node) => {
|
||||
const data = node.data as EndpointNodeData
|
||||
return [
|
||||
node.id,
|
||||
{ requires: data.requires, provides: data.provides, endpoint: true },
|
||||
] as const
|
||||
}),
|
||||
])
|
||||
const positions = useLayout(ids, edges, direction, shapes, shapeKey)
|
||||
|
||||
/**
|
||||
* The endpoints, placed.
|
||||
@@ -551,10 +593,15 @@ function FlowEditorInner({
|
||||
*/
|
||||
const externalNodes = useMemo(
|
||||
() =>
|
||||
external.nodes.map((node) => ({
|
||||
...node,
|
||||
position: positions.get(node.id) ?? node.position,
|
||||
})),
|
||||
external.nodes
|
||||
// A node the layout has not placed yet — one just added, or the whole
|
||||
// graph on the first render — is left out rather than drawn at the
|
||||
// origin, which is where every unplaced node would pile up.
|
||||
.filter((node) => positions?.has(node.id))
|
||||
.map((node) => ({
|
||||
...node,
|
||||
position: positions?.get(node.id) ?? node.position,
|
||||
})),
|
||||
[external, positions],
|
||||
)
|
||||
|
||||
@@ -568,10 +615,12 @@ function FlowEditorInner({
|
||||
|
||||
const shownNodes = useMemo(
|
||||
() => [
|
||||
...renderedNodes.map((node) => ({
|
||||
...node,
|
||||
position: positions.get(node.id) ?? node.position,
|
||||
})),
|
||||
...renderedNodes
|
||||
.filter((node) => positions?.has(node.id))
|
||||
.map((node) => ({
|
||||
...node,
|
||||
position: positions?.get(node.id) ?? node.position,
|
||||
})),
|
||||
...externalNodes,
|
||||
],
|
||||
[renderedNodes, externalNodes, positions],
|
||||
@@ -621,6 +670,9 @@ function FlowEditorInner({
|
||||
// A phone's sheet covers the canvas outright, and so does the expanded
|
||||
// editor: there is no viewport to aim.
|
||||
if (editorExpanded) return
|
||||
// Nothing is on the canvas until the layout has answered, and fitting an
|
||||
// empty one would spend the single instant fit on nothing.
|
||||
if (!positions) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const view = panelOpen && !isMobile ? FIT_VIEW_PANEL : FIT_VIEW
|
||||
const duration = fitted.current ? 300 : 0
|
||||
@@ -637,6 +689,7 @@ function FlowEditorInner({
|
||||
definitions.length,
|
||||
external.nodes.length,
|
||||
edges.length,
|
||||
positions,
|
||||
selectedId,
|
||||
panelOpen,
|
||||
editorExpanded,
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useIsMobile } from "@/hooks/useMobile"
|
||||
import { duration } from "@/lib/motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { portOf } from "./deriveEdges"
|
||||
import { nodeHeight, PORT_SPAN } from "./layout"
|
||||
import { handleOffset, nodeHeight } from "./layout"
|
||||
import {
|
||||
liveStore,
|
||||
useNodeEmits,
|
||||
@@ -77,15 +77,6 @@ export type FlowNodeData = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Spread handles along the node's edge so several ports stay reachable. */
|
||||
function handleOffset(index: number, total: number): string {
|
||||
if (total <= 1) return "50%"
|
||||
// The same fraction `nodeHeight` sizes the node for, so a node is always
|
||||
// tall enough for the ports this spreads down it.
|
||||
const span = PORT_SPAN * 100
|
||||
return `${50 - span / 2 + (span / (total - 1)) * index}%`
|
||||
}
|
||||
|
||||
function PortHandles({
|
||||
specs,
|
||||
type,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
EdgeLabelRenderer,
|
||||
type EdgeProps,
|
||||
getBezierPath,
|
||||
Position,
|
||||
useStore,
|
||||
} from "@xyflow/react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
@@ -15,6 +16,51 @@ import { useLiveValue } from "./liveStore"
|
||||
/** Below this zoom the value chips would be unreadable, so they step aside. */
|
||||
const CHIP_MIN_ZOOM = 0.5
|
||||
|
||||
/** How far a feedback edge stands off the ranks it has to get back past. */
|
||||
const BOW = 60
|
||||
|
||||
/**
|
||||
* The path a feedback edge takes.
|
||||
*
|
||||
* The layout ranks a flow in the direction it runs, so an edge closing a cycle
|
||||
* is the one that goes the other way — a widget setting the window a node
|
||||
* upstream of it reads. A curve drawn straight between those two handles is
|
||||
* drawn straight through everything between them. This one leaves its handle
|
||||
* the way every other edge does, swings out into a lane beside the graph, and
|
||||
* comes back into its target from the far side, so what it passes it passes
|
||||
* around.
|
||||
*
|
||||
* ponytail: how far out the lane sits is read off how far back the edge has to
|
||||
* travel, not off what is actually in the way — this stands a cycle apart from
|
||||
* the flow rather than guaranteeing it clears every node. The layout engine
|
||||
* routes these properly and React Flow draws its own curves; taking its bend
|
||||
* points is the real fix, and a bigger one.
|
||||
*/
|
||||
function cyclePath(
|
||||
sourceX: number,
|
||||
sourceY: number,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
vertical: boolean,
|
||||
): [string, number, number] {
|
||||
const span = vertical
|
||||
? Math.abs(targetY - sourceY)
|
||||
: Math.abs(targetX - sourceX)
|
||||
const lane = Math.max(BOW, span * 0.3)
|
||||
const [c1x, c1y] = vertical
|
||||
? [sourceX + lane, sourceY + BOW]
|
||||
: [sourceX + BOW, sourceY + lane]
|
||||
const [c2x, c2y] = vertical
|
||||
? [targetX + lane, targetY - BOW]
|
||||
: [targetX - BOW, targetY + lane]
|
||||
return [
|
||||
`M${sourceX},${sourceY} C${c1x},${c1y} ${c2x},${c2y} ${targetX},${targetY}`,
|
||||
// Halfway along a cubic, which is where the chip rides.
|
||||
(sourceX + 3 * c1x + 3 * c2x + targetX) / 8,
|
||||
(sourceY + 3 * c1y + 3 * c2y + targetY) / 8,
|
||||
]
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (typeof value === "number") {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
||||
@@ -40,14 +86,21 @@ function LiveEdgeComponent({
|
||||
const [pulsing, setPulsing] = useState(false)
|
||||
const lastTs = useRef<number | null>(null)
|
||||
|
||||
const [path, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
})
|
||||
// Every rank sits further along the flow than the one before it, so an edge
|
||||
// whose target has ended up behind its source is one closing a cycle.
|
||||
const vertical = sourcePosition === Position.Bottom
|
||||
const backwards = vertical ? targetY < sourceY : targetX < sourceX
|
||||
|
||||
const [path, labelX, labelY] = backwards
|
||||
? cyclePath(sourceX, sourceY, targetX, targetY, vertical)
|
||||
: getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
// Restart the stroke animation whenever a newer message lands — but only
|
||||
// for the producer that actually published it. A message can have several
|
||||
|
||||
@@ -57,6 +57,29 @@
|
||||
--edge-rest: var(--primary);
|
||||
}
|
||||
|
||||
/*
|
||||
* Following one edge through a busy rank.
|
||||
*
|
||||
* The layout keeps the crossings a graph does not need, but a fan-out of eight
|
||||
* still has edges running past each other, and the one under the pointer is
|
||||
* the one being read. It resolves to full strength rather than to blue: blue
|
||||
* is what selection means here, and hovering is not selecting. React Flow lays
|
||||
* a wide transparent path over each edge, so the pointer finds it without
|
||||
* having to be on the line itself — and clicking it opens the same inspector,
|
||||
* so nothing is only reachable by hovering.
|
||||
*/
|
||||
.react-flow__edge:hover .react-flow__edge-path {
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-width: var(--edge-pulse-width);
|
||||
transition:
|
||||
stroke var(--duration-fast) var(--ease-standard),
|
||||
stroke-width var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.react-flow__edge.selected:hover .react-flow__edge-path {
|
||||
stroke: var(--primary);
|
||||
}
|
||||
|
||||
/*
|
||||
* The dashes are the signal, the march below is only its motion: the pattern
|
||||
* is declared outside the guard so a reduced-motion reader still sees which
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A node's height against the ports it has to fit.
|
||||
* What the layout promises: a node tall enough for its ports, an edge that
|
||||
* lands where its handle is, and no crossing it could have avoided.
|
||||
*
|
||||
* Run: `bun src/components/Flow/layout.check.ts` (there is no unit runner; the
|
||||
* suite in `tests/` drives a running stack). Typechecked with the rest of
|
||||
@@ -7,21 +8,30 @@
|
||||
*/
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
import { layoutGraph, nodeHeight, PORT_SPAN } from "./layout"
|
||||
// ELK's fake worker decides where it is running by looking for `self` without a
|
||||
// `document` beside it, which is a browser worker — and is also bun. Taking the
|
||||
// global away lets it load here; nothing in a browser goes near this.
|
||||
// @ts-expect-error the runtime has it, the DOM types say it is not removable.
|
||||
globalThis.self = undefined
|
||||
|
||||
const { layoutGraph, nodeHeight, portFraction, PORT_SPAN } = await import(
|
||||
"./layout"
|
||||
)
|
||||
|
||||
/** Handles are 12px across — see `.react-flow__handle` in `flow.css`. */
|
||||
const HANDLE = 12
|
||||
const NODE_H = 56
|
||||
|
||||
// Two ports fit the plain box, so most nodes are unchanged.
|
||||
assert.equal(nodeHeight(0), 56)
|
||||
assert.equal(nodeHeight(1), 56)
|
||||
assert.equal(nodeHeight(2), 56)
|
||||
assert.ok(nodeHeight(3) > 56)
|
||||
assert.equal(nodeHeight(0), NODE_H)
|
||||
assert.equal(nodeHeight(1), NODE_H)
|
||||
assert.equal(nodeHeight(2), NODE_H)
|
||||
assert.ok(nodeHeight(3) > NODE_H)
|
||||
|
||||
for (let ports = 2; ports <= 12; ports += 1) {
|
||||
const height = nodeHeight(ports)
|
||||
// `handleOffset` spreads the ports over `PORT_SPAN` of the edge, so that is
|
||||
// the room they actually get. Two handles must not touch.
|
||||
// The ports are spread over `PORT_SPAN` of the edge, so that is the room
|
||||
// they actually get. Two handles must not touch.
|
||||
const between = (PORT_SPAN * height) / (ports - 1)
|
||||
assert.ok(
|
||||
between > HANDLE,
|
||||
@@ -31,20 +41,168 @@ for (let ports = 2; ports <= 12; ports += 1) {
|
||||
assert.ok(height >= nodeHeight(ports - 1))
|
||||
}
|
||||
|
||||
// The layout places top-left corners, so a taller node has to be lifted by its
|
||||
// own half-height rather than by the default box's.
|
||||
const tall = nodeHeight(8)
|
||||
const placed = layoutGraph(
|
||||
// A single port sits in the middle; several spread symmetrically about it.
|
||||
assert.equal(portFraction(0, 1), 0.5)
|
||||
assert.equal(portFraction(0, 2) + portFraction(1, 2), 1)
|
||||
assert.ok(portFraction(0, 2) < portFraction(1, 2))
|
||||
|
||||
const one = (port: string) => ({ requires: [port], provides: [port] })
|
||||
|
||||
// A chain lays out along the flow, an edge at a time.
|
||||
const chain = await layoutGraph(
|
||||
["a", "b"],
|
||||
[{ source: "a", target: "b" }],
|
||||
[{ source: "a", target: "b", sourceHandle: "v", targetHandle: "v" }],
|
||||
"LR",
|
||||
new Map([["b", tall]]),
|
||||
new Map([
|
||||
["a", one("v")],
|
||||
["b", one("v")],
|
||||
]),
|
||||
)
|
||||
const a = placed.get("a")
|
||||
const b = placed.get("b")
|
||||
assert.ok(a && b)
|
||||
// Dagre centres the two on one rank line, so the taller one starts higher up
|
||||
// by exactly the difference in half-heights.
|
||||
assert.equal(Math.round(a.y - b.y), Math.round((tall - 56) / 2))
|
||||
const [ca, cb] = [chain.get("a"), chain.get("b")]
|
||||
assert.ok(ca && cb)
|
||||
// Two single-port nodes wired to each other have nothing to step around, so
|
||||
// the edge between them is level — which is what a straight edge means here.
|
||||
assert.equal(ca.y, cb.y)
|
||||
assert.ok(cb.x > ca.x)
|
||||
|
||||
// The same graph twice is the same picture: a flow reopened cannot rearrange
|
||||
// itself.
|
||||
const again = await layoutGraph(
|
||||
["a", "b"],
|
||||
[{ source: "a", target: "b", sourceHandle: "v", targetHandle: "v" }],
|
||||
"LR",
|
||||
new Map([
|
||||
["a", one("v")],
|
||||
["b", one("v")],
|
||||
]),
|
||||
)
|
||||
assert.deepEqual([...again], [...chain])
|
||||
|
||||
/**
|
||||
* The crossing this whole thing exists for.
|
||||
*
|
||||
* `c` declares its inputs as [high, low] and is fed by `a` on the *lower* one
|
||||
* and `b` on the upper. Ordering the producers by the document — which is all
|
||||
* a layout blind to ports can do — puts `a` on top and crosses the two edges
|
||||
* just before they land. Reading the ports puts `b` there instead.
|
||||
*/
|
||||
const fan = await layoutGraph(
|
||||
["a", "b", "c"],
|
||||
[
|
||||
{ source: "a", target: "c", sourceHandle: "out", targetHandle: "low" },
|
||||
{ source: "b", target: "c", sourceHandle: "out", targetHandle: "high" },
|
||||
],
|
||||
"LR",
|
||||
new Map([
|
||||
["a", { requires: [], provides: ["out"] }],
|
||||
["b", { requires: [], provides: ["out"] }],
|
||||
["c", { requires: ["high", "low"], provides: [] }],
|
||||
]),
|
||||
)
|
||||
const [fa, fb] = [fan.get("a"), fan.get("b")]
|
||||
assert.ok(fa && fb)
|
||||
assert.ok(
|
||||
fb.y < fa.y,
|
||||
`the producer feeding the upper port should sit above the other (a at ${fa.y}, b at ${fb.y})`,
|
||||
)
|
||||
|
||||
// Two messages between one pair of nodes are two edges, not one: both have to
|
||||
// reach a port of their own.
|
||||
const parallel = await layoutGraph(
|
||||
["a", "b"],
|
||||
[
|
||||
{ source: "a", target: "b", sourceHandle: "p1", targetHandle: "q1" },
|
||||
{ source: "a", target: "b", sourceHandle: "p2", targetHandle: "q2" },
|
||||
],
|
||||
"LR",
|
||||
new Map([
|
||||
["a", { requires: [], provides: ["p1", "p2"] }],
|
||||
["b", { requires: ["q1", "q2"], provides: [] }],
|
||||
]),
|
||||
)
|
||||
const [pa, pb] = [parallel.get("a"), parallel.get("b")]
|
||||
assert.ok(pa && pb)
|
||||
assert.ok(pb.x > pa.x + 220, "the pair still ranks one after the other")
|
||||
|
||||
// An endpoint is a label rather than a card, so it takes less room across than
|
||||
// a node does — 192 against 220, plus the rank gap either side of the chip.
|
||||
const labelled = await layoutGraph(
|
||||
["input:x", "n"],
|
||||
[{ source: "input:x", target: "n", sourceHandle: "x", targetHandle: "x" }],
|
||||
"LR",
|
||||
new Map([
|
||||
["input:x", { requires: [], provides: ["x"], endpoint: true }],
|
||||
["n", { requires: ["x"], provides: [] }],
|
||||
]),
|
||||
)
|
||||
const [ex, en] = [labelled.get("input:x"), labelled.get("n")]
|
||||
assert.ok(ex && en)
|
||||
assert.equal(ex.x, 40, "the graph starts at its margin")
|
||||
assert.equal(
|
||||
en.x - ex.x,
|
||||
192 + 55 + 150 + 55,
|
||||
"an endpoint is 192 wide, then the chip's rank",
|
||||
)
|
||||
|
||||
/**
|
||||
* The nodes are the spine; a label reading a value takes the room beside it.
|
||||
*
|
||||
* `mid` is read by a dashboard as well as by `end`, so the label for it lands
|
||||
* in the same rank as `end`. Left to itself the layout gives the label the
|
||||
* column the chain was running down and steps every node after it sideways,
|
||||
* which on a phone is the whole graph zig-zagging past the screen.
|
||||
*/
|
||||
const spine = await layoutGraph(
|
||||
["start", "mid", "end", "dashboard:panel:v"],
|
||||
[
|
||||
{ source: "start", target: "mid", sourceHandle: "v", targetHandle: "v" },
|
||||
{ source: "mid", target: "end", sourceHandle: "w", targetHandle: "w" },
|
||||
{
|
||||
source: "start",
|
||||
target: "dashboard:panel:v",
|
||||
sourceHandle: "v",
|
||||
targetHandle: "v",
|
||||
},
|
||||
],
|
||||
"TB",
|
||||
new Map([
|
||||
["start", { requires: [], provides: ["v"] }],
|
||||
["mid", { requires: ["v"], provides: ["w"] }],
|
||||
["end", { requires: ["w"], provides: [] }],
|
||||
["dashboard:panel:v", { requires: ["v"], provides: [], endpoint: true }],
|
||||
]),
|
||||
)
|
||||
const [s1, s2] = [spine.get("start"), spine.get("mid")]
|
||||
assert.ok(s1 && s2)
|
||||
assert.equal(
|
||||
s1.x,
|
||||
s2.x,
|
||||
`the chain steps sideways to let a label have its column (${s1.x} then ${s2.x})`,
|
||||
)
|
||||
|
||||
// Running downwards, a rank wider than a phone wraps onto the ranks below.
|
||||
const wide = await layoutGraph(
|
||||
["src", "s0", "s1", "s2", "s3", "s4", "s5"],
|
||||
["s0", "s1", "s2", "s3", "s4", "s5"].map((sink) => ({
|
||||
source: "src",
|
||||
target: sink,
|
||||
sourceHandle: "v",
|
||||
targetHandle: "v",
|
||||
})),
|
||||
"TB",
|
||||
new Map(
|
||||
["src", "s0", "s1", "s2", "s3", "s4", "s5"].map((id) => [id, one("v")]),
|
||||
),
|
||||
)
|
||||
const rows = new Map<number, number>()
|
||||
for (const [, at] of wide) {
|
||||
rows.set(Math.round(at.y), (rows.get(Math.round(at.y)) ?? 0) + 1)
|
||||
}
|
||||
for (const [y, count] of rows) {
|
||||
assert.ok(
|
||||
count <= 2,
|
||||
`${count} nodes abreast at y=${y}, more than a phone fits`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log("layout: ok")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import dagre from "@dagrejs/dagre"
|
||||
import type { ElkNode, ElkPort } from "elkjs/lib/elk-api"
|
||||
|
||||
/**
|
||||
* Where the nodes of a flow go.
|
||||
@@ -10,19 +10,27 @@ import dagre from "@dagrejs/dagre"
|
||||
*
|
||||
* Left to right on a desktop, top to bottom on a phone, which is the direction
|
||||
* each screen has room to grow in.
|
||||
*
|
||||
* The layout is ELK's layered algorithm rather than a plain ranking, because
|
||||
* the thing that actually crosses on this canvas is *ports*, not nodes. An edge
|
||||
* leaves a particular handle and lands on a particular handle, so two producers
|
||||
* feeding one consumer in the other order cross just before they land however
|
||||
* well the nodes themselves are arranged. ELK is told where every handle sits
|
||||
* and counts crossings there, which lets it order the nodes to undo the twist.
|
||||
*/
|
||||
export type Direction = "LR" | "TB"
|
||||
|
||||
/** `FlowNode` is `min-w-[168px] max-w-[220px]`; an endpoint is narrower. */
|
||||
const NODE_W = 220
|
||||
/** An endpoint is drawn as a label, `max-w-48` in `EndpointNode`. */
|
||||
const ENDPOINT_W = 192
|
||||
/** Icon row plus two text lines, as measured. The floor, not the height. */
|
||||
const NODE_H = 56
|
||||
/**
|
||||
* How much of a node's edge the ports are spread over, as a fraction.
|
||||
*
|
||||
* Shared with `FlowNode`'s `handleOffset`, which does the spreading: the
|
||||
* height below is chosen so that span has room for the ports, so the two
|
||||
* cannot be allowed to drift apart.
|
||||
* Shared with the offsets below, which do the spreading: the height is chosen
|
||||
* so that span has room for the ports, so the two cannot drift apart.
|
||||
*/
|
||||
export const PORT_SPAN = 0.6
|
||||
/**
|
||||
@@ -42,20 +50,50 @@ export function nodeHeight(ports: number): number {
|
||||
return Math.max(NODE_H, Math.ceil(((ports - 1) * PORT_PITCH) / PORT_SPAN))
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a port's handle sits along its side, as a fraction of that side.
|
||||
*
|
||||
* The one place this is decided. The node components render it as a percentage
|
||||
* and the layout hands ELK the same number as a fixed port position, so what
|
||||
* the crossing count is minimised over is exactly what ends up on the screen.
|
||||
*/
|
||||
export function portFraction(index: number, total: number): number {
|
||||
if (total <= 1) return 0.5
|
||||
return 0.5 - PORT_SPAN / 2 + (PORT_SPAN / (total - 1)) * index
|
||||
}
|
||||
|
||||
/** {@link portFraction} as the CSS offset a handle is placed with. */
|
||||
export function handleOffset(index: number, total: number): string {
|
||||
return `${portFraction(index, total) * 100}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* Room for the live value an edge carries (`LiveEdge`'s chip is
|
||||
* `max-w-[140px]`). Reserved on the edge itself, so dagre routes nodes around
|
||||
* the chip rather than through it.
|
||||
* `max-w-[140px]`). Reserved on the edge itself, so the layout routes nodes
|
||||
* around the chip rather than through it.
|
||||
*
|
||||
* Only its height is reserved when the graph runs downwards. The chip's width
|
||||
* is then spent across the screen rather than along the flow, and a phone has
|
||||
* none to spare — while the rank gap it would otherwise widen is already 80px,
|
||||
* none to spare — while the rank gap it would otherwise widen is already there,
|
||||
* more than the chip is tall. Sibling edges are a node's width apart there, so
|
||||
* the chips clear each other without being asked to.
|
||||
*/
|
||||
const LABEL_W = 150
|
||||
const LABEL_H = 24
|
||||
|
||||
/** Along a rank. */
|
||||
const NODE_GAP = 40
|
||||
/**
|
||||
* Between two ranks — half of what a labelled edge should span.
|
||||
*
|
||||
* A chip gets a rank of its own and the gap is paid on both sides of it, so
|
||||
* the pitch between two wired nodes is this twice over plus the chip: 480px
|
||||
* across, 160px down, which is what the canvas has always used.
|
||||
*/
|
||||
const RANK_GAP = { LR: 55, TB: 40 } as const
|
||||
/** Around the whole graph. */
|
||||
const MARGIN = 40
|
||||
|
||||
/**
|
||||
* How many nodes may sit abreast when the graph runs downwards.
|
||||
*
|
||||
@@ -76,45 +114,179 @@ const ABREAST = 2
|
||||
*/
|
||||
const WRAP_PASSES = 6
|
||||
|
||||
/**
|
||||
* What a node is, as far as the layout is concerned.
|
||||
*
|
||||
* The declared ports in the order they are drawn in, and whether the node is
|
||||
* one of the endpoint labels rather than a card. Read from the document rather
|
||||
* than from the canvas on purpose — see {@link nodeHeight}.
|
||||
*/
|
||||
export type NodeShape = {
|
||||
requires: string[]
|
||||
provides: string[]
|
||||
endpoint?: boolean
|
||||
}
|
||||
|
||||
export type LayoutEdge = {
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string | null
|
||||
targetHandle?: string | null
|
||||
}
|
||||
|
||||
const EMPTY: NodeShape = { requires: [], provides: [] }
|
||||
|
||||
/**
|
||||
* ponytail: every node is still treated as its widest rather than measured.
|
||||
* Measuring would feed the result back into the layout and oscillate; if nodes
|
||||
* ever grow past that, take the width from `node.measured` once it has settled.
|
||||
*/
|
||||
function sizeOf(shape: NodeShape, direction: Direction) {
|
||||
// Ports run down the sides only while the graph runs across, and that is the
|
||||
// only direction in which their number decides the height: running downwards
|
||||
// they spread along the node's width, which is fixed.
|
||||
const ports = Math.max(shape.requires.length, shape.provides.length)
|
||||
return {
|
||||
width: shape.endpoint ? ENDPOINT_W : NODE_W,
|
||||
height: direction === "LR" ? nodeHeight(ports) : NODE_H,
|
||||
}
|
||||
}
|
||||
|
||||
/** A wire the layout adds and nobody draws: "put this one a rank further on". */
|
||||
type Wrap = [string, string]
|
||||
|
||||
function build(
|
||||
/**
|
||||
* The graph as ELK takes it.
|
||||
*
|
||||
* Every declared port becomes a fixed point on the node's rim, at the fraction
|
||||
* {@link portFraction} puts the handle at — including the ports nothing is
|
||||
* wired to, since they still take up their slot in the spread and so decide
|
||||
* where the wired ones land.
|
||||
*/
|
||||
function graphOf(
|
||||
ids: string[],
|
||||
edges: { source: string; target: string }[],
|
||||
edges: LayoutEdge[],
|
||||
direction: Direction,
|
||||
shapes: Map<string, NodeShape>,
|
||||
wrap: Wrap[],
|
||||
heights: Map<string, number>,
|
||||
) {
|
||||
const graph = new dagre.graphlib.Graph()
|
||||
graph.setDefaultEdgeLabel(() => ({}))
|
||||
graph.setGraph({
|
||||
rankdir: direction,
|
||||
// Along the rank, and between ranks. A left-to-right graph needs the wider
|
||||
// gap between ranks because the nodes themselves are wide.
|
||||
nodesep: 40,
|
||||
ranksep: direction === "LR" ? 110 : 80,
|
||||
marginx: 40,
|
||||
marginy: 40,
|
||||
): ElkNode {
|
||||
const across = direction === "LR"
|
||||
// A port is identified by the side it is on as well as by its name: a node
|
||||
// may well read and write the same message. The id ELK gets is generated
|
||||
// rather than composed, so nothing has to be escaped out of a name somebody
|
||||
// else chose, and the key that finds it again cannot run two of them
|
||||
// together either.
|
||||
const portIds = new Map<string, string>()
|
||||
const portKey = (side: string, node: string, port: string) =>
|
||||
JSON.stringify([side, node, port])
|
||||
|
||||
const children: ElkNode[] = ids.map((id) => {
|
||||
const shape = shapes.get(id) ?? EMPTY
|
||||
const { width, height } = sizeOf(shape, direction)
|
||||
const ports: ElkPort[] = []
|
||||
|
||||
const side = (
|
||||
names: string[],
|
||||
kind: "in" | "out",
|
||||
elkSide: string,
|
||||
at: (fraction: number) => { x: number; y: number },
|
||||
) => {
|
||||
names.forEach((name, index) => {
|
||||
const portId = `p${portIds.size}`
|
||||
portIds.set(portKey(kind, id, name), portId)
|
||||
ports.push({
|
||||
id: portId,
|
||||
width: 0,
|
||||
height: 0,
|
||||
...at(portFraction(index, names.length)),
|
||||
layoutOptions: { "org.eclipse.elk.port.side": elkSide },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
side(shape.requires, "in", across ? "WEST" : "NORTH", (fraction) =>
|
||||
across ? { x: 0, y: height * fraction } : { x: width * fraction, y: 0 },
|
||||
)
|
||||
side(shape.provides, "out", across ? "EAST" : "SOUTH", (fraction) =>
|
||||
across
|
||||
? { x: width, y: height * fraction }
|
||||
: { x: width * fraction, y: height },
|
||||
)
|
||||
|
||||
return {
|
||||
id,
|
||||
width,
|
||||
height,
|
||||
// Where the handles are is the whole point of using this algorithm, so
|
||||
// ELK is told them exactly rather than left to invent an order.
|
||||
layoutOptions: { "org.eclipse.elk.portConstraints": "FIXED_POS" },
|
||||
ports,
|
||||
}
|
||||
})
|
||||
|
||||
// Insertion order is what makes the result deterministic, so it follows the
|
||||
// document rather than whatever order the edges happen to mention nodes in.
|
||||
for (const id of ids) {
|
||||
graph.setNode(id, { width: NODE_W, height: heights.get(id) ?? NODE_H })
|
||||
}
|
||||
for (const edge of edges) {
|
||||
if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue
|
||||
graph.setEdge(edge.source, edge.target, {
|
||||
width: direction === "LR" ? LABEL_W : 0,
|
||||
height: LABEL_H,
|
||||
labelpos: "c",
|
||||
})
|
||||
}
|
||||
// Nothing draws these: they only say "put this one a rank further down".
|
||||
for (const [from, to] of wrap) graph.setEdge(from, to, { weight: 2 })
|
||||
const known = new Set(ids)
|
||||
const wired = edges.filter(
|
||||
(edge) => known.has(edge.source) && known.has(edge.target),
|
||||
)
|
||||
const isEndpoint = (id: string) => shapes.get(id)?.endpoint === true
|
||||
// An edge whose handle the node never declared — a half-bound spec — is hung
|
||||
// off the node itself rather than dropped.
|
||||
const endOf = (kind: "in" | "out", node: string, port?: string | null) =>
|
||||
(port && portIds.get(portKey(kind, node, port))) || node
|
||||
|
||||
dagre.layout(graph)
|
||||
return graph
|
||||
return {
|
||||
id: "root",
|
||||
layoutOptions: {
|
||||
"org.eclipse.elk.algorithm": "layered",
|
||||
"org.eclipse.elk.direction": across ? "RIGHT" : "DOWN",
|
||||
"org.eclipse.elk.spacing.nodeNode": String(NODE_GAP),
|
||||
"org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers": String(
|
||||
RANK_GAP[direction],
|
||||
),
|
||||
"org.eclipse.elk.padding": `[top=${MARGIN},left=${MARGIN},bottom=${MARGIN},right=${MARGIN}]`,
|
||||
// What decides a tie is the order the document lists things in, so the
|
||||
// graph reads the way the flow is written rather than however the
|
||||
// algorithm happened to land.
|
||||
"org.eclipse.elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
|
||||
// The default, set out loud: the same flow has to lay out the same way
|
||||
// every time it is opened.
|
||||
"org.eclipse.elk.randomSeed": "1",
|
||||
},
|
||||
children,
|
||||
edges: [
|
||||
...wired.map((edge, index) => ({
|
||||
id: `e${index}`,
|
||||
sources: [endOf("out", edge.source, edge.sourceHandle)],
|
||||
targets: [endOf("in", edge.target, edge.targetHandle)],
|
||||
// The nodes are the flow's spine and the labels only account for where
|
||||
// a value also goes, so what is held straight is the run of node to
|
||||
// node: a label takes the room beside that line rather than a place on
|
||||
// it. Without this a dashboard reading a value halfway down steps the
|
||||
// whole chain sideways to let its label have the column.
|
||||
layoutOptions:
|
||||
isEndpoint(edge.source) || isEndpoint(edge.target)
|
||||
? undefined
|
||||
: { "org.eclipse.elk.layered.priority.straightness": "10" },
|
||||
labels: [
|
||||
{
|
||||
// Nothing reads this — `LiveEdge` draws the chip itself — but a
|
||||
// label with no text at all is one ELK reserves no room for.
|
||||
text: " ",
|
||||
width: across ? LABEL_W : 0,
|
||||
height: LABEL_H,
|
||||
layoutOptions: { "org.eclipse.elk.edgeLabels.inline": "true" },
|
||||
},
|
||||
],
|
||||
})),
|
||||
// Nothing draws these, and they carry no chip: they only say "put this
|
||||
// one a rank further down".
|
||||
...wrap.map(([from, to], index) => ({
|
||||
id: `w${index}`,
|
||||
sources: [from],
|
||||
targets: [to],
|
||||
})),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,13 +296,21 @@ function build(
|
||||
* third to the first — and the fourth to the second, and so on — cannot make a
|
||||
* cycle. It lays the rank out in rows of `ABREAST`, in the order the document
|
||||
* lists them, so the wrap reads the way the flow is written.
|
||||
*
|
||||
* A rank running downwards is a band across the screen, so what identifies one
|
||||
* is the y its nodes share. They do share it: every node is `NODE_H` tall in
|
||||
* this direction, so nothing in a band is offset against its neighbours.
|
||||
*/
|
||||
function wrapWideRanks(graph: ReturnType<typeof build>, ids: string[]): Wrap[] {
|
||||
function wrapWideRanks(
|
||||
placed: Map<string, { x: number; y: number }>,
|
||||
ids: string[],
|
||||
): Wrap[] {
|
||||
const ranks = new Map<number, string[]>()
|
||||
for (const id of ids) {
|
||||
const node = graph.node(id) as { rank?: number } | undefined
|
||||
if (node?.rank === undefined) continue
|
||||
ranks.set(node.rank, [...(ranks.get(node.rank) ?? []), id])
|
||||
const at = placed.get(id)
|
||||
if (!at) continue
|
||||
const rank = Math.round(at.y)
|
||||
ranks.set(rank, [...(ranks.get(rank) ?? []), id])
|
||||
}
|
||||
|
||||
const wrap: Wrap[] = []
|
||||
@@ -143,24 +323,48 @@ function wrapWideRanks(graph: ReturnType<typeof build>, ids: string[]): Wrap[] {
|
||||
return wrap
|
||||
}
|
||||
|
||||
/**
|
||||
* The layout engine, fetched the first time a flow needs laying out.
|
||||
*
|
||||
* Its own chunk: ELK is a compiled Java library and weighs more than the rest
|
||||
* of the canvas put together, so nothing but this route ever loads it.
|
||||
*/
|
||||
let engine: Promise<{ layout: (graph: ElkNode) => Promise<ElkNode> }> | null =
|
||||
null
|
||||
|
||||
function elk() {
|
||||
engine ??= import("elkjs/lib/elk.bundled.js").then(
|
||||
(module) => new module.default(),
|
||||
)
|
||||
return engine
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay the graph out and return each node's top-left corner.
|
||||
*
|
||||
* `heights` is what a node's ports make it, from {@link nodeHeight}; anything
|
||||
* left out is the plain box. It is read from the document rather than from the
|
||||
* canvas on purpose — see that function.
|
||||
*
|
||||
* ponytail: every node is still treated as 220 wide rather than measured.
|
||||
* Measuring would feed the result back into the layout and oscillate; if nodes
|
||||
* ever grow past that, take the width from `node.measured` once it has settled.
|
||||
* `shapes` is what the document says each node is — its ports in the order
|
||||
* they are drawn in, and whether it is an endpoint label. Anything left out is
|
||||
* a plain box with no ports.
|
||||
*/
|
||||
export function layoutGraph(
|
||||
export async function layoutGraph(
|
||||
ids: string[],
|
||||
edges: { source: string; target: string }[],
|
||||
edges: LayoutEdge[],
|
||||
direction: Direction,
|
||||
heights: Map<string, number> = new Map(),
|
||||
): Map<string, { x: number; y: number }> {
|
||||
let graph = build(ids, edges, direction, [], heights)
|
||||
shapes: Map<string, NodeShape> = new Map(),
|
||||
): Promise<Map<string, { x: number; y: number }>> {
|
||||
const engine = await elk()
|
||||
const place = async (wrap: Wrap[]) => {
|
||||
const laid = await engine.layout(
|
||||
graphOf(ids, edges, direction, shapes, wrap),
|
||||
)
|
||||
const placed = new Map<string, { x: number; y: number }>()
|
||||
for (const child of laid.children ?? []) {
|
||||
placed.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 })
|
||||
}
|
||||
return placed
|
||||
}
|
||||
|
||||
let placed = await place([])
|
||||
|
||||
// Running downwards, a rank wider than the screen is the one thing the
|
||||
// layout can still do something about. Wrapping one rank pushes whatever was
|
||||
@@ -170,23 +374,13 @@ export function layoutGraph(
|
||||
if (direction === "TB") {
|
||||
const wrap: Wrap[] = []
|
||||
for (let pass = 0; pass < WRAP_PASSES; pass += 1) {
|
||||
const more = wrapWideRanks(graph, ids)
|
||||
const more = wrapWideRanks(placed, ids)
|
||||
if (!more.length) break
|
||||
wrap.push(...more)
|
||||
graph = build(ids, edges, direction, wrap, heights)
|
||||
placed = await place(wrap)
|
||||
}
|
||||
}
|
||||
|
||||
// dagre places centres; React Flow wants top-left corners.
|
||||
return new Map(
|
||||
ids.map((id) => {
|
||||
const node = graph.node(id)
|
||||
return [
|
||||
id,
|
||||
node
|
||||
? { x: node.x - NODE_W / 2, y: node.y - node.height / 2 }
|
||||
: { x: 0, y: 0 },
|
||||
]
|
||||
}),
|
||||
)
|
||||
// ELK places top-left corners already, which is what React Flow wants.
|
||||
return new Map(ids.map((id) => [id, placed.get(id) ?? { x: 0, y: 0 }]))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user