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:
2026-10-21 21:45:42 +03:00
parent 3ed2d7eed8
commit 17e07adb33
4 changed files with 198 additions and 27 deletions
+116 -25
View File
@@ -3,6 +3,7 @@ import fcose from 'cytoscape-fcose';
import type { GraphEdge, GraphNode, ModelGraph } from '../model/graph';
import { QueryResult, runQuery } from '../model/graphQuery';
import { GRAPH_PAGE_STYLE } from './style';
import { layeredLayout } from './layout';
import type { ArchifyHost } from '../webview/host';
/**
@@ -20,6 +21,10 @@ interface SavedView {
hidden?: string[];
/** A theme chosen on the page; absent means follow VS Code. */
theme?: 'dark' | 'light';
/** Layered by type (default) or force-directed. */
layout?: 'layered' | 'force';
/** Show every decision and registry link, not only the selected element's. */
links?: boolean;
}
type Theme = 'dark' | 'light';
@@ -37,6 +42,21 @@ const TYPE_COLORS: Record<string, string> = { frontend: '#22d3ee', backend: '#34
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';
/** An opaque mix of two `#rrggbb` colours (`amount` of the first), so nodes hide the connections behind them. */
function mix(front: string, back: string, amount: number): string {
const f = parseInt(front.slice(1), 16);
const b = parseInt(back.replace(/^#/, '').padEnd(6, '0').slice(0, 6), 16);
const channel = (shift: number) => Math.round(((f >> shift) & 255) * amount + ((b >> shift) & 255) * (1 - amount));
return `rgb(${channel(16)}, ${channel(8)}, ${channel(0)})`;
}
/** `#rrggbb` form of a CSS colour, read through the canvas so `rgb()` and names work too. */
function toHex(color: string): string {
if (/^#[0-9a-f]{6}$/i.test(color)) return color;
const canvas = document.createElement('canvas').getContext('2d');
if (!canvas) return '#000000';
canvas.fillStyle = color;
return /^#[0-9a-f]{6}$/i.test(canvas.fillStyle) ? canvas.fillStyle : '#000000';
}
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;
@@ -97,6 +117,8 @@ class GraphPage {
private readonly queryInput = el('input', 'ag-query');
private readonly countEl = el('span', 'ag-count');
private readonly labelsButton = el('button', 'ag-button', 'Labels');
private readonly linksButton = el('button', 'ag-button', 'Links');
private readonly layoutButton = el('button', 'ag-button', 'Layered');
private readonly themeButton = el('button', 'ag-button ag-theme');
private readonly legend = el('div', 'ag-legend');
private readonly side = el('aside', 'ag-side');
@@ -116,6 +138,7 @@ class GraphPage {
this.buildGraph(true);
this.syncLabels();
this.renderLegend();
this.syncLayoutButton();
this.queryInput.value = this.saved.query ?? '';
this.applyQuery();
this.showEmptySide();
@@ -133,7 +156,13 @@ 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 ?? '', 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(' / ') } }));
// Labels of connections that leave the same element are staggered along the line so they do not pile up.
const leaving = new Map<string, number>();
const edges = this.graph.edges.map((e) => {
const index = e.kind === 'connection' ? leaving.get(e.from) ?? 0 : 0;
if (e.kind === 'connection') leaving.set(e.from, index + 1);
return { group: 'edges' as const, data: { id: e.id, source: e.from, target: e.to, kind: e.kind, label: e.labels.join(' / '), offset: 44 + (index % 4) * 30 } };
});
return [...nodes, ...edges];
}
@@ -154,7 +183,8 @@ class GraphPage {
if (at) n.position(at);
else fresh += 1;
});
if (fresh) this.layout(false);
if (fresh || this.saved.layout !== 'force') this.layout(false);
this.syncLinks();
this.renderLegend();
this.applyHidden();
this.applyQuery();
@@ -211,10 +241,20 @@ class GraphPage {
fit.type = 'button';
fit.title = 'Fit the matching elements into view (0)';
fit.addEventListener('click', () => this.fit());
const relayout = el('button', 'ag-button', 'Layout');
relayout.type = 'button';
relayout.title = 'Lay the graph out again';
relayout.addEventListener('click', () => this.layout(true));
this.layoutButton.type = 'button';
this.layoutButton.addEventListener('click', () => {
this.saved.layout = this.saved.layout === 'force' ? 'layered' : 'force';
this.save();
this.layout(true);
this.syncLayoutButton();
});
this.linksButton.type = 'button';
this.linksButton.title = 'Show the links from decisions and registries to their elements; off, they show for the selected element only';
this.linksButton.addEventListener('click', () => {
this.saved.links = !this.saved.links;
this.save();
this.syncLinks();
});
this.labelsButton.type = 'button';
this.labelsButton.title = 'Show or hide connection labels';
this.labelsButton.addEventListener('click', () => {
@@ -230,7 +270,7 @@ class GraphPage {
this.help.hidden = !this.help.hidden;
helpButton.setAttribute('aria-expanded', String(!this.help.hidden));
});
bar.append(wrap, this.countEl, fit, relayout, this.labelsButton, helpButton);
bar.append(wrap, this.countEl, fit, this.layoutButton, this.labelsButton, this.linksButton, helpButton);
const cyHolder = el('div', 'ag-cy');
this.canvas.append(cyHolder, this.legend);
@@ -248,7 +288,7 @@ class GraphPage {
table.appendChild(row);
}
this.help.appendChild(table);
this.help.appendChild(el('p', '', 'Matches stay bright, the rest dims. Connections show when both ends match. Click an element for its details; drag to move it; scroll to zoom. Press / to type a query.'));
this.help.appendChild(el('p', '', 'Matches stay bright, the rest dims. Connections show when both ends match. Click an element for its details; drag to move it; scroll to zoom. Press / to type a query. Layered puts clients on the left and data on the right, with registries before and decisions after; Links shows every decision and registry link instead of the selected element\u2019s only.'));
this.main.append(this.canvas, this.side, this.help);
this.root.append(top, bar, this.main);
document.body.appendChild(this.root);
@@ -293,38 +333,89 @@ class GraphPage {
maxZoom: 4,
});
this.applyHidden();
this.syncLinks();
this.layout(first);
this.cy.on('tap', 'node, edge', (event) => this.showSide(event.target as NodeSingular | EdgeSingular));
this.cy.on('tap', (event) => {
if (event.target === this.cy) this.showEmptySide();
});
// Hover lights up an element and its connections; selection also reveals its decision and registry links.
this.cy.on('mouseover', 'node', (event) => {
const node = event.target as NodeSingular;
node.addClass('hover');
node.connectedEdges('[kind = "connection"]').addClass('edge-hover');
});
this.cy.on('mouseout', 'node', (event) => {
const node = event.target as NodeSingular;
node.removeClass('hover');
node.connectedEdges().removeClass('edge-hover');
});
this.cy.on('select unselect', 'node', () => this.syncLinks());
}
/** Decision and registry links: all of them when Links is on, else those of the selected nodes. */
private syncLinks(): void {
const all = Boolean(this.saved.links);
this.linksButton.setAttribute('aria-pressed', String(all));
const meta = this.cy.edges('[kind != "connection"]');
meta.removeClass('shown');
if (all) meta.addClass('shown');
else this.cy.nodes(':selected').connectedEdges('[kind != "connection"]').addClass('shown');
}
private syncLayoutButton(): void {
const force = this.saved.layout === 'force';
this.layoutButton.textContent = force ? 'Force' : 'Layered';
this.layoutButton.title = force ? 'Force-directed layout; click for the layered one (clients left, data right)' : 'Layered by type; click for a force-directed layout';
}
private stylesheet(): cytoscape.StylesheetJson {
const p = palette();
const dark = currentTheme() === 'dark';
const bg = toHex(p.bg);
const fill = (amount: number) => (n: NodeSingular) => mix(String(n.data('color')), bg, amount);
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', style: { shape: 'round-rectangle', width: (n: NodeSingular) => Math.min(240, Math.max(110, String(n.data('label')).length * 7.6 + 40)), height: 46, 'background-color': fill(dark ? 0.16 : 0.1), 'border-width': 1.5, 'border-color': 'data(color)', 'border-opacity': 0.9, 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': '210px', 'overlay-opacity': 0, 'underlay-color': 'data(color)', 'underlay-padding': 6, 'underlay-opacity': 0, 'underlay-shape': 'round-rectangle', 'transition-property': 'underlay-opacity, border-width', 'transition-duration': 120 } },
{ 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[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: 'node[kind = "decision"]', style: { shape: 'round-tag', height: 34, 'font-size': 11, 'font-weight': 500, 'background-color': fill(dark ? 0.12 : 0.08), 'text-max-width': '190px', width: (n: NodeSingular) => Math.min(220, Math.max(120, String(n.data('label')).length * 6.4 + 44)) } },
{ selector: 'node[kind = "registry"]', style: { shape: 'cut-rectangle', height: 32, 'font-size': 11, 'font-weight': 500, 'font-family': 'ui-monospace, Menlo, monospace', 'border-width': 1, 'border-color': p.muted, 'background-color': mix(dark ? '#94a3b8' : '#64748b', bg, 0.08), width: (n: NodeSingular) => Math.min(220, Math.max(110, String(n.data('label')).length * 6.8 + 36)) } },
{ selector: 'node.hover', style: { 'underlay-opacity': dark ? 0.22 : 0.16, 'border-width': 2, 'background-color': fill(dark ? 0.28 : 0.18) } },
{ selector: 'edge', style: { width: 1.4, 'line-color': p.edge, 'line-opacity': 0.85, 'target-arrow-color': p.edge, 'target-arrow-shape': 'triangle', 'arrow-scale': 0.8, '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': '3px', 'text-background-shape': 'round-rectangle', 'text-rotation': 'autorotate', 'transition-property': 'width, line-color', 'transition-duration': 120 } },
{ selector: 'edge.ortho', style: { 'curve-style': 'taxi', 'taxi-direction': 'auto', 'taxi-turn': '45%', 'taxi-turn-min-distance': 24, 'text-rotation': 'none', 'source-text-offset': 'data(offset)', 'source-text-rotation': 'none', 'source-text-background-color': p.bg, 'source-text-background-opacity': 1, 'source-text-background-padding': '3px', 'source-text-background-shape': 'round-rectangle' } },
{ selector: 'edge.edge-hover', style: { width: 2.4, 'line-color': p.fg, 'target-arrow-color': p.fg, 'line-opacity': 1, color: p.fg } },
{ selector: 'edge[kind != "connection"]', style: { display: 'none', width: 1, 'curve-style': 'bezier', 'line-style': 'dotted', 'target-arrow-shape': 'none', 'line-color': p.muted, 'line-opacity': 0.55 } },
{ selector: 'edge[kind = "supersedes"]', style: { 'line-style': 'dashed', 'line-color': TYPE_COLORS.decision, 'target-arrow-shape': 'triangle', 'target-arrow-color': TYPE_COLORS.decision } },
{ selector: 'edge[kind != "connection"].shown', style: { display: 'element' } },
{ 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 } },
{ selector: 'node:selected', style: { 'border-color': p.accent, 'border-width': 3, 'background-color': p.panel, 'overlay-opacity': 0 } },
{ selector: 'edge:selected', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, 'overlay-opacity': 0 } },
{ selector: 'edge.labelled.ortho[kind = "connection"]', style: { label: '', 'source-label': 'data(label)' } },
{ selector: '.dim', style: { opacity: 0.12, events: 'no' } },
{ selector: 'node.hit', style: { 'border-width': 2.5 } },
{ selector: 'edge.path', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, color: p.fg, 'line-opacity': 1 } },
{ selector: 'node:selected', style: { 'border-color': p.accent, 'border-width': 2.5, 'underlay-color': p.accent, 'underlay-opacity': dark ? 0.28 : 0.18, 'background-color': fill(dark ? 0.3 : 0.2) } },
{ selector: 'edge:selected', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, 'line-opacity': 1 } },
{ selector: '.hidden', style: { display: 'none' } },
] as unknown as cytoscape.StylesheetJson;
}
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: (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();
private layout(fresh: boolean): void {
const force = this.saved.layout === 'force';
this.cy.edges().toggleClass('ortho', !force);
if (force) {
this.cy
.elements()
.not('.hidden')
.layout({ name: 'fcose', animate: false, randomize: fresh, 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();
return;
}
const positions = layeredLayout(this.graph.nodes, this.graph.edges);
this.cy.batch(() => {
this.cy.nodes().forEach((n) => {
const at = positions.get(n.id());
if (at) n.position(at);
});
});
this.cy.fit(this.cy.elements().not('.hidden'), 40);
}
// --------------------------------------------------------------- theme
@@ -697,7 +788,7 @@ class GraphPage {
}
private save(): void {
this.host.setState({ graphView: { query: this.saved.query, labels: this.saved.labels, hidden: this.saved.hidden ?? [], theme: this.saved.theme } });
this.host.setState({ graphView: { query: this.saved.query, labels: this.saved.labels, hidden: this.saved.hidden ?? [], theme: this.saved.theme, layout: this.saved.layout, links: this.saved.links } });
}
}