Algorithms
Layered layout
A directed graph laid out to read one way: layers by longest path, order by fewest crossings, places in line with neighbours.
Behaviour
Layers
Every edge points onward: to a later layer, or — said to be reversed — from one
fc.assert(
fc.property(anyGraph, ({ count, edges, sizes }) => {
const graph = graphFrom(count, edges.flat());
const { layer, reversed } = layeredLayout(graph, sizes);
let edge = 0;
for (let node = 0; node < count; node += 1) {
for (; edge < graph.offsets[node + 1]!; edge += 1) {
const to = graph.targets[edge]!;
if (to === node) continue;
const onward = reversed[edge] === 1 ? layer[node]! > layer[to]! : layer[to]! > layer[node]!;
if (!onward) return false;
}
}
return true;
}),
);A graph without cycles has no edge reversed, and a node's layer is one past the furthest that leads to it
// A diamond with a long side: 0 → 1 → 2 → 3, and 0 → 3.
const graph = graphFrom(4, [0, 1, 1, 2, 2, 3, 0, 3]);
const { layer, reversed } = layeredLayout(graph, sized(4));
expect([...layer]).toEqual([0, 1, 2, 3]);
expect([...reversed]).toEqual([0, 0, 0, 0]);A cycle has exactly one of its edges reversed
const { reversed } = layeredLayout(graphFrom(3, [0, 1, 1, 2, 2, 0]), sized(3));
expect([...reversed].reduce((sum, flag) => sum + flag, 0)).toBe(1);An edge from a node to itself moves nothing
const alone = layeredLayout(graphFrom(2, [0, 1]), sized(2));
const looped = layeredLayout(graphFrom(2, [0, 0, 0, 1]), sized(2));
expect([...looped.x, ...looped.y]).toEqual([...alone.x, ...alone.y]);Places
No two nodes overlap; neighbours in a layer are a gap apart, and layers a gap apart
fc.assert(
fc.property(
anyGraph,
fc.constantFrom<"right" | "down">("right", "down"),
({ count, edges, sizes }, direction) => {
const { x, y, layer } = layeredLayout(graphFrom(count, edges.flat()), sizes, {
direction,
layerGap: 30,
nodeGap: 12,
});
const slack = 1e-6;
for (let a = 0; a < count; a += 1) {
for (let b = a + 1; b < count; b += 1) {
const [mainA, mainB, crossA, crossB] = direction === "down" ? [y, y, x, x] : [x, x, y, y];
const main = direction === "down" ? 1 : 0;
const first = layer[a]! <= layer[b]! ? a : b;
const second = first === a ? b : a;
if (layer[a] === layer[b]) {
const [low, high] = crossA[a]! <= crossB[b]! ? [a, b] : [b, a];
if (crossA[high]! - (crossA[low]! + sizes[low * 2 + (1 - main)]!) < 12 - slack)
return false;
} else if (mainB[second]! - (mainA[first]! + sizes[first * 2 + main]!) < 30 - slack) {
return false;
}
}
}
return true;
},
),
);Every node lies within the size the layout says it has
fc.assert(
fc.property(anyGraph, ({ count, edges, sizes }) => {
const { x, y, width, height } = layeredLayout(graphFrom(count, edges.flat()), sizes);
const slack = 1e-6;
for (let node = 0; node < count; node += 1) {
if (x[node]! < -slack || y[node]! < -slack) return false;
if (
x[node]! + sizes[node * 2]! > width + slack ||
y[node]! + sizes[node * 2 + 1]! > height + slack
)
return false;
}
return true;
}),
);A chain is a straight line
const { y } = layeredLayout(graphFrom(4, [0, 1, 1, 2, 2, 3]), sized(4));
expect(new Set(y).size).toBe(1);A node is in the middle of the nodes it leads to, and they are either side of it
const { x, y } = layeredLayout(graphFrom(3, [0, 1, 0, 2]), sized(3), { direction: "down" });
expect(x[0]! + 50).toBeCloseTo((x[1]! + 50 + x[2]! + 50) / 2, 6);
expect(y[1]).toBe(y[2]);
expect(y[1]!).toBeGreaterThan(y[0]!);A node is drawn into line with what it is connected to, though others share its layer
// Node 0 leads to 1 and 2; node 3 is connected to nothing and shares 0's layer.
const { x } = layeredLayout(graphFrom(4, [0, 1, 0, 2]), sized(4), { direction: "down" });
expect(x[0]!).toBeCloseTo((x[1]! + x[2]!) / 2, 6);Two nodes that want the same place give way to each other equally
// 2 hangs from 0 and 1; 3 hangs from 1 alone. Both want to be under 1's side.
const { x } = layeredLayout(graphFrom(6, [0, 2, 1, 2, 1, 3, 2, 4, 2, 5]), sized(6), {
direction: "down",
});
const wantedBy2 = (x[0]! + x[1]!) / 2;
const wantedBy3 = x[1]!;
expect(Math.abs(x[2]! - wantedBy2)).toBeCloseTo(Math.abs(x[3]! - wantedBy3), 6);Nodes of differing thickness in a layer are centred on one line
const sizes = Float64Array.of(100, 40, 60, 40, 200, 40);
const { x } = layeredLayout(graphFrom(3, [0, 1, 0, 2]), sizes, { direction: "right" });
// The two in the second layer, one 60 wide and one 200, share a centre.
expect(x[1]! + 30).toBeCloseTo(x[2]! + 100, 6);Parts that are not connected are laid side by side, not on top of each other
const { y } = layeredLayout(graphFrom(4, [0, 1, 2, 3]), sized(4));
expect(y[0]).toBe(y[1]);
expect(y[2]).toBe(y[3]);
expect(Math.abs(y[0]! - y[2]!)).toBeGreaterThanOrEqual(40 + 24);Order
Nodes are ordered within a layer so that edges that need not cross do not
// Two parents, each with one child, the children numbered the other way round.
const edges = [
[0, 3],
[1, 2],
] as const;
const { x } = layeredLayout(graphFrom(4, edges.flat()), sized(4), { direction: "down" });
expect(crossings(edges, x)).toBe(0);The order is found by sweeping: left as the graph was walked, these edges would cross
const edges = [
[0, 2],
[1, 2],
[0, 3],
] as const;
const graph = () => graphFrom(4, edges.flat());
expect(crossings(edges, layeredLayout(graph(), sized(4), { direction: "down" }).x)).toBe(0);
expect(
crossings(edges, layeredLayout(graph(), sized(4), { direction: "down", sweeps: 0 }).x),
).toBeGreaterThan(0);A tree is drawn without a crossing, however its nodes are numbered
fc.assert(
fc.property(fc.array(fc.nat(1000), { minLength: 1, maxLength: 12 }), (picks) => {
// Node n + 1 hangs from some earlier node.
const edges = picks.map((pick, index) => [pick % (index + 1), index + 1] as const);
const count = picks.length + 1;
const { x, layer } = layeredLayout(graphFrom(count, edges.flat()), sized(count), {
direction: "down",
});
// Crossings are counted between neighbouring layers; in a tree every edge joins two.
const between = new Map<number, Array<readonly [number, number]>>();
for (const edge of edges)
between.set(layer[edge[0]]!, [...(between.get(layer[edge[0]]!) ?? []), edge]);
return [...between.values()].every((group) => crossings(group, x) === 0);
}),
);Cost
The same graph always has the same layout
fc.assert(
fc.property(anyGraph, ({ count, edges, sizes }) => {
const once = layeredLayout(graphFrom(count, edges.flat()), sizes);
const again = layeredLayout(graphFrom(count, edges.flat()), sizes);
return [...once.x, ...once.y].join() === [...again.x, ...again.y].join();
}),
);Twice the nodes and edges take about twice the work
const work = (count: number) => {
// A ladder: two chains with a rung every step, layers two wide.
const edges: number[] = [];
for (let node = 0; node + 2 < count; node += 1) edges.push(node, node + 2);
for (let node = 0; node + 3 < count; node += 2) edges.push(node, node + 3);
const graph = graphFrom(count, edges);
const offsets = counted(graph.offsets);
const targets = counted(graph.targets);
const sizes = counted(sized(count));
layeredLayout({ offsets: offsets.array, targets: targets.array }, sizes.array);
return offsets.accesses() + targets.accesses() + sizes.accesses();
};
expect(work(4000) / work(2000)).toBeLessThan(2.2);Sizes that are not a width and a height for each node are refused
expect(() => layeredLayout(graphFrom(2, [0, 1]), sized(1))).toThrow(RangeError);A graph of no nodes has a layout of no size
expect(layeredLayout(graphFrom(0, []), new Float64Array(0))).toMatchObject({ width: 0, height: 0 });React
Install
npx shadcn@latest add https://ui.publictheta.com/r/layered-layout.jsonSource
src/algorithms/layered-layout.ts
/**
* Laying out a directed graph in layers, so that it reads in one direction: a
* flow, a pipeline, the dependencies of a build. Each node is given a layer
* such that its edges point onward; within a layer the nodes are put in the
* order that crosses the fewest edges; and each is placed, as nearly as its
* neighbours allow, in line with what it is connected to.
*
* Four passes, each linear in the nodes and edges for a fixed number of sweeps:
*
* 1. **Direction.** An edge that closes a cycle is taken backwards, and said to
* be: a cycle cannot read one way, so one of its edges is drawn against it.
* 2. **Layers.** A node's layer is one more than the furthest of the nodes that
* lead to it — the longest path from a source.
* 3. **Order.** Sweeping forth and back, each layer is put in the order of the
* average place of its neighbours in the layer before: the barycentre
* heuristic, which does not always find the fewest crossings but finds few.
* 4. **Places.** Layers are spaced by their thickest node; within a layer, nodes
* are drawn toward the middle of their neighbours without changing order or
* coming closer than the gap.
*
* Everything is typed arrays indexed by node: nothing is allocated per node or
* per edge beyond them, and the same graph always gives the same layout.
*/
import type { Graph } from "./topological.ts";
export interface LayeredOptions {
/** The room between one layer and the next, and between neighbours in a layer. */
readonly layerGap?: number;
readonly nodeGap?: number;
/** Which way the layers run: across the page, or down it. */
readonly direction?: "right" | "down";
/** How many times the order and the places are swept forth and back. */
readonly sweeps?: number;
}
export interface LayeredLayout {
/** Each node's top-left corner. */
readonly x: Float64Array;
readonly y: Float64Array;
/** Each node's layer, from 0. */
readonly layer: Int32Array;
/**
* For each edge, in the order the graph holds them, whether it is drawn
* against its direction: an edge that closed a cycle.
*/
readonly reversed: Uint8Array;
/** The size of the whole: every node lies within `[0, width] × [0, height]`. */
readonly width: number;
readonly height: number;
}
/**
* The layout of `graph`, whose node `n` is `sizes[2n]` wide and `sizes[2n + 1]`
* tall.
*/
export function layeredLayout(
graph: Graph,
sizes: ArrayLike<number>,
{ layerGap = 64, nodeGap = 24, direction = "right", sweeps = 4 }: LayeredOptions = {},
): LayeredLayout {
const { offsets, targets } = graph;
const count = offsets.length - 1;
const edgeCount = targets.length;
if (sizes.length !== count * 2) {
throw new RangeError(
`Sizes are a width and a height for each of ${count} nodes, not ${sizes.length} numbers.`,
);
}
const reversed = new Uint8Array(edgeCount);
const layer = new Int32Array(count);
const x = new Float64Array(count);
const y = new Float64Array(count);
if (count === 0) return { x, y, layer, reversed, width: 0, height: 0 };
// 1. Direction: a depth-first walk; an edge to a node still being walked closes a cycle.
const state = new Uint8Array(count); // 0 unseen, 1 on the walk, 2 done
const stack = new Int32Array(count);
const cursor = offsets.slice(0, count);
// The nodes in the order their walks finished, backwards: sources first.
const order = new Int32Array(count);
let placed = count;
for (let root = 0; root < count; root += 1) {
if (state[root] !== 0) continue;
let depth = 0;
stack[0] = root;
state[root] = 1;
while (depth >= 0) {
const node = stack[depth]!;
const edge = cursor[node]!;
if (edge < offsets[node + 1]!) {
cursor[node] = edge + 1;
const to = targets[edge]!;
if (state[to] === 1) reversed[edge] = 1;
else if (state[to] === 0) {
state[to] = 1;
depth += 1;
stack[depth] = to;
}
} else {
state[node] = 2;
placed -= 1;
order[placed] = node;
depth -= 1;
}
}
}
// 2. Layers: the longest path from a source, over the edges as they now run.
// `order` has every node after those that lead to it, except by a reversed edge.
let layers = 0;
for (let index = 0; index < count; index += 1) {
const node = order[index]!;
for (let edge = offsets[node]!; edge < offsets[node + 1]!; edge += 1) {
const to = targets[edge]!;
// A reversed edge is not followed — and an edge from a node to itself is one: it leads nowhere.
if (reversed[edge] === 1) continue;
if (layer[to]! < layer[node]! + 1) layer[to] = layer[node]! + 1;
}
if (layer[node]! + 1 > layers) layers = layer[node]! + 1;
}
// A reversed edge needs no more: its source was reached from its target by
// edges that run forward, so it already lies in a later layer.
// The nodes of each layer, side by side in one array: layer `l` is `rank[starts[l] … starts[l + 1])`.
const starts = new Int32Array(layers + 1);
for (let node = 0; node < count; node += 1) starts[layer[node]! + 1]! += 1;
for (let l = 0; l < layers; l += 1) starts[l + 1]! += starts[l]!;
const rank = new Int32Array(count);
const fill = starts.slice(0, layers);
// In the order of the walk, so that what is connected starts out together.
for (let index = 0; index < count; index += 1) {
const node = order[index]!;
rank[fill[layer[node]!]!] = node;
fill[layer[node]!]! += 1;
}
// Where each node is in its layer, as a number that can be averaged.
const place = new Float64Array(count);
const setPlaces = (l: number) => {
for (let at = starts[l]!; at < starts[l + 1]!; at += 1) place[rank[at]!] = at - starts[l]!;
};
for (let l = 0; l < layers; l += 1) setPlaces(l);
// The edges the other way about, for the neighbours that lead to a node.
const inOffsets = new Int32Array(count + 1);
for (let edge = 0; edge < edgeCount; edge += 1) inOffsets[targets[edge]! + 1]! += 1;
for (let node = 0; node < count; node += 1) inOffsets[node + 1]! += inOffsets[node]!;
const sources = new Int32Array(edgeCount);
const inFill = inOffsets.slice(0, count);
for (let node = 0; node < count; node += 1) {
for (let edge = offsets[node]!; edge < offsets[node + 1]!; edge += 1) {
const to = targets[edge]!;
sources[inFill[to]!] = node;
inFill[to]! += 1;
}
}
const key = new Float64Array(count);
const slots = new Int32Array(count);
const movers = new Int32Array(count);
/** The average of `of` over a node's neighbours in earlier layers (`side` −1) or later (`side` 1); `NaN` for none. */
const mean = (node: number, side: 1 | -1, of: Float64Array): number => {
let sum = 0;
let found = 0;
const own = layer[node]!;
for (let edge = offsets[node]!; edge < offsets[node + 1]!; edge += 1) {
const other = targets[edge]!;
if ((layer[other]! - own) * side > 0) {
sum += of[other]!;
found += 1;
}
}
for (let edge = inOffsets[node]!; edge < inOffsets[node + 1]!; edge += 1) {
const other = sources[edge]!;
if ((layer[other]! - own) * side > 0) {
sum += of[other]!;
found += 1;
}
}
return found === 0 ? Number.NaN : sum / found;
};
// 3. Order: each layer by the average place of its neighbours on the side the sweep came from.
for (let sweep = 0; sweep < sweeps; sweep += 1) {
const side: 1 | -1 = sweep % 2 === 0 ? -1 : 1;
for (let step = 1; step < layers; step += 1) {
const l = side === -1 ? step : layers - 1 - step;
const from = starts[l]!;
const to = starts[l + 1]!;
// A node with no neighbour that side has nothing to say where it goes: it
// keeps its slot, and the rest are put in order among the slots they held.
let moving = 0;
for (let at = from; at < to; at += 1) {
const node = rank[at]!;
const average = mean(node, side, place);
if (Number.isNaN(average)) continue;
key[node] = average;
slots[moving] = at;
movers[moving] = node;
moving += 1;
}
// An insertion sort: layers are short, nearly in order after the first sweep, and it is stable.
for (let at = 1; at < moving; at += 1) {
const node = movers[at]!;
let slot = at;
while (slot > 0 && key[movers[slot - 1]!]! > key[node]!) {
movers[slot] = movers[slot - 1]!;
slot -= 1;
}
movers[slot] = node;
}
for (let at = 0; at < moving; at += 1) rank[slots[at]!] = movers[at]!;
setPlaces(l);
}
}
// 4. Places. Along the layers' direction a node is `main` long; across it, `cross`.
const down = direction === "down";
const main = (node: number) => sizes[node * 2 + (down ? 1 : 0)]!;
const cross = (node: number) => sizes[node * 2 + (down ? 0 : 1)]!;
const along = down ? y : x;
const across = down ? x : y;
let offset = 0;
for (let l = 0; l < layers; l += 1) {
let thickest = 0;
for (let at = starts[l]!; at < starts[l + 1]!; at += 1) thickest = Math.max(thickest, main(rank[at]!));
// Each node in the middle of its layer's thickness.
for (let at = starts[l]!; at < starts[l + 1]!; at += 1) {
const node = rank[at]!;
along[node] = offset + (thickest - main(node)) / 2;
}
offset += thickest + (l === layers - 1 ? 0 : layerGap);
}
const length = offset;
// Across: the middle of each node. First packed, each layer centred on zero.
const centre = new Float64Array(count);
const onward = new Float64Array(count);
for (let l = 0; l < layers; l += 1) {
let at = starts[l]!;
let edge = 0;
for (; at < starts[l + 1]!; at += 1) {
const node = rank[at]!;
centre[node] = edge + cross(node) / 2;
edge += cross(node) + nodeGap;
}
const shift = (edge - nodeGap) / 2;
for (at = starts[l]!; at < starts[l + 1]!; at += 1) centre[rank[at]!]! -= shift;
}
// Then each drawn toward the middle of its neighbours, and the layer put back
// in order with its gaps.
for (let sweep = 0; sweep < sweeps * 2; sweep += 1) {
const side: 1 | -1 = sweep % 2 === 0 ? -1 : 1;
for (let step = 1; step < layers; step += 1) {
const l = side === -1 ? step : layers - 1 - step;
const from = starts[l]!;
const to = starts[l + 1]!;
for (let at = from; at < to; at += 1) {
const node = rank[at]!;
const average = mean(node, side, centre);
key[node] = Number.isNaN(average) ? centre[node]! : average;
}
// Kept in order with the gap between, twice: once pushed onward from the
// start, once pushed back from the end. Each is a layout that holds; so is
// their average, which leans neither way.
for (let at = from; at < to; at += 1) {
const node = rank[at]!;
let wanted = key[node]!;
if (at > from) {
const before = rank[at - 1]!;
wanted = Math.max(wanted, onward[before]! + cross(before) / 2 + nodeGap + cross(node) / 2);
}
onward[node] = wanted;
}
for (let at = to - 1; at >= from; at -= 1) {
const node = rank[at]!;
let wanted = key[node]!;
if (at < to - 1) {
const after = rank[at + 1]!;
wanted = Math.min(
wanted,
centre[after]! * 2 - onward[after]! - cross(after) / 2 - nodeGap - cross(node) / 2,
);
}
// `centre` holds the average; the place pushed back is twice it less the place pushed onward.
centre[node] = (onward[node]! + wanted) / 2;
}
}
}
let least = Infinity;
let most = -Infinity;
for (let node = 0; node < count; node += 1) {
least = Math.min(least, centre[node]! - cross(node) / 2);
most = Math.max(most, centre[node]! + cross(node) / 2);
}
for (let node = 0; node < count; node += 1) across[node] = centre[node]! - cross(node) / 2 - least;
const breadth = most - least;
return {
x,
y,
layer,
reversed,
width: down ? breadth : length,
height: down ? length : breadth,
};
}