89 lines
4.6 KiB
TypeScript
89 lines
4.6 KiB
TypeScript
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<string, { x: number; y: number }>;
|
|
|
|
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<number, GraphNode[]>();
|
|
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<string, number>();
|
|
const neighbours = new Map<string, string[]>();
|
|
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<string, number>();
|
|
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;
|
|
}
|