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 = { 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 = { 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: · 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:', '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(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(); private readonly edgesById = new Map(); private saved: SavedView; private queryTimer: number | undefined; private result: QueryResult | undefined; private readonly hiddenTypes: Set; 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(); 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(); 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('') ? example.replace('', 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(); 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();