A phone's wrapped flow stacks into one column: when a graph is still wider than three nodes after wrapping, a last pass centres every rank, so wrapped rows sit under the row above (Demo house 3721 to 947px across at 390, crossings 295 to 263)

This commit is contained in:
2026-10-15 18:32:44 +02:00
parent 453b0be6f6
commit 5ca90ffece
3 changed files with 92 additions and 12 deletions
+3 -1
View File
@@ -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
@@ -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
+57 -11
View File
@@ -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<string, NodeShape>,
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<string, NodeShape> = new Map(),
): Promise<Layout> {
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<string, Point>()
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<string, Route>()
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<string, Point>,
shapes: Map<string, NodeShape>,
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.
*