diff --git a/CHANGELOG.md b/CHANGELOG.md index ad3131b..eda055d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 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:`, `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**. - **Entry points.** On a diagram page inside a model the switch reads **Diagram | Graph** and opens the graph narrowed to `diagram:`. New command *Archify | Open Model Graph* in the editor title, the Command Palette and the Explorer menu, wherever *Open Model* is. diff --git a/README.md b/README.md index 7c58127..888b63f 100644 --- a/README.md +++ b/README.md @@ -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. - **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`. -- **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 0) 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 0) 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 (/ 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:` (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**. - **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. diff --git a/src/graph/index.ts b/src/graph/index.ts index 191ba5b..1fc15b9 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -3,6 +3,7 @@ import fcose from 'cytoscape-fcose'; import type { GraphEdge, GraphNode, ModelGraph } from '../model/graph'; import { QueryResult, runQuery } from '../model/graphQuery'; import { GRAPH_PAGE_STYLE } from './style'; +import { layeredLayout } from './layout'; import type { ArchifyHost } from '../webview/host'; /** @@ -20,6 +21,10 @@ interface SavedView { hidden?: string[]; /** A theme chosen on the page; absent means follow VS Code. */ theme?: 'dark' | 'light'; + /** Layered by type (default) or force-directed. */ + layout?: 'layered' | 'force'; + /** Show every decision and registry link, not only the selected element's. */ + links?: boolean; } type Theme = 'dark' | 'light'; @@ -37,6 +42,21 @@ const TYPE_COLORS: Record = { frontend: '#22d3ee', backend: '#34 const TYPE_ORDER = ['external', 'frontend', 'cloud', 'security', 'backend', 'messagebus', 'database', 'decision', 'registry']; const STATUS_COLORS: Record = { accepted: '#34d399', proposed: '#fbbf24', rejected: '#fb7185', superseded: '#94a3b8' }; const colorOf = (type: string | undefined) => TYPE_COLORS[type ?? ''] ?? '#94a3b8'; +/** An opaque mix of two `#rrggbb` colours (`amount` of the first), so nodes hide the connections behind them. */ +function mix(front: string, back: string, amount: number): string { + const f = parseInt(front.slice(1), 16); + const b = parseInt(back.replace(/^#/, '').padEnd(6, '0').slice(0, 6), 16); + const channel = (shift: number) => Math.round(((f >> shift) & 255) * amount + ((b >> shift) & 255) * (1 - amount)); + return `rgb(${channel(16)}, ${channel(8)}, ${channel(0)})`; +} +/** `#rrggbb` form of a CSS colour, read through the canvas so `rgb()` and names work too. */ +function toHex(color: string): string { + if (/^#[0-9a-f]{6}$/i.test(color)) return color; + const canvas = document.createElement('canvas').getContext('2d'); + if (!canvas) return '#000000'; + canvas.fillStyle = color; + return /^#[0-9a-f]{6}$/i.test(canvas.fillStyle) ? canvas.fillStyle : '#000000'; +} const nodeColor = (n: GraphNode) => (n.kind === 'decision' ? STATUS_COLORS[n.status ?? ''] ?? TYPE_COLORS.decision : colorOf(n.type)); /** Edge labels are drawn by default up to this many connections; above it they clutter. */ const LABELS_UP_TO = 40; @@ -97,6 +117,8 @@ class GraphPage { private readonly queryInput = el('input', 'ag-query'); private readonly countEl = el('span', 'ag-count'); private readonly labelsButton = el('button', 'ag-button', 'Labels'); + private readonly linksButton = el('button', 'ag-button', 'Links'); + private readonly layoutButton = el('button', 'ag-button', 'Layered'); private readonly themeButton = el('button', 'ag-button ag-theme'); private readonly legend = el('div', 'ag-legend'); private readonly side = el('aside', 'ag-side'); @@ -116,6 +138,7 @@ class GraphPage { this.buildGraph(true); this.syncLabels(); this.renderLegend(); + this.syncLayoutButton(); this.queryInput.value = this.saved.query ?? ''; this.applyQuery(); this.showEmptySide(); @@ -133,7 +156,13 @@ 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 ?? '', 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(); + 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]; } @@ -154,7 +183,8 @@ class GraphPage { if (at) n.position(at); else fresh += 1; }); - if (fresh) this.layout(false); + if (fresh || this.saved.layout !== 'force') this.layout(false); + this.syncLinks(); this.renderLegend(); this.applyHidden(); this.applyQuery(); @@ -211,10 +241,20 @@ class GraphPage { 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.layoutButton.type = 'button'; + this.layoutButton.addEventListener('click', () => { + this.saved.layout = this.saved.layout === 'force' ? 'layered' : 'force'; + this.save(); + this.layout(true); + this.syncLayoutButton(); + }); + this.linksButton.type = 'button'; + this.linksButton.title = 'Show the links from decisions and registries to their elements; off, they show for the selected element only'; + this.linksButton.addEventListener('click', () => { + this.saved.links = !this.saved.links; + this.save(); + this.syncLinks(); + }); this.labelsButton.type = 'button'; this.labelsButton.title = 'Show or hide connection labels'; this.labelsButton.addEventListener('click', () => { @@ -230,7 +270,7 @@ class GraphPage { this.help.hidden = !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'); this.canvas.append(cyHolder, this.legend); @@ -248,7 +288,7 @@ class GraphPage { 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.help.appendChild(el('p', '', 'Matches stay bright, the rest dims. Connections show when both ends match. Click an element for its details; drag to move it; scroll to zoom. Press / to type a query. Layered puts clients on the left and data on the right, with registries before and decisions after; Links shows every decision and registry link instead of the selected element\u2019s only.')); this.main.append(this.canvas, this.side, this.help); this.root.append(top, bar, this.main); document.body.appendChild(this.root); @@ -293,38 +333,89 @@ class GraphPage { maxZoom: 4, }); this.applyHidden(); + this.syncLinks(); this.layout(first); this.cy.on('tap', 'node, edge', (event) => this.showSide(event.target as NodeSingular | EdgeSingular)); this.cy.on('tap', (event) => { if (event.target === this.cy) this.showEmptySide(); }); + // Hover lights up an element and its connections; selection also reveals its decision and registry links. + this.cy.on('mouseover', 'node', (event) => { + const node = event.target as NodeSingular; + node.addClass('hover'); + node.connectedEdges('[kind = "connection"]').addClass('edge-hover'); + }); + this.cy.on('mouseout', 'node', (event) => { + const node = event.target as NodeSingular; + node.removeClass('hover'); + node.connectedEdges().removeClass('edge-hover'); + }); + this.cy.on('select unselect', 'node', () => this.syncLinks()); + } + + /** Decision and registry links: all of them when Links is on, else those of the selected nodes. */ + private syncLinks(): void { + const all = Boolean(this.saved.links); + this.linksButton.setAttribute('aria-pressed', String(all)); + const meta = this.cy.edges('[kind != "connection"]'); + meta.removeClass('shown'); + if (all) meta.addClass('shown'); + else this.cy.nodes(':selected').connectedEdges('[kind != "connection"]').addClass('shown'); + } + + private syncLayoutButton(): void { + const force = this.saved.layout === 'force'; + this.layoutButton.textContent = force ? 'Force' : 'Layered'; + this.layoutButton.title = force ? 'Force-directed layout; click for the layered one (clients left, data right)' : 'Layered by type; click for a force-directed layout'; } private stylesheet(): cytoscape.StylesheetJson { const p = palette(); + const dark = currentTheme() === 'dark'; + const bg = toHex(p.bg); + const fill = (amount: number) => (n: NodeSingular) => mix(String(n.data('color')), bg, amount); return [ - { selector: 'node', style: { shape: 'round-rectangle', width: (n: NodeSingular) => Math.min(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[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[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: 'node[kind = "decision"]', style: { shape: 'round-tag', height: 34, 'font-size': 11, 'font-weight': 500, 'background-color': fill(dark ? 0.12 : 0.08), 'text-max-width': '190px', width: (n: NodeSingular) => Math.min(220, Math.max(120, String(n.data('label')).length * 6.4 + 44)) } }, + { selector: 'node[kind = "registry"]', style: { shape: 'cut-rectangle', height: 32, 'font-size': 11, 'font-weight': 500, 'font-family': 'ui-monospace, Menlo, monospace', 'border-width': 1, 'border-color': p.muted, 'background-color': mix(dark ? '#94a3b8' : '#64748b', bg, 0.08), width: (n: NodeSingular) => Math.min(220, Math.max(110, String(n.data('label')).length * 6.8 + 36)) } }, + { selector: 'node.hover', style: { 'underlay-opacity': dark ? 0.22 : 0.16, 'border-width': 2, 'background-color': fill(dark ? 0.28 : 0.18) } }, + { selector: 'edge', style: { width: 1.4, 'line-color': p.edge, 'line-opacity': 0.85, 'target-arrow-color': p.edge, 'target-arrow-shape': 'triangle', 'arrow-scale': 0.8, 'curve-style': 'bezier', label: '', 'font-size': 10, 'font-family': p.font, color: p.muted, 'text-background-color': p.bg, 'text-background-opacity': 1, 'text-background-padding': '3px', 'text-background-shape': 'round-rectangle', 'text-rotation': 'autorotate', 'transition-property': 'width, line-color', 'transition-duration': 120 } }, + { selector: 'edge.ortho', style: { 'curve-style': 'taxi', 'taxi-direction': 'auto', 'taxi-turn': '45%', 'taxi-turn-min-distance': 24, 'text-rotation': 'none', 'source-text-offset': 'data(offset)', 'source-text-rotation': 'none', 'source-text-background-color': p.bg, 'source-text-background-opacity': 1, 'source-text-background-padding': '3px', 'source-text-background-shape': 'round-rectangle' } }, + { selector: 'edge.edge-hover', style: { width: 2.4, 'line-color': p.fg, 'target-arrow-color': p.fg, 'line-opacity': 1, color: p.fg } }, + { selector: 'edge[kind != "connection"]', style: { display: 'none', width: 1, 'curve-style': 'bezier', 'line-style': 'dotted', 'target-arrow-shape': 'none', 'line-color': p.muted, 'line-opacity': 0.55 } }, + { selector: 'edge[kind = "supersedes"]', style: { 'line-style': 'dashed', 'line-color': TYPE_COLORS.decision, 'target-arrow-shape': 'triangle', 'target-arrow-color': TYPE_COLORS.decision } }, + { selector: 'edge[kind != "connection"].shown', style: { display: 'element' } }, { selector: 'edge.labelled[kind = "connection"], edge.labelled[kind = "supersedes"]', style: { label: 'data(label)' } }, - { selector: '.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: 'edge.labelled.ortho[kind = "connection"]', style: { label: '', 'source-label': 'data(label)' } }, + { selector: '.dim', style: { opacity: 0.12, events: 'no' } }, + { selector: 'node.hit', style: { 'border-width': 2.5 } }, + { selector: 'edge.path', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, color: p.fg, 'line-opacity': 1 } }, + { selector: 'node:selected', style: { 'border-color': p.accent, 'border-width': 2.5, 'underlay-color': p.accent, 'underlay-opacity': dark ? 0.28 : 0.18, 'background-color': fill(dark ? 0.3 : 0.2) } }, + { selector: 'edge:selected', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, 'line-opacity': 1 } }, { selector: '.hidden', style: { display: 'none' } }, ] as unknown as cytoscape.StylesheetJson; } - private layout(randomize: boolean): void { - const visible = this.cy.elements().not('.hidden'); - visible - .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(); + private layout(fresh: boolean): void { + const force = this.saved.layout === 'force'; + this.cy.edges().toggleClass('ortho', !force); + if (force) { + this.cy + .elements() + .not('.hidden') + .layout({ name: 'fcose', animate: false, randomize: fresh, quality: 'default', nodeDimensionsIncludeLabels: true, nodeRepulsion: () => 9000, idealEdgeLength: (e: EdgeSingular) => (e.data('kind') === 'connection' ? 150 : 110), edgeElasticity: (e: EdgeSingular) => (e.data('kind') === 'connection' ? 0.2 : 0.08), gravity: 0.3, padding: 40, fit: true } as cytoscape.LayoutOptions) + .run(); + return; + } + const positions = layeredLayout(this.graph.nodes, this.graph.edges); + this.cy.batch(() => { + this.cy.nodes().forEach((n) => { + const at = positions.get(n.id()); + if (at) n.position(at); + }); + }); + this.cy.fit(this.cy.elements().not('.hidden'), 40); } // --------------------------------------------------------------- theme @@ -697,7 +788,7 @@ class GraphPage { } 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 } }); } } diff --git a/src/graph/layout.ts b/src/graph/layout.ts new file mode 100644 index 0000000..37ac34b --- /dev/null +++ b/src/graph/layout.ts @@ -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; + +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(); + 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(); + const neighbours = new Map(); + 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(); + 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; +}