Model graph: layered layout and a cleaner look
- layered layout by type (clients left, data right), empty columns left out, barycenter row ordering; registries in a row above, decisions in a row below; Force layout as the alternative - orthogonal connections with labels staggered next to their source; opaque tinted nodes hide lines behind them - hover glow on elements and their connections; decision and registry links show for the selected element or all with Links
This commit is contained in:
1 parent
3ed2d7eed8
commit
17e07adb33
4 files changed
+198
-27
No files matched your search
@@ -0,0 +1,80 @@
|
||||
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;
|
||||
const spread = (list: GraphNode[], y: number, gap: number) => {
|
||||
const total = Math.max(0, list.length - 1) * gap;
|
||||
list.forEach((n, i) => positions.set(n.id, { x: width / 2 - total / 2 + i * gap, y }));
|
||||
};
|
||||
spread(registries, -options.rowGap * 1.6, Math.max(options.columnGap, 200));
|
||||
spread(decisions, tallest * options.rowGap + options.rowGap * 0.6, Math.max(options.columnGap, 240));
|
||||
return positions;
|
||||
}
|
||||
Reference in new issue
Block a user