Files
archify-vscode-ext/src/graph/layout.ts
T

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