Model graph: layered layout and a cleaner look
- layered layout by type (clients left, data right), empty columns left out, barycenter row ordering; registries in a row above, decisions in a row below; Force layout as the alternative - orthogonal connections with labels staggered next to their source; opaque tinted nodes hide lines behind them - hover glow on elements and their connections; decision and registry links show for the selected element or all with Links
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 0.9.0 — 2026-10-21
|
## 0.9.0 — 2026-10-21
|
||||||
|
|
||||||
- **Model graph.** A **Model | Graph** switch on the `main.archify` page (next to **Export** when the file has a diagram, top right otherwise) replaces the page with the whole model as one graph: every element any diagram draws plus every registry element, every connection between them, each once, drawn with Cytoscape.js in Archify's type colours. Registry elements have a solid border, inline ones a dashed border. **Decisions** and **registry files** are nodes as well: a decision is a tag coloured by status with dotted links to the elements and connections it is about and dashed links to what it supersedes; a registry links to the elements it defines. Drag, zoom, **Fit**, **Layout**, **Labels**, a dark/light theme toggle of its own (remembered, and shared with the diagram pages' theme key), legend chips that hide a type (decisions and registries included), **Export PNG**. The choice is remembered per file; the graph updates in place as the model changes.
|
- **Model graph.** A **Model | Graph** switch on the `main.archify` page (next to **Export** when the file has a diagram, top right otherwise) replaces the page with the whole model as one graph: every element any diagram draws plus every registry element, every connection between them, each once, drawn with Cytoscape.js in Archify's type colours. Registry elements have a solid border, inline ones a dashed border. **Decisions** and **registry files** are nodes as well: a decision is a tag coloured by status with dotted links to the elements and connections it is about and dashed links to what it supersedes; a registry links to the elements it defines. Layered layout by type (clients left, data right, registries above, decisions below, orthogonal connections) or a force layout; hover lights up an element and its connections; decision and registry links show for the selected element, or all of them with **Links**. Drag, zoom, **Fit**, **Labels**, a dark/light theme toggle of its own (remembered, and shared with the diagram pages' theme key), legend chips that hide a type (decisions and registries included), **Export PNG**. The choice is remembered per file; the graph updates in place as the model changes.
|
||||||
- **Queries.** A query box narrows the graph: `database`, `type:backend`, `diagram:0200-*`, `decision:<id>`, `uses:api`, `near:api,2`, `from:web to:db` (shortest path), `unused`, `shared`, `-shared`, `type:decision`, `type:registry`; terms AND together, a leading `-` excludes; decisions and registries follow their elements into a result, and walks never pass through them; the count reads "9 of 14 elements"; **?** lists the forms and runs an example on click.
|
- **Queries.** A query box narrows the graph: `database`, `type:backend`, `diagram:0200-*`, `decision:<id>`, `uses:api`, `near:api,2`, `from:web to:db` (shortest path), `unused`, `shared`, `-shared`, `type:decision`, `type:registry`; terms AND together, a leading `-` excludes; decisions and registries follow their elements into a result, and walks never pass through them; the count reads "9 of 14 elements"; **?** lists the forms and runs an example on click.
|
||||||
- **Side panel.** An element shows its description, type, registry file and line (**Registry** opens it), the diagrams that draw it, its connections and its decisions, each one click away; **Neighbours** queries around it. A connection shows its labels, diagrams and decisions. A decision node shows its status, text, the elements and connections it is about and **Open decision**; a registry node the elements it defines and **Open registry**.
|
- **Side panel.** An element shows its description, type, registry file and line (**Registry** opens it), the diagrams that draw it, its connections and its decisions, each one click away; **Neighbours** queries around it. A connection shows its labels, diagrams and decisions. A decision node shows its status, text, the elements and connections it is about and **Open decision**; a registry node the elements it defines and **Open registry**.
|
||||||
- **Entry points.** On a diagram page inside a model the switch reads **Diagram | Graph** and opens the graph narrowed to `diagram:<that diagram>`. New command *Archify | Open Model Graph* in the editor title, the Command Palette and the Explorer menu, wherever *Open Model* is.
|
- **Entry points.** On a diagram page inside a model the switch reads **Diagram | Graph** and opens the graph narrowed to `diagram:<that diagram>`. New command *Archify | Open Model Graph* in the editor title, the Command Palette and the Explorer menu, wherever *Open Model* is.
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ online-shop/
|
|||||||
- **Registry view.** A `.archify-registry` file opens as its elements, as cards. Click one and a dialog shows its description, where it is defined and the diagrams that use it; click a diagram to open it. **Show Source** opens the YAML beside it.
|
- **Registry view.** A `.archify-registry` file opens as its elements, as cards. Click one and a dialog shows its description, where it is defined and the diagrams that use it; click a diagram to open it. **Show Source** opens the YAML beside it.
|
||||||
- **Elements and Connectors.** Under every diagram, Archify's node index is titled **Elements**, and a **Connectors** panel next to it lists every connection as "from → to" with its label. Hovering highlights the connector in the diagram; clicking keeps it highlighted and scrolls to it.
|
- **Elements and Connectors.** Under every diagram, Archify's node index is titled **Elements**, and a **Connectors** panel next to it lists every connection as "from → to" with its label. Hovering highlights the connector in the diagram; clicking keeps it highlighted and scrolls to it.
|
||||||
- **Starting one.** *Archify | New Model…* (right-click a folder) copies one of the shipped examples into a new folder, named as you choose: **C4 - Standard Web App** (single-page app, API, database, cache, storage, identity provider), **C4 - E-Shop** (web shop, API, worker, queue, payment and email providers) or **C4 - Event-Based Integration** (event bus, router, dead-letter queue, event store, consumers). Each has a registry and context, container and component diagrams. For an empty model, *Archify | New Diagram… → Model* creates just a `main.archify`.
|
- **Starting one.** *Archify | New Model…* (right-click a folder) copies one of the shipped examples into a new folder, named as you choose: **C4 - Standard Web App** (single-page app, API, database, cache, storage, identity provider), **C4 - E-Shop** (web shop, API, worker, queue, payment and email providers) or **C4 - Event-Based Integration** (event bus, router, dead-letter queue, event store, consumers). Each has a registry and context, container and component diagrams. For an empty model, *Archify | New Diagram… → Model* creates just a `main.archify`.
|
||||||
- **The graph.** The **Model | Graph** switch at the top of the `main.archify` page (next to **Export** when the file has a diagram) replaces the page with the whole model as one graph: every element any diagram draws plus every registry element, every connection between them, each once. Registry elements have a solid border, inline ones a dashed border; colours are Archify's type colours and the legend chips at the bottom hide or show a type. **Decisions** are nodes too (tags coloured by status, dotted links to the elements they are about, dashed links to what they supersede), and so are **registries** (one node per file, dotted links to the elements it defines); their chips hide them when the picture gets busy. Drag nodes, scroll to zoom, **Fit** (or <kbd>0</kbd>) frames what matches, **Layout** lays it out again, **Labels** toggles connection labels, the sun/moon button switches the page between its dark and light theme (remembered, shared with the diagram pages' theme choice), **Export PNG** saves the picture. On a diagram page the switch reads **Diagram | Graph** and opens the graph already narrowed to that diagram. *Archify | Open Model Graph* does the same from the Command Palette or Explorer.
|
- **The graph.** The **Model | Graph** switch at the top of the `main.archify` page (next to **Export** when the file has a diagram) replaces the page with the whole model as one graph: every element any diagram draws plus every registry element, every connection between them, each once. Registry elements have a solid border, inline ones a dashed border; colours are Archify's type colours and the legend chips at the bottom hide or show a type. **Decisions** are nodes too (tags coloured by status, dotted links to the elements they are about, dashed links to what they supersede), and so are **registries** (one node per file, dotted links to the elements it defines); their chips hide them when the picture gets busy. The layout is **Layered** by default, like Archify's stack: clients on the left, data on the right, registries in a row above, decisions in a row below, connections drawn orthogonally; click the button for a **Force** layout instead. Drag nodes, scroll to zoom, **Fit** (or <kbd>0</kbd>) frames what matches, **Labels** toggles connection labels, **Links** shows every decision and registry link (otherwise only the selected element's links show; hovering an element lights up its connections), the sun/moon button switches the page between its dark and light theme (remembered, shared with the diagram pages' theme choice), **Export PNG** saves the picture. On a diagram page the switch reads **Diagram | Graph** and opens the graph already narrowed to that diagram. *Archify | Open Model Graph* does the same from the Command Palette or Explorer.
|
||||||
- **Queries.** Type a few words in the box (<kbd>/</kbd> focuses it); matches stay bright, the rest dims, the count shows "9 of 14 elements". Terms combine with AND and a leading `-` excludes: `database` (id, label, sublabel, brand or type contain it), `type:backend`, `diagram:0200-*` (what those diagrams draw), `decision:<id>` (what a decision is about or is attached to), `uses:api` (an element and its neighbours), `near:api,2` (within two steps), `from:web to:db` (the shortest path, following the arrows first), `unused` (registry elements no diagram draws), `shared` and `-shared` (registry or inline elements only), `type:decision` and `type:registry` for the decision and registry nodes. Decisions and registries follow the elements they are attached to into a result; `-type:decision` or `-type:registry` leaves them out. Walks and paths follow the diagrams' connections only, so a registry is never a shortcut between its elements. The **?** button lists them; clicking an example runs it.
|
- **Queries.** Type a few words in the box (<kbd>/</kbd> focuses it); matches stay bright, the rest dims, the count shows "9 of 14 elements". Terms combine with AND and a leading `-` excludes: `database` (id, label, sublabel, brand or type contain it), `type:backend`, `diagram:0200-*` (what those diagrams draw), `decision:<id>` (what a decision is about or is attached to), `uses:api` (an element and its neighbours), `near:api,2` (within two steps), `from:web to:db` (the shortest path, following the arrows first), `unused` (registry elements no diagram draws), `shared` and `-shared` (registry or inline elements only), `type:decision` and `type:registry` for the decision and registry nodes. Decisions and registries follow the elements they are attached to into a result; `-type:decision` or `-type:registry` leaves them out. Walks and paths follow the diagrams' connections only, so a registry is never a shortcut between its elements. The **?** button lists them; clicking an example runs it.
|
||||||
- **Side panel.** Click an element for its description, type, where it is defined (**Registry** opens the file at the line), the diagrams that draw it (click to open), its connections and its decisions (click to open). **Neighbours** runs `uses:` for it. Click a connection for its labels, the diagrams that draw it and its decisions. Click a decision node for its status, context and decision text, the elements and connections it is about, and **Open decision**; a registry node for the elements it defines, the ones no diagram draws yet, and **Open registry**.
|
- **Side panel.** Click an element for its description, type, where it is defined (**Registry** opens the file at the line), the diagrams that draw it (click to open), its connections and its decisions (click to open). **Neighbours** runs `uses:` for it. Click a connection for its labels, the diagrams that draw it and its decisions. Click a decision node for its status, context and decision text, the elements and connections it is about, and **Open decision**; a registry node for the elements it defines, the ones no diagram draws yet, and **Open registry**.
|
||||||
- **Commands.** *Archify | Open Model* opens the model page of the current file, from the editor title, the Command Palette or the Explorer menu of a `.archify` or `.archify-registry` file; *Archify | Open Model Graph* opens the graph.
|
- **Commands.** *Archify | Open Model* opens the model page of the current file, from the editor title, the Command Palette or the Explorer menu of a `.archify` or `.archify-registry` file; *Archify | Open Model Graph* opens the graph.
|
||||||
|
|||||||
+115
-24
@@ -3,6 +3,7 @@ import fcose from 'cytoscape-fcose';
|
|||||||
import type { GraphEdge, GraphNode, ModelGraph } from '../model/graph';
|
import type { GraphEdge, GraphNode, ModelGraph } from '../model/graph';
|
||||||
import { QueryResult, runQuery } from '../model/graphQuery';
|
import { QueryResult, runQuery } from '../model/graphQuery';
|
||||||
import { GRAPH_PAGE_STYLE } from './style';
|
import { GRAPH_PAGE_STYLE } from './style';
|
||||||
|
import { layeredLayout } from './layout';
|
||||||
import type { ArchifyHost } from '../webview/host';
|
import type { ArchifyHost } from '../webview/host';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +21,10 @@ interface SavedView {
|
|||||||
hidden?: string[];
|
hidden?: string[];
|
||||||
/** A theme chosen on the page; absent means follow VS Code. */
|
/** A theme chosen on the page; absent means follow VS Code. */
|
||||||
theme?: 'dark' | 'light';
|
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';
|
type Theme = 'dark' | 'light';
|
||||||
@@ -37,6 +42,21 @@ const TYPE_COLORS: Record<string, string> = { frontend: '#22d3ee', backend: '#34
|
|||||||
const TYPE_ORDER = ['external', 'frontend', 'cloud', 'security', 'backend', 'messagebus', 'database', 'decision', 'registry'];
|
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 STATUS_COLORS: Record<string, string> = { accepted: '#34d399', proposed: '#fbbf24', rejected: '#fb7185', superseded: '#94a3b8' };
|
||||||
const colorOf = (type: string | undefined) => TYPE_COLORS[type ?? ''] ?? '#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));
|
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. */
|
/** Edge labels are drawn by default up to this many connections; above it they clutter. */
|
||||||
const LABELS_UP_TO = 40;
|
const LABELS_UP_TO = 40;
|
||||||
@@ -97,6 +117,8 @@ class GraphPage {
|
|||||||
private readonly queryInput = el('input', 'ag-query');
|
private readonly queryInput = el('input', 'ag-query');
|
||||||
private readonly countEl = el('span', 'ag-count');
|
private readonly countEl = el('span', 'ag-count');
|
||||||
private readonly labelsButton = el('button', 'ag-button', 'Labels');
|
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 themeButton = el('button', 'ag-button ag-theme');
|
||||||
private readonly legend = el('div', 'ag-legend');
|
private readonly legend = el('div', 'ag-legend');
|
||||||
private readonly side = el('aside', 'ag-side');
|
private readonly side = el('aside', 'ag-side');
|
||||||
@@ -116,6 +138,7 @@ class GraphPage {
|
|||||||
this.buildGraph(true);
|
this.buildGraph(true);
|
||||||
this.syncLabels();
|
this.syncLabels();
|
||||||
this.renderLegend();
|
this.renderLegend();
|
||||||
|
this.syncLayoutButton();
|
||||||
this.queryInput.value = this.saved.query ?? '';
|
this.queryInput.value = this.saved.query ?? '';
|
||||||
this.applyQuery();
|
this.applyQuery();
|
||||||
this.showEmptySide();
|
this.showEmptySide();
|
||||||
@@ -133,7 +156,13 @@ class GraphPage {
|
|||||||
|
|
||||||
private elements() {
|
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) } }));
|
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(' / ') } }));
|
// 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];
|
return [...nodes, ...edges];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +183,8 @@ class GraphPage {
|
|||||||
if (at) n.position(at);
|
if (at) n.position(at);
|
||||||
else fresh += 1;
|
else fresh += 1;
|
||||||
});
|
});
|
||||||
if (fresh) this.layout(false);
|
if (fresh || this.saved.layout !== 'force') this.layout(false);
|
||||||
|
this.syncLinks();
|
||||||
this.renderLegend();
|
this.renderLegend();
|
||||||
this.applyHidden();
|
this.applyHidden();
|
||||||
this.applyQuery();
|
this.applyQuery();
|
||||||
@@ -211,10 +241,20 @@ class GraphPage {
|
|||||||
fit.type = 'button';
|
fit.type = 'button';
|
||||||
fit.title = 'Fit the matching elements into view (0)';
|
fit.title = 'Fit the matching elements into view (0)';
|
||||||
fit.addEventListener('click', () => this.fit());
|
fit.addEventListener('click', () => this.fit());
|
||||||
const relayout = el('button', 'ag-button', 'Layout');
|
this.layoutButton.type = 'button';
|
||||||
relayout.type = 'button';
|
this.layoutButton.addEventListener('click', () => {
|
||||||
relayout.title = 'Lay the graph out again';
|
this.saved.layout = this.saved.layout === 'force' ? 'layered' : 'force';
|
||||||
relayout.addEventListener('click', () => this.layout(true));
|
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.type = 'button';
|
||||||
this.labelsButton.title = 'Show or hide connection labels';
|
this.labelsButton.title = 'Show or hide connection labels';
|
||||||
this.labelsButton.addEventListener('click', () => {
|
this.labelsButton.addEventListener('click', () => {
|
||||||
@@ -230,7 +270,7 @@ class GraphPage {
|
|||||||
this.help.hidden = !this.help.hidden;
|
this.help.hidden = !this.help.hidden;
|
||||||
helpButton.setAttribute('aria-expanded', String(!this.help.hidden));
|
helpButton.setAttribute('aria-expanded', String(!this.help.hidden));
|
||||||
});
|
});
|
||||||
bar.append(wrap, this.countEl, fit, relayout, this.labelsButton, helpButton);
|
bar.append(wrap, this.countEl, fit, this.layoutButton, this.labelsButton, this.linksButton, helpButton);
|
||||||
|
|
||||||
const cyHolder = el('div', 'ag-cy');
|
const cyHolder = el('div', 'ag-cy');
|
||||||
this.canvas.append(cyHolder, this.legend);
|
this.canvas.append(cyHolder, this.legend);
|
||||||
@@ -248,7 +288,7 @@ class GraphPage {
|
|||||||
table.appendChild(row);
|
table.appendChild(row);
|
||||||
}
|
}
|
||||||
this.help.appendChild(table);
|
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.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.main.append(this.canvas, this.side, this.help);
|
||||||
this.root.append(top, bar, this.main);
|
this.root.append(top, bar, this.main);
|
||||||
document.body.appendChild(this.root);
|
document.body.appendChild(this.root);
|
||||||
@@ -293,38 +333,89 @@ class GraphPage {
|
|||||||
maxZoom: 4,
|
maxZoom: 4,
|
||||||
});
|
});
|
||||||
this.applyHidden();
|
this.applyHidden();
|
||||||
|
this.syncLinks();
|
||||||
this.layout(first);
|
this.layout(first);
|
||||||
this.cy.on('tap', 'node, edge', (event) => this.showSide(event.target as NodeSingular | EdgeSingular));
|
this.cy.on('tap', 'node, edge', (event) => this.showSide(event.target as NodeSingular | EdgeSingular));
|
||||||
this.cy.on('tap', (event) => {
|
this.cy.on('tap', (event) => {
|
||||||
if (event.target === this.cy) this.showEmptySide();
|
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 {
|
private stylesheet(): cytoscape.StylesheetJson {
|
||||||
const p = palette();
|
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 [
|
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', 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[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 = "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: 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: '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: '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: 'node.hover', style: { 'underlay-opacity': dark ? 0.22 : 0.16, 'border-width': 2, 'background-color': fill(dark ? 0.28 : 0.18) } },
|
||||||
{ 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', 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[kind = "supersedes"]', style: { width: 1, 'line-style': 'dashed', 'line-color': TYPE_COLORS.decision, 'target-arrow-color': TYPE_COLORS.decision } },
|
{ 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[kind = "connection"], edge.labelled[kind = "supersedes"]', style: { label: 'data(label)' } },
|
||||||
{ selector: '.dim', style: { opacity: 0.14, events: 'no' } },
|
{ selector: 'edge.labelled.ortho[kind = "connection"]', style: { label: '', 'source-label': 'data(label)' } },
|
||||||
{ selector: 'node.hit', style: { 'border-width': 3 } },
|
{ selector: '.dim', style: { opacity: 0.12, events: 'no' } },
|
||||||
{ selector: 'edge.path', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, color: p.fg } },
|
{ selector: 'node.hit', style: { 'border-width': 2.5 } },
|
||||||
{ selector: 'node:selected', style: { 'border-color': p.accent, 'border-width': 3, 'background-color': p.panel, 'overlay-opacity': 0 } },
|
{ selector: 'edge.path', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, color: p.fg, 'line-opacity': 1 } },
|
||||||
{ selector: 'edge:selected', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, 'overlay-opacity': 0 } },
|
{ 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' } },
|
{ selector: '.hidden', style: { display: 'none' } },
|
||||||
] as unknown as cytoscape.StylesheetJson;
|
] as unknown as cytoscape.StylesheetJson;
|
||||||
}
|
}
|
||||||
|
|
||||||
private layout(randomize: boolean): void {
|
private layout(fresh: boolean): void {
|
||||||
const visible = this.cy.elements().not('.hidden');
|
const force = this.saved.layout === 'force';
|
||||||
visible
|
this.cy.edges().toggleClass('ortho', !force);
|
||||||
.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)
|
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();
|
.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
|
// --------------------------------------------------------------- theme
|
||||||
@@ -697,7 +788,7 @@ class GraphPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private save(): void {
|
private save(): void {
|
||||||
this.host.setState({ graphView: { query: this.saved.query, labels: this.saved.labels, hidden: this.saved.hidden ?? [], theme: this.saved.theme } });
|
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 } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { GraphEdge, GraphNode } from '../model/graph';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layered layout: a column per element type in Archify's stack order
|
||||||
|
* (clients on the left, data on the right), empty columns left out, rows
|
||||||
|
* ordered to shorten the connections; registries in a row above the
|
||||||
|
* columns, decisions in a row below. Deterministic, so the picture is stable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const COLUMN_ORDER = ['external', 'frontend', 'cloud', 'security', 'backend', 'other', 'messagebus', 'database'];
|
||||||
|
const STATUS_ORDER = ['accepted', 'proposed', 'superseded', 'rejected'];
|
||||||
|
|
||||||
|
export interface LayoutOptions {
|
||||||
|
columnGap: number;
|
||||||
|
rowGap: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Positions = Map<string, { x: number; y: number }>;
|
||||||
|
|
||||||
|
function columnOf(node: GraphNode): number {
|
||||||
|
if (node.kind === 'registry') return -1;
|
||||||
|
if (node.kind === 'decision') return COLUMN_ORDER.length;
|
||||||
|
const i = COLUMN_ORDER.indexOf(node.type ?? 'other');
|
||||||
|
return i < 0 ? COLUMN_ORDER.indexOf('other') : i;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layeredLayout(nodes: GraphNode[], edges: GraphEdge[], options: LayoutOptions = { columnGap: 230, rowGap: 84 }): Positions {
|
||||||
|
const columns = new Map<number, GraphNode[]>();
|
||||||
|
const registries = nodes.filter((n) => n.kind === 'registry').sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
const decisions = nodes.filter((n) => n.kind === 'decision').sort((a, b) => STATUS_ORDER.indexOf(a.status ?? '') - STATUS_ORDER.indexOf(b.status ?? '') || a.label.localeCompare(b.label));
|
||||||
|
for (const n of nodes) if (n.kind === 'element') columns.set(columnOf(n), [...(columns.get(columnOf(n)) ?? []), n]);
|
||||||
|
const order = new Map<string, number>();
|
||||||
|
const neighbours = new Map<string, string[]>();
|
||||||
|
for (const e of edges) {
|
||||||
|
if (e.kind === 'supersedes') continue;
|
||||||
|
neighbours.set(e.from, [...(neighbours.get(e.from) ?? []), e.to]);
|
||||||
|
neighbours.set(e.to, [...(neighbours.get(e.to) ?? []), e.from]);
|
||||||
|
}
|
||||||
|
// Start from a stable order: elements by label, decisions by status, registries by file.
|
||||||
|
for (const [col, list] of columns) {
|
||||||
|
list.sort((a, b) => {
|
||||||
|
if (a.kind === 'decision' && b.kind === 'decision') return STATUS_ORDER.indexOf(a.status ?? '') - STATUS_ORDER.indexOf(b.status ?? '') || a.label.localeCompare(b.label);
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
list.forEach((n, i) => order.set(n.id, i));
|
||||||
|
columns.set(col, list);
|
||||||
|
}
|
||||||
|
// Barycenter sweeps: each node moves towards the average row of what it is connected to.
|
||||||
|
const keys = [...columns.keys()].sort((a, b) => a - b);
|
||||||
|
for (let sweep = 0; sweep < 4; sweep += 1) {
|
||||||
|
const sequence = sweep % 2 === 0 ? keys : [...keys].reverse();
|
||||||
|
for (const col of sequence) {
|
||||||
|
const list = columns.get(col)!;
|
||||||
|
const weight = new Map<string, number>();
|
||||||
|
for (const n of list) {
|
||||||
|
const rows = (neighbours.get(n.id) ?? []).map((id) => order.get(id)).filter((r): r is number => r !== undefined);
|
||||||
|
weight.set(n.id, rows.length ? rows.reduce((a, b) => a + b, 0) / rows.length : order.get(n.id)!);
|
||||||
|
}
|
||||||
|
list.sort((a, b) => weight.get(a.id)! - weight.get(b.id)! || order.get(a.id)! - order.get(b.id)!);
|
||||||
|
list.forEach((n, i) => order.set(n.id, i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const positions: Positions = new Map();
|
||||||
|
const tallest = Math.max(1, ...[...columns.values()].map((list) => list.length));
|
||||||
|
// Empty columns are left out, so the picture is as wide as the types the model uses.
|
||||||
|
const used = keys.filter((col) => columns.get(col)!.length);
|
||||||
|
used.forEach((col, index) => {
|
||||||
|
const list = columns.get(col)!;
|
||||||
|
const offset = ((tallest - list.length) * options.rowGap) / 2;
|
||||||
|
list.forEach((n, i) => positions.set(n.id, { x: index * options.columnGap, y: offset + i * options.rowGap }));
|
||||||
|
});
|
||||||
|
const width = Math.max(0, used.length - 1) * options.columnGap;
|
||||||
|
const spread = (list: GraphNode[], y: number, gap: number) => {
|
||||||
|
const total = Math.max(0, list.length - 1) * gap;
|
||||||
|
list.forEach((n, i) => positions.set(n.id, { x: width / 2 - total / 2 + i * gap, y }));
|
||||||
|
};
|
||||||
|
spread(registries, -options.rowGap * 1.6, Math.max(options.columnGap, 200));
|
||||||
|
spread(decisions, tallest * options.rowGap + options.rowGap * 0.6, Math.max(options.columnGap, 240));
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user