import type { GraphEdge, GraphNode } from '../model/graph'; /** * Layered layout: a column per element type in Archify's stack order * (clients on the left, data on the right), empty columns left out, rows * ordered to shorten the connections; registries in a row above the * columns, decisions in a row below. Deterministic, so the picture is stable. */ const COLUMN_ORDER = ['external', 'frontend', 'cloud', 'security', 'backend', 'other', 'messagebus', 'database']; const STATUS_ORDER = ['accepted', 'proposed', 'superseded', 'rejected']; export interface LayoutOptions { columnGap: number; rowGap: number; } export type Positions = Map; function columnOf(node: GraphNode): number { if (node.kind === 'registry') return -1; if (node.kind === 'decision') return COLUMN_ORDER.length; const i = COLUMN_ORDER.indexOf(node.type ?? 'other'); return i < 0 ? COLUMN_ORDER.indexOf('other') : i; } export function layeredLayout(nodes: GraphNode[], edges: GraphEdge[], options: LayoutOptions = { columnGap: 230, rowGap: 84 }): Positions { const columns = new Map(); const registries = nodes.filter((n) => n.kind === 'registry').sort((a, b) => a.label.localeCompare(b.label)); const decisions = nodes.filter((n) => n.kind === 'decision').sort((a, b) => STATUS_ORDER.indexOf(a.status ?? '') - STATUS_ORDER.indexOf(b.status ?? '') || a.label.localeCompare(b.label)); for (const n of nodes) if (n.kind === 'element') columns.set(columnOf(n), [...(columns.get(columnOf(n)) ?? []), n]); const order = new Map(); const neighbours = new Map(); for (const e of edges) { if (e.kind === 'supersedes') continue; neighbours.set(e.from, [...(neighbours.get(e.from) ?? []), e.to]); neighbours.set(e.to, [...(neighbours.get(e.to) ?? []), e.from]); } // Start from a stable order: elements by label, decisions by status, registries by file. for (const [col, list] of columns) { list.sort((a, b) => { if (a.kind === 'decision' && b.kind === 'decision') return STATUS_ORDER.indexOf(a.status ?? '') - STATUS_ORDER.indexOf(b.status ?? '') || a.label.localeCompare(b.label); return a.label.localeCompare(b.label); }); list.forEach((n, i) => order.set(n.id, i)); columns.set(col, list); } // Barycenter sweeps: each node moves towards the average row of what it is connected to. const keys = [...columns.keys()].sort((a, b) => a - b); for (let sweep = 0; sweep < 4; sweep += 1) { const sequence = sweep % 2 === 0 ? keys : [...keys].reverse(); for (const col of sequence) { const list = columns.get(col)!; const weight = new Map(); for (const n of list) { const rows = (neighbours.get(n.id) ?? []).map((id) => order.get(id)).filter((r): r is number => r !== undefined); weight.set(n.id, rows.length ? rows.reduce((a, b) => a + b, 0) / rows.length : order.get(n.id)!); } list.sort((a, b) => weight.get(a.id)! - weight.get(b.id)! || order.get(a.id)! - order.get(b.id)!); list.forEach((n, i) => order.set(n.id, i)); } } const positions: Positions = new Map(); const tallest = Math.max(1, ...[...columns.values()].map((list) => list.length)); // Empty columns are left out, so the picture is as wide as the types the model uses. const used = keys.filter((col) => columns.get(col)!.length); used.forEach((col, index) => { const list = columns.get(col)!; const offset = ((tallest - list.length) * options.rowGap) / 2; list.forEach((n, i) => positions.set(n.id, { x: index * options.columnGap, y: offset + i * options.rowGap })); }); const width = Math.max(0, used.length - 1) * options.columnGap; // Registries and decisions sit above or below the elements they link to: at the mean x of those // elements, pushed apart only as far as needed so they do not overlap. const spread = (list: GraphNode[], y: number, gap: number) => { const wanted = list.map((n) => { const xs = (neighbours.get(n.id) ?? []).map((id) => positions.get(id)?.x).filter((x): x is number => x !== undefined); return { n, x: xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : width / 2 }; }); wanted.sort((a, b) => a.x - b.x || a.n.label.localeCompare(b.n.label)); for (let i = 1; i < wanted.length; i += 1) wanted[i].x = Math.max(wanted[i].x, wanted[i - 1].x + gap); for (let i = wanted.length - 2; i >= 0; i -= 1) wanted[i].x = Math.min(wanted[i].x, wanted[i + 1].x - gap); for (const { n, x } of wanted) positions.set(n.id, { x, y }); }; spread(registries, -options.rowGap * 1.6, 200); spread(decisions, tallest * options.rowGap + options.rowGap * 0.6, 250); return positions; }