- A node's height follows the ports on its busiest side. It is a function of the document, so `layoutGraph` reserves exactly what is drawn and nothing measured is fed back into the layout. - The three status controls now sit in slots that are there whether the control is or not. A node running many times a second mounted and unmounted the stop button on every execution, resizing the card each time. - A port bound to another flow's message is drawn as a label, naming the node at the far end and its type. Only the opposite direction was answered before. The scan behind both is now cached on the store's commit counter rather than reading every flow per request.
193 lines
6.8 KiB
TypeScript
193 lines
6.8 KiB
TypeScript
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. The floor, not the height. */
|
|
const NODE_H = 56
|
|
/**
|
|
* How much of a node's edge the ports are spread over, as a fraction.
|
|
*
|
|
* Shared with `FlowNode`'s `handleOffset`, which does the spreading: the
|
|
* height below is chosen so that span has room for the ports, so the two
|
|
* cannot be allowed to drift apart.
|
|
*/
|
|
export const PORT_SPAN = 0.6
|
|
/**
|
|
* Centre to centre between two handles. They are 12px across (`flow.css`), so
|
|
* this leaves 6px of rim between them.
|
|
*/
|
|
const PORT_PITCH = 18
|
|
|
|
/**
|
|
* How tall a node with this many ports on one side has to be.
|
|
*
|
|
* A pure function of the document — the ports are declared, so the height is
|
|
* known before anything is drawn. That is what keeps it out of the measuring
|
|
* problem below: nothing is fed back, so there is nothing to oscillate.
|
|
*/
|
|
export function nodeHeight(ports: number): number {
|
|
return Math.max(NODE_H, Math.ceil(((ports - 1) * PORT_PITCH) / PORT_SPAN))
|
|
}
|
|
|
|
/**
|
|
* 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[],
|
|
heights: Map<string, number>,
|
|
) {
|
|
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: heights.get(id) ?? 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.
|
|
*
|
|
* `heights` is what a node's ports make it, from {@link nodeHeight}; anything
|
|
* left out is the plain box. It is read from the document rather than from the
|
|
* canvas on purpose — see that function.
|
|
*
|
|
* ponytail: every node is still treated as 220 wide rather than measured.
|
|
* Measuring would feed the result back into the layout and oscillate; if nodes
|
|
* ever grow past that, take the width from `node.measured` once it has settled.
|
|
*/
|
|
export function layoutGraph(
|
|
ids: string[],
|
|
edges: { source: string; target: string }[],
|
|
direction: Direction,
|
|
heights: Map<string, number> = new Map(),
|
|
): Map<string, { x: number; y: number }> {
|
|
let graph = build(ids, edges, direction, [], heights)
|
|
|
|
// 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, heights)
|
|
}
|
|
}
|
|
|
|
// 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.height / 2 }
|
|
: { x: 0, y: 0 },
|
|
]
|
|
}),
|
|
)
|
|
}
|