Model graph: decisions and registry files as nodes
- one node per decision (tag coloured by status) with decides links to its elements and supersedes links - one node per registry file with defines links to its elements - own legend chips, side panels (Open decision, Open registry), type:decision and type:registry - walks and paths follow the diagrams' connections only; attached nodes follow their elements into a result
This commit is contained in:
1 parent
c8645b228b
commit
99ea076571
8 files changed
+237
-42
No files matched your search
+112
-14
@@ -22,9 +22,11 @@ interface SavedView {
|
||||
|
||||
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 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';
|
||||
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';
|
||||
@@ -117,8 +119,8 @@ class GraphPage {
|
||||
}
|
||||
|
||||
private elements() {
|
||||
const nodes = this.graph.nodes.map((n) => ({ group: 'nodes' as const, data: { id: n.id, label: n.label, type: n.type ?? '', 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(' / ') } }));
|
||||
const nodes = this.graph.nodes.map((n) => ({ group: 'nodes' as const, data: { id: n.id, label: n.label, type: n.type ?? '', kind: n.kind, shared: n.shared ? 1 : 0, color: nodeColor(n) } }));
|
||||
const edges = this.graph.edges.map((e) => ({ group: 'edges' as const, data: { id: e.id, source: e.from, target: e.to, kind: e.kind, label: e.labels.join(' / ') } }));
|
||||
return [...nodes, ...edges];
|
||||
}
|
||||
|
||||
@@ -287,8 +289,12 @@ class GraphPage {
|
||||
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: 'node[kind = "decision"]', style: { shape: 'round-tag', height: 36, 'font-size': 11, 'font-weight': 500, 'background-color': p.bg, 'text-max-width': '270px', width: (n: NodeSingular) => Math.min(300, Math.max(90, String(n.data('label')).length * 6.6 + 44)) } },
|
||||
{ selector: 'node[kind = "registry"]', style: { shape: 'cut-rectangle', height: 34, 'font-size': 11, 'font-weight': 500, 'font-family': 'ui-monospace, Menlo, monospace', 'border-width': 1.5, 'border-color': p.muted, 'background-color': p.bg, width: (n: NodeSingular) => Math.min(230, Math.max(90, String(n.data('label')).length * 6.8 + 36)) } },
|
||||
{ selector: 'edge', style: { width: 1.5, 'line-color': p.edge, 'target-arrow-color': p.edge, 'target-arrow-shape': 'triangle', 'arrow-scale': 0.9, 'curve-style': 'bezier', label: '', 'font-size': 10, 'font-family': p.font, color: p.muted, 'text-background-color': p.bg, 'text-background-opacity': 1, 'text-background-padding': '2px', 'text-rotation': 'autorotate' } },
|
||||
{ selector: 'edge.labelled', style: { label: 'data(label)' } },
|
||||
{ selector: 'edge[kind = "decides"], edge[kind = "defines"]', style: { width: 1, 'line-style': 'dotted', 'target-arrow-shape': 'none', 'line-color': p.muted, 'line-opacity': 0.6 } },
|
||||
{ selector: 'edge[kind = "supersedes"]', style: { width: 1, 'line-style': 'dashed', 'line-color': TYPE_COLORS.decision, 'target-arrow-color': TYPE_COLORS.decision } },
|
||||
{ selector: 'edge.labelled[kind = "connection"], edge.labelled[kind = "supersedes"]', style: { label: 'data(label)' } },
|
||||
{ selector: '.dim', style: { opacity: 0.14, events: 'no' } },
|
||||
{ selector: 'node.hit', style: { 'border-width': 3 } },
|
||||
{ selector: 'edge.path', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, color: p.fg } },
|
||||
@@ -301,7 +307,7 @@ class GraphPage {
|
||||
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)
|
||||
.layout({ name: 'fcose', animate: false, randomize, quality: 'default', nodeDimensionsIncludeLabels: true, nodeRepulsion: () => 9000, idealEdgeLength: (e: EdgeSingular) => (e.data('kind') === 'connection' ? 150 : 110), edgeElasticity: (e: EdgeSingular) => (e.data('kind') === 'connection' ? 0.2 : 0.08), gravity: 0.3, padding: 40, fit: true } as cytoscape.LayoutOptions)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -347,14 +353,14 @@ class GraphPage {
|
||||
}
|
||||
|
||||
private updateCount(): void {
|
||||
const total = this.cy.nodes().not('.hidden').length;
|
||||
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().not('.hidden').length} connections`;
|
||||
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').not('.hidden').length;
|
||||
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));
|
||||
}
|
||||
@@ -372,9 +378,11 @@ class GraphPage {
|
||||
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.`);
|
||||
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 = this.graph.nodes.filter((n) => n.shared && !n.diagrams.length).length;
|
||||
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';
|
||||
@@ -384,8 +392,98 @@ class GraphPage {
|
||||
}
|
||||
|
||||
private showSide(target: NodeSingular | EdgeSingular): void {
|
||||
if (target.isNode()) this.showNode(this.nodesById.get(target.id()));
|
||||
else this.showEdge(this.edgesById.get(target.id()));
|
||||
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 {
|
||||
@@ -438,7 +536,7 @@ class GraphPage {
|
||||
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);
|
||||
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) {
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ export const GRAPH_PAGE_STYLE = `
|
||||
--ag-panel: var(--vscode-editorWidget-background, #111a2e); --ag-border: var(--vscode-editorWidget-border, rgba(128,128,128,.35)); --ag-accent: var(--vscode-textLink-foreground, #22d3ee);
|
||||
--ag-input: var(--vscode-input-background, #1f2937); --ag-input-fg: var(--vscode-input-foreground, #e5e7eb); --ag-input-border: var(--vscode-input-border, var(--ag-border));
|
||||
--ag-error: var(--vscode-errorForeground, #f87171); --ag-font: var(--vscode-font-family, system-ui, sans-serif); --ag-mono: var(--vscode-editor-font-family, ui-monospace, Menlo, monospace);
|
||||
--ag-frontend: #22d3ee; --ag-backend: #34d399; --ag-database: #a78bfa; --ag-cloud: #fbbf24; --ag-security: #fb7185; --ag-messagebus: #fb923c; --ag-external: #94a3b8; }
|
||||
--ag-frontend: #22d3ee; --ag-backend: #34d399; --ag-database: #a78bfa; --ag-cloud: #fbbf24; --ag-security: #fb7185; --ag-messagebus: #fb923c; --ag-external: #94a3b8; --ag-decision: #e879f9; --ag-registry: #cbd5e1; }
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--ag-bg); color: var(--ag-fg); font-family: var(--ag-font); font-size: 13px; }
|
||||
.ag { display: grid; grid-template-rows: auto auto minmax(0, 1fr); height: 100%; }
|
||||
.ag-top { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--ag-border); }
|
||||
|
||||
+51
-2
@@ -8,8 +8,13 @@ import { DecisionAttachment, DiagramSummary, ModelMeta, Registry } from './forma
|
||||
* the webview bundle and the unit tests share it.
|
||||
*/
|
||||
|
||||
export type GraphNodeKind = 'element' | 'decision' | 'registry';
|
||||
export type GraphEdgeKind = 'connection' | 'decides' | 'defines' | 'supersedes';
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
/** An element of the model, a decision (`decision:<id>`) or a registry file (`registry:<file>`). */
|
||||
kind: GraphNodeKind;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
type?: string;
|
||||
@@ -26,11 +31,17 @@ export interface GraphNode {
|
||||
diagrams: string[];
|
||||
/** Decisions about it: named by the decision, or attached to its node in a diagram. */
|
||||
decisions: string[];
|
||||
/** Decision nodes: the effective status. */
|
||||
status?: string;
|
||||
/** Registry nodes: ids of the elements the file defines. */
|
||||
elements?: string[];
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
/** `from>to`, unique in the graph. */
|
||||
id: string;
|
||||
/** A connection drawn by a diagram, or a link to a decision or registry node. */
|
||||
kind: GraphEdgeKind;
|
||||
from: string;
|
||||
to: string;
|
||||
/** Distinct labels the diagrams give this connection. */
|
||||
@@ -69,6 +80,8 @@ export interface GraphInput {
|
||||
}
|
||||
|
||||
export const edgeId = (from: string, to: string): string => `${from}>${to}`;
|
||||
export const decisionNodeId = (id: string): string => `decision:${id}`;
|
||||
export const registryNodeId = (file: string): string => `registry:${file}`;
|
||||
|
||||
function pushUnique(list: string[], value: string): void {
|
||||
if (!list.includes(value)) list.push(value);
|
||||
@@ -82,6 +95,7 @@ export function buildGraph(input: GraphInput): ModelGraph {
|
||||
for (const e of input.registry.elements.values()) {
|
||||
nodes.set(e.id, {
|
||||
id: e.id,
|
||||
kind: 'element',
|
||||
label: e.label ?? e.id,
|
||||
sublabel: e.sublabel,
|
||||
type: e.type,
|
||||
@@ -104,7 +118,7 @@ export function buildGraph(input: GraphInput): ModelGraph {
|
||||
const element = n.ref ? input.registry.elements.get(n.ref) : undefined;
|
||||
node = element
|
||||
? { ...nodes.get(element.id)!, id: n.id, label: n.label ?? element.label ?? n.id, sublabel: n.sublabel ?? element.sublabel, diagrams: [], decisions: [] }
|
||||
: { id: n.id, label: n.label ?? n.ref ?? n.id, sublabel: n.sublabel, type: n.type, brand: n.brand, shared: false, diagrams: [], decisions: [] };
|
||||
: { id: n.id, kind: 'element', label: n.label ?? n.ref ?? n.id, sublabel: n.sublabel, type: n.type, brand: n.brand, shared: false, diagrams: [], decisions: [] };
|
||||
nodes.set(n.id, node);
|
||||
}
|
||||
pushUnique(node.diagrams, d.relativePath);
|
||||
@@ -113,7 +127,7 @@ export function buildGraph(input: GraphInput): ModelGraph {
|
||||
const id = edgeId(e.from, e.to);
|
||||
let edge = edges.get(id);
|
||||
if (!edge) {
|
||||
edge = { id, from: e.from, to: e.to, labels: [], diagrams: [], decisions: [] };
|
||||
edge = { id, kind: 'connection', from: e.from, to: e.to, labels: [], diagrams: [], decisions: [] };
|
||||
edges.set(id, edge);
|
||||
}
|
||||
if (e.label) pushUnique(edge.labels, e.label);
|
||||
@@ -137,6 +151,41 @@ export function buildGraph(input: GraphInput): ModelGraph {
|
||||
}
|
||||
|
||||
const decisionRow = (d: Decision): GraphDecision => ({ id: d.id, title: d.title, status: effectiveStatus(d, all).status, file: d.file });
|
||||
|
||||
// Registry files as nodes, each linked to the elements it defines.
|
||||
const byFile = new Map<string, string[]>();
|
||||
for (const e of input.registry.elements.values()) byFile.set(e.file, [...(byFile.get(e.file) ?? []), e.id]);
|
||||
for (const file of input.registry.files) if (!byFile.has(file)) byFile.set(file, []);
|
||||
for (const [file, ids] of byFile) {
|
||||
const id = registryNodeId(file);
|
||||
nodes.set(id, { id, kind: 'registry', label: file.replace(/\.archify-registry$/, ''), type: 'registry', shared: true, file, diagrams: [], decisions: [], elements: ids });
|
||||
for (const element of ids) {
|
||||
const edge = edgeId(id, element);
|
||||
edges.set(edge, { id: edge, kind: 'defines', from: id, to: element, labels: [], diagrams: [], decisions: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// Decisions as nodes, linked to the elements and connections they are about, and to what they supersede.
|
||||
for (const d of all.values()) {
|
||||
const id = decisionNodeId(d.id);
|
||||
const row = decisionRow(d);
|
||||
nodes.set(id, { id, kind: 'decision', label: d.title ?? d.id, sublabel: d.id, type: 'decision', shared: true, description: [d.context, d.decision].filter(Boolean).join('\n\n') || undefined, file: d.file, line: 0, diagrams: [...d.diagrams], decisions: [], status: row.status });
|
||||
}
|
||||
for (const node of [...nodes.values()]) {
|
||||
if (node.kind !== 'element') continue;
|
||||
for (const decision of node.decisions) {
|
||||
if (!all.has(decision)) continue;
|
||||
const edge = edgeId(decisionNodeId(decision), node.id);
|
||||
edges.set(edge, { id: edge, kind: 'decides', from: decisionNodeId(decision), to: node.id, labels: [], diagrams: [], decisions: [] });
|
||||
}
|
||||
}
|
||||
for (const d of all.values()) {
|
||||
for (const other of d.supersedes) {
|
||||
if (!all.has(other)) continue;
|
||||
const edge = edgeId(decisionNodeId(d.id), decisionNodeId(other));
|
||||
edges.set(edge, { id: edge, kind: 'supersedes', from: decisionNodeId(d.id), to: decisionNodeId(other), labels: ['supersedes'], diagrams: [], decisions: [] });
|
||||
}
|
||||
}
|
||||
return {
|
||||
meta: input.meta,
|
||||
nodes: [...nodes.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
|
||||
+26
-9
@@ -67,12 +67,16 @@ export function parseQuery(text: string): QueryTerm[] {
|
||||
|
||||
const lower = (s: string | undefined) => (s ?? '').toLowerCase();
|
||||
|
||||
/** An element by id, then by id or label ignoring case. */
|
||||
const isElement = (n: GraphNode) => n.kind === 'element';
|
||||
const isConnection = (e: GraphEdge) => e.kind === 'connection';
|
||||
|
||||
/** An element by id, then by id or label ignoring case. Decision and registry nodes are not walked from. */
|
||||
export function findNode(graph: ModelGraph, ref: string): GraphNode | undefined {
|
||||
const exact = graph.nodes.find((n) => n.id === ref);
|
||||
const elements = graph.nodes.filter(isElement);
|
||||
const exact = elements.find((n) => n.id === ref);
|
||||
if (exact) return exact;
|
||||
const l = ref.toLowerCase();
|
||||
return graph.nodes.find((n) => n.id.toLowerCase() === l) ?? graph.nodes.find((n) => n.label.toLowerCase() === l);
|
||||
return elements.find((n) => n.id.toLowerCase() === l) ?? elements.find((n) => n.label.toLowerCase() === l);
|
||||
}
|
||||
|
||||
function globToRegExp(pattern: string): RegExp {
|
||||
@@ -88,10 +92,11 @@ function diagramMatches(path: string, pattern: string): boolean {
|
||||
return bare.toLowerCase() === l || bare.toLowerCase().startsWith(`${l}/`) || bare.toLowerCase().startsWith(l) || bare.toLowerCase().includes(`/${l}`);
|
||||
}
|
||||
|
||||
/** Element-to-element adjacency over the diagrams' connections; decision and registry links do not count as steps. */
|
||||
function neighbours(graph: ModelGraph): Map<string, Set<string>> {
|
||||
const map = new Map<string, Set<string>>();
|
||||
for (const n of graph.nodes) map.set(n.id, new Set());
|
||||
for (const e of graph.edges) {
|
||||
for (const e of graph.edges.filter(isConnection)) {
|
||||
map.get(e.from)?.add(e.to);
|
||||
map.get(e.to)?.add(e.from);
|
||||
}
|
||||
@@ -116,7 +121,7 @@ export function shortestPath(graph: ModelGraph, from: string, to: string): { nod
|
||||
const attempt = (directed: boolean) => {
|
||||
const out = new Map<string, { to: string; edge: GraphEdge }[]>();
|
||||
for (const n of graph.nodes) out.set(n.id, []);
|
||||
for (const e of graph.edges) {
|
||||
for (const e of graph.edges.filter(isConnection)) {
|
||||
out.get(e.from)?.push({ to: e.to, edge: e });
|
||||
if (!directed) out.get(e.to)?.push({ to: e.from, edge: e });
|
||||
}
|
||||
@@ -174,12 +179,13 @@ export function runQuery(graph: ModelGraph, text: string): QueryResult {
|
||||
const ids = new Set(graph.nodes.filter((n) => n.decisions.includes(v)).map((n) => n.id));
|
||||
for (const e of graph.edges) if (e.decisions.includes(v)) { ids.add(e.from); ids.add(e.to); }
|
||||
if (!graph.decisions.some((d) => d.id === v)) notes.push(`No decision "${v}" in this model.`);
|
||||
else ids.add(`decision:${v}`);
|
||||
return ids;
|
||||
}
|
||||
case 'shared':
|
||||
return new Set(graph.nodes.filter((n) => n.shared).map((n) => n.id));
|
||||
return new Set(graph.nodes.filter((n) => isElement(n) && n.shared).map((n) => n.id));
|
||||
case 'unused':
|
||||
return new Set(graph.nodes.filter((n) => n.shared && n.diagrams.length === 0).map((n) => n.id));
|
||||
return new Set(graph.nodes.filter((n) => isElement(n) && n.shared && n.diagrams.length === 0).map((n) => n.id));
|
||||
case 'uses':
|
||||
case 'near': {
|
||||
const [ref, hopsText] = term.key === 'near' ? v.split(',') : [v, '1'];
|
||||
@@ -198,11 +204,22 @@ export function runQuery(graph: ModelGraph, text: string): QueryResult {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const negated: Set<string>[] = [];
|
||||
for (const term of terms) {
|
||||
const ids = matching(term);
|
||||
if (!ids) continue;
|
||||
if (term.negate) for (const id of ids) result.delete(id);
|
||||
else result = new Set([...result].filter((id) => ids.has(id)));
|
||||
if (term.negate) {
|
||||
negated.push(ids);
|
||||
for (const id of ids) result.delete(id);
|
||||
} else result = new Set([...result].filter((id) => ids.has(id)));
|
||||
}
|
||||
// Decisions and registries follow their elements: a matching element brings the nodes attached to it.
|
||||
if (terms.length) {
|
||||
for (const e of graph.edges) {
|
||||
if (isConnection(e)) continue;
|
||||
if (result.has(e.to) && graph.nodes.some((n) => n.id === e.from && !isElement(n))) result.add(e.from);
|
||||
}
|
||||
for (const ids of negated) for (const id of ids) result.delete(id);
|
||||
}
|
||||
|
||||
if (from || to) {
|
||||
|
||||
Reference in new issue
Block a user