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
@@ -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.
*