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, ids: string[]): Wrap[] { const ranks = new Map() 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 { 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 }, ] }), ) }