diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index e188169..56d2620 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -5,7 +5,9 @@ the connections and arranges the canvas for you. ## The canvas -The graph runs left to right on desktop and top to bottom on phones. +The graph runs left to right on desktop and top to bottom on phones. On a +phone, at most two nodes sit side by side and the rest wrap onto the rows +below, so a large flow grows downwards in one column. - **Nodes** show their execution status. - **Edges** are routed around the nodes, a loop back to an earlier node diff --git a/frontend/src/components/Flow/layout.check.ts b/frontend/src/components/Flow/layout.check.ts index d96ff15..8876f68 100644 --- a/frontend/src/components/Flow/layout.check.ts +++ b/frontend/src/components/Flow/layout.check.ts @@ -270,6 +270,38 @@ for (const [y, count] of rows) { ) } +/** + * A hub read by eight readers, each feeding a sink of its own: the shape of + * the Demo's house and its dashboards. Wrapped two abreast with straight + * edges, each row landed where its edges pulled it and the graph staircased + * to 1400px across. Centred, the rows stack into one column. + */ +const readers = Array.from({ length: 8 }, (_, i) => `r${i}`) +const hub = await layoutGraph( + ["hub", ...readers, ...readers.map((r) => `${r}.sink`)], + readers.flatMap((r) => [ + { id: r, source: "hub", target: r, sourceHandle: "v", targetHandle: "v" }, + { + id: `${r}.sink`, + source: r, + target: `${r}.sink`, + sourceHandle: "v", + targetHandle: "v", + }, + ]), + "TB", + new Map( + ["hub", ...readers, ...readers.map((r) => `${r}.sink`)].map((id) => [ + id, + one("v"), + ]), + ), +) +const hubX = [...hub.positions.values()].map((at) => at.x) +const across = Math.max(...hubX) + 220 - Math.min(...hubX) +// Three nodes and their gaps: one more than the two a row holds. +assert.ok(across <= 3 * 260, `a wrapped graph runs ${across}px across`) + /** * Every edge comes back routed, port to port, square, and around what is in * the way. `back` closes a cycle over `mid`: drawn straight between its two diff --git a/frontend/src/components/Flow/layout.ts b/frontend/src/components/Flow/layout.ts index d540c72..61ca7cb 100644 --- a/frontend/src/components/Flow/layout.ts +++ b/frontend/src/components/Flow/layout.ts @@ -114,6 +114,27 @@ const ABREAST = 2 */ const WRAP_PASSES = 6 +/** + * How wide a wrapped graph may still be before its ranks are centred. + * + * Wrapping keeps a rank to `ABREAST` nodes, but the layout straightens edges, + * so each wrapped row lands where its edges pull it: the rows staircase + * sideways, and a big flow ended up thousands of pixels across on a phone. + * Past one node more than the rows allow, every rank is centred instead, which + * stacks the rows into one column. Below it the straight edges are worth more, + * which is the case for a flow that wraps a rank or two. + */ +const TOO_WIDE = (ABREAST + 1) * (NODE_W + NODE_GAP) + +/** + * Room for a value chip when the ranks are centred, running downwards. + * + * Centring packs a rank's chips together where the edges meet in the middle, + * so with no width at all they piled onto each other. This is a number's + * worth: a longer value overlaps its neighbour rather than widening the graph. + */ +const CENTRED_LABEL_W = 60 + /** * What a node is, as far as the layout is concerned. * @@ -200,6 +221,7 @@ function graphOf( direction: Direction, shapes: Map, wrap: Wrap[], + centred: boolean, ): ElkNode { const across = direction === "LR" // A port is identified by the side it is on as well as by its name: a node @@ -279,6 +301,16 @@ function graphOf( // 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", + // See `TOO_WIDE`. Every rank is centred on one axis, so a wrapped row + // sits under the row above rather than where its edges pull it. What + // bends the edges then takes room down the channels between the ranks, + // so the runs there sit closer together. + ...(centred + ? { + "org.eclipse.elk.layered.nodePlacement.strategy": "SIMPLE", + "org.eclipse.elk.layered.spacing.edgeEdgeBetweenLayers": "6", + } + : {}), }, children, edges: [ @@ -300,7 +332,7 @@ function graphOf( // Only where it lands is read — `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, + width: across ? LABEL_W : centred ? CENTRED_LABEL_W : 0, height: LABEL_H, layoutOptions: { "org.eclipse.elk.edgeLabels.inline": "true" }, }, @@ -378,9 +410,9 @@ export async function layoutGraph( shapes: Map = new Map(), ): Promise { const engine = await elk() - const place = async (wrap: Wrap[]) => { + const place = async (wrap: Wrap[], centred = false) => { const laid = await engine.layout( - graphOf(ids, edges, direction, shapes, wrap), + graphOf(ids, edges, direction, shapes, wrap, centred), ) const placed = new Map() for (const child of laid.children ?? []) { @@ -404,6 +436,10 @@ export async function layoutGraph( wrap.push(...more) ;({ placed, laid } = await place(wrap)) } + const [left, right] = extentOf(placed, shapes, direction) + if (wrap.length && right - left > TOO_WIDE) { + ;({ placed, laid } = await place(wrap, true)) + } } // ELK places top-left corners already, which is what React Flow wants, and @@ -413,14 +449,7 @@ export async function layoutGraph( ) const wired = wiredOf(ids, edges) // How far across the nodes reach, for `longestRun`. - let [left, right] = [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY] - for (const [id, at] of positions) { - left = Math.min(left, at.x) - right = Math.max( - right, - at.x + sizeOf(shapes.get(id) ?? EMPTY, direction).width, - ) - } + const [left, right] = extentOf(positions, shapes, direction) const routes = new Map() for (const edge of laid.edges ?? []) { const index = Number(edge.id.slice(1)) @@ -453,6 +482,23 @@ export async function layoutGraph( return { positions, routes } } +/** The leftmost and rightmost x any node reaches. */ +function extentOf( + placed: Map, + shapes: Map, + direction: Direction, +): [number, number] { + let [left, right] = [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY] + for (const [id, at] of placed) { + left = Math.min(left, at.x) + right = Math.max( + right, + at.x + sizeOf(shapes.get(id) ?? EMPTY, direction).width, + ) + } + return [left, right] +} + /** * The middle of a route's longest run across the flow, running downwards. *