- 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
814 lines
39 KiB
TypeScript
814 lines
39 KiB
TypeScript
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();
|