Model graph: decisions and registry files as nodes

- one node per decision (tag coloured by status) with decides links to its elements and supersedes links
- one node per registry file with defines links to its elements
- own legend chips, side panels (Open decision, Open registry), type:decision and type:registry
- walks and paths follow the diagrams' connections only; attached nodes follow their elements into a result
This commit is contained in:
2026-10-21 20:46:05 +03:00
parent c8645b228b
commit 99ea076571
8 changed files with 237 additions and 42 deletions
+112 -14
View File
@@ -22,9 +22,11 @@ interface SavedView {
cytoscape.use(fcose);
const TYPE_COLORS: Record<string, string> = { frontend: '#22d3ee', backend: '#34d399', database: '#a78bfa', cloud: '#fbbf24', security: '#fb7185', messagebus: '#fb923c', external: '#94a3b8' };
const TYPE_ORDER = ['external', 'frontend', 'cloud', 'security', 'backend', 'messagebus', 'database'];
const TYPE_COLORS: Record<string, string> = { frontend: '#22d3ee', backend: '#34d399', database: '#a78bfa', cloud: '#fbbf24', security: '#fb7185', messagebus: '#fb923c', external: '#94a3b8', decision: '#e879f9', registry: '#cbd5e1' };
const TYPE_ORDER = ['external', 'frontend', 'cloud', 'security', 'backend', 'messagebus', 'database', 'decision', 'registry'];
const STATUS_COLORS: Record<string, string> = { accepted: '#34d399', proposed: '#fbbf24', rejected: '#fb7185', superseded: '#94a3b8' };
const colorOf = (type: string | undefined) => TYPE_COLORS[type ?? ''] ?? '#94a3b8';
const nodeColor = (n: GraphNode) => (n.kind === 'decision' ? STATUS_COLORS[n.status ?? ''] ?? TYPE_COLORS.decision : colorOf(n.type));
/** Edge labels are drawn by default up to this many connections; above it they clutter. */
const LABELS_UP_TO = 40;
const PLACEHOLDER = 'database · type:backend · diagram:0200-* · uses:api · near:api,2 · from:web to:db · decision:<id> · unused · -shared';
@@ -117,8 +119,8 @@ class GraphPage {
}
private elements() {
const nodes = this.graph.nodes.map((n) => ({ group: 'nodes' as const, data: { id: n.id, label: n.label, type: n.type ?? '', shared: n.shared ? 1 : 0, color: colorOf(n.type) } }));
const edges = this.graph.edges.map((e) => ({ group: 'edges' as const, data: { id: e.id, source: e.from, target: e.to, label: e.labels.join(' / ') } }));
const nodes = this.graph.nodes.map((n) => ({ group: 'nodes' as const, data: { id: n.id, label: n.label, type: n.type ?? '', kind: n.kind, shared: n.shared ? 1 : 0, color: nodeColor(n) } }));
const edges = this.graph.edges.map((e) => ({ group: 'edges' as const, data: { id: e.id, source: e.from, target: e.to, kind: e.kind, label: e.labels.join(' / ') } }));
return [...nodes, ...edges];
}
@@ -287,8 +289,12 @@ class GraphPage {
return [
{ selector: 'node', style: { shape: 'round-rectangle', width: (n: NodeSingular) => Math.min(260, Math.max(84, String(n.data('label')).length * 7.5 + 34)), height: 42, 'background-color': p.panel, 'border-width': 2, 'border-color': 'data(color)', label: 'data(label)', color: p.fg, 'font-size': 13, 'font-weight': 600, 'font-family': p.font, 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'ellipsis', 'text-max-width': '230px' } },
{ selector: 'node[shared = 0]', style: { 'border-style': 'dashed' } },
{ selector: 'node[kind = "decision"]', style: { shape: 'round-tag', height: 36, 'font-size': 11, 'font-weight': 500, 'background-color': p.bg, 'text-max-width': '270px', width: (n: NodeSingular) => Math.min(300, Math.max(90, String(n.data('label')).length * 6.6 + 44)) } },
{ selector: 'node[kind = "registry"]', style: { shape: 'cut-rectangle', height: 34, 'font-size': 11, 'font-weight': 500, 'font-family': 'ui-monospace, Menlo, monospace', 'border-width': 1.5, 'border-color': p.muted, 'background-color': p.bg, width: (n: NodeSingular) => Math.min(230, Math.max(90, String(n.data('label')).length * 6.8 + 36)) } },
{ selector: 'edge', style: { width: 1.5, 'line-color': p.edge, 'target-arrow-color': p.edge, 'target-arrow-shape': 'triangle', 'arrow-scale': 0.9, 'curve-style': 'bezier', label: '', 'font-size': 10, 'font-family': p.font, color: p.muted, 'text-background-color': p.bg, 'text-background-opacity': 1, 'text-background-padding': '2px', 'text-rotation': 'autorotate' } },
{ selector: 'edge.labelled', style: { label: 'data(label)' } },
{ selector: 'edge[kind = "decides"], edge[kind = "defines"]', style: { width: 1, 'line-style': 'dotted', 'target-arrow-shape': 'none', 'line-color': p.muted, 'line-opacity': 0.6 } },
{ selector: 'edge[kind = "supersedes"]', style: { width: 1, 'line-style': 'dashed', 'line-color': TYPE_COLORS.decision, 'target-arrow-color': TYPE_COLORS.decision } },
{ selector: 'edge.labelled[kind = "connection"], edge.labelled[kind = "supersedes"]', style: { label: 'data(label)' } },
{ selector: '.dim', style: { opacity: 0.14, events: 'no' } },
{ selector: 'node.hit', style: { 'border-width': 3 } },
{ selector: 'edge.path', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, color: p.fg } },
@@ -301,7 +307,7 @@ class GraphPage {
private layout(randomize: boolean): void {
const visible = this.cy.elements().not('.hidden');
visible
.layout({ name: 'fcose', animate: false, randomize, quality: 'default', nodeDimensionsIncludeLabels: true, nodeRepulsion: () => 9000, idealEdgeLength: () => 150, edgeElasticity: () => 0.2, gravity: 0.3, padding: 40, fit: true } as cytoscape.LayoutOptions)
.layout({ name: 'fcose', animate: false, randomize, quality: 'default', nodeDimensionsIncludeLabels: true, nodeRepulsion: () => 9000, idealEdgeLength: (e: EdgeSingular) => (e.data('kind') === 'connection' ? 150 : 110), edgeElasticity: (e: EdgeSingular) => (e.data('kind') === 'connection' ? 0.2 : 0.08), gravity: 0.3, padding: 40, fit: true } as cytoscape.LayoutOptions)
.run();
}
@@ -347,14 +353,14 @@ class GraphPage {
}
private updateCount(): void {
const total = this.cy.nodes().not('.hidden').length;
const total = this.cy.nodes('[kind = "element"]').not('.hidden').length;
const r = this.result;
if (!r || !this.queryInput.value.trim()) {
this.countEl.textContent = `${total} elements · ${this.cy.edges().not('.hidden').length} connections`;
this.countEl.textContent = `${total} elements · ${this.cy.edges('[kind = "connection"]').not('.hidden').length} connections`;
this.countEl.classList.remove('is-error');
return;
}
const shown = this.cy.nodes('.hit').not('.hidden').length;
const shown = this.cy.nodes('.hit[kind = "element"]').not('.hidden').length;
this.countEl.textContent = r.message ? r.message : r.path ? `path of ${r.path.length} elements` : `${shown} of ${total} elements`;
this.countEl.classList.toggle('is-error', Boolean(r.message));
}
@@ -372,9 +378,11 @@ class GraphPage {
this.side.appendChild(el('p', 'ag-empty', 'Click an element or a connection.'));
const h3 = el('h3', '', 'This model');
this.side.appendChild(h3);
const summary = el('p', '', `${this.graph.nodes.length} elements, ${this.graph.edges.length} connections, ${this.graph.diagrams.length} diagrams, ${this.graph.decisions.length} decisions.`);
const elements = this.graph.nodes.filter((n) => n.kind === 'element');
const registries = this.graph.nodes.filter((n) => n.kind === 'registry');
const summary = el('p', '', `${elements.length} elements, ${this.graph.edges.filter((e) => e.kind === 'connection').length} connections, ${this.graph.diagrams.length} diagrams, ${registries.length} ${registries.length === 1 ? 'registry' : 'registries'}, ${this.graph.decisions.length} decisions.`);
this.side.appendChild(summary);
const unused = this.graph.nodes.filter((n) => n.shared && !n.diagrams.length).length;
const unused = elements.filter((n) => n.shared && !n.diagrams.length).length;
if (unused) {
const hint = el('button', 'ag-link', `${unused} unused registry element${unused === 1 ? '' : 's'}`);
hint.type = 'button';
@@ -384,8 +392,98 @@ class GraphPage {
}
private showSide(target: NodeSingular | EdgeSingular): void {
if (target.isNode()) this.showNode(this.nodesById.get(target.id()));
else this.showEdge(this.edgesById.get(target.id()));
if (!target.isNode()) {
this.showEdge(this.edgesById.get(target.id()));
return;
}
const node = this.nodesById.get(target.id());
if (node?.kind === 'decision') this.showDecision(node);
else if (node?.kind === 'registry') this.showRegistry(node);
else this.showNode(node);
}
private selectNode(id: string): void {
const n = this.cy.$id(id);
this.cy.elements().unselect();
n.select();
this.showSide(n as NodeSingular);
}
private elementLinks(ids: string[]): HTMLElement {
const holder = el('div');
for (const id of ids) {
const n = this.nodesById.get(id);
if (!n) continue;
const button = this.link(n.label, `Show ${n.id}`, () => this.selectNode(n.id));
const dot = el('span', 'ag-dot');
dot.style.setProperty('--c', nodeColor(n));
button.prepend(dot);
holder.appendChild(button);
}
return holder;
}
/** Side panel of a decision node: status, text, what it is about, and the file. */
private showDecision(node: GraphNode): void {
this.side.textContent = '';
const title = el('h2');
const dot = el('span', 'ag-dot');
dot.style.setProperty('--c', nodeColor(node));
title.append(dot, document.createTextNode(node.label));
this.side.appendChild(title);
this.side.appendChild(el('div', 'ag-id', ['decision', node.sublabel, node.status].filter(Boolean).join(' · ')));
if (node.description) this.side.appendChild(el('p', '', node.description));
const actions = el('div', 'ag-side-actions');
if (node.file) actions.appendChild(this.link('Open decision', `Open ${node.file}`, () => this.host.post({ type: 'openDecision', file: node.file })));
if (node.sublabel) actions.appendChild(this.link('What it touches', `Show everything decision ${node.sublabel} is about`, () => this.setQuery(`decision:${node.sublabel}`)));
this.side.appendChild(actions);
const about = this.graph.edges.filter((e) => e.kind === 'decides' && e.from === node.id).map((e) => e.to);
this.side.appendChild(el('h3', '', `About ${about.length} element${about.length === 1 ? '' : 's'}`));
this.side.appendChild(about.length ? this.elementLinks(about) : el('p', 'ag-empty', 'Names no element of this model.'));
const onEdges = this.graph.edges.filter((e) => e.kind === 'connection' && node.sublabel && e.decisions.includes(node.sublabel));
if (onEdges.length) {
this.side.appendChild(el('h3', '', 'On connections'));
const holder = el('div');
for (const e of onEdges) holder.appendChild(this.link(`${this.nodesById.get(e.from)?.label ?? e.from} → ${this.nodesById.get(e.to)?.label ?? e.to}`, e.id, () => { this.cy.elements().unselect(); this.cy.$id(e.id).select(); this.showEdge(e); }));
this.side.appendChild(holder);
}
if (node.diagrams.length) {
this.side.appendChild(el('h3', '', 'Diagrams it names'));
this.side.appendChild(this.diagramLinks(node.diagrams));
}
const related = this.graph.edges.filter((e) => e.kind === 'supersedes' && (e.from === node.id || e.to === node.id));
if (related.length) {
this.side.appendChild(el('h3', '', 'Related decisions'));
const holder = el('div');
for (const e of related) {
const otherId = e.from === node.id ? e.to : e.from;
const other = this.nodesById.get(otherId);
if (other) holder.appendChild(this.link(`${e.from === node.id ? 'supersedes' : 'superseded by'} ${other.label}`, other.sublabel ?? otherId, () => this.selectNode(otherId)));
}
this.side.appendChild(holder);
}
}
/** Side panel of a registry node: the file and the elements it defines. */
private showRegistry(node: GraphNode): void {
this.side.textContent = '';
const title = el('h2');
const dot = el('span', 'ag-dot');
dot.style.setProperty('--c', nodeColor(node));
title.append(dot, document.createTextNode(node.label));
this.side.appendChild(title);
this.side.appendChild(el('div', 'ag-id', `registry · ${node.file ?? ''}`));
const actions = el('div', 'ag-side-actions');
if (node.file) actions.appendChild(this.link('Open registry', `Open ${node.file}`, () => this.host.post({ type: 'openRegistry', file: node.file, line: 0 })));
this.side.appendChild(actions);
const ids = node.elements ?? [];
this.side.appendChild(el('h3', '', `Defines ${ids.length} element${ids.length === 1 ? '' : 's'}`));
this.side.appendChild(ids.length ? this.elementLinks(ids) : el('p', 'ag-empty', 'No elements yet.'));
const unused = ids.filter((id) => !(this.nodesById.get(id)?.diagrams.length));
if (unused.length) {
this.side.appendChild(el('h3', '', `${unused.length} not drawn by any diagram`));
this.side.appendChild(this.elementLinks(unused));
}
}
private link(text: string, title: string, onClick?: () => void, className = ''): HTMLButtonElement {
@@ -438,7 +536,7 @@ class GraphPage {
this.side.appendChild(el('h3', '', `Used in ${node.diagrams.length} diagram${node.diagrams.length === 1 ? '' : 's'}`));
this.side.appendChild(node.diagrams.length ? this.diagramLinks(node.diagrams) : el('p', 'ag-empty', 'Not drawn by any diagram yet.'));
const connected = this.graph.edges.filter((e) => e.from === node.id || e.to === node.id);
const connected = this.graph.edges.filter((e) => e.kind === 'connection' && (e.from === node.id || e.to === node.id));
this.side.appendChild(el('h3', '', `${connected.length} connection${connected.length === 1 ? '' : 's'}`));
const list = el('div');
for (const e of connected) {