Files
app/frontend/src/components/Flow/layout.ts
T
stroblmeandClaude Opus 5 b16408f4d6 Cap the long lists, and wrap a wide rank on a phone
Two things the first pass got wrong.

The Changes and "Given up on" lists had no cap, so they ran as long as the
audit trail did — and worse, a truncating flex row still offers its whole
unwrapped line as a min-content contribution, so the card sized itself to the
longest entry and took the page sideways with it. Both now use the same
capped, scrolling box the runs and failures lists already had, which fixes the
length and the width together.

Running downwards, a rank of eight nodes was two thousand pixels across
because a node box is landscape: siblings cost a rank four times as much
across as they do down it. A rank wider than two now wraps onto the ranks
below, settling over a few passes since wrapping one rank pushes what was
under it up into the room that freed. The value chip on an edge no longer
reserves its width there either — that width is spent across the screen rather
than along the flow, and the rank gap it would widen is already wider than the
chip is tall. The same flow that laid out 2040x216 is now 803x722; a chain and
a diamond are unchanged, and so is every desktop layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDSXaRhvqHYNevgDGmNAto
2026-08-17 18:22:15 +02:00

162 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import dagre from "@dagrejs/dagre"
/**
* Where the nodes of a flow go.
*
* Nothing on this canvas is placed by hand: a flow is a graph the editor draws,
* not a picture someone arranges. That is the design decision — a canvas nobody
* can rearrange is one worth keeping small, which is what "atomic flow" means
* here — and it also means a flow document carries no positions to go stale.
*
* Left to right on a desktop, top to bottom on a phone, which is the direction
* each screen has room to grow in.
*/
export type Direction = "LR" | "TB"
/** `FlowNode` is `min-w-[168px] max-w-[220px]`; an endpoint is narrower. */
const NODE_W = 220
/** Icon row plus two text lines, as measured. */
const NODE_H = 56
/**
* 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.
*
* 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,
* 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
/**
* How many nodes may sit abreast when the graph runs downwards.
*
* A node box is landscape, so siblings cost a rank four times as much across
* as they do down it: eight of them side by side is two thousand pixels, which
* a phone can only show by shrinking the labels out of existence. Two abreast
* is 480px — the fit still reads at 390 — and anything beyond that wraps onto
* the ranks below, so a wide fan-out grows the way the screen does.
*/
const ABREAST = 2
/**
* How many times to let the wrapping settle before taking what it has.
*
* A bound rather than a fixed point: each pass moves nodes strictly downwards,
* so it does converge, but a pathological graph should not be allowed to
* relayout itself twenty times on a phone.
*/
const WRAP_PASSES = 6
type Wrap = [string, string]
function build(
ids: string[],
edges: { source: string; target: string }[],
direction: Direction,
wrap: Wrap[],
) {
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,
})
// 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: 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 })
dagre.layout(graph)
return graph
}
/**
* Which nodes have to move down a rank for the graph to stay narrow.
*
* Two nodes of the same rank never have an edge between them, so chaining the
* 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.
*/
function wrapWideRanks(graph: ReturnType<typeof build>, 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 wrap: Wrap[] = []
for (const rank of ranks.values()) {
if (rank.length <= ABREAST) continue
for (let i = ABREAST; i < rank.length; i += 1) {
wrap.push([rank[i - ABREAST], rank[i]])
}
}
return wrap
}
/**
* Lay the graph out and return each node's top-left corner.
*
* ponytail: every node is treated as 220×56 rather than measured. Measuring
* would feed the result back into the layout and oscillate; if nodes ever grow
* past that box, take the sizes from `node.measured` once they have settled.
*/
export function layoutGraph(
ids: string[],
edges: { source: string; target: string }[],
direction: Direction,
): Map<string, { x: number; y: number }> {
let graph = build(ids, edges, direction, [])
// 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
// below it up into the room that freed, which can overfill a rank that was
// fine — so it settles rather than being done once. A handful of passes over
// a graph of a few dozen nodes, and none at all when nothing is too wide.
if (direction === "TB") {
const wrap: Wrap[] = []
for (let pass = 0; pass < WRAP_PASSES; pass += 1) {
const more = wrapWideRanks(graph, ids)
if (!more.length) break
wrap.push(...more)
graph = build(ids, edges, direction, 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_H / 2 }
: { x: 0, y: 0 },
]
}),
)
}