Files
archify-vscode-ext/src/graph/index.ts
T
root-at-skic 17e07adb33 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
2026-10-21 21:45:42 +03:00

814 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import cytoscape, { Core, EdgeSingular, NodeSingular } from 'cytoscape';
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';
/**
* The Graph page of a model: the whole model as one graph, a query box that
* narrows it, a side panel for the selected element or connection. Runs in
* the webview; the extension hands the graph over through the bridge
* (`window.__archifyHost.graph`) and posts a new one when the model changes.
*/
type Host = ArchifyHost;
interface SavedView {
query?: string;
labels?: boolean;
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';
/** The theme in effect: the page's own choice, else VS Code's. */
function currentTheme(): Theme {
const chosen = document.documentElement.dataset.theme;
if (chosen === 'dark' || chosen === 'light') return chosen;
return document.body.classList.contains('vscode-light') ? 'light' : 'dark';
}
cytoscape.use(fcose);
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';
/** 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;
const PLACEHOLDER = 'database · type:backend · diagram:0200-* · uses:api · near:api,2 · from:web to:db · decision:<id> · unused · -shared';
const HELP: [string, string][] = [
['database', 'elements whose id, label, sublabel, brand or type contain the word'],
['type:backend', 'elements of that type'],
['diagram:0200-*', 'what the diagrams matching the path draw (glob)'],
['decision:<id>', 'what a decision is about or is attached to'],
['uses:api', 'an element and its neighbours'],
['near:api,2', 'an element and everything within two steps'],
['from:web to:db', 'the shortest path between two elements'],
['unused', 'registry elements no diagram draws'],
['shared', 'registry elements only; -shared: inline elements only'],
['-worker', 'a leading minus excludes; terms combine with AND'],
];
function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function cssVar(name: string, fallback: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
/** Theme colours Cytoscape needs as literals: the page's tokens, read again whenever the theme changes. */
function palette() {
const dark = currentTheme() === 'dark';
return {
bg: cssVar('--ag-bg', dark ? '#0b1020' : '#ffffff'),
fg: cssVar('--ag-fg', dark ? '#e5e7eb' : '#1f2937'),
muted: cssVar('--ag-muted', dark ? '#9ca3af' : '#6b7280'),
panel: cssVar('--ag-panel', dark ? '#131c31' : '#f3f4f6'),
accent: cssVar('--ag-accent', '#22d3ee'),
edge: dark ? '#64748b' : '#94a3b8',
font: cssVar('--ag-font', 'system-ui, sans-serif'),
};
}
class GraphPage {
cy!: Core;
private graph: ModelGraph;
private readonly nodesById = new Map<string, GraphNode>();
private readonly edgesById = new Map<string, GraphEdge>();
private saved: SavedView;
private queryTimer: number | undefined;
private result: QueryResult | undefined;
private readonly hiddenTypes: Set<string>;
private readonly root = el('div', 'ag');
private readonly nameEl = el('div', 'ag-name');
private readonly kickerEl = el('div', 'ag-kicker');
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');
private readonly main = el('div', 'ag-main');
private readonly canvas = el('div', 'ag-canvas');
private readonly help = el('div', 'ag-help');
constructor(private readonly host: Host, initial: { graph: ModelGraph; query?: string }) {
this.graph = initial.graph;
this.saved = (host.getState().graphView as SavedView | undefined) ?? {};
if (initial.query !== undefined) this.saved.query = initial.query;
if (this.saved.labels === undefined) this.saved.labels = initial.graph.edges.length <= LABELS_UP_TO;
this.hiddenTypes = new Set(this.saved.hidden ?? []);
this.applyTheme(this.saved.theme ?? archifyThemePreference());
this.index();
this.buildChrome();
this.buildGraph(true);
this.syncLabels();
this.renderLegend();
this.syncLayoutButton();
this.queryInput.value = this.saved.query ?? '';
this.applyQuery();
this.showEmptySide();
this.listen();
}
// ---------------------------------------------------------------- data
private index(): void {
this.nodesById.clear();
this.edgesById.clear();
for (const n of this.graph.nodes) this.nodesById.set(n.id, n);
for (const e of this.graph.edges) this.edgesById.set(e.id, e);
}
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) } }));
// 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];
}
/** A new graph from the extension: keep the positions of the elements that are still there. */
replace(graph: ModelGraph): void {
const positions = new Map<string, { x: number; y: number }>();
this.cy.nodes().forEach((n) => {
positions.set(n.id(), { ...n.position() });
});
const selected = this.cy.$(':selected').map((e) => e.id());
this.graph = graph;
this.index();
this.cy.elements().remove();
this.cy.add(this.elements());
let fresh = 0;
this.cy.nodes().forEach((n) => {
const at = positions.get(n.id());
if (at) n.position(at);
else fresh += 1;
});
if (fresh || this.saved.layout !== 'force') this.layout(false);
this.syncLinks();
this.renderLegend();
this.applyHidden();
this.applyQuery();
const again = this.cy.$(selected.map((id) => `#${CSS.escape(id)}`).join(','));
if (again.length) {
again.select();
this.showSide(again[0]);
} else this.showEmptySide();
this.kickerEl.textContent = `Model${graph.meta.id ? ` · ${graph.meta.id}` : ''}`;
this.nameEl.textContent = graph.meta.name || graph.meta.id || 'Untitled model';
}
// -------------------------------------------------------------- chrome
private buildChrome(): void {
const style = el('style');
style.textContent = GRAPH_PAGE_STYLE;
document.head.appendChild(style);
const top = el('header', 'ag-top');
const titles = el('div');
this.kickerEl.textContent = `Model${this.graph.meta.id ? ` · ${this.graph.meta.id}` : ''}`;
this.nameEl.textContent = this.graph.meta.name || this.graph.meta.id || 'Untitled model';
titles.append(this.kickerEl, this.nameEl);
const actions = el('div', 'ag-actions');
const sw = el('button', 'ag-switch');
sw.type = 'button';
sw.title = 'Back to the model page';
sw.setAttribute('aria-label', 'Graph view on; switch to the model page');
sw.append(el('span', 'ag-seg', 'Model'), el('span', 'ag-seg on', 'Graph'));
sw.addEventListener('click', () => this.host.post({ type: 'showModel' }));
const exportButton = el('button', 'ag-button', 'Export PNG');
exportButton.type = 'button';
exportButton.title = 'Save the graph as it is on screen, as a PNG';
exportButton.addEventListener('click', () => this.exportPng());
this.themeButton.type = 'button';
this.themeButton.addEventListener('click', () => this.setTheme(currentTheme() === 'dark' ? 'light' : 'dark'));
this.syncThemeButton();
actions.append(sw, this.themeButton, exportButton);
top.append(titles, actions);
const bar = el('div', 'ag-bar');
const wrap = el('div', 'ag-query-wrap');
this.queryInput.type = 'text';
this.queryInput.placeholder = PLACEHOLDER;
this.queryInput.spellcheck = false;
this.queryInput.setAttribute('aria-label', 'Query');
const clear = el('button', 'ag-clear', '×');
clear.type = 'button';
clear.title = 'Clear (Esc)';
clear.addEventListener('click', () => this.setQuery(''));
wrap.append(this.queryInput, clear);
const fit = el('button', 'ag-button', 'Fit');
fit.type = 'button';
fit.title = 'Fit the matching elements into view (0)';
fit.addEventListener('click', () => this.fit());
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', () => {
this.saved.labels = !this.saved.labels;
this.save();
this.syncLabels();
});
const helpButton = el('button', 'ag-button', '?');
helpButton.type = 'button';
helpButton.title = 'What can I type?';
helpButton.setAttribute('aria-expanded', 'false');
helpButton.addEventListener('click', () => {
this.help.hidden = !this.help.hidden;
helpButton.setAttribute('aria-expanded', String(!this.help.hidden));
});
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);
this.help.hidden = true;
this.help.appendChild(el('h3', '', 'Queries'));
const table = el('table');
for (const [example, meaning] of HELP) {
const row = el('tr');
const code = el('td', '', example);
code.title = 'Try it';
code.addEventListener('click', () => {
this.setQuery(example.includes('<id>') ? example.replace('<id>', this.graph.decisions[0]?.id ?? '') : example);
});
row.append(code, el('td', '', meaning));
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. 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);
this.syncLabels();
}
private renderLegend(): void {
this.legend.textContent = '';
const counts = new Map<string, number>();
for (const n of this.graph.nodes) counts.set(n.type ?? 'other', (counts.get(n.type ?? 'other') ?? 0) + 1);
const types = [...counts.keys()].sort((a, b) => (TYPE_ORDER.indexOf(a) + 1 || 99) - (TYPE_ORDER.indexOf(b) + 1 || 99) || a.localeCompare(b));
for (const type of types) {
const chip = el('button', 'ag-chip');
chip.type = 'button';
chip.style.setProperty('--c', colorOf(type));
chip.setAttribute('aria-pressed', String(!this.hiddenTypes.has(type)));
chip.title = `Show or hide ${type} elements`;
chip.append(document.createTextNode(type), el('small', '', String(counts.get(type))));
chip.addEventListener('click', () => {
if (this.hiddenTypes.has(type)) this.hiddenTypes.delete(type);
else this.hiddenTypes.add(type);
chip.setAttribute('aria-pressed', String(!this.hiddenTypes.has(type)));
this.saved.hidden = [...this.hiddenTypes];
this.save();
this.applyHidden();
this.updateCount();
});
this.legend.appendChild(chip);
}
}
// --------------------------------------------------------------- graph
private buildGraph(first: boolean): void {
const holder = this.canvas.firstElementChild as HTMLElement;
this.cy = cytoscape({
container: holder,
elements: this.elements(),
style: this.stylesheet(),
wheelSensitivity: 0.2,
minZoom: 0.1,
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(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: 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: '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(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
/** Sets the page's own theme (or follows VS Code when undefined) without touching the saved choice. */
private applyTheme(theme: Theme | undefined): void {
if (theme) document.documentElement.dataset.theme = theme;
else delete document.documentElement.dataset.theme;
}
private setTheme(theme: Theme): void {
this.applyTheme(theme);
this.saved.theme = theme;
this.save();
try {
localStorage.setItem('archify-theme', theme); // the diagram pages read the same key
} catch {
// storage may be unavailable; the page state keeps the choice
}
this.restyle();
}
private syncThemeButton(): void {
const dark = currentTheme() === 'dark';
this.themeButton.textContent = dark ? '\u263e' : '\u2600';
this.themeButton.title = dark ? 'Switch to the light theme' : 'Switch to the dark theme';
this.themeButton.setAttribute('aria-label', this.themeButton.title);
}
private restyle(): void {
this.cy?.style(this.stylesheet());
this.syncLabels();
this.syncThemeButton();
}
private applyHidden(): void {
this.cy.nodes().forEach((n) => {
n.toggleClass('hidden', this.hiddenTypes.has(String(n.data('type') || 'other')));
});
}
private syncLabels(): void {
const on = Boolean(this.saved.labels);
this.labelsButton.setAttribute('aria-pressed', String(on));
this.cy?.edges().toggleClass('labelled', on);
}
// --------------------------------------------------------------- query
private setQuery(text: string): void {
this.queryInput.value = text;
this.applyQuery();
this.queryInput.focus();
}
private applyQuery(): void {
const text = this.queryInput.value;
this.saved.query = text;
this.save();
this.result = runQuery(this.graph, text);
const { nodes, edges, path } = this.result;
const empty = !text.trim();
this.cy.batch(() => {
this.cy.elements().removeClass('dim hit path');
if (empty) return;
this.cy.nodes().forEach((n) => {
n.toggleClass('dim', !nodes.has(n.id())).toggleClass('hit', nodes.has(n.id()));
});
this.cy.edges().forEach((e) => {
e.toggleClass('dim', !edges.has(e.id()));
});
if (path) for (const id of edges) this.cy.$id(id).addClass('path');
});
this.updateCount();
}
private updateCount(): void {
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('[kind = "connection"]').not('.hidden').length} connections`;
this.countEl.classList.remove('is-error');
return;
}
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));
}
private fit(): void {
const hits = this.cy.nodes('.hit').not('.hidden');
const target = hits.length ? hits.union(hits.connectedEdges().not('.dim')) : this.cy.elements().not('.hidden');
this.cy.animate({ fit: { eles: target, padding: 40 }, duration: 250 });
}
// ---------------------------------------------------------------- side
private showEmptySide(): void {
this.side.textContent = '';
this.side.appendChild(el('p', 'ag-empty', 'Click an element or a connection.'));
const h3 = el('h3', '', 'This model');
this.side.appendChild(h3);
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 = 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';
hint.addEventListener('click', () => this.setQuery('unused'));
this.side.appendChild(hint);
}
}
private showSide(target: NodeSingular | EdgeSingular): void {
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 {
const button = el('button', `ag-link ${className}${onClick ? '' : ' is-static'}`);
button.type = 'button';
button.title = title;
button.appendChild(document.createTextNode(text));
if (onClick) button.addEventListener('click', onClick);
return button;
}
private decisionLinks(ids: string[]): HTMLElement {
const holder = el('div');
for (const id of ids) {
const d = this.graph.decisions.find((x) => x.id === id);
const button = this.link('', d ? `${d.title ?? d.id} (${d.status})` : id, d ? () => this.host.post({ type: 'openDecision', file: d.file }) : undefined, `is-${d?.status ?? 'unknown'}`);
button.prepend(el('span', 'ag-dot'));
button.appendChild(document.createTextNode([id, d?.title, d?.status].filter(Boolean).join(' · ')));
holder.appendChild(button);
}
return holder;
}
private diagramLinks(paths: string[]): HTMLElement {
const holder = el('div');
for (const path of paths) {
const d = this.graph.diagrams.find((x) => x.path === path);
holder.appendChild(this.link(path.replace(/\.archify$/, ''), d?.title ? `Open ${d.title} (${path})` : `Open ${path}`, () => this.host.post({ type: 'openDiagram', path })));
}
return holder;
}
private showNode(node: GraphNode | undefined): void {
if (!node) return;
this.side.textContent = '';
const title = el('h2');
const dot = el('span', 'ag-dot');
dot.style.setProperty('--c', colorOf(node.type));
title.append(dot, document.createTextNode(node.label));
this.side.appendChild(title);
this.side.appendChild(el('div', 'ag-id', [node.id, node.type, node.sublabel, node.brand ? `brand ${node.brand}` : undefined, node.shared ? 'registry' : 'inline'].filter(Boolean).join(' · ')));
this.side.appendChild(node.description ? el('p', '', node.description) : el('p', 'ag-empty', node.shared ? 'No description in the registry.' : 'Inline element: defined in the diagram, not in a registry.'));
const actions = el('div', 'ag-side-actions');
actions.appendChild(this.link('Neighbours', `Show ${node.id} and everything connected to it`, () => this.setQuery(`uses:${node.id}`)));
if (node.file) actions.appendChild(this.link('Registry', `Open ${node.file} at line ${(node.line ?? 0) + 1}`, () => this.host.post({ type: 'openRegistry', file: node.file, line: node.line ?? 0 })));
if (node.link) actions.appendChild(this.link('Explained by', `Open ${node.link}`, () => this.host.post({ type: 'openDiagram', path: node.link })));
this.side.appendChild(actions);
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.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) {
const other = e.from === node.id ? e.to : e.from;
const otherLabel = this.nodesById.get(other)?.label ?? other;
const text = e.from === node.id ? `→ ${otherLabel}` : `← ${otherLabel}`;
list.appendChild(this.link(e.labels.length ? `${text} · ${e.labels.join(' / ')}` : text, `${e.from} → ${e.to}`, () => {
const edge = this.cy.$id(e.id);
this.cy.elements().unselect();
edge.select();
this.showEdge(e);
}));
}
this.side.appendChild(list);
if (node.decisions.length) {
this.side.appendChild(el('h3', '', 'Decisions'));
this.side.appendChild(this.decisionLinks(node.decisions));
}
}
private showEdge(edge: GraphEdge | undefined): void {
if (!edge) return;
this.side.textContent = '';
const from = this.nodesById.get(edge.from);
const to = this.nodesById.get(edge.to);
const title = el('h2');
title.textContent = `${from?.label ?? edge.from} → ${to?.label ?? edge.to}`;
this.side.appendChild(title);
this.side.appendChild(el('div', 'ag-id', `${edge.from} → ${edge.to}${edge.labels.length ? ` · ${edge.labels.join(' / ')}` : ''}`));
const ends = el('div', 'ag-side-actions');
for (const end of [from, to]) {
if (!end) continue;
const button = this.link(end.label, `Show ${end.id}`, () => {
const n = this.cy.$id(end.id);
this.cy.elements().unselect();
n.select();
this.showNode(end);
});
const dot = el('span', 'ag-dot');
dot.style.setProperty('--c', colorOf(end.type));
button.prepend(dot);
ends.appendChild(button);
}
this.side.appendChild(ends);
this.side.appendChild(el('h3', '', `Drawn in ${edge.diagrams.length} diagram${edge.diagrams.length === 1 ? '' : 's'}`));
this.side.appendChild(this.diagramLinks(edge.diagrams));
if (edge.decisions.length) {
this.side.appendChild(el('h3', '', 'Decisions'));
this.side.appendChild(this.decisionLinks(edge.decisions));
}
}
// -------------------------------------------------------------- export
private exportPng(): void {
const blob = this.cy.png({ output: 'blob', full: true, scale: 2, bg: palette().bg });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${this.graph.meta.id || 'model'}-graph.png`;
anchor.hidden = true;
document.body.appendChild(anchor);
anchor.click(); // the bridge turns the download into a VS Code save dialog
setTimeout(() => {
anchor.remove();
URL.revokeObjectURL(url);
}, 1000);
}
// -------------------------------------------------------------- events
private listen(): void {
this.queryInput.addEventListener('input', () => {
window.clearTimeout(this.queryTimer);
this.queryTimer = window.setTimeout(() => this.applyQuery(), 120);
});
this.queryInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
window.clearTimeout(this.queryTimer);
this.applyQuery();
this.fit();
} else if (event.key === 'Escape') {
if (this.queryInput.value) this.setQuery('');
else this.queryInput.blur();
}
});
window.addEventListener('keydown', (event) => {
const target = event.target as HTMLElement | null;
const typing = target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName));
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === '/') {
event.preventDefault();
this.queryInput.focus();
this.queryInput.select();
} else if (event.key === '0') this.fit();
else if (event.key === 'Escape') {
this.cy.elements().unselect();
this.showEmptySide();
this.help.hidden = true;
}
});
window.addEventListener('resize', () => this.cy.resize());
new MutationObserver(() => this.restyle()).observe(document.body, { attributes: true, attributeFilter: ['class'] });
window.addEventListener('message', (event) => {
const message = event.data || {};
if (message.type === 'graph' && message.graph) this.replace(message.graph as ModelGraph);
if (message.type === 'query' && typeof message.query === 'string') this.setQuery(message.query);
});
}
private save(): void {
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 } });
}
}
/** The theme the Archify diagram pages last used, when they stored one. */
function archifyThemePreference(): Theme | undefined {
try {
const value = localStorage.getItem('archify-theme');
return value === 'dark' || value === 'light' ? value : undefined;
} catch {
return undefined;
}
}
function start(): void {
const host = window.__archifyHost;
if (!host?.graph) return;
// Exposed for the integration harness; nothing in the page depends on it.
(window as unknown as { __archifyGraphPage?: GraphPage }).__archifyGraphPage = new GraphPage(host, host.graph);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();