Model graph: the whole model as one graph with queries; 0.9.0
- Model | Graph switch on main.archify (Diagram | Graph on diagram pages, narrowed to that diagram) - union graph of every element and connection, registry and inline, built from the model index - query box: text, type:, diagram:, decision:, uses:, near:, from: to:, unused, shared, -shared - side panel per element and connection with diagrams, registry line and decisions one click away - legend chips, Fit, Layout, Labels, Export PNG; Cytoscape.js bundled as dist/graph.js - command Archify | Open Model Graph; integration check; unit tests for graph and queries
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
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 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[];
|
||||
}
|
||||
|
||||
cytoscape.use(fcose);
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = { frontend: '#22d3ee', backend: '#34d399', database: '#a78bfa', cloud: '#fbbf24', security: '#fb7185', messagebus: '#fb923c', external: '#94a3b8' };
|
||||
const TYPE_ORDER = ['external', 'frontend', 'cloud', 'security', 'backend', 'messagebus', 'database'];
|
||||
const colorOf = (type: string | undefined) => TYPE_COLORS[type ?? ''] ?? '#94a3b8';
|
||||
/** 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; read again when VS Code switches theme. */
|
||||
function palette() {
|
||||
const dark = !document.body.classList.contains('vscode-light');
|
||||
return {
|
||||
bg: cssVar('--vscode-editor-background', dark ? '#0b1020' : '#ffffff'),
|
||||
fg: cssVar('--vscode-foreground', dark ? '#e5e7eb' : '#1f2937'),
|
||||
muted: cssVar('--vscode-descriptionForeground', dark ? '#9ca3af' : '#6b7280'),
|
||||
panel: cssVar('--vscode-editorWidget-background', dark ? '#131c31' : '#f3f4f6'),
|
||||
accent: cssVar('--vscode-textLink-foreground', '#22d3ee'),
|
||||
edge: dark ? '#64748b' : '#94a3b8',
|
||||
font: cssVar('--vscode-font-family', '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 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.index();
|
||||
this.buildChrome();
|
||||
this.buildGraph(true);
|
||||
this.syncLabels();
|
||||
this.renderLegend();
|
||||
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 ?? '', shared: n.shared ? 1 : 0, color: colorOf(n.type) } }));
|
||||
const edges = this.graph.edges.map((e) => ({ group: 'edges' as const, data: { id: e.id, source: e.from, target: e.to, label: e.labels.join(' / ') } }));
|
||||
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.layout(false);
|
||||
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());
|
||||
actions.append(sw, 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());
|
||||
const relayout = el('button', 'ag-button', 'Layout');
|
||||
relayout.type = 'button';
|
||||
relayout.title = 'Lay the graph out again';
|
||||
relayout.addEventListener('click', () => this.layout(true));
|
||||
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, relayout, this.labelsButton, 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.'));
|
||||
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.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();
|
||||
});
|
||||
}
|
||||
|
||||
private stylesheet(): cytoscape.StylesheetJson {
|
||||
const p = palette();
|
||||
return [
|
||||
{ selector: 'node', style: { shape: 'round-rectangle', width: (n: NodeSingular) => Math.min(260, Math.max(84, String(n.data('label')).length * 7.5 + 34)), height: 42, 'background-color': p.panel, 'border-width': 2, 'border-color': 'data(color)', label: 'data(label)', color: p.fg, 'font-size': 13, 'font-weight': 600, 'font-family': p.font, 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'ellipsis', 'text-max-width': '230px' } },
|
||||
{ selector: 'node[shared = 0]', style: { 'border-style': 'dashed' } },
|
||||
{ selector: 'edge', style: { width: 1.5, 'line-color': p.edge, 'target-arrow-color': p.edge, 'target-arrow-shape': 'triangle', 'arrow-scale': 0.9, 'curve-style': 'bezier', label: '', 'font-size': 10, 'font-family': p.font, color: p.muted, 'text-background-color': p.bg, 'text-background-opacity': 1, 'text-background-padding': '2px', 'text-rotation': 'autorotate' } },
|
||||
{ selector: 'edge.labelled', style: { label: 'data(label)' } },
|
||||
{ selector: '.dim', style: { opacity: 0.14, events: 'no' } },
|
||||
{ selector: 'node.hit', style: { 'border-width': 3 } },
|
||||
{ selector: 'edge.path', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, color: p.fg } },
|
||||
{ selector: 'node:selected', style: { 'border-color': p.accent, 'border-width': 3, 'background-color': p.panel, 'overlay-opacity': 0 } },
|
||||
{ selector: 'edge:selected', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, 'overlay-opacity': 0 } },
|
||||
{ selector: '.hidden', style: { display: 'none' } },
|
||||
] as unknown as cytoscape.StylesheetJson;
|
||||
}
|
||||
|
||||
private layout(randomize: boolean): void {
|
||||
const visible = this.cy.elements().not('.hidden');
|
||||
visible
|
||||
.layout({ name: 'fcose', animate: false, randomize, quality: 'default', nodeDimensionsIncludeLabels: true, nodeRepulsion: () => 9000, idealEdgeLength: () => 150, edgeElasticity: () => 0.2, gravity: 0.3, padding: 40, fit: true } as cytoscape.LayoutOptions)
|
||||
.run();
|
||||
}
|
||||
|
||||
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().not('.hidden').length;
|
||||
const r = this.result;
|
||||
if (!r || !this.queryInput.value.trim()) {
|
||||
this.countEl.textContent = `${total} elements · ${this.cy.edges().not('.hidden').length} connections`;
|
||||
this.countEl.classList.remove('is-error');
|
||||
return;
|
||||
}
|
||||
const shown = this.cy.nodes('.hit').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 summary = el('p', '', `${this.graph.nodes.length} elements, ${this.graph.edges.length} connections, ${this.graph.diagrams.length} diagrams, ${this.graph.decisions.length} decisions.`);
|
||||
this.side.appendChild(summary);
|
||||
const unused = this.graph.nodes.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.showNode(this.nodesById.get(target.id()));
|
||||
else this.showEdge(this.edgesById.get(target.id()));
|
||||
}
|
||||
|
||||
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.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.cy.style(this.stylesheet());
|
||||
this.syncLabels();
|
||||
}).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 ?? [] } });
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
Reference in New Issue
Block a user