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.json
Source

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,
  };
}