From c8645b228b26ca0784ce4f732312109e196d79f0 Mon Sep 17 00:00:00 2001 From: Root At Skic Date: Wed, 21 Oct 2026 15:47:00 +0300 Subject: [PATCH 01/10] Model graph: the whole model as one graph with queries; 0.9.0 - Model | Graph switch on main.archify (Diagram | Graph on diagram pages, narrowed to that diagram) - union graph of every element and connection, registry and inline, built from the model index - query box: text, type:, diagram:, decision:, uses:, near:, from: to:, unused, shared, -shared - side panel per element and connection with diagrams, registry line and decisions one click away - legend chips, Fit, Layout, Labels, Export PNG; Cytoscape.js bundled as dist/graph.js - command Archify | Open Model Graph; integration check; unit tests for graph and queries --- CHANGELOG.md | 8 + README.md | 8 +- docs/model.md | 13 + esbuild.mjs | 9 + package-lock.json | 57 ++++ package.json | 25 +- src/commands/modelCommands.ts | 11 + src/graph/index.ts | 568 ++++++++++++++++++++++++++++++++++ src/graph/style.ts | 63 ++++ src/model/format.ts | 51 ++- src/model/graph.ts | 147 +++++++++ src/model/graphQuery.ts | 234 ++++++++++++++ src/viewer3d/index.ts | 14 +- src/views/diagramView.ts | 118 ++++++- src/webview/host.ts | 20 ++ src/webview/messages.ts | 4 + src/webview/page.ts | 75 ++++- src/webview/viewer3d.ts | 5 + test/graph.test.ts | 135 ++++++++ test/integration/suite.ts | 16 + 20 files changed, 1551 insertions(+), 30 deletions(-) create mode 100644 src/graph/index.ts create mode 100644 src/graph/style.ts create mode 100644 src/model/graph.ts create mode 100644 src/model/graphQuery.ts create mode 100644 src/webview/host.ts create mode 100644 test/graph.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3918627..1a65647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 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. Drag, zoom, **Fit**, **Layout**, **Labels**, legend chips that hide a type, **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`; terms AND together, a leading `-` excludes; 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. +- **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. +- Diagram summaries in the model index now carry every node and edge of a diagram (shared and inline), which the graph is built from. + ## 0.8.1 — 2026-10-20 - **Archify | New Registry…** on a folder (also *File → New File…* and the Command Palette) creates an `.archify-registry` file from the template and opens it. diff --git a/README.md b/README.md index dcd11de..7f9bf68 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ The Archify CLI and the AI agents that use it write diagrams as JSON, named `nam - **New diagram from a template**: *Archify | New Diagram…* creates a starter `.archify` file for any of the five diagram kinds, or an everyday plan, in the chosen folder. - **New model from an example**: *Archify | New Model…* creates a complete model folder from a C4 example (Standard Web App, E-Shop, Event-Based Integration). +- **Model graph**: the **Graph** switch on any page of a model shows the whole model as one graph, with a query box (`type:database`, `uses:api`, `from:web to:db`, …), a side panel per element and PNG export. See [Models](#models). - **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live. - **Live updates**: the diagram re-renders as you edit, and keeps the focus and route you selected (`#focus=…`, `#route=…`). - **HTML viewer**: right-click a rendered `.html` file and choose **Archify | Open in HTML Viewer**, or use *Reopen Editor With… → Archify HTML Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk. @@ -73,7 +74,7 @@ The Archify CLI and the AI agents that use it write diagrams as JSON, named `nam - **Schema help**: completion and hover from Archify's own schemas, through the YAML extension for `.archify` files. - **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead. - **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog. -- **Commands**: *Archify | New Model…*, *Archify | New Registry…*, *Archify | New Diagram…*, *Archify | New Decision…*, *Archify | Open Model*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*. +- **Commands**: *Archify | New Model…*, *Archify | New Registry…*, *Archify | New Diagram…*, *Archify | New Decision…*, *Archify | Open Model*, *Archify | Open Model Graph*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*. ## 3D view @@ -126,7 +127,10 @@ 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`. -- **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. +- **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. Drag nodes, scroll to zoom, **Fit** (or 0) frames what matches, **Layout** lays it out again, **Labels** toggles connection labels, **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). 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. +- **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. Formats in detail: [docs/model.md](docs/model.md). A full example: [examples/online-shop](examples/online-shop). diff --git a/docs/model.md b/docs/model.md index af117d9..7fc2dd3 100644 --- a/docs/model.md +++ b/docs/model.md @@ -127,3 +127,16 @@ connections: ``` The key is stripped before Archify renders. An unknown id is a problem at its line; outside a model the key is an error, like `ref`. The `main.archify` page lists all decisions; a diagram page lists the ones it points at or that name it; an element's card and dialog list the decisions about it. A `.archify-decision` file opens as its own page. Outside a model it still opens, with a note that it has lost its model. + +## The model graph + +The **Model | Graph** switch on the `main.archify` page replaces it with the whole model as one graph. It is built from the model index, not from Archify's renderer: + +- **Nodes** are every element any diagram draws (`components`, `nodes`, `participants`, `states`), joined by id across diagrams, plus every registry element even when no diagram draws it yet. Registry elements carry their description, file and line; inline elements only what the diagram says. The same inline id in two diagrams is one node. +- **Edges** are every `connections`, `edges`, `flows`, `transitions` and `messages` entry between two nodes of the same diagram, keyed by `from>to`; the labels different diagrams give the same pair are joined with " / ". Direction is kept. +- **Decisions** reach nodes through `elements:` in the decision file and through `decisions:` on a node in a diagram; they reach edges through `decisions:` on a connection. + +Queries run in the page against that graph; the forms are listed under **?** on the page and in the README. `uses:` and `near:` ignore direction; `from: to:` follows the arrows first and ignores them only when no directed path exists. Connections are shown when both ends match, so `type:backend` shows backend-to-backend connections only. + +The switch is remembered per `main.archify` in the workspace. The graph page updates in place when a file of the model changes: elements that are still there keep their position, new ones are laid out around them. + diff --git a/esbuild.mjs b/esbuild.mjs index ba1b66e..fc5d2ab 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -49,6 +49,15 @@ if (tests) { entryPoints: ['src/viewer3d/index.ts'], outfile: 'dist/viewer3d.js', }), + // The Graph page of a model; Cytoscape.js is bundled in. + esbuild.context({ + ...common, + format: 'iife', + platform: 'browser', + target: 'es2022', + entryPoints: ['src/graph/index.ts'], + outfile: 'dist/graph.js', + }), ]); if (watch) { await Promise.all(contexts.map((ctx) => ctx.watch())); diff --git a/package-lock.json b/package-lock.json index 22af545..3b594c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,14 @@ "version": "0.8.1", "license": "MIT", "dependencies": { + "cytoscape": "^3.34.4", + "cytoscape-fcose": "^2.2.0", "three": "^0.186.1", "yaml": "^2.9.1" }, "devDependencies": { + "@types/cytoscape": "^3.21.9", + "@types/cytoscape-fcose": "^2.2.5", "@types/node": "^22.0.0", "@types/three": "^0.186.0", "@types/vscode": "~1.100.0", @@ -1015,6 +1019,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cytoscape": { + "version": "3.21.9", + "resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz", + "integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cytoscape-fcose": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@types/cytoscape-fcose/-/cytoscape-fcose-2.2.5.tgz", + "integrity": "sha512-FaM6MM5zC4Yh72E60vkpbM4tstJS90wj3u1Vq/SMiK/MShhLaMd17QkmR39S14aUWdfNSlUEfx/g/XkDdFmXDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cytoscape": "^3.31" + } + }, "node_modules/@types/node": { "version": "22.20.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", @@ -1755,6 +1776,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -1785,6 +1815,27 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cytoscape": { + "version": "3.34.4", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.4.tgz", + "integrity": "sha512-msY99tMY8iWTg+sIPGfSqDcPNOt4xcj+v/K/7qUFaCUxxlqnMkUYLBjRUzn9W5CU/R2hrmtJa5+UtovrEM2ctg==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2935,6 +2986,12 @@ "prebuild-install": "^7.0.1" } }, + "node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/package.json b/package.json index 701ca03..b92146b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "archify-vscode-ext", "displayName": "Archify Diagram Viewer", "description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, …) and opens rendered Archify HTML.", - "version": "0.8.1", + "version": "0.9.0", "publisher": "root-at-skic", "license": "MIT", "sponsor": { @@ -108,6 +108,11 @@ "command": "archify.openModel", "title": "Archify | Open Model", "icon": "$(symbol-structure)" + }, + { + "command": "archify.openModelGraph", + "title": "Archify | Open Model Graph", + "icon": "$(type-hierarchy)" } ], "menus": { @@ -143,6 +148,10 @@ { "command": "archify.openModel", "when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'" + }, + { + "command": "archify.openModelGraph", + "when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'" } ], "editor/title": [ @@ -180,6 +189,11 @@ "command": "archify.openModel", "when": "archify.inModel && (archify.isDiagramSource || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision') && !notebookEditorFocused", "group": "navigation@60" + }, + { + "command": "archify.openModelGraph", + "when": "archify.inModel && (archify.isDiagramSource || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision') && !notebookEditorFocused", + "group": "navigation@61" } ], "explorer/context": [ @@ -222,6 +236,11 @@ "command": "archify.openModel", "when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/", "group": "zzz_archify@8" + }, + { + "command": "archify.openModelGraph", + "when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/", + "group": "zzz_archify@9" } ], "editor/title/context": [ @@ -453,6 +472,8 @@ "test:integration": "node esbuild.mjs && node esbuild.mjs --tests && node test/integration/run.mjs" }, "devDependencies": { + "@types/cytoscape": "^3.21.9", + "@types/cytoscape-fcose": "^2.2.5", "@types/node": "^22.0.0", "@types/three": "^0.186.0", "@types/vscode": "~1.100.0", @@ -468,6 +489,8 @@ }, "homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme", "dependencies": { + "cytoscape": "^3.34.4", + "cytoscape-fcose": "^2.2.0", "three": "^0.186.1", "yaml": "^2.9.1" } diff --git a/src/commands/modelCommands.ts b/src/commands/modelCommands.ts index 87bbbf0..5450468 100644 --- a/src/commands/modelCommands.ts +++ b/src/commands/modelCommands.ts @@ -33,6 +33,17 @@ export function modelCommands(ctx: CommandContext): vscode.Disposable[] { await vscode.commands.executeCommand('vscode.openWith', vscode.Uri.joinPath(root, MAIN_FILE), DIAGRAM_EDITOR_VIEW_TYPE, vscode.ViewColumn.Active); }), + // Opens the model's Graph page: every element and connection of the model in one picture, with a query box. + vscode.commands.registerCommand('archify.openModelGraph', async (arg?: unknown) => { + const uri = targetUri(ctx, arg); + const root = uri ? await ctx.models.rootOf(uri) : undefined; + if (!root) { + vscode.window.showInformationMessage(`Archify: this file is not inside a model (no ${MAIN_FILE} in a folder above it).`); + return; + } + await ctx.views.showGraph(root); + }), + // Creates a registry file in the clicked folder and opens it. vscode.commands.registerCommand('archify.newRegistry', async (arg?: unknown) => { const folder = await parentFolder(arg); diff --git a/src/graph/index.ts b/src/graph/index.ts new file mode 100644 index 0000000..8b4346a --- /dev/null +++ b/src/graph/index.ts @@ -0,0 +1,568 @@ +import cytoscape, { Core, EdgeSingular, NodeSingular } from 'cytoscape'; +import fcose from 'cytoscape-fcose'; +import type { GraphEdge, GraphNode, ModelGraph } from '../model/graph'; +import { QueryResult, runQuery } from '../model/graphQuery'; +import { GRAPH_PAGE_STYLE } from './style'; +import type { ArchifyHost } from '../webview/host'; + +/** + * The Graph page of a model: the whole model as one graph, a query box that + * narrows it, a side panel for the selected element or connection. Runs in + * the webview; the extension hands the graph over through the bridge + * (`window.__archifyHost.graph`) and posts a new one when the model changes. + */ + +type Host = ArchifyHost; + +interface SavedView { + query?: string; + labels?: boolean; + hidden?: string[]; +} + +cytoscape.use(fcose); + +const TYPE_COLORS: Record = { frontend: '#22d3ee', backend: '#34d399', database: '#a78bfa', cloud: '#fbbf24', security: '#fb7185', messagebus: '#fb923c', external: '#94a3b8' }; +const TYPE_ORDER = ['external', 'frontend', 'cloud', 'security', 'backend', 'messagebus', 'database']; +const colorOf = (type: string | undefined) => TYPE_COLORS[type ?? ''] ?? '#94a3b8'; +/** Edge labels are drawn by default up to this many connections; above it they clutter. */ +const LABELS_UP_TO = 40; +const PLACEHOLDER = 'database · type:backend · diagram:0200-* · uses:api · near:api,2 · from:web to:db · decision: · unused · -shared'; + +const HELP: [string, string][] = [ + ['database', 'elements whose id, label, sublabel, brand or type contain the word'], + ['type:backend', 'elements of that type'], + ['diagram:0200-*', 'what the diagrams matching the path draw (glob)'], + ['decision:', 'what a decision is about or is attached to'], + ['uses:api', 'an element and its neighbours'], + ['near:api,2', 'an element and everything within two steps'], + ['from:web to:db', 'the shortest path between two elements'], + ['unused', 'registry elements no diagram draws'], + ['shared', 'registry elements only; -shared: inline elements only'], + ['-worker', 'a leading minus excludes; terms combine with AND'], +]; + +function el(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function cssVar(name: string, fallback: string): string { + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return value || fallback; +} + +/** Theme colours Cytoscape needs as literals; read again when VS Code switches theme. */ +function palette() { + const dark = !document.body.classList.contains('vscode-light'); + return { + bg: cssVar('--vscode-editor-background', dark ? '#0b1020' : '#ffffff'), + fg: cssVar('--vscode-foreground', dark ? '#e5e7eb' : '#1f2937'), + muted: cssVar('--vscode-descriptionForeground', dark ? '#9ca3af' : '#6b7280'), + panel: cssVar('--vscode-editorWidget-background', dark ? '#131c31' : '#f3f4f6'), + accent: cssVar('--vscode-textLink-foreground', '#22d3ee'), + edge: dark ? '#64748b' : '#94a3b8', + font: cssVar('--vscode-font-family', 'system-ui, sans-serif'), + }; +} + +class GraphPage { + cy!: Core; + private graph: ModelGraph; + private readonly nodesById = new Map(); + private readonly edgesById = new Map(); + private saved: SavedView; + private queryTimer: number | undefined; + private result: QueryResult | undefined; + private readonly hiddenTypes: Set; + + private readonly root = el('div', 'ag'); + private readonly nameEl = el('div', 'ag-name'); + private readonly kickerEl = el('div', 'ag-kicker'); + private readonly queryInput = el('input', 'ag-query'); + private readonly countEl = el('span', 'ag-count'); + private readonly labelsButton = el('button', 'ag-button', 'Labels'); + private readonly legend = el('div', 'ag-legend'); + private readonly side = el('aside', 'ag-side'); + private readonly main = el('div', 'ag-main'); + private readonly canvas = el('div', 'ag-canvas'); + private readonly help = el('div', 'ag-help'); + + constructor(private readonly host: Host, initial: { graph: ModelGraph; query?: string }) { + this.graph = initial.graph; + this.saved = (host.getState().graphView as SavedView | undefined) ?? {}; + if (initial.query !== undefined) this.saved.query = initial.query; + if (this.saved.labels === undefined) this.saved.labels = initial.graph.edges.length <= LABELS_UP_TO; + this.hiddenTypes = new Set(this.saved.hidden ?? []); + this.index(); + this.buildChrome(); + this.buildGraph(true); + this.syncLabels(); + this.renderLegend(); + this.queryInput.value = this.saved.query ?? ''; + this.applyQuery(); + this.showEmptySide(); + this.listen(); + } + + // ---------------------------------------------------------------- data + + private index(): void { + this.nodesById.clear(); + this.edgesById.clear(); + for (const n of this.graph.nodes) this.nodesById.set(n.id, n); + for (const e of this.graph.edges) this.edgesById.set(e.id, e); + } + + private elements() { + const nodes = this.graph.nodes.map((n) => ({ group: 'nodes' as const, data: { id: n.id, label: n.label, type: n.type ?? '', shared: n.shared ? 1 : 0, color: colorOf(n.type) } })); + const edges = this.graph.edges.map((e) => ({ group: 'edges' as const, data: { id: e.id, source: e.from, target: e.to, label: e.labels.join(' / ') } })); + return [...nodes, ...edges]; + } + + /** A new graph from the extension: keep the positions of the elements that are still there. */ + replace(graph: ModelGraph): void { + const positions = new Map(); + this.cy.nodes().forEach((n) => { + positions.set(n.id(), { ...n.position() }); + }); + const selected = this.cy.$(':selected').map((e) => e.id()); + this.graph = graph; + this.index(); + this.cy.elements().remove(); + this.cy.add(this.elements()); + let fresh = 0; + this.cy.nodes().forEach((n) => { + const at = positions.get(n.id()); + if (at) n.position(at); + else fresh += 1; + }); + if (fresh) this.layout(false); + this.renderLegend(); + this.applyHidden(); + this.applyQuery(); + const again = this.cy.$(selected.map((id) => `#${CSS.escape(id)}`).join(',')); + if (again.length) { + again.select(); + this.showSide(again[0]); + } else this.showEmptySide(); + this.kickerEl.textContent = `Model${graph.meta.id ? ` · ${graph.meta.id}` : ''}`; + this.nameEl.textContent = graph.meta.name || graph.meta.id || 'Untitled model'; + } + + // -------------------------------------------------------------- chrome + + private buildChrome(): void { + const style = el('style'); + style.textContent = GRAPH_PAGE_STYLE; + document.head.appendChild(style); + + const top = el('header', 'ag-top'); + const titles = el('div'); + this.kickerEl.textContent = `Model${this.graph.meta.id ? ` · ${this.graph.meta.id}` : ''}`; + this.nameEl.textContent = this.graph.meta.name || this.graph.meta.id || 'Untitled model'; + titles.append(this.kickerEl, this.nameEl); + const actions = el('div', 'ag-actions'); + const sw = el('button', 'ag-switch'); + sw.type = 'button'; + sw.title = 'Back to the model page'; + sw.setAttribute('aria-label', 'Graph view on; switch to the model page'); + sw.append(el('span', 'ag-seg', 'Model'), el('span', 'ag-seg on', 'Graph')); + sw.addEventListener('click', () => this.host.post({ type: 'showModel' })); + const exportButton = el('button', 'ag-button', 'Export PNG'); + exportButton.type = 'button'; + exportButton.title = 'Save the graph as it is on screen, as a PNG'; + exportButton.addEventListener('click', () => this.exportPng()); + actions.append(sw, exportButton); + top.append(titles, actions); + + const bar = el('div', 'ag-bar'); + const wrap = el('div', 'ag-query-wrap'); + this.queryInput.type = 'text'; + this.queryInput.placeholder = PLACEHOLDER; + this.queryInput.spellcheck = false; + this.queryInput.setAttribute('aria-label', 'Query'); + const clear = el('button', 'ag-clear', '×'); + clear.type = 'button'; + clear.title = 'Clear (Esc)'; + clear.addEventListener('click', () => this.setQuery('')); + wrap.append(this.queryInput, clear); + const fit = el('button', 'ag-button', 'Fit'); + fit.type = 'button'; + fit.title = 'Fit the matching elements into view (0)'; + fit.addEventListener('click', () => this.fit()); + const relayout = el('button', 'ag-button', 'Layout'); + relayout.type = 'button'; + relayout.title = 'Lay the graph out again'; + relayout.addEventListener('click', () => this.layout(true)); + this.labelsButton.type = 'button'; + this.labelsButton.title = 'Show or hide connection labels'; + this.labelsButton.addEventListener('click', () => { + this.saved.labels = !this.saved.labels; + this.save(); + this.syncLabels(); + }); + const helpButton = el('button', 'ag-button', '?'); + helpButton.type = 'button'; + helpButton.title = 'What can I type?'; + helpButton.setAttribute('aria-expanded', 'false'); + helpButton.addEventListener('click', () => { + this.help.hidden = !this.help.hidden; + helpButton.setAttribute('aria-expanded', String(!this.help.hidden)); + }); + bar.append(wrap, this.countEl, fit, relayout, this.labelsButton, helpButton); + + const cyHolder = el('div', 'ag-cy'); + this.canvas.append(cyHolder, this.legend); + this.help.hidden = true; + this.help.appendChild(el('h3', '', 'Queries')); + const table = el('table'); + for (const [example, meaning] of HELP) { + const row = el('tr'); + const code = el('td', '', example); + code.title = 'Try it'; + code.addEventListener('click', () => { + this.setQuery(example.includes('') ? example.replace('', this.graph.decisions[0]?.id ?? '') : example); + }); + row.append(code, el('td', '', meaning)); + table.appendChild(row); + } + this.help.appendChild(table); + this.help.appendChild(el('p', '', 'Matches stay bright, the rest dims. Connections show when both ends match. Click an element for its details; drag to move it; scroll to zoom. Press / to type a query.')); + this.main.append(this.canvas, this.side, this.help); + this.root.append(top, bar, this.main); + document.body.appendChild(this.root); + this.syncLabels(); + } + + private renderLegend(): void { + this.legend.textContent = ''; + const counts = new Map(); + for (const n of this.graph.nodes) counts.set(n.type ?? 'other', (counts.get(n.type ?? 'other') ?? 0) + 1); + const types = [...counts.keys()].sort((a, b) => (TYPE_ORDER.indexOf(a) + 1 || 99) - (TYPE_ORDER.indexOf(b) + 1 || 99) || a.localeCompare(b)); + for (const type of types) { + const chip = el('button', 'ag-chip'); + chip.type = 'button'; + chip.style.setProperty('--c', colorOf(type)); + chip.setAttribute('aria-pressed', String(!this.hiddenTypes.has(type))); + chip.title = `Show or hide ${type} elements`; + chip.append(document.createTextNode(type), el('small', '', String(counts.get(type)))); + chip.addEventListener('click', () => { + if (this.hiddenTypes.has(type)) this.hiddenTypes.delete(type); + else this.hiddenTypes.add(type); + chip.setAttribute('aria-pressed', String(!this.hiddenTypes.has(type))); + this.saved.hidden = [...this.hiddenTypes]; + this.save(); + this.applyHidden(); + this.updateCount(); + }); + this.legend.appendChild(chip); + } + } + + // --------------------------------------------------------------- graph + + private buildGraph(first: boolean): void { + const holder = this.canvas.firstElementChild as HTMLElement; + this.cy = cytoscape({ + container: holder, + elements: this.elements(), + style: this.stylesheet(), + wheelSensitivity: 0.2, + minZoom: 0.1, + maxZoom: 4, + }); + this.applyHidden(); + this.layout(first); + this.cy.on('tap', 'node, edge', (event) => this.showSide(event.target as NodeSingular | EdgeSingular)); + this.cy.on('tap', (event) => { + if (event.target === this.cy) this.showEmptySide(); + }); + } + + private stylesheet(): cytoscape.StylesheetJson { + const p = palette(); + return [ + { selector: 'node', style: { shape: 'round-rectangle', width: (n: NodeSingular) => Math.min(260, Math.max(84, String(n.data('label')).length * 7.5 + 34)), height: 42, 'background-color': p.panel, 'border-width': 2, 'border-color': 'data(color)', label: 'data(label)', color: p.fg, 'font-size': 13, 'font-weight': 600, 'font-family': p.font, 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'ellipsis', 'text-max-width': '230px' } }, + { selector: 'node[shared = 0]', style: { 'border-style': 'dashed' } }, + { selector: 'edge', style: { width: 1.5, 'line-color': p.edge, 'target-arrow-color': p.edge, 'target-arrow-shape': 'triangle', 'arrow-scale': 0.9, 'curve-style': 'bezier', label: '', 'font-size': 10, 'font-family': p.font, color: p.muted, 'text-background-color': p.bg, 'text-background-opacity': 1, 'text-background-padding': '2px', 'text-rotation': 'autorotate' } }, + { selector: 'edge.labelled', style: { label: 'data(label)' } }, + { selector: '.dim', style: { opacity: 0.14, events: 'no' } }, + { selector: 'node.hit', style: { 'border-width': 3 } }, + { selector: 'edge.path', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, color: p.fg } }, + { selector: 'node:selected', style: { 'border-color': p.accent, 'border-width': 3, 'background-color': p.panel, 'overlay-opacity': 0 } }, + { selector: 'edge:selected', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, 'overlay-opacity': 0 } }, + { selector: '.hidden', style: { display: 'none' } }, + ] as unknown as cytoscape.StylesheetJson; + } + + private layout(randomize: boolean): void { + const visible = this.cy.elements().not('.hidden'); + visible + .layout({ name: 'fcose', animate: false, randomize, quality: 'default', nodeDimensionsIncludeLabels: true, nodeRepulsion: () => 9000, idealEdgeLength: () => 150, edgeElasticity: () => 0.2, gravity: 0.3, padding: 40, fit: true } as cytoscape.LayoutOptions) + .run(); + } + + private applyHidden(): void { + this.cy.nodes().forEach((n) => { + n.toggleClass('hidden', this.hiddenTypes.has(String(n.data('type') || 'other'))); + }); + } + + private syncLabels(): void { + const on = Boolean(this.saved.labels); + this.labelsButton.setAttribute('aria-pressed', String(on)); + this.cy?.edges().toggleClass('labelled', on); + } + + // --------------------------------------------------------------- query + + private setQuery(text: string): void { + this.queryInput.value = text; + this.applyQuery(); + this.queryInput.focus(); + } + + private applyQuery(): void { + const text = this.queryInput.value; + this.saved.query = text; + this.save(); + this.result = runQuery(this.graph, text); + const { nodes, edges, path } = this.result; + const empty = !text.trim(); + this.cy.batch(() => { + this.cy.elements().removeClass('dim hit path'); + if (empty) return; + this.cy.nodes().forEach((n) => { + n.toggleClass('dim', !nodes.has(n.id())).toggleClass('hit', nodes.has(n.id())); + }); + this.cy.edges().forEach((e) => { + e.toggleClass('dim', !edges.has(e.id())); + }); + if (path) for (const id of edges) this.cy.$id(id).addClass('path'); + }); + this.updateCount(); + } + + private updateCount(): void { + const total = this.cy.nodes().not('.hidden').length; + const r = this.result; + if (!r || !this.queryInput.value.trim()) { + this.countEl.textContent = `${total} elements · ${this.cy.edges().not('.hidden').length} connections`; + this.countEl.classList.remove('is-error'); + return; + } + const shown = this.cy.nodes('.hit').not('.hidden').length; + this.countEl.textContent = r.message ? r.message : r.path ? `path of ${r.path.length} elements` : `${shown} of ${total} elements`; + this.countEl.classList.toggle('is-error', Boolean(r.message)); + } + + private fit(): void { + const hits = this.cy.nodes('.hit').not('.hidden'); + const target = hits.length ? hits.union(hits.connectedEdges().not('.dim')) : this.cy.elements().not('.hidden'); + this.cy.animate({ fit: { eles: target, padding: 40 }, duration: 250 }); + } + + // ---------------------------------------------------------------- side + + private showEmptySide(): void { + this.side.textContent = ''; + this.side.appendChild(el('p', 'ag-empty', 'Click an element or a connection.')); + const h3 = el('h3', '', 'This model'); + this.side.appendChild(h3); + const summary = el('p', '', `${this.graph.nodes.length} elements, ${this.graph.edges.length} connections, ${this.graph.diagrams.length} diagrams, ${this.graph.decisions.length} decisions.`); + this.side.appendChild(summary); + const unused = this.graph.nodes.filter((n) => n.shared && !n.diagrams.length).length; + if (unused) { + const hint = el('button', 'ag-link', `${unused} unused registry element${unused === 1 ? '' : 's'}`); + hint.type = 'button'; + hint.addEventListener('click', () => this.setQuery('unused')); + this.side.appendChild(hint); + } + } + + private showSide(target: NodeSingular | EdgeSingular): void { + if (target.isNode()) this.showNode(this.nodesById.get(target.id())); + else this.showEdge(this.edgesById.get(target.id())); + } + + private link(text: string, title: string, onClick?: () => void, className = ''): HTMLButtonElement { + const button = el('button', `ag-link ${className}${onClick ? '' : ' is-static'}`); + button.type = 'button'; + button.title = title; + button.appendChild(document.createTextNode(text)); + if (onClick) button.addEventListener('click', onClick); + return button; + } + + private decisionLinks(ids: string[]): HTMLElement { + const holder = el('div'); + for (const id of ids) { + const d = this.graph.decisions.find((x) => x.id === id); + const button = this.link('', d ? `${d.title ?? d.id} (${d.status})` : id, d ? () => this.host.post({ type: 'openDecision', file: d.file }) : undefined, `is-${d?.status ?? 'unknown'}`); + button.prepend(el('span', 'ag-dot')); + button.appendChild(document.createTextNode([id, d?.title, d?.status].filter(Boolean).join(' · '))); + holder.appendChild(button); + } + return holder; + } + + private diagramLinks(paths: string[]): HTMLElement { + const holder = el('div'); + for (const path of paths) { + const d = this.graph.diagrams.find((x) => x.path === path); + holder.appendChild(this.link(path.replace(/\.archify$/, ''), d?.title ? `Open ${d.title} (${path})` : `Open ${path}`, () => this.host.post({ type: 'openDiagram', path }))); + } + return holder; + } + + private showNode(node: GraphNode | undefined): void { + if (!node) return; + this.side.textContent = ''; + const title = el('h2'); + const dot = el('span', 'ag-dot'); + dot.style.setProperty('--c', colorOf(node.type)); + title.append(dot, document.createTextNode(node.label)); + this.side.appendChild(title); + this.side.appendChild(el('div', 'ag-id', [node.id, node.type, node.sublabel, node.brand ? `brand ${node.brand}` : undefined, node.shared ? 'registry' : 'inline'].filter(Boolean).join(' · '))); + this.side.appendChild(node.description ? el('p', '', node.description) : el('p', 'ag-empty', node.shared ? 'No description in the registry.' : 'Inline element: defined in the diagram, not in a registry.')); + + const actions = el('div', 'ag-side-actions'); + actions.appendChild(this.link('Neighbours', `Show ${node.id} and everything connected to it`, () => this.setQuery(`uses:${node.id}`))); + if (node.file) actions.appendChild(this.link('Registry', `Open ${node.file} at line ${(node.line ?? 0) + 1}`, () => this.host.post({ type: 'openRegistry', file: node.file, line: node.line ?? 0 }))); + if (node.link) actions.appendChild(this.link('Explained by', `Open ${node.link}`, () => this.host.post({ type: 'openDiagram', path: node.link }))); + this.side.appendChild(actions); + + this.side.appendChild(el('h3', '', `Used in ${node.diagrams.length} diagram${node.diagrams.length === 1 ? '' : 's'}`)); + this.side.appendChild(node.diagrams.length ? this.diagramLinks(node.diagrams) : el('p', 'ag-empty', 'Not drawn by any diagram yet.')); + + const connected = this.graph.edges.filter((e) => e.from === node.id || e.to === node.id); + this.side.appendChild(el('h3', '', `${connected.length} connection${connected.length === 1 ? '' : 's'}`)); + const list = el('div'); + for (const e of connected) { + const other = e.from === node.id ? e.to : e.from; + const otherLabel = this.nodesById.get(other)?.label ?? other; + const text = e.from === node.id ? `→ ${otherLabel}` : `← ${otherLabel}`; + list.appendChild(this.link(e.labels.length ? `${text} · ${e.labels.join(' / ')}` : text, `${e.from} → ${e.to}`, () => { + const edge = this.cy.$id(e.id); + this.cy.elements().unselect(); + edge.select(); + this.showEdge(e); + })); + } + this.side.appendChild(list); + if (node.decisions.length) { + this.side.appendChild(el('h3', '', 'Decisions')); + this.side.appendChild(this.decisionLinks(node.decisions)); + } + } + + private showEdge(edge: GraphEdge | undefined): void { + if (!edge) return; + this.side.textContent = ''; + const from = this.nodesById.get(edge.from); + const to = this.nodesById.get(edge.to); + const title = el('h2'); + title.textContent = `${from?.label ?? edge.from} → ${to?.label ?? edge.to}`; + this.side.appendChild(title); + this.side.appendChild(el('div', 'ag-id', `${edge.from} → ${edge.to}${edge.labels.length ? ` · ${edge.labels.join(' / ')}` : ''}`)); + const ends = el('div', 'ag-side-actions'); + for (const end of [from, to]) { + if (!end) continue; + const button = this.link(end.label, `Show ${end.id}`, () => { + const n = this.cy.$id(end.id); + this.cy.elements().unselect(); + n.select(); + this.showNode(end); + }); + const dot = el('span', 'ag-dot'); + dot.style.setProperty('--c', colorOf(end.type)); + button.prepend(dot); + ends.appendChild(button); + } + this.side.appendChild(ends); + this.side.appendChild(el('h3', '', `Drawn in ${edge.diagrams.length} diagram${edge.diagrams.length === 1 ? '' : 's'}`)); + this.side.appendChild(this.diagramLinks(edge.diagrams)); + if (edge.decisions.length) { + this.side.appendChild(el('h3', '', 'Decisions')); + this.side.appendChild(this.decisionLinks(edge.decisions)); + } + } + + // -------------------------------------------------------------- export + + private exportPng(): void { + const blob = this.cy.png({ output: 'blob', full: true, scale: 2, bg: palette().bg }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${this.graph.meta.id || 'model'}-graph.png`; + anchor.hidden = true; + document.body.appendChild(anchor); + anchor.click(); // the bridge turns the download into a VS Code save dialog + setTimeout(() => { + anchor.remove(); + URL.revokeObjectURL(url); + }, 1000); + } + + // -------------------------------------------------------------- events + + private listen(): void { + this.queryInput.addEventListener('input', () => { + window.clearTimeout(this.queryTimer); + this.queryTimer = window.setTimeout(() => this.applyQuery(), 120); + }); + this.queryInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + window.clearTimeout(this.queryTimer); + this.applyQuery(); + this.fit(); + } else if (event.key === 'Escape') { + if (this.queryInput.value) this.setQuery(''); + else this.queryInput.blur(); + } + }); + window.addEventListener('keydown', (event) => { + const target = event.target as HTMLElement | null; + const typing = target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)); + if (typing || event.metaKey || event.ctrlKey || event.altKey) return; + if (event.key === '/') { + event.preventDefault(); + this.queryInput.focus(); + this.queryInput.select(); + } else if (event.key === '0') this.fit(); + else if (event.key === 'Escape') { + this.cy.elements().unselect(); + this.showEmptySide(); + this.help.hidden = true; + } + }); + window.addEventListener('resize', () => this.cy.resize()); + new MutationObserver(() => { + this.cy.style(this.stylesheet()); + this.syncLabels(); + }).observe(document.body, { attributes: true, attributeFilter: ['class'] }); + window.addEventListener('message', (event) => { + const message = event.data || {}; + if (message.type === 'graph' && message.graph) this.replace(message.graph as ModelGraph); + if (message.type === 'query' && typeof message.query === 'string') this.setQuery(message.query); + }); + } + + private save(): void { + this.host.setState({ graphView: { query: this.saved.query, labels: this.saved.labels, hidden: this.saved.hidden ?? [] } }); + } +} + +function start(): void { + const host = window.__archifyHost; + if (!host?.graph) return; + // Exposed for the integration harness; nothing in the page depends on it. + (window as unknown as { __archifyGraphPage?: GraphPage }).__archifyGraphPage = new GraphPage(host, host.graph); +} + +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); +else start(); diff --git a/src/graph/style.ts b/src/graph/style.ts new file mode 100644 index 0000000..63359e1 --- /dev/null +++ b/src/graph/style.ts @@ -0,0 +1,63 @@ +/** Page styles of the graph view. VS Code's theme variables, Archify's type colours. */ +export const GRAPH_PAGE_STYLE = ` +:root { --ag-bg: var(--vscode-editor-background, #0b1020); --ag-fg: var(--vscode-foreground, #e5e7eb); --ag-muted: var(--vscode-descriptionForeground, #9ca3af); + --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; } +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); } +.ag-kicker { font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ag-accent); } +.ag-name { font-weight: 700; font-size: 1.125rem; letter-spacing: -0.01em; } +.ag-actions { margin-left: auto; display: flex; align-items: center; gap: 0.5rem; } +.ag-switch { display: inline-flex; padding: 0; border: 1px solid var(--ag-border); border-radius: 999px; background: var(--ag-panel); color: var(--ag-muted); font: inherit; cursor: pointer; overflow: hidden; } +.ag-switch:focus-visible { outline: 2px solid var(--ag-accent); outline-offset: 2px; } +.ag-seg { display: inline-flex; align-items: center; height: 1.75rem; padding: 0 0.8rem; border-radius: 999px; font-weight: 600; font-size: 0.8125rem; transition: background 0.15s, color 0.15s; } +.ag-seg.on { color: var(--ag-bg); background: var(--ag-fg); } +.ag-switch:hover .ag-seg:not(.on) { color: var(--ag-fg); } +.ag-button { font: inherit; font-size: 0.8125rem; font-weight: 600; height: 1.9rem; padding: 0 0.85rem; border-radius: 999px; border: 1px solid var(--ag-border); background: var(--ag-panel); color: var(--ag-fg); cursor: pointer; white-space: nowrap; } +.ag-button:hover { border-color: var(--ag-accent); } +.ag-button[aria-pressed="true"] { background: var(--ag-fg); color: var(--ag-bg); border-color: var(--ag-fg); } +.ag-button:focus-visible { outline: 2px solid var(--ag-accent); outline-offset: 2px; } +.ag-bar { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 1.25rem; border-bottom: 1px solid var(--ag-border); flex-wrap: wrap; } +.ag-query-wrap { flex: 1 1 18rem; display: flex; align-items: center; gap: 0.5rem; min-width: 0; background: var(--ag-input); border: 1px solid var(--ag-input-border); border-radius: 0.6rem; padding: 0 0.6rem; } +.ag-query-wrap:focus-within { border-color: var(--ag-accent); } +.ag-query { flex: 1; min-width: 0; height: 2rem; border: 0; background: transparent; color: var(--ag-input-fg); font-family: var(--ag-mono); font-size: 0.875rem; outline: none; } +.ag-query::placeholder { color: var(--ag-muted); opacity: 0.8; } +.ag-clear { border: 0; background: transparent; color: var(--ag-muted); font-size: 1rem; cursor: pointer; padding: 0 0.1rem; } +.ag-clear:hover { color: var(--ag-fg); } +.ag-count { font-family: var(--ag-mono); font-size: 0.8125rem; color: var(--ag-muted); white-space: nowrap; } +.ag-count.is-error { color: var(--ag-error); } +.ag-main { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 20rem; min-height: 0; } +.ag-main.is-side-hidden { grid-template-columns: minmax(0, 1fr); } +.ag-canvas { position: relative; min-width: 0; min-height: 0; } +.ag-cy { position: absolute; inset: 0; } +.ag-legend { position: absolute; left: 1rem; bottom: 0.9rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; z-index: 2; max-width: calc(100% - 2rem); } +.ag-chip { display: inline-flex; align-items: center; gap: 0.4rem; font: inherit; font-size: 0.75rem; padding: 0.2rem 0.6rem; border-radius: 999px; border: 1px solid var(--ag-border); background: color-mix(in srgb, var(--ag-panel) 85%, transparent); color: var(--ag-fg); cursor: pointer; backdrop-filter: blur(6px); } +.ag-chip::before { content: ""; width: 0.55rem; height: 0.55rem; border-radius: 2px; background: var(--c); } +.ag-chip[aria-pressed="false"] { opacity: 0.45; text-decoration: line-through; } +.ag-chip small { color: var(--ag-muted); font-family: var(--ag-mono); } +.ag-side { border-left: 1px solid var(--ag-border); background: var(--ag-panel); overflow: auto; padding: 1rem 1.1rem 1.5rem; min-width: 0; } +.ag-side h2 { margin: 0; font-size: 1.0625rem; font-weight: 700; display: flex; align-items: center; gap: 0.5rem; } +.ag-dot { display: inline-block; width: 0.6rem; height: 0.6rem; border-radius: 50%; background: var(--c, var(--ag-external)); flex: none; } +.ag-id { font-family: var(--ag-mono); color: var(--ag-muted); font-size: 0.75rem; margin: 0.25rem 0 0.8rem; word-break: break-all; } +.ag-side p { margin: 0 0 0.8rem; line-height: 1.5; white-space: pre-line; } +.ag-side h3 { margin: 0.9rem 0 0.4rem; font-size: 0.6875rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ag-muted); font-weight: 700; } +.ag-link { display: inline-flex; align-items: baseline; gap: 0.35rem; line-height: 1.4; font: inherit; font-family: var(--ag-mono); font-size: 0.75rem; padding: 0.15rem 0.55rem; margin: 0 0.3rem 0.35rem 0; border: 1px solid var(--ag-border); border-radius: 999px; background: transparent; color: var(--ag-fg); cursor: pointer; text-align: left; max-width: 100%; } +.ag-link:hover { border-color: var(--ag-accent); } +.ag-link.is-static { cursor: default; } +.ag-link.is-static:hover { border-color: var(--ag-border); } +.ag-link.is-accepted .ag-dot { background: var(--ag-backend); } .ag-link.is-proposed .ag-dot { background: var(--ag-cloud); } .ag-link.is-rejected .ag-dot { background: var(--ag-security); } .ag-link.is-superseded .ag-dot { background: var(--ag-external); } +.ag-empty { color: var(--ag-muted); } +.ag-side-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.6rem; } +.ag-help { position: absolute; top: 0.5rem; right: 20.75rem; z-index: 5; width: min(26rem, calc(100% - 2rem)); max-height: calc(100% - 1rem); overflow: auto; padding: 0.9rem 1rem; border: 1px solid var(--ag-border); border-radius: 0.75rem; background: var(--ag-panel); box-shadow: 0 16px 40px rgba(0,0,0,.35); } +.ag-main.is-side-hidden .ag-help { right: 0.75rem; } +.ag-help h3 { margin: 0 0 0.5rem; font-size: 0.8125rem; } +.ag-help table { border-collapse: collapse; width: 100%; font-size: 0.8125rem; } +.ag-help td { padding: 0.25rem 0.4rem 0.25rem 0; vertical-align: top; border-top: 1px solid var(--ag-border); } +.ag-help td:first-child { font-family: var(--ag-mono); white-space: nowrap; color: var(--ag-accent); cursor: pointer; } +.ag-help td:first-child:hover { text-decoration: underline; } +.ag-help p { margin: 0.6rem 0 0; color: var(--ag-muted); font-size: 0.75rem; } +@media (max-width: 760px) { .ag-main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; } .ag-side { border-left: 0; border-top: 1px solid var(--ag-border); max-height: 40%; } .ag-help { right: 0.75rem; } } +`; diff --git a/src/model/format.ts b/src/model/format.ts index fae57fe..0941f0e 100644 --- a/src/model/format.ts +++ b/src/model/format.ts @@ -309,6 +309,23 @@ export function groupOf(relativePath: string): string { return parts.join('/'); } +/** A node as a diagram declares it: a `ref` to the registry, or an inline element. */ +export interface DiagramNode { + id: string; + ref?: string; + type?: string; + label?: string; + sublabel?: string; + brand?: string; +} + +/** A connection, edge, flow, transition or message between two nodes of a diagram. */ +export interface DiagramEdge { + from: string; + to: string; + label?: string; +} + export interface DiagramSummary { relativePath: string; group: string; @@ -317,6 +334,10 @@ export interface DiagramSummary { used: string[]; /** Decisions the diagram points at. */ decisions: DecisionRef[]; + /** Every node the diagram draws, shared or inline, in file order. */ + nodes: DiagramNode[]; + /** Every edge between two of those nodes. */ + edges: DiagramEdge[]; parseError?: string; } @@ -326,14 +347,40 @@ export function summarizeDiagram(text: string, relativePath: string): DiagramSum try { doc = parseYaml(text, { prettyErrors: false, uniqueKeys: false }); } catch (error) { - return { relativePath, group: groupOf(relativePath), used: [], decisions: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) }; + return { relativePath, group: groupOf(relativePath), used: [], decisions: [], nodes: [], edges: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) }; } const obj = isObj(doc) ? doc : {}; const meta = isObj(obj.meta) ? obj.meta : {}; const components = Array.isArray(obj.components) ? obj.components : []; const used = [...new Set(components.map((c) => (isObj(c) ? str(c.ref) : undefined)).filter((r): r is string => !!r))]; const decisions = takeDecisionRefs({ ...obj }, { registry: { elements: new Map(), files: [], problems: [] }, fileName: relativePath }, []); - return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used, decisions }; + const nodes: DiagramNode[] = []; + const seen = new Set(); + for (const key of NODE_COLLECTIONS) { + const items = obj[key]; + if (!Array.isArray(items)) continue; + for (const item of items) { + if (!isObj(item)) continue; + const ref = str(item.ref); + const id = str(item.id) ?? ref; + if (!id || seen.has(id)) continue; + seen.add(id); + nodes.push({ id, ref, type: str(item.type), label: str(item.label), sublabel: str(item.sublabel), brand: str(item.brand) }); + } + } + const edges: DiagramEdge[] = []; + for (const key of EDGE_COLLECTIONS) { + const items = obj[key]; + if (!Array.isArray(items)) continue; + for (const item of items) { + if (!isObj(item)) continue; + const from = str(item.from); + const to = str(item.to); + if (!from || !to || !seen.has(from) || !seen.has(to)) continue; + edges.push({ from, to, label: str(item.label) }); + } + } + return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used, decisions, nodes, edges }; } /** Groups in display order: the root group first, then folders in path order. */ diff --git a/src/model/graph.ts b/src/model/graph.ts new file mode 100644 index 0000000..03a4c4d --- /dev/null +++ b/src/model/graph.ts @@ -0,0 +1,147 @@ +import { Decision, DecisionSet, effectiveStatus } from './decisions'; +import { DecisionAttachment, DiagramSummary, ModelMeta, Registry } from './format'; + +/** + * The model as one graph: every element any diagram draws (and every registry + * element, drawn or not), every connection any diagram has between two of + * them, each once. Pure: built from the model index, no VS Code imports, so + * the webview bundle and the unit tests share it. + */ + +export interface GraphNode { + id: string; + label: string; + sublabel?: string; + type?: string; + brand?: string; + /** Defined in a registry (true) or inline in one diagram (false). */ + shared: boolean; + description?: string; + /** Registry file and 0-based line, for shared elements. */ + file?: string; + line?: number; + /** Diagram that explains the element, relative to the model root. */ + link?: string; + /** Diagrams that draw it, relative paths in path order. */ + diagrams: string[]; + /** Decisions about it: named by the decision, or attached to its node in a diagram. */ + decisions: string[]; +} + +export interface GraphEdge { + /** `from>to`, unique in the graph. */ + id: string; + from: string; + to: string; + /** Distinct labels the diagrams give this connection. */ + labels: string[]; + diagrams: string[]; + decisions: string[]; +} + +export interface GraphDecision { + id: string; + title?: string; + status: string; + file: string; +} + +export interface GraphDiagram { + path: string; + title?: string; + kind?: string; +} + +export interface ModelGraph { + meta: ModelMeta; + nodes: GraphNode[]; + edges: GraphEdge[]; + decisions: GraphDecision[]; + diagrams: GraphDiagram[]; +} + +export interface GraphInput { + meta: ModelMeta; + registry: Registry; + diagrams: DiagramSummary[]; + decisions: DecisionSet; + decisionUsage: Map; +} + +export const edgeId = (from: string, to: string): string => `${from}>${to}`; + +function pushUnique(list: string[], value: string): void { + if (!list.includes(value)) list.push(value); +} + +export function buildGraph(input: GraphInput): ModelGraph { + const nodes = new Map(); + const edges = new Map(); + + // Registry elements first: shared, and present even when no diagram draws them yet. + for (const e of input.registry.elements.values()) { + nodes.set(e.id, { + id: e.id, + label: e.label ?? e.id, + sublabel: e.sublabel, + type: e.type, + brand: e.brand, + shared: true, + description: e.description?.trim() || undefined, + file: e.file, + line: e.line, + link: e.diagram, + diagrams: [], + decisions: [], + }); + } + + const diagrams = [...input.diagrams].filter((d) => !d.parseError).sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + for (const d of diagrams) { + for (const n of d.nodes) { + let node = nodes.get(n.id); + if (!node) { + 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: [] }; + nodes.set(n.id, node); + } + pushUnique(node.diagrams, d.relativePath); + } + for (const e of d.edges) { + 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: [] }; + edges.set(id, edge); + } + if (e.label) pushUnique(edge.labels, e.label); + pushUnique(edge.diagrams, d.relativePath); + } + } + + const all = input.decisions.decisions; + for (const d of all.values()) for (const id of d.elements) nodes.get(id)?.decisions && pushUnique(nodes.get(id)!.decisions, d.id); + for (const [decisionId, uses] of input.decisionUsage) { + if (!all.has(decisionId)) continue; + for (const use of uses) { + if (use.attachedTo.kind === 'node') { + const node = nodes.get(use.attachedTo.id); + if (node) pushUnique(node.decisions, decisionId); + } else if (use.attachedTo.kind === 'edge') { + const edge = edges.get(edgeId(use.attachedTo.from, use.attachedTo.to)); + if (edge) pushUnique(edge.decisions, decisionId); + } + } + } + + const decisionRow = (d: Decision): GraphDecision => ({ id: d.id, title: d.title, status: effectiveStatus(d, all).status, file: d.file }); + return { + meta: input.meta, + nodes: [...nodes.values()].sort((a, b) => a.id.localeCompare(b.id)), + edges: [...edges.values()].sort((a, b) => a.id.localeCompare(b.id)), + decisions: [...all.values()].map(decisionRow).sort((a, b) => a.id.localeCompare(b.id)), + diagrams: diagrams.map((d) => ({ path: d.relativePath, title: d.title, kind: d.kind })), + }; +} diff --git a/src/model/graphQuery.ts b/src/model/graphQuery.ts new file mode 100644 index 0000000..3c4bbfc --- /dev/null +++ b/src/model/graphQuery.ts @@ -0,0 +1,234 @@ +import { GraphEdge, GraphNode, ModelGraph } from './graph'; + +/** + * The graph page's query box. A few words, no language to learn: terms are + * ANDed, a leading `-` excludes, `key:value` terms filter by a field or walk + * the graph. Pure, shared by the webview bundle and the unit tests. + * + * database label, id, sublabel, brand or type contain it + * type:backend elements of that type + * diagram:0200-* drawn by a diagram whose path matches (glob) + * decision: what the decision is about or attached to + * uses:api api and its neighbours + * near:api,2 api and everything within two steps + * from:web to:db the shortest path between two elements + * unused registry elements no diagram draws + * shared / -shared registry elements / inline elements + */ + +export interface QueryTerm { + key: string; + value: string; + negate: boolean; +} + +export interface QueryResult { + /** Ids of the elements that match. Every element when the query is empty. */ + nodes: Set; + /** Ids of the connections shown: both ends match, or the path found. */ + edges: Set; + /** The path found by `from: to:`, in order. */ + path?: string[]; + /** Why the result is empty or partial, when it is. */ + message?: string; + terms: QueryTerm[]; +} + +const WALK_KEYS = new Set(['uses', 'near', 'from', 'to']); +const FIELD_KEYS = new Set(['type', 'diagram', 'decision', 'shared', 'unused', 'text']); + +export function parseQuery(text: string): QueryTerm[] { + const tokens = text.trim().split(/\s+/).filter(Boolean); + const terms: QueryTerm[] = []; + for (let i = 0; i < tokens.length; i += 1) { + let token = tokens[i]; + const negate = token.startsWith('-') && token.length > 1; + if (negate) token = token.slice(1); + const colon = token.indexOf(':'); + let key = colon > 0 ? token.slice(0, colon).toLowerCase() : token.toLowerCase(); + let value = colon > 0 ? token.slice(colon + 1) : ''; + if (colon > 0 && !WALK_KEYS.has(key) && !FIELD_KEYS.has(key)) { + key = 'text'; + value = token; + } else if (colon < 0 && key !== 'shared' && key !== 'unused') { + key = 'text'; + value = token; + } + // `near:api 2` is also accepted: a bare number after near is the hop count. + if (key === 'near' && !value.includes(',') && /^\d+$/.test(tokens[i + 1] ?? '')) { + value = `${value},${tokens[i + 1]}`; + i += 1; + } + if (!value && key === 'text') continue; + terms.push({ key, value, negate }); + } + return terms; +} + +const lower = (s: string | undefined) => (s ?? '').toLowerCase(); + +/** An element by id, then by id or label ignoring case. */ +export function findNode(graph: ModelGraph, ref: string): GraphNode | undefined { + const exact = graph.nodes.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); +} + +function globToRegExp(pattern: string): RegExp { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.'); + return new RegExp(`^${escaped}$`, 'i'); +} + +function diagramMatches(path: string, pattern: string): boolean { + const bare = path.replace(/\.archify$/, ''); + const wanted = pattern.replace(/\.archify$/, ''); + if (wanted.includes('*') || wanted.includes('?')) return globToRegExp(wanted).test(bare) || globToRegExp(wanted).test(path); + const l = wanted.toLowerCase(); + return bare.toLowerCase() === l || bare.toLowerCase().startsWith(`${l}/`) || bare.toLowerCase().startsWith(l) || bare.toLowerCase().includes(`/${l}`); +} + +function neighbours(graph: ModelGraph): Map> { + const map = new Map>(); + for (const n of graph.nodes) map.set(n.id, new Set()); + for (const e of graph.edges) { + map.get(e.from)?.add(e.to); + map.get(e.to)?.add(e.from); + } + return map; +} + +function within(graph: ModelGraph, start: string, hops: number): Set { + const adjacency = neighbours(graph); + let frontier = new Set([start]); + const seen = new Set([start]); + for (let i = 0; i < hops; i += 1) { + const next = new Set(); + for (const id of frontier) for (const other of adjacency.get(id) ?? []) if (!seen.has(other)) { seen.add(other); next.add(other); } + frontier = next; + if (!frontier.size) break; + } + return seen; +} + +/** Shortest path by breadth-first search; directed first, then ignoring direction. */ +export function shortestPath(graph: ModelGraph, from: string, to: string): { nodes: string[]; edges: string[] } | undefined { + const attempt = (directed: boolean) => { + const out = new Map(); + for (const n of graph.nodes) out.set(n.id, []); + for (const e of graph.edges) { + out.get(e.from)?.push({ to: e.to, edge: e }); + if (!directed) out.get(e.to)?.push({ to: e.from, edge: e }); + } + const previous = new Map(); + const queue = [from]; + const seen = new Set([from]); + while (queue.length) { + const id = queue.shift()!; + if (id === to) break; + for (const step of out.get(id) ?? []) { + if (seen.has(step.to)) continue; + seen.add(step.to); + previous.set(step.to, { from: id, edge: step.edge }); + queue.push(step.to); + } + } + if (!seen.has(to)) return undefined; + const nodes = [to]; + const edges: string[] = []; + let at = to; + while (at !== from) { + const step = previous.get(at)!; + edges.unshift(step.edge.id); + nodes.unshift(step.from); + at = step.from; + } + return { nodes, edges }; + }; + if (from === to) return { nodes: [from], edges: [] }; + return attempt(true) ?? attempt(false); +} + +export function runQuery(graph: ModelGraph, text: string): QueryResult { + const terms = parseQuery(text); + const everything = new Set(graph.nodes.map((n) => n.id)); + let result = new Set(everything); + const notes: string[] = []; + let from: string | undefined; + let to: string | undefined; + let pathEdges: Set | undefined; + let path: string[] | undefined; + + const matching = (term: QueryTerm): Set | undefined => { + const v = term.value; + switch (term.key) { + case 'text': { + const l = v.toLowerCase(); + return new Set(graph.nodes.filter((n) => [n.id, n.label, n.sublabel, n.brand, n.type].some((f) => lower(f).includes(l))).map((n) => n.id)); + } + case 'type': + return new Set(graph.nodes.filter((n) => lower(n.type) === v.toLowerCase()).map((n) => n.id)); + case 'diagram': + return new Set(graph.nodes.filter((n) => n.diagrams.some((d) => diagramMatches(d, v))).map((n) => n.id)); + case 'decision': { + 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.`); + return ids; + } + case 'shared': + return new Set(graph.nodes.filter((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)); + case 'uses': + case 'near': { + const [ref, hopsText] = term.key === 'near' ? v.split(',') : [v, '1']; + const node = findNode(graph, ref); + if (!node) { notes.push(`No element "${ref}".`); return new Set(); } + const hops = Math.max(1, Math.min(Number(hopsText) || 1, 50)); + return within(graph, node.id, hops); + } + case 'from': + from = v; + return undefined; + case 'to': + to = v; + return undefined; + } + return undefined; + }; + + 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 (from || to) { + if (!from || !to) notes.push('A path needs both from: and to:.'); + else { + const a = findNode(graph, from); + const b = findNode(graph, to); + if (!a || !b) notes.push(`No element "${!a ? from : to}".`); + else { + const found = shortestPath(graph, a.id, b.id); + if (!found) notes.push(`No path from ${a.id} to ${b.id}.`); + else { + path = found.nodes; + pathEdges = new Set(found.edges); + result = new Set(found.nodes.filter((id) => result.has(id))); + } + } + if (!path) result = new Set(); + } + } + + const edges = new Set(); + for (const e of graph.edges) { + if (!result.has(e.from) || !result.has(e.to)) continue; + if (pathEdges && !pathEdges.has(e.id)) continue; + edges.add(e.id); + } + return { nodes: result, edges, path, message: notes[0], terms }; +} diff --git a/src/viewer3d/index.ts b/src/viewer3d/index.ts index e1eb782..acaa640 100644 --- a/src/viewer3d/index.ts +++ b/src/viewer3d/index.ts @@ -2,6 +2,7 @@ import { layerMarkup, rasterize } from './raster'; import { CameraState, Layers, Stage } from './stage'; import { DiagramModel, readModel } from './svgModel'; import { STYLE } from './style'; +import type { ArchifyHost } from '../webview/host'; /** * 3D mode for the Archify viewer. It adds a 2D | 3D switch to Archify's @@ -12,12 +13,7 @@ import { STYLE } from './style'; * follows Archify's animations as they play. */ -interface Host { - defaultView: '2d' | '3d'; - post(message: unknown): void; - getState(): Record; - setState(patch: Record): void; -} +type Host = ArchifyHost; interface SavedView { active?: boolean; @@ -25,12 +21,6 @@ interface SavedView { camera?: CameraState; } -declare global { - interface Window { - __archifyHost?: Host; - } -} - const REDRAW_MS = 220; const MARGIN = 80; /** Texture pixels per SVG unit; more costs memory and upload time for little gain. */ diff --git a/src/views/diagramView.ts b/src/views/diagramView.ts index 154d449..f6c23a8 100644 --- a/src/views/diagramView.ts +++ b/src/views/diagramView.ts @@ -2,7 +2,8 @@ import path from 'node:path'; import * as vscode from 'vscode'; import { renderDiagram } from '../archify/cli'; import { evidenceMode, runtimeFor } from '../archify/runtime'; -import { isMainFile, isRegistryFile } from '../model/format'; +import { MAIN_FILE, isMainFile, isRegistryFile } from '../model/format'; +import { buildGraph } from '../model/graph'; import { ModelPanel, panelFromIndex } from '../model/panel'; import { prepareDiagram } from '../model/prepare'; import { ModelService } from '../model/service'; @@ -11,8 +12,8 @@ import { readText } from '../util/files'; import { log } from '../util/log'; import { viewerTheme } from '../util/settings'; import { handleWebviewMessage, WebviewMessage } from '../webview/messages'; -import { makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page'; -import { viewer3dOption, viewerRoots } from '../webview/viewer3d'; +import { graphPage, makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page'; +import { graphScriptUri, viewer3dOption, viewerRoots } from '../webview/viewer3d'; import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes'; @@ -21,8 +22,18 @@ export { DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE }; interface PreviewState { uri: string; hash?: string; + /** main.archify only: the Graph page is shown instead of the model page. */ + graph?: boolean; } +/** What a preview asks of its manager. */ +interface PreviewHost { + /** Opens the Graph page of the model at `root`, with a query to start from. */ + openGraph(root: vscode.Uri, query?: string): Promise; +} + +const GRAPH_MODE_KEY = 'archify.graphMode'; + class DiagramPreview implements vscode.Disposable { private seq = 0; private hash: string | undefined; @@ -34,10 +45,16 @@ class DiagramPreview implements vscode.Disposable { readonly onDidDispose = this.onDisposeEmitter.event; /** Root of the model this file belongs to, once known. */ root: vscode.Uri | undefined; + /** main.archify: show the Graph page instead of the model page. Remembered per file across reloads. */ + private graph = false; + private graphQuery: string | undefined; + /** True while the webview shows the Graph page, which takes model updates as messages. */ + private graphShown = false; constructor( private readonly context: vscode.ExtensionContext, private readonly models: ModelService, + private readonly host: PreviewHost, readonly panel: vscode.WebviewPanel, readonly uri: vscode.Uri, state?: PreviewState, @@ -45,6 +62,7 @@ class DiagramPreview implements vscode.Disposable { readonly isEditor = false, ) { this.hash = state?.hash; + this.graph = state?.graph ?? this.graphModes()[uri.toString()] === true; panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) }; if (!isEditor) { panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg'); @@ -70,6 +88,15 @@ class DiagramPreview implements vscode.Disposable { void this.openLinked(message.file, DECISION_VIEW_TYPE); return; } + if (message.type === 'showGraph') { + if (isMainFile(this.uri.path)) this.setGraph(true, message.query); + else if (this.root) void this.host.openGraph(this.root, message.query); + return; + } + if (message.type === 'showModel') { + this.setGraph(false); + return; + } void handleWebviewMessage(message, this.uri, this.panel); }), ); @@ -89,8 +116,51 @@ class DiagramPreview implements vscode.Disposable { void this.update(); } + /** Posts to the webview; a panel closed mid-update is not an error. */ + private post(message: unknown): void { + Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined); + } + private state(): PreviewState { - return { uri: this.uri.toString(), hash: this.hash }; + return { uri: this.uri.toString(), hash: this.hash, graph: this.graph }; + } + + private graphModes(): Record { + return this.context.workspaceState.get>(GRAPH_MODE_KEY, {}); + } + + /** Switches a main.archify editor between its model page and the Graph page. */ + setGraph(on: boolean, query?: string): void { + this.graph = on; + this.graphQuery = query; + const modes = this.graphModes(); + if (on) modes[this.uri.toString()] = true; + else delete modes[this.uri.toString()]; + void this.context.workspaceState.update(GRAPH_MODE_KEY, modes); + this.lastHtml = undefined; + this.rendered = false; + if (!on) this.graphShown = false; + else if (this.graphShown && query !== undefined) this.post({ type: 'query', query }); + void this.update(true); + } + + /** The Graph page of the model, or an update of it when it is already showing. */ + private showGraphPage(index: Awaited>): void { + const graph = buildGraph(index); + const fingerprint = `graph\u0000${JSON.stringify(graph)}`; + if (fingerprint === this.lastHtml && this.graphShown) return; + this.lastHtml = fingerprint; + this.rendered = false; + if (this.graphShown) { + this.post({ type: 'graph', graph }); + return; + } + this.graphShown = true; + this.panel.webview.html = graphPage( + { cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() }, + { graph, query: this.graphQuery, scriptUri: graphScriptUri(this.panel.webview, this.context.extensionUri) }, + ); + this.graphQuery = undefined; } private showMessage(title: string, body: string, detail?: string): void { @@ -121,6 +191,7 @@ class DiagramPreview implements vscode.Disposable { if (fingerprint === this.lastHtml) return; this.lastHtml = fingerprint; this.rendered = false; + this.graphShown = false; this.panel.webview.html = modelPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() }, model); } @@ -146,7 +217,13 @@ class DiagramPreview implements vscode.Disposable { if (main) { this.root = vscode.Uri.file(path.dirname(this.uri.fsPath)); try { - model = panelFromIndex(await this.models.index(this.root)); + const index = await this.models.index(this.root); + if (seq !== this.seq) return; + if (this.graph) { + this.showGraphPage(index); + return; + } + model = panelFromIndex(index); } catch (error) { log().warn(`Model index failed for ${this.root.fsPath}: ${error instanceof Error ? error.message : String(error)}`); } @@ -159,6 +236,7 @@ class DiagramPreview implements vscode.Disposable { } if (seq !== this.seq) return; if (!main) this.root = prepared?.root; + this.graphShown = false; if (!prepared) { if (model) { this.showModelPage(model); @@ -184,7 +262,7 @@ class DiagramPreview implements vscode.Disposable { if (!result.ok || !result.html) { log().warn(`Render failed for ${this.uri.fsPath}: ${result.error}`); if (this.rendered && this.lastHtml) { - void this.panel.webview.postMessage({ + this.post({ type: 'notice', notice: { kind: 'error', text: `Render failed; showing the last successful render.\n${firstLines(result.error)}` }, }); @@ -206,11 +284,12 @@ class DiagramPreview implements vscode.Disposable { descriptions: Object.fromEntries(prepared.descriptions), decisions: prepared.decisions, model, + graphSwitch: main ? { left: 'Model' } : prepared.root ? { left: 'Diagram', query: `diagram:${path.relative(prepared.root.fsPath, this.uri.fsPath).split(path.sep).join('/')}` } : undefined, }); // Nonces differ per render, so compare the renderer output instead. const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${JSON.stringify([model ?? null, [...prepared.links], [...prepared.descriptions], prepared.decisions])}\u0000${result.html}`; if (!force && this.rendered && fingerprint === this.lastHtml) { - void this.panel.webview.postMessage({ type: 'notice', notice: undefined }); + this.post({ type: 'notice', notice: undefined }); return; } this.lastHtml = fingerprint; @@ -286,10 +365,29 @@ export class DiagramViewManager return [...this.previews.values(), ...this.editors].filter((preview) => preview.uri.toString() === key); } + private readonly host: PreviewHost = { openGraph: (root, query) => this.showGraph(root, query) }; + /** Opening a .archify file lands here: the diagram is the editor. */ async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise { panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) }; - this.track(new DiagramPreview(this.context, this.models, panel, document.uri, undefined, true)); + this.track(new DiagramPreview(this.context, this.models, this.host, panel, document.uri, undefined, true)); + } + + /** Opens the model's main.archify as its Graph page, with a query to start from. */ + async showGraph(root: vscode.Uri, query?: string): Promise { + const main = vscode.Uri.joinPath(root, MAIN_FILE); + await vscode.commands.executeCommand('vscode.openWith', main, DIAGRAM_EDITOR_VIEW_TYPE, vscode.ViewColumn.Active); + const key = main.toString(); + const deadline = Date.now() + 5000; + for (;;) { + const editor = [...this.editors].find((e) => e.uri.toString() === key && e.panel.active) ?? [...this.editors].find((e) => e.uri.toString() === key); + if (editor) { + editor.setGraph(true, query); + return; + } + if (Date.now() > deadline) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } } show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void { @@ -304,7 +402,7 @@ export class DiagramViewManager { viewColumn, preserveFocus }, { enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: viewerRoots(this.context.extensionUri) }, ); - this.track(new DiagramPreview(this.context, this.models, panel, uri)); + this.track(new DiagramPreview(this.context, this.models, this.host, panel, uri)); } async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise { @@ -312,7 +410,7 @@ export class DiagramViewManager panel.dispose(); return; } - this.track(new DiagramPreview(this.context, this.models, panel, vscode.Uri.parse(state.uri), state)); + this.track(new DiagramPreview(this.context, this.models, this.host, panel, vscode.Uri.parse(state.uri), state)); } private track(preview: DiagramPreview): void { diff --git a/src/webview/host.ts b/src/webview/host.ts new file mode 100644 index 0000000..be961ce --- /dev/null +++ b/src/webview/host.ts @@ -0,0 +1,20 @@ +import type { ModelGraph } from '../model/graph'; + +/** + * What the bridge script (page.ts) exposes to the bundled page scripts as + * `window.__archifyHost`: they cannot call acquireVsCodeApi a second time. + */ +export interface ArchifyHost { + defaultView: '2d' | '3d'; + post(message: unknown): void; + getState(): Record; + setState(patch: Record): void; + /** The Graph page's data, present on that page only. */ + graph?: { graph: ModelGraph; query?: string }; +} + +declare global { + interface Window { + __archifyHost?: ArchifyHost; + } +} diff --git a/src/webview/messages.ts b/src/webview/messages.ts index 5e4bc7f..e2a091d 100644 --- a/src/webview/messages.ts +++ b/src/webview/messages.ts @@ -12,6 +12,8 @@ export type WebviewMessage = | { type: 'openDiagram'; path: string } | { type: 'openRegistry'; file: string; line?: number } | { type: 'openDecision'; file: string } + | { type: 'showGraph'; query?: string } + | { type: 'showModel' } | { type: 'error'; message: string }; /** Column for opening files next to a webview without covering it. */ @@ -96,6 +98,8 @@ export async function handleWebviewMessage( break; case 'hash': case 'openDiagram': + case 'showGraph': + case 'showModel': // Handled by the view, which knows the model root. break; } diff --git a/src/webview/page.ts b/src/webview/page.ts index b026337..7909374 100644 --- a/src/webview/page.ts +++ b/src/webview/page.ts @@ -26,8 +26,13 @@ export interface PageOptions { decision?: DecisionPanel; /** Diagram page: decisions of this diagram, for the Decisions rail panel. */ decisions?: DiagramDecision[]; + /** Pages inside a model get a switch to the model's Graph page; `left` names the page they are on. */ + graphSwitch?: { left: 'Model' | 'Diagram'; query?: string }; + /** The Graph page: the model as one graph, the bundled script that draws it, and the query to start with. */ + graph?: { graph: ModelGraph; query?: string; scriptUri: string }; } +import type { ModelGraph } from '../model/graph'; import type { DecisionPanel, ModelPanel, RegistryPanel } from '../model/panel'; import type { DiagramDecision } from '../model/prepare'; @@ -87,6 +92,8 @@ function bridgeScript(options: PageOptions): string { decision: options.decision, decisions: options.decisions || [], descriptions: options.descriptions || {}, + graphSwitch: options.graphSwitch, + graph: options.graph ? { graph: options.graph.graph, query: options.graph.query } : undefined, }; return `(function () { var config = ${scriptJson(config)}; @@ -110,7 +117,8 @@ function bridgeScript(options: PageOptions): string { defaultView: config.defaultView || '2d', post: post, getState: function () { return vscode.getState() || {}; }, - setState: function (patch) { vscode.setState(Object.assign({}, vscode.getState() || {}, patch)); } + setState: function (patch) { vscode.setState(Object.assign({}, vscode.getState() || {}, patch)); }, + graph: config.graph }; function saveBlobUrl(url, filename) { @@ -668,6 +676,32 @@ function bridgeScript(options: PageOptions): string { if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body); } + // Model | Graph (or Diagram | Graph): Export's twin in Archify's toolbar, + // or at the top of the model page. Graph opens the model's Graph page. + function renderModeSwitch() { + var sw = config.graphSwitch; + if (!sw || document.getElementById('archify-mode-switch')) return; + var button = el('button', 'archify-mode-switch'); + button.id = 'archify-mode-switch'; + button.type = 'button'; + button.title = 'Open the model as one graph, with a query box'; + button.setAttribute('aria-label', sw.left + ' view; switch to the model graph'); + button.appendChild(el('span', 'archify-mode-seg on', sw.left)); + button.appendChild(el('span', 'archify-mode-seg', 'Graph')); + button.addEventListener('click', function (event) { + event.stopPropagation(); + post({ type: 'showGraph', query: sw.query }); + }); + var exportWrap = document.querySelector('.toolbar .export-wrap'); + var head = document.querySelector('.archify-model-head'); + if (exportWrap && exportWrap.parentNode) exportWrap.parentNode.insertBefore(button, exportWrap); + else if (head) { + var row = el('div', 'archify-model-switch-row'); + row.appendChild(button); + head.insertBefore(row, head.firstChild); + } else document.body.appendChild(button); + } + window.addEventListener('message', function (event) { var message = event.data || {}; if (message.type === 'notice') showNotice(message.notice); @@ -682,6 +716,7 @@ function bridgeScript(options: PageOptions): string { onReady(renderConnectors); onReady(renderDecisionsRail); onReady(describeElements); + onReady(renderModeSwitch); if (config.notice) { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); }); @@ -778,6 +813,19 @@ const MODEL_STYLE = ` .archify-model-used-link:hover { border-color: var(--m-accent); } `; +/* The Model | Graph switch: in Archify's toolbar it is the 2D | 3D switch's twin; on the model page it sits top right. */ +const SWITCH_STYLE = ` +.archify-mode-switch { display: inline-flex; align-items: center; padding: 0 0.25rem; gap: 0; border: 0; background: transparent; color: var(--text-muted, var(--vscode-descriptionForeground)); font: inherit; cursor: pointer; position: relative; } +.toolbar .archify-mode-switch::before { content: ""; position: absolute; inset: 0.25rem 0; border-radius: 0.6875rem; background: color-mix(in srgb, var(--panel) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--panel-border) 85%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); backdrop-filter: blur(12px); } +.toolbar .archify-mode-switch:hover::before { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 22%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); } +.archify-mode-seg { position: relative; display: inline-flex; align-items: center; height: 1.75rem; padding: 0 0.7rem; border-radius: 0.5rem; font-weight: 550; font-size: 0.8125rem; transition: background 0.15s, color 0.15s; } +.archify-mode-seg.on { color: var(--bg, var(--vscode-editor-background)); background: var(--text, var(--vscode-foreground)); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); } +.archify-mode-switch:hover .archify-mode-seg:not(.on) { color: var(--text, var(--vscode-foreground)); } +.archify-mode-switch:focus-visible { outline: 2px solid var(--frontend-stroke, var(--vscode-focusBorder)); outline-offset: 2px; border-radius: 0.75rem; } +.archify-model-switch-row { float: right; margin: 1.5rem 0 0 1rem; } +.archify-model-switch-row .archify-mode-switch { border: 1px solid var(--m-border); border-radius: 0.75rem; background: var(--m-panel); padding: 0.1rem 0.25rem; } +`; + /* Connectors rail panel: highlight of the hovered or selected edge in the SVG. */ const CONNECTOR_STYLE = ` .diagram-container svg [data-edge-key][data-archify-hit] path, .diagram-container svg [data-edge-key][data-archify-selected] path { stroke-width: 3px; filter: drop-shadow(0 0 3px currentColor); } @@ -820,7 +868,7 @@ export function prepareDiagramHtml(html: string, options: PageOptions): string { const head = `\n` + `\n` + - `\n`; + `\n`; let page = html // Archify calls its node rail "Node index"; next to the registry it reads better as Elements. @@ -848,7 +896,7 @@ export function prepareDiagramHtml(html: string, options: PageOptions): string { /** The model page for a main.archify without a diagram of its own. */ export function modelPage(options: Pick, model: ModelPanel): string { - const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, model, links: {} }); + const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, model, links: {}, graphSwitch: { left: 'Model' } }); return ` @@ -859,6 +907,27 @@ export function modelPage(options: Pick body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); } ${MODEL_STYLE} + ${SWITCH_STYLE} + + + +`; +} + +/** The Graph page of a model: the bundled script builds everything from the host's graph data. */ +export function graphPage(options: Pick, graph: NonNullable): string { + const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, links: {}, graph }); + return ` + + + + + + + + diff --git a/src/webview/viewer3d.ts b/src/webview/viewer3d.ts index 201de7b..885d0cc 100644 --- a/src/webview/viewer3d.ts +++ b/src/webview/viewer3d.ts @@ -13,3 +13,8 @@ export function viewer3dOption(webview: vscode.Webview, extensionUri: vscode.Uri defaultView, }; } + +/** The Graph page script (a webview URI). */ +export function graphScriptUri(webview: vscode.Webview, extensionUri: vscode.Uri): string { + return webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'graph.js')).toString(); +} diff --git a/test/graph.test.ts b/test/graph.test.ts new file mode 100644 index 0000000..20f0c71 --- /dev/null +++ b/test/graph.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import { mergeDecisions, parseDecision } from '../src/model/decisions'; +import { DecisionAttachment, mergeRegistries, parseRegistry, summarizeDiagram } from '../src/model/format'; +import { ModelGraph, buildGraph } from '../src/model/graph'; +import { parseQuery, runQuery, shortestPath } from '../src/model/graphQuery'; + +const model = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'model'); +const read = (rel: string) => fs.readFileSync(path.join(model, rel), 'utf8'); +const REGISTRY_FILES = ['registry/people.archify-registry', 'registry/systems.archify-registry', 'registry/containers.archify-registry']; +const DIAGRAM_FILES = ['0100-context/online-shop.archify', '0200-containers/online-shop.archify', '0300-components/api.archify']; +const DECISION_FILES = ['decisions/0001-postgres-as-source-of-truth.archify-decision', 'decisions/0002-queue-between-api-and-worker.archify-decision', 'decisions/0003-opensearch-for-product-search.archify-decision']; + +function graphOfModel(): ModelGraph { + const registry = mergeRegistries(REGISTRY_FILES.map((file) => ({ file, ...parseRegistry(read(file), file) }))); + const diagrams = DIAGRAM_FILES.map((file) => summarizeDiagram(read(file), file)); + const decisions = mergeDecisions(DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) }))); + const decisionUsage = new Map(); + for (const d of diagrams) for (const ref of d.decisions) decisionUsage.set(ref.id, [...(decisionUsage.get(ref.id) ?? []), { path: d.relativePath, attachedTo: ref.attachedTo }]); + return buildGraph({ meta: { id: 'online-shop', name: 'Online Shop' }, registry, diagrams, decisions, decisionUsage }); +} + +test('diagram summaries carry nodes and edges, shared and inline', () => { + const api = summarizeDiagram(read('0300-components/api.archify'), '0300-components/api.archify'); + assert.deepEqual(api.nodes.map((n) => n.id), ['web', 'auth', 'orders', 'catalogue', 'db', 'queue']); + assert.equal(api.nodes[0].ref, 'web'); + assert.equal(api.nodes[1].ref, undefined); + assert.equal(api.nodes[1].type, 'security'); + assert.equal(api.edges.length, 6); + assert.deepEqual(api.edges[0], { from: 'web', to: 'auth', label: 'sign in' }); + const broken = summarizeDiagram('diagram_type: [', 'x.archify'); + assert.deepEqual(broken.nodes, []); + assert.deepEqual(broken.edges, []); +}); + +test('the model graph unites every diagram: elements once, connections once, labels merged', () => { + const graph = graphOfModel(); + // 11 registry elements + 3 inline components of the API diagram. + assert.equal(graph.nodes.length, 14); + const db = graph.nodes.find((n) => n.id === 'db')!; + assert.equal(db.shared, true); + assert.equal(db.type, 'database'); + assert.deepEqual(db.diagrams, ['0200-containers/online-shop.archify', '0300-components/api.archify']); + assert.equal(db.file, 'registry/containers.archify-registry'); + assert.ok(db.description); + const orders = graph.nodes.find((n) => n.id === 'orders')!; + assert.equal(orders.shared, false); + assert.deepEqual(orders.diagrams, ['0300-components/api.archify']); + // api → db is drawn once in the containers diagram; orders → db and catalogue → db are separate connections. + assert.equal(graph.edges.length, 4 + 7 + 6); + const apiDb = graph.edges.find((e) => e.id === 'api>db')!; + assert.deepEqual(apiDb.labels, ['SQL']); + assert.deepEqual(apiDb.diagrams, ['0200-containers/online-shop.archify']); + assert.deepEqual(graph.diagrams.map((d) => d.path), DIAGRAM_FILES); +}); + +test('decisions reach the elements they name and the nodes and edges they are attached to', () => { + const graph = graphOfModel(); + const queue = graph.nodes.find((n) => n.id === 'queue')!; + assert.deepEqual(queue.decisions, ['queue-between-api-and-worker']); + const api = graph.nodes.find((n) => n.id === 'api')!; + assert.deepEqual([...api.decisions].sort(), ['opensearch-for-product-search', 'postgres-as-source-of-truth', 'queue-between-api-and-worker']); + assert.deepEqual(graph.edges.find((e) => e.id === 'api>queue')!.decisions, ['queue-between-api-and-worker']); + assert.equal(graph.decisions.length, 3); + assert.equal(graph.decisions.find((d) => d.id === 'postgres-as-source-of-truth')!.status, 'accepted'); +}); + +test('an unused registry element is in the graph with no diagrams', () => { + const registry = mergeRegistries([{ file: 'r', ...parseRegistry('elements:\n api: { type: backend, label: API }\n ghost: { type: database, label: Ghost }\n', 'r') }]); + const diagrams = [summarizeDiagram('diagram_type: architecture\ncomponents:\n - ref: api\n', 'a.archify')]; + const graph = buildGraph({ meta: {}, registry, diagrams, decisions: mergeDecisions([]), decisionUsage: new Map() }); + assert.deepEqual(graph.nodes.map((n) => [n.id, n.diagrams.length]), [['api', 1], ['ghost', 0]]); + assert.deepEqual([...runQuery(graph, 'unused').nodes], ['ghost']); +}); + +test('queries parse into terms; unknown keys are plain text', () => { + assert.deepEqual(parseQuery('database type:backend -shared near:api 2 foo:bar unused'), [ + { key: 'text', value: 'database', negate: false }, + { key: 'type', value: 'backend', negate: false }, + { key: 'shared', value: '', negate: true }, + { key: 'near', value: 'api,2', negate: false }, + { key: 'text', value: 'foo:bar', negate: false }, + { key: 'unused', value: '', negate: false }, + ]); + assert.deepEqual(parseQuery(' '), []); +}); + +test('queries narrow the graph: text, type, diagram, decision, shared', () => { + const graph = graphOfModel(); + const ids = (q: string) => [...runQuery(graph, q).nodes].sort(); + assert.equal(runQuery(graph, '').nodes.size, 14); + assert.deepEqual(ids('type:database'), ['cache', 'db']); + assert.deepEqual(ids('type:external'), ['customer', 'email', 'psp', 'support']); + assert.deepEqual(ids('worker'), ['worker']); + assert.deepEqual(ids('diagram:0100-*'), ['customer', 'email', 'psp', 'shop', 'support']); + assert.deepEqual(ids('diagram:0300-components/api'), ['auth', 'catalogue', 'db', 'orders', 'queue', 'web']); + assert.deepEqual(ids('-shared'), ['auth', 'catalogue', 'orders']); + assert.deepEqual(ids('shared type:backend'), ['api', 'worker']); + assert.deepEqual(ids('decision:queue-between-api-and-worker'), ['api', 'queue', 'worker']); + assert.deepEqual(ids('type:backend -worker'), ['api', 'catalogue', 'orders']); + const unknown = runQuery(graph, 'decision:nope'); + assert.equal(unknown.nodes.size, 0); + assert.match(unknown.message ?? '', /No decision "nope"/); +}); + +test('queries walk the graph: uses, near, from/to', () => { + const graph = graphOfModel(); + const ids = (q: string) => [...runQuery(graph, q).nodes].sort(); + assert.deepEqual(ids('uses:api'), ['api', 'cache', 'db', 'queue', 'web']); + assert.deepEqual(ids('uses:API'), ['api', 'cache', 'db', 'queue', 'web']); + assert.deepEqual(ids('near:worker,2'), ['api', 'catalogue', 'db', 'orders', 'queue', 'worker']); + assert.deepEqual(ids('near:worker 2 type:database'), ['db']); + const result = runQuery(graph, 'from:customer to:db'); + assert.deepEqual(result.path, ['customer', 'web', 'api', 'db']); + assert.deepEqual([...result.edges].sort(), ['api>db', 'customer>web', 'web>api']); + // Direction is tried first; against the arrows the search still finds a way. + assert.deepEqual(shortestPath(graph, 'db', 'customer')?.nodes, ['db', 'api', 'web', 'customer']); + assert.equal(shortestPath(graph, 'psp', 'auth')?.nodes.length, 5); + const none = runQuery(graph, 'from:customer to:nowhere'); + assert.equal(none.nodes.size, 0); + assert.match(none.message ?? '', /No element "nowhere"/); + const missing = runQuery(graph, 'from:customer'); + assert.match(missing.message ?? '', /both from: and to:/); +}); + +test('connections are shown only when both ends match', () => { + const graph = graphOfModel(); + const result = runQuery(graph, 'type:backend'); + assert.ok(result.nodes.has('api')); + assert.ok(!result.edges.has('api>db')); + assert.ok(result.edges.has('orders>queue') === false); + assert.ok([...result.edges].every((id) => id.split('>').every((end) => result.nodes.has(end)))); +}); diff --git a/test/integration/suite.ts b/test/integration/suite.ts index d6565fb..59bc390 100644 --- a/test/integration/suite.ts +++ b/test/integration/suite.ts @@ -317,6 +317,22 @@ export async function run(): Promise { assert.ok(tab); }); + await check('Archify | Open Model Graph switches the main.archify editor to the Graph page and back', async () => { + const main = workspaceFile('model/main.archify'); + const diagram = workspaceFile('model/0100-context/online-shop.archify'); + const commands = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.contributes.commands as { command: string; title: string }[]; + assert.equal(commands.find((item) => item.command === 'archify.openModelGraph')?.title, 'Archify | Open Model Graph'); + await vscode.commands.executeCommand('archify.openModelGraph', diagram); + const tab = await waitFor('model editor active', () => + vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.uri.toString() === main.toString()), + ); + assert.ok(tab); + // The choice is remembered per file in the workspace state until the page is switched back. + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext?.isActive); + await new Promise((resolve) => setTimeout(resolve, 500)); + }); + await check('a .archify-registry file opens as the registry view and Show Source opens the YAML', async () => { const registry = workspaceFile('model/registry/people.archify-registry'); await vscode.commands.executeCommand('vscode.open', registry); -- 2.54.0 From 99ea076571638ab1c1f8a65f5ed69dcc5f65ac04 Mon Sep 17 00:00:00 2001 From: Root At Skic Date: Wed, 21 Oct 2026 20:46:05 +0300 Subject: [PATCH 02/10] 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 --- CHANGELOG.md | 6 +- README.md | 6 +- docs/model.md | 4 ++ src/graph/index.ts | 126 +++++++++++++++++++++++++++++++++++----- src/graph/style.ts | 2 +- src/model/graph.ts | 53 ++++++++++++++++- src/model/graphQuery.ts | 35 ++++++++--- test/graph.test.ts | 47 +++++++++++---- 8 files changed, 237 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a65647..d777a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ ## 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. Drag, zoom, **Fit**, **Layout**, **Labels**, legend chips that hide a type, **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`; terms AND together, a leading `-` excludes; 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. +- **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**, 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. - Diagram summaries in the model index now carry every node and edge of a diagram (shared and inline), which the graph is built from. diff --git a/README.md b/README.md index 7f9bf68..7899ae9 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,9 @@ 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. Drag nodes, scroll to zoom, **Fit** (or 0) frames what matches, **Layout** lays it out again, **Labels** toggles connection labels, **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). 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. +- **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, **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. Formats in detail: [docs/model.md](docs/model.md). A full example: [examples/online-shop](examples/online-shop). diff --git a/docs/model.md b/docs/model.md index 7fc2dd3..b239878 100644 --- a/docs/model.md +++ b/docs/model.md @@ -135,6 +135,10 @@ The **Model | Graph** switch on the `main.archify` page replaces it with the who - **Nodes** are every element any diagram draws (`components`, `nodes`, `participants`, `states`), joined by id across diagrams, plus every registry element even when no diagram draws it yet. Registry elements carry their description, file and line; inline elements only what the diagram says. The same inline id in two diagrams is one node. - **Edges** are every `connections`, `edges`, `flows`, `transitions` and `messages` entry between two nodes of the same diagram, keyed by `from>to`; the labels different diagrams give the same pair are joined with " / ". Direction is kept. - **Decisions** reach nodes through `elements:` in the decision file and through `decisions:` on a node in a diagram; they reach edges through `decisions:` on a connection. +- **Decision nodes** (`decision:`, type `decision`) carry the effective status, the context and decision text, and the diagrams the decision names; `decides` edges point at the elements it is about, `supersedes` edges at the decisions it replaces. +- **Registry nodes** (`registry:`, type `registry`) stand for each `.archify-registry` file; `defines` edges point at its elements. + +Walks (`uses:`, `near:`, `from: to:`) follow `connection` edges only. Decision and registry nodes join a result when an element they link to matches, and `-type:decision` or `-type:registry` removes them. Queries run in the page against that graph; the forms are listed under **?** on the page and in the README. `uses:` and `near:` ignore direction; `from: to:` follows the arrows first and ignores them only when no directed path exists. Connections are shown when both ends match, so `type:backend` shows backend-to-backend connections only. diff --git a/src/graph/index.ts b/src/graph/index.ts index 8b4346a..d428200 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -22,9 +22,11 @@ interface SavedView { cytoscape.use(fcose); -const TYPE_COLORS: Record = { 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 = { frontend: '#22d3ee', backend: '#34d399', database: '#a78bfa', cloud: '#fbbf24', security: '#fb7185', messagebus: '#fb923c', external: '#94a3b8', decision: '#e879f9', registry: '#cbd5e1' }; +const TYPE_ORDER = ['external', 'frontend', 'cloud', 'security', 'backend', 'messagebus', 'database', 'decision', 'registry']; +const STATUS_COLORS: Record = { accepted: '#34d399', proposed: '#fbbf24', rejected: '#fb7185', superseded: '#94a3b8' }; const colorOf = (type: string | undefined) => TYPE_COLORS[type ?? ''] ?? '#94a3b8'; +const nodeColor = (n: GraphNode) => (n.kind === 'decision' ? STATUS_COLORS[n.status ?? ''] ?? TYPE_COLORS.decision : colorOf(n.type)); /** Edge labels are drawn by default up to this many connections; above it they clutter. */ const LABELS_UP_TO = 40; const PLACEHOLDER = 'database · type:backend · diagram:0200-* · uses:api · near:api,2 · from:web to:db · decision: · unused · -shared'; @@ -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) { diff --git a/src/graph/style.ts b/src/graph/style.ts index 63359e1..3ed39f0 100644 --- a/src/graph/style.ts +++ b/src/graph/style.ts @@ -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); } diff --git a/src/model/graph.ts b/src/model/graph.ts index 03a4c4d..fd007fb 100644 --- a/src/model/graph.ts +++ b/src/model/graph.ts @@ -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:`) or a registry file (`registry:`). */ + 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(); + 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)), diff --git a/src/model/graphQuery.ts b/src/model/graphQuery.ts index 3c4bbfc..9f9c5b6 100644 --- a/src/model/graphQuery.ts +++ b/src/model/graphQuery.ts @@ -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> { const map = new Map>(); 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(); 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[] = []; 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) { diff --git a/test/graph.test.ts b/test/graph.test.ts index 20f0c71..07b7ccb 100644 --- a/test/graph.test.ts +++ b/test/graph.test.ts @@ -37,8 +37,9 @@ test('diagram summaries carry nodes and edges, shared and inline', () => { test('the model graph unites every diagram: elements once, connections once, labels merged', () => { const graph = graphOfModel(); - // 11 registry elements + 3 inline components of the API diagram. - assert.equal(graph.nodes.length, 14); + // 11 registry elements + 3 inline components of the API diagram, plus 3 decision and 3 registry nodes. + assert.equal(graph.nodes.filter((n) => n.kind === 'element').length, 14); + assert.equal(graph.nodes.length, 20); const db = graph.nodes.find((n) => n.id === 'db')!; assert.equal(db.shared, true); assert.equal(db.type, 'database'); @@ -49,7 +50,7 @@ test('the model graph unites every diagram: elements once, connections once, lab assert.equal(orders.shared, false); assert.deepEqual(orders.diagrams, ['0300-components/api.archify']); // api → db is drawn once in the containers diagram; orders → db and catalogue → db are separate connections. - assert.equal(graph.edges.length, 4 + 7 + 6); + assert.equal(graph.edges.filter((e) => e.kind === 'connection').length, 4 + 7 + 6); const apiDb = graph.edges.find((e) => e.id === 'api>db')!; assert.deepEqual(apiDb.labels, ['SQL']); assert.deepEqual(apiDb.diagrams, ['0200-containers/online-shop.archify']); @@ -67,12 +68,36 @@ test('decisions reach the elements they name and the nodes and edges they are at assert.equal(graph.decisions.find((d) => d.id === 'postgres-as-source-of-truth')!.status, 'accepted'); }); +test('decisions and registries are nodes of their own, linked to the elements they are about or define', () => { + const graph = graphOfModel(); + const decision = graph.nodes.find((n) => n.id === 'decision:queue-between-api-and-worker')!; + assert.equal(decision.kind, 'decision'); + assert.equal(decision.type, 'decision'); + assert.equal(decision.status, 'accepted'); + assert.equal(decision.sublabel, 'queue-between-api-and-worker'); + assert.deepEqual(graph.edges.filter((e) => e.kind === 'decides' && e.from === decision.id).map((e) => e.to).sort(), ['api', 'queue', 'worker']); + const registry = graph.nodes.find((n) => n.id === 'registry:registry/containers.archify-registry')!; + assert.equal(registry.kind, 'registry'); + assert.equal(registry.label, 'registry/containers'); + assert.deepEqual(registry.elements, ['web', 'api', 'worker', 'queue', 'db', 'cache']); + assert.equal(graph.edges.filter((e) => e.kind === 'defines' && e.from === registry.id).length, 6); + // Walks and paths do not go through them: a registry is not a shortcut between its elements. + assert.deepEqual(shortestPath(graph, 'customer', 'cache')?.nodes, ['customer', 'web', 'api', 'cache']); + const ids = (q: string) => [...runQuery(graph, q).nodes].sort(); + assert.ok(!ids('uses:api').some((id) => id.startsWith('registry:') && id !== 'registry:registry/containers.archify-registry')); + // They follow their elements into a result, and a negative term removes them again. + assert.deepEqual(ids('type:database').filter((id) => id.includes(':')), ['decision:opensearch-for-product-search', 'decision:postgres-as-source-of-truth', 'registry:registry/containers.archify-registry']); + assert.deepEqual(ids('type:database -type:decision -type:registry'), ['cache', 'db']); + assert.deepEqual(ids('type:registry'), ['registry:registry/containers.archify-registry', 'registry:registry/people.archify-registry', 'registry:registry/systems.archify-registry']); + assert.ok(ids('decision:queue-between-api-and-worker').includes('decision:queue-between-api-and-worker')); +}); + test('an unused registry element is in the graph with no diagrams', () => { const registry = mergeRegistries([{ file: 'r', ...parseRegistry('elements:\n api: { type: backend, label: API }\n ghost: { type: database, label: Ghost }\n', 'r') }]); const diagrams = [summarizeDiagram('diagram_type: architecture\ncomponents:\n - ref: api\n', 'a.archify')]; const graph = buildGraph({ meta: {}, registry, diagrams, decisions: mergeDecisions([]), decisionUsage: new Map() }); - assert.deepEqual(graph.nodes.map((n) => [n.id, n.diagrams.length]), [['api', 1], ['ghost', 0]]); - assert.deepEqual([...runQuery(graph, 'unused').nodes], ['ghost']); + assert.deepEqual(graph.nodes.filter((n) => n.kind === 'element').map((n) => [n.id, n.diagrams.length]), [['api', 1], ['ghost', 0]]); + assert.deepEqual([...runQuery(graph, 'unused').nodes], ['ghost', 'registry:r']); }); test('queries parse into terms; unknown keys are plain text', () => { @@ -87,10 +112,12 @@ test('queries parse into terms; unknown keys are plain text', () => { assert.deepEqual(parseQuery(' '), []); }); +const elementIds = (graph: ModelGraph, q: string) => [...runQuery(graph, q).nodes].filter((id) => !id.includes(':')).sort(); + test('queries narrow the graph: text, type, diagram, decision, shared', () => { const graph = graphOfModel(); - const ids = (q: string) => [...runQuery(graph, q).nodes].sort(); - assert.equal(runQuery(graph, '').nodes.size, 14); + const ids = (q: string) => elementIds(graph, q); + assert.equal(runQuery(graph, '').nodes.size, 20); assert.deepEqual(ids('type:database'), ['cache', 'db']); assert.deepEqual(ids('type:external'), ['customer', 'email', 'psp', 'support']); assert.deepEqual(ids('worker'), ['worker']); @@ -107,14 +134,14 @@ test('queries narrow the graph: text, type, diagram, decision, shared', () => { test('queries walk the graph: uses, near, from/to', () => { const graph = graphOfModel(); - const ids = (q: string) => [...runQuery(graph, q).nodes].sort(); + const ids = (q: string) => elementIds(graph, q); assert.deepEqual(ids('uses:api'), ['api', 'cache', 'db', 'queue', 'web']); assert.deepEqual(ids('uses:API'), ['api', 'cache', 'db', 'queue', 'web']); assert.deepEqual(ids('near:worker,2'), ['api', 'catalogue', 'db', 'orders', 'queue', 'worker']); assert.deepEqual(ids('near:worker 2 type:database'), ['db']); const result = runQuery(graph, 'from:customer to:db'); assert.deepEqual(result.path, ['customer', 'web', 'api', 'db']); - assert.deepEqual([...result.edges].sort(), ['api>db', 'customer>web', 'web>api']); + assert.deepEqual([...result.edges].filter((id) => !id.includes(':')).sort(), ['api>db', 'customer>web', 'web>api']); // Direction is tried first; against the arrows the search still finds a way. assert.deepEqual(shortestPath(graph, 'db', 'customer')?.nodes, ['db', 'api', 'web', 'customer']); assert.equal(shortestPath(graph, 'psp', 'auth')?.nodes.length, 5); @@ -131,5 +158,5 @@ test('connections are shown only when both ends match', () => { assert.ok(result.nodes.has('api')); assert.ok(!result.edges.has('api>db')); assert.ok(result.edges.has('orders>queue') === false); - assert.ok([...result.edges].every((id) => id.split('>').every((end) => result.nodes.has(end)))); + assert.ok([...result.edges].every((id) => { const at = id.indexOf('>', id.startsWith('decision:') || id.startsWith('registry:') ? id.indexOf(':') : 0); return result.nodes.has(id.slice(0, at)) && result.nodes.has(id.slice(at + 1)); })); }); -- 2.54.0 From 3ed2d7eed8565d7b5656e43cfc7aef4f94b50354 Mon Sep 17 00:00:00 2001 From: Root At Skic Date: Wed, 21 Oct 2026 21:33:38 +0300 Subject: [PATCH 03/10] Model graph: dark/light theme toggle on the page - sun/moon button next to Export; the choice is remembered in the page state and written to the archify-theme key the diagram pages use - explicit dark and light token sets; without a choice the page follows VS Code's theme as before - Cytoscape restyles from the page tokens whenever the theme changes --- CHANGELOG.md | 2 +- README.md | 2 +- src/graph/index.ts | 84 ++++++++++++++++++++++++++++++++++++++-------- src/graph/style.ts | 4 +++ 4 files changed, 76 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d777a52..ad3131b 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**, 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. 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. - **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 7899ae9..7c58127 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, **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. 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. - **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 d428200..191ba5b 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -18,6 +18,17 @@ interface SavedView { query?: string; labels?: boolean; hidden?: string[]; + /** A theme chosen on the page; absent means follow VS Code. */ + theme?: 'dark' | 'light'; +} + +type Theme = 'dark' | 'light'; + +/** The theme in effect: the page's own choice, else VS Code's. */ +function currentTheme(): Theme { + const chosen = document.documentElement.dataset.theme; + if (chosen === 'dark' || chosen === 'light') return chosen; + return document.body.classList.contains('vscode-light') ? 'light' : 'dark'; } cytoscape.use(fcose); @@ -56,17 +67,17 @@ function cssVar(name: string, fallback: string): string { return value || fallback; } -/** Theme colours Cytoscape needs as literals; read again when VS Code switches theme. */ +/** Theme colours Cytoscape needs as literals: the page's tokens, read again whenever the theme changes. */ function palette() { - const dark = !document.body.classList.contains('vscode-light'); + const dark = currentTheme() === 'dark'; return { - bg: cssVar('--vscode-editor-background', dark ? '#0b1020' : '#ffffff'), - fg: cssVar('--vscode-foreground', dark ? '#e5e7eb' : '#1f2937'), - muted: cssVar('--vscode-descriptionForeground', dark ? '#9ca3af' : '#6b7280'), - panel: cssVar('--vscode-editorWidget-background', dark ? '#131c31' : '#f3f4f6'), - accent: cssVar('--vscode-textLink-foreground', '#22d3ee'), + bg: cssVar('--ag-bg', dark ? '#0b1020' : '#ffffff'), + fg: cssVar('--ag-fg', dark ? '#e5e7eb' : '#1f2937'), + muted: cssVar('--ag-muted', dark ? '#9ca3af' : '#6b7280'), + panel: cssVar('--ag-panel', dark ? '#131c31' : '#f3f4f6'), + accent: cssVar('--ag-accent', '#22d3ee'), edge: dark ? '#64748b' : '#94a3b8', - font: cssVar('--vscode-font-family', 'system-ui, sans-serif'), + font: cssVar('--ag-font', 'system-ui, sans-serif'), }; } @@ -86,6 +97,7 @@ 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 themeButton = el('button', 'ag-button ag-theme'); private readonly legend = el('div', 'ag-legend'); private readonly side = el('aside', 'ag-side'); private readonly main = el('div', 'ag-main'); @@ -98,6 +110,7 @@ class GraphPage { if (initial.query !== undefined) this.saved.query = initial.query; if (this.saved.labels === undefined) this.saved.labels = initial.graph.edges.length <= LABELS_UP_TO; this.hiddenTypes = new Set(this.saved.hidden ?? []); + this.applyTheme(this.saved.theme ?? archifyThemePreference()); this.index(); this.buildChrome(); this.buildGraph(true); @@ -177,7 +190,10 @@ class GraphPage { exportButton.type = 'button'; exportButton.title = 'Save the graph as it is on screen, as a PNG'; exportButton.addEventListener('click', () => this.exportPng()); - actions.append(sw, exportButton); + this.themeButton.type = 'button'; + this.themeButton.addEventListener('click', () => this.setTheme(currentTheme() === 'dark' ? 'light' : 'dark')); + this.syncThemeButton(); + actions.append(sw, this.themeButton, exportButton); top.append(titles, actions); const bar = el('div', 'ag-bar'); @@ -311,6 +327,39 @@ class GraphPage { .run(); } + // --------------------------------------------------------------- theme + + /** Sets the page's own theme (or follows VS Code when undefined) without touching the saved choice. */ + private applyTheme(theme: Theme | undefined): void { + if (theme) document.documentElement.dataset.theme = theme; + else delete document.documentElement.dataset.theme; + } + + private setTheme(theme: Theme): void { + this.applyTheme(theme); + this.saved.theme = theme; + this.save(); + try { + localStorage.setItem('archify-theme', theme); // the diagram pages read the same key + } catch { + // storage may be unavailable; the page state keeps the choice + } + this.restyle(); + } + + private syncThemeButton(): void { + const dark = currentTheme() === 'dark'; + this.themeButton.textContent = dark ? '\u263e' : '\u2600'; + this.themeButton.title = dark ? 'Switch to the light theme' : 'Switch to the dark theme'; + this.themeButton.setAttribute('aria-label', this.themeButton.title); + } + + private restyle(): void { + this.cy?.style(this.stylesheet()); + this.syncLabels(); + this.syncThemeButton(); + } + private applyHidden(): void { this.cy.nodes().forEach((n) => { n.toggleClass('hidden', this.hiddenTypes.has(String(n.data('type') || 'other'))); @@ -639,10 +688,7 @@ class GraphPage { } }); window.addEventListener('resize', () => this.cy.resize()); - new MutationObserver(() => { - this.cy.style(this.stylesheet()); - this.syncLabels(); - }).observe(document.body, { attributes: true, attributeFilter: ['class'] }); + new MutationObserver(() => this.restyle()).observe(document.body, { attributes: true, attributeFilter: ['class'] }); window.addEventListener('message', (event) => { const message = event.data || {}; if (message.type === 'graph' && message.graph) this.replace(message.graph as ModelGraph); @@ -651,7 +697,17 @@ class GraphPage { } private save(): void { - this.host.setState({ graphView: { query: this.saved.query, labels: this.saved.labels, hidden: this.saved.hidden ?? [] } }); + this.host.setState({ graphView: { query: this.saved.query, labels: this.saved.labels, hidden: this.saved.hidden ?? [], theme: this.saved.theme } }); + } +} + +/** The theme the Archify diagram pages last used, when they stored one. */ +function archifyThemePreference(): Theme | undefined { + try { + const value = localStorage.getItem('archify-theme'); + return value === 'dark' || value === 'light' ? value : undefined; + } catch { + return undefined; } } diff --git a/src/graph/style.ts b/src/graph/style.ts index 3ed39f0..b2fbdbb 100644 --- a/src/graph/style.ts +++ b/src/graph/style.ts @@ -5,7 +5,11 @@ export const GRAPH_PAGE_STYLE = ` --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-decision: #e879f9; --ag-registry: #cbd5e1; } +/* A theme chosen on the page overrides VS Code's; without one the VS Code variables above apply. */ +:root[data-theme="dark"] { --ag-bg: #0b1020; --ag-fg: #e5e7eb; --ag-muted: #9ca3af; --ag-panel: #111a2e; --ag-border: rgba(148, 163, 184, 0.28); --ag-accent: #22d3ee; --ag-input: #0f172a; --ag-input-fg: #e5e7eb; --ag-input-border: rgba(148, 163, 184, 0.28); --ag-error: #f87171; color-scheme: dark; } +:root[data-theme="light"] { --ag-bg: #ffffff; --ag-fg: #0f172a; --ag-muted: #64748b; --ag-panel: #f8fafc; --ag-border: rgba(15, 23, 42, 0.14); --ag-accent: #0e7490; --ag-input: #ffffff; --ag-input-fg: #0f172a; --ag-input-border: rgba(15, 23, 42, 0.2); --ag-error: #b91c1c; --ag-external: #64748b; --ag-registry: #94a3b8; color-scheme: light; } 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-theme { width: 1.9rem; padding: 0; font-size: 0.95rem; line-height: 1; } .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); } .ag-kicker { font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ag-accent); } -- 2.54.0 From 17e07adb33ed0a1c39644c789b9870720ac433e8 Mon Sep 17 00:00:00 2001 From: Root At Skic Date: Wed, 21 Oct 2026 21:45:42 +0300 Subject: [PATCH 04/10] 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 --- CHANGELOG.md | 2 +- README.md | 2 +- src/graph/index.ts | 141 ++++++++++++++++++++++++++++++++++++-------- src/graph/layout.ts | 80 +++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 27 deletions(-) create mode 100644 src/graph/layout.ts 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; +} -- 2.54.0 From 91fcde9fa50999c1520e90ddc0093b595c9dac94 Mon Sep 17 00:00:00 2001 From: Root At Skic Date: Wed, 21 Oct 2026 21:47:08 +0300 Subject: [PATCH 05/10] Model graph: connection labels at the less crowded end --- src/graph/index.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/graph/index.ts b/src/graph/index.ts index 1fc15b9..cff0be0 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -156,12 +156,22 @@ 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) } })); - // Labels of connections that leave the same element are staggered along the line so they do not pile up. - const leaving = new Map(); + // In the layered layout a label sits at the end of the connection where fewer connections meet, so a + // fan-out labels at its targets and a fan-in at its sources; what still shares an end is staggered. + const outCount = new Map(); + const inCount = new Map(); + for (const e of this.graph.edges) { + if (e.kind !== 'connection') continue; + outCount.set(e.from, (outCount.get(e.from) ?? 0) + 1); + inCount.set(e.to, (inCount.get(e.to) ?? 0) + 1); + } + const used = 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 } }; + const at = e.kind === 'connection' && (outCount.get(e.from) ?? 0) > (inCount.get(e.to) ?? 0) ? 'target' : 'source'; + const key = `${at}:${at === 'source' ? e.from : e.to}`; + const index = used.get(key) ?? 0; + used.set(key, index + 1); + return { group: 'edges' as const, data: { id: e.id, source: e.from, target: e.to, kind: e.kind, label: e.labels.join(' / '), at, offset: 40 + (index % 3) * 34 } }; }); return [...nodes, ...edges]; } @@ -381,13 +391,14 @@ class GraphPage { { 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.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', 'target-text-offset': 'data(offset)', 'target-text-rotation': 'none', 'target-text-background-color': p.bg, 'target-text-background-opacity': 1, 'target-text-background-padding': '3px', 'target-text-background-shape': 'round-rectangle' } }, { selector: 'edge.edge-hover', style: { width: 2.4, 'line-color': p.fg, 'target-arrow-color': p.fg, 'line-opacity': 1, color: p.fg } }, { selector: 'edge[kind != "connection"]', style: { display: 'none', width: 1, 'curve-style': 'bezier', 'line-style': 'dotted', 'target-arrow-shape': 'none', 'line-color': p.muted, 'line-opacity': 0.55 } }, { selector: 'edge[kind = "supersedes"]', style: { 'line-style': 'dashed', 'line-color': TYPE_COLORS.decision, 'target-arrow-shape': 'triangle', 'target-arrow-color': TYPE_COLORS.decision } }, { selector: 'edge[kind != "connection"].shown', style: { display: 'element' } }, { selector: 'edge.labelled[kind = "connection"], edge.labelled[kind = "supersedes"]', style: { label: 'data(label)' } }, - { selector: 'edge.labelled.ortho[kind = "connection"]', style: { label: '', 'source-label': 'data(label)' } }, + { selector: 'edge.labelled.ortho[kind = "connection"][at = "source"]', style: { label: '', 'source-label': 'data(label)' } }, + { selector: 'edge.labelled.ortho[kind = "connection"][at = "target"]', style: { label: '', 'target-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 } }, -- 2.54.0 From 82dd10253ba84df11e564f39f648de1a28a6c2c2 Mon Sep 17 00:00:00 2001 From: Root At Skic Date: Wed, 21 Oct 2026 21:55:24 +0300 Subject: [PATCH 06/10] Model graph: toolbar on Archify's rules - Archify's theme toggle: pill track, sliding knob, masked sun/moon icon - buttons as quiet 0.5rem pills inside 0.5625rem controls, pressed state like Archify's, Export PNG as the filled 0.6875rem primary - Model | Graph switch styled as the 2D | 3D switch's twin --- src/graph/index.ts | 9 ++++++--- src/graph/style.ts | 43 ++++++++++++++++++++++++++++++++----------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/graph/index.ts b/src/graph/index.ts index cff0be0..38bcf60 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -226,11 +226,13 @@ class GraphPage { sw.setAttribute('aria-label', 'Graph view on; switch to the model page'); sw.append(el('span', 'ag-seg', 'Model'), el('span', 'ag-seg on', 'Graph')); sw.addEventListener('click', () => this.host.post({ type: 'showModel' })); - const exportButton = el('button', 'ag-button', 'Export PNG'); + const exportButton = el('button', 'ag-button ag-primary', 'Export PNG'); exportButton.type = 'button'; exportButton.title = 'Save the graph as it is on screen, as a PNG'; exportButton.addEventListener('click', () => this.exportPng()); this.themeButton.type = 'button'; + this.themeButton.setAttribute('role', 'switch'); + this.themeButton.append(el('span', 'ag-theme-icon'), el('span', 'ag-sr', 'Dark theme')); this.themeButton.addEventListener('click', () => this.setTheme(currentTheme() === 'dark' ? 'light' : 'dark')); this.syncThemeButton(); actions.append(sw, this.themeButton, exportButton); @@ -451,8 +453,9 @@ class GraphPage { private syncThemeButton(): void { const dark = currentTheme() === 'dark'; - this.themeButton.textContent = dark ? '\u263e' : '\u2600'; - this.themeButton.title = dark ? 'Switch to the light theme' : 'Switch to the dark theme'; + this.themeButton.dataset.on = dark ? 'dark' : 'light'; + this.themeButton.setAttribute('aria-checked', String(dark)); + this.themeButton.title = dark ? 'Dark theme on; switch to light' : 'Light theme on; switch to dark'; this.themeButton.setAttribute('aria-label', this.themeButton.title); } diff --git a/src/graph/style.ts b/src/graph/style.ts index b2fbdbb..e219590 100644 --- a/src/graph/style.ts +++ b/src/graph/style.ts @@ -9,23 +9,44 @@ export const GRAPH_PAGE_STYLE = ` :root[data-theme="dark"] { --ag-bg: #0b1020; --ag-fg: #e5e7eb; --ag-muted: #9ca3af; --ag-panel: #111a2e; --ag-border: rgba(148, 163, 184, 0.28); --ag-accent: #22d3ee; --ag-input: #0f172a; --ag-input-fg: #e5e7eb; --ag-input-border: rgba(148, 163, 184, 0.28); --ag-error: #f87171; color-scheme: dark; } :root[data-theme="light"] { --ag-bg: #ffffff; --ag-fg: #0f172a; --ag-muted: #64748b; --ag-panel: #f8fafc; --ag-border: rgba(15, 23, 42, 0.14); --ag-accent: #0e7490; --ag-input: #ffffff; --ag-input-fg: #0f172a; --ag-input-border: rgba(15, 23, 42, 0.2); --ag-error: #b91c1c; --ag-external: #64748b; --ag-registry: #94a3b8; color-scheme: light; } 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-theme { width: 1.9rem; padding: 0; font-size: 0.95rem; line-height: 1; } + .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); } .ag-kicker { font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ag-accent); } .ag-name { font-weight: 700; font-size: 1.125rem; letter-spacing: -0.01em; } .ag-actions { margin-left: auto; display: flex; align-items: center; gap: 0.5rem; } -.ag-switch { display: inline-flex; padding: 0; border: 1px solid var(--ag-border); border-radius: 999px; background: var(--ag-panel); color: var(--ag-muted); font: inherit; cursor: pointer; overflow: hidden; } -.ag-switch:focus-visible { outline: 2px solid var(--ag-accent); outline-offset: 2px; } -.ag-seg { display: inline-flex; align-items: center; height: 1.75rem; padding: 0 0.8rem; border-radius: 999px; font-weight: 600; font-size: 0.8125rem; transition: background 0.15s, color 0.15s; } -.ag-seg.on { color: var(--ag-bg); background: var(--ag-fg); } +.ag-bar .ag-button { min-height: 2rem; } +/* Toolbar controls follow Archify's: quiet pills (0.5rem) inside 0.5625rem buttons, one filled primary action (0.6875rem), the 2D | 3D switch's twin. */ +.ag-switch { position: relative; isolation: isolate; display: inline-flex; padding: 0 0.25rem; border: 0; border-radius: 0.5625rem; background: transparent; color: var(--ag-muted); font: inherit; cursor: pointer; } +.ag-switch::before { content: ""; position: absolute; z-index: -1; inset: 0.25rem 0; border-radius: 0.6875rem; background: color-mix(in srgb, var(--ag-panel) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ag-border) 85%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); } +.ag-switch:hover::before { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ag-fg) 22%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); } +.ag-switch:focus-visible { outline: 2px solid var(--ag-accent); outline-offset: -4px; } +.ag-seg { display: inline-flex; align-items: center; height: 1.75rem; margin: 0.25rem 0; padding: 0 0.7rem; border-radius: 0.5rem; font-weight: 550; font-size: 0.8125rem; transition: background 0.15s, color 0.15s; } +.ag-seg.on { color: var(--ag-bg); background: var(--ag-fg); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); } .ag-switch:hover .ag-seg:not(.on) { color: var(--ag-fg); } -.ag-button { font: inherit; font-size: 0.8125rem; font-weight: 600; height: 1.9rem; padding: 0 0.85rem; border-radius: 999px; border: 1px solid var(--ag-border); background: var(--ag-panel); color: var(--ag-fg); cursor: pointer; white-space: nowrap; } -.ag-button:hover { border-color: var(--ag-accent); } -.ag-button[aria-pressed="true"] { background: var(--ag-fg); color: var(--ag-bg); border-color: var(--ag-fg); } -.ag-button:focus-visible { outline: 2px solid var(--ag-accent); outline-offset: 2px; } +.ag-button { position: relative; isolation: isolate; display: inline-flex; align-items: center; gap: 0.4rem; font: inherit; font-size: 0.8125rem; font-weight: 500; min-height: 2.25rem; padding: 0.25rem 0.58rem; border-radius: 0.5625rem; border: 0; background: transparent; color: var(--ag-muted); cursor: pointer; white-space: nowrap; transition: color 0.15s; } +.ag-button::before { content: ""; position: absolute; z-index: -1; inset: 0.25rem 0; border-radius: 0.5rem; background: transparent; transition: background 0.15s, box-shadow 0.15s; } +.ag-button:hover { color: var(--ag-fg); } +.ag-button:hover::before { background: color-mix(in srgb, var(--ag-fg) 6%, transparent); } +.ag-button[aria-pressed="true"] { color: var(--ag-fg); } +.ag-button[aria-pressed="true"]::before { background: color-mix(in srgb, var(--ag-fg) 8%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ag-fg) 14%, transparent); } +.ag-button:focus-visible { outline: 2px solid var(--ag-accent); outline-offset: -4px; } +.ag-primary { color: var(--ag-bg); font-weight: 600; padding: 0.25rem 0.9rem; } +.ag-primary::before { inset: 0.25rem 0; border-radius: 0.6875rem; background: var(--ag-fg); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); } +.ag-primary:hover { color: var(--ag-bg); } +.ag-primary:hover::before { background: color-mix(in srgb, var(--ag-fg) 86%, var(--ag-bg)); } +/* Theme toggle: Archify's own, a pill track with a knob that slides and carries the sun or moon. */ +.ag-theme { width: 3.5rem; padding: 0; justify-content: center; } +.ag-theme::before { inset: 0.6rem 0.4375rem; border-radius: 999px; background: color-mix(in srgb, var(--ag-fg) 9%, transparent); box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08); } +.ag-theme:hover::before { background: color-mix(in srgb, var(--ag-fg) 13%, transparent); } +.ag-theme::after { content: ""; position: absolute; z-index: 0; top: 50%; left: 0.5625rem; width: 1.125rem; height: 1.125rem; border-radius: 50%; background: var(--ag-panel); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18), 0 2px 6px rgba(15, 23, 42, 0.12); transform: translateY(-50%); transition: left 0.22s cubic-bezier(0.22, 1, 0.36, 1); } +.ag-theme[data-on="dark"]::before { background: rgba(148, 163, 184, 0.16); } +.ag-theme[data-on="dark"]::after { left: calc(100% - 0.5625rem - 1.125rem); background: #3b4a63; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); } +.ag-theme-icon { position: absolute; z-index: 1; top: 50%; left: calc(0.5625rem + 0.1875rem); width: 0.75rem; height: 0.75rem; color: var(--ag-cloud); background: currentColor; transform: translateY(-50%); transition: left 0.22s cubic-bezier(0.22, 1, 0.36, 1); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round'%3E%3Ccircle cx='10' cy='10' r='3.1'/%3E%3Cpath d='M10 2.2v1.4M10 16.4v1.4M2.2 10h1.4M16.4 10h1.4M4.5 4.5l1 1M14.5 14.5l1 1M15.5 4.5l-1 1M5.5 14.5l-1 1'/%3E%3C/g%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round'%3E%3Ccircle cx='10' cy='10' r='3.1'/%3E%3Cpath d='M10 2.2v1.4M10 16.4v1.4M2.2 10h1.4M16.4 10h1.4M4.5 4.5l1 1M14.5 14.5l1 1M15.5 4.5l-1 1M5.5 14.5l-1 1'/%3E%3C/g%3E%3C/svg%3E") center / contain no-repeat; } +.ag-theme[data-on="dark"] .ag-theme-icon { left: calc(100% - 0.5625rem - 1.125rem + 0.1875rem); color: #c7d2fe; -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M15.2 12.7A6.3 6.3 0 017.3 4.8a6.3 6.3 0 107.9 7.9Z' fill='none' stroke='black' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M15.2 12.7A6.3 6.3 0 017.3 4.8a6.3 6.3 0 107.9 7.9Z' fill='none' stroke='black' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E"); } +.ag-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } .ag-bar { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 1.25rem; border-bottom: 1px solid var(--ag-border); flex-wrap: wrap; } -.ag-query-wrap { flex: 1 1 18rem; display: flex; align-items: center; gap: 0.5rem; min-width: 0; background: var(--ag-input); border: 1px solid var(--ag-input-border); border-radius: 0.6rem; padding: 0 0.6rem; } +.ag-query-wrap { flex: 1 1 18rem; display: flex; align-items: center; gap: 0.5rem; min-width: 0; background: var(--ag-input); border: 1px solid var(--ag-input-border); border-radius: 0.5625rem; padding: 0 0.6rem; } .ag-query-wrap:focus-within { border-color: var(--ag-accent); } .ag-query { flex: 1; min-width: 0; height: 2rem; border: 0; background: transparent; color: var(--ag-input-fg); font-family: var(--ag-mono); font-size: 0.875rem; outline: none; } .ag-query::placeholder { color: var(--ag-muted); opacity: 0.8; } @@ -55,7 +76,7 @@ html, body { margin: 0; height: 100%; overflow: hidden; background: var(--ag-bg) .ag-link.is-accepted .ag-dot { background: var(--ag-backend); } .ag-link.is-proposed .ag-dot { background: var(--ag-cloud); } .ag-link.is-rejected .ag-dot { background: var(--ag-security); } .ag-link.is-superseded .ag-dot { background: var(--ag-external); } .ag-empty { color: var(--ag-muted); } .ag-side-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.6rem; } -.ag-help { position: absolute; top: 0.5rem; right: 20.75rem; z-index: 5; width: min(26rem, calc(100% - 2rem)); max-height: calc(100% - 1rem); overflow: auto; padding: 0.9rem 1rem; border: 1px solid var(--ag-border); border-radius: 0.75rem; background: var(--ag-panel); box-shadow: 0 16px 40px rgba(0,0,0,.35); } +.ag-help { position: absolute; top: 0.5rem; right: 20.75rem; z-index: 5; width: min(26rem, calc(100% - 2rem)); max-height: calc(100% - 1rem); overflow: auto; padding: 0.9rem 1rem; border: 1px solid var(--ag-border); border-radius: 0.6875rem; background: var(--ag-panel); box-shadow: 0 16px 40px rgba(0,0,0,.35); } .ag-main.is-side-hidden .ag-help { right: 0.75rem; } .ag-help h3 { margin: 0 0 0.5rem; font-size: 0.8125rem; } .ag-help table { border-collapse: collapse; width: 100%; font-size: 0.8125rem; } -- 2.54.0 From 27d1b4381c602cef5417e53ee542aef0f957c993 Mon Sep 17 00:00:00 2001 From: Root At Skic Date: Wed, 21 Oct 2026 22:03:39 +0300 Subject: [PATCH 07/10] Model graph: theme toggle in the same pill as the switch and Export --- src/graph/index.ts | 2 +- src/graph/style.ts | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/graph/index.ts b/src/graph/index.ts index 38bcf60..ecaf50c 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -232,7 +232,7 @@ class GraphPage { exportButton.addEventListener('click', () => this.exportPng()); this.themeButton.type = 'button'; this.themeButton.setAttribute('role', 'switch'); - this.themeButton.append(el('span', 'ag-theme-icon'), el('span', 'ag-sr', 'Dark theme')); + this.themeButton.append(el('span', 'ag-theme-track'), el('span', 'ag-theme-icon'), el('span', 'ag-sr', 'Dark theme')); this.themeButton.addEventListener('click', () => this.setTheme(currentTheme() === 'dark' ? 'light' : 'dark')); this.syncThemeButton(); actions.append(sw, this.themeButton, exportButton); diff --git a/src/graph/style.ts b/src/graph/style.ts index e219590..98455f3 100644 --- a/src/graph/style.ts +++ b/src/graph/style.ts @@ -36,14 +36,17 @@ html, body { margin: 0; height: 100%; overflow: hidden; background: var(--ag-bg) .ag-primary:hover { color: var(--ag-bg); } .ag-primary:hover::before { background: color-mix(in srgb, var(--ag-fg) 86%, var(--ag-bg)); } /* Theme toggle: Archify's own, a pill track with a knob that slides and carries the sun or moon. */ -.ag-theme { width: 3.5rem; padding: 0; justify-content: center; } -.ag-theme::before { inset: 0.6rem 0.4375rem; border-radius: 999px; background: color-mix(in srgb, var(--ag-fg) 9%, transparent); box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08); } -.ag-theme:hover::before { background: color-mix(in srgb, var(--ag-fg) 13%, transparent); } -.ag-theme::after { content: ""; position: absolute; z-index: 0; top: 50%; left: 0.5625rem; width: 1.125rem; height: 1.125rem; border-radius: 50%; background: var(--ag-panel); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18), 0 2px 6px rgba(15, 23, 42, 0.12); transform: translateY(-50%); transition: left 0.22s cubic-bezier(0.22, 1, 0.36, 1); } -.ag-theme[data-on="dark"]::before { background: rgba(148, 163, 184, 0.16); } -.ag-theme[data-on="dark"]::after { left: calc(100% - 0.5625rem - 1.125rem); background: #3b4a63; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); } -.ag-theme-icon { position: absolute; z-index: 1; top: 50%; left: calc(0.5625rem + 0.1875rem); width: 0.75rem; height: 0.75rem; color: var(--ag-cloud); background: currentColor; transform: translateY(-50%); transition: left 0.22s cubic-bezier(0.22, 1, 0.36, 1); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round'%3E%3Ccircle cx='10' cy='10' r='3.1'/%3E%3Cpath d='M10 2.2v1.4M10 16.4v1.4M2.2 10h1.4M16.4 10h1.4M4.5 4.5l1 1M14.5 14.5l1 1M15.5 4.5l-1 1M5.5 14.5l-1 1'/%3E%3C/g%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round'%3E%3Ccircle cx='10' cy='10' r='3.1'/%3E%3Cpath d='M10 2.2v1.4M10 16.4v1.4M2.2 10h1.4M16.4 10h1.4M4.5 4.5l1 1M14.5 14.5l1 1M15.5 4.5l-1 1M5.5 14.5l-1 1'/%3E%3C/g%3E%3C/svg%3E") center / contain no-repeat; } -.ag-theme[data-on="dark"] .ag-theme-icon { left: calc(100% - 0.5625rem - 1.125rem + 0.1875rem); color: #c7d2fe; -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M15.2 12.7A6.3 6.3 0 017.3 4.8a6.3 6.3 0 107.9 7.9Z' fill='none' stroke='black' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M15.2 12.7A6.3 6.3 0 017.3 4.8a6.3 6.3 0 107.9 7.9Z' fill='none' stroke='black' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E"); } +.ag-theme { width: 4rem; padding: 0; justify-content: center; } +/* The same outer pill as the Model | Graph switch and Export, so the three line up; Archify's track and knob inside it. */ +.ag-theme::before { inset: 0.25rem 0; border-radius: 0.6875rem; background: color-mix(in srgb, var(--ag-panel) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ag-border) 85%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); } +.ag-theme:hover::before { background: color-mix(in srgb, var(--ag-panel) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ag-fg) 22%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); } +.ag-theme-track { position: absolute; z-index: 0; inset: 0.6875rem 0.5625rem; border-radius: 999px; background: color-mix(in srgb, var(--ag-fg) 9%, transparent); box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08); transition: background 0.15s; } +.ag-theme:hover .ag-theme-track { background: color-mix(in srgb, var(--ag-fg) 13%, transparent); } +.ag-theme::after { content: ""; position: absolute; z-index: 1; top: 50%; left: 0.6875rem; width: 1.125rem; height: 1.125rem; border-radius: 50%; background: var(--ag-panel); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18), 0 2px 6px rgba(15, 23, 42, 0.12); transform: translateY(-50%); transition: left 0.22s cubic-bezier(0.22, 1, 0.36, 1); } +.ag-theme[data-on="dark"] .ag-theme-track { background: rgba(148, 163, 184, 0.16); } +.ag-theme[data-on="dark"]::after { left: calc(100% - 0.6875rem - 1.125rem); background: #3b4a63; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); } +.ag-theme-icon { position: absolute; z-index: 2; top: 50%; left: calc(0.6875rem + 0.1875rem); width: 0.75rem; height: 0.75rem; color: var(--ag-cloud); background: currentColor; transform: translateY(-50%); transition: left 0.22s cubic-bezier(0.22, 1, 0.36, 1); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round'%3E%3Ccircle cx='10' cy='10' r='3.1'/%3E%3Cpath d='M10 2.2v1.4M10 16.4v1.4M2.2 10h1.4M16.4 10h1.4M4.5 4.5l1 1M14.5 14.5l1 1M15.5 4.5l-1 1M5.5 14.5l-1 1'/%3E%3C/g%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round'%3E%3Ccircle cx='10' cy='10' r='3.1'/%3E%3Cpath d='M10 2.2v1.4M10 16.4v1.4M2.2 10h1.4M16.4 10h1.4M4.5 4.5l1 1M14.5 14.5l1 1M15.5 4.5l-1 1M5.5 14.5l-1 1'/%3E%3C/g%3E%3C/svg%3E") center / contain no-repeat; } +.ag-theme[data-on="dark"] .ag-theme-icon { left: calc(100% - 0.6875rem - 1.125rem + 0.1875rem); color: #c7d2fe; -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M15.2 12.7A6.3 6.3 0 017.3 4.8a6.3 6.3 0 107.9 7.9Z' fill='none' stroke='black' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M15.2 12.7A6.3 6.3 0 017.3 4.8a6.3 6.3 0 107.9 7.9Z' fill='none' stroke='black' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E"); } .ag-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } .ag-bar { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 1.25rem; border-bottom: 1px solid var(--ag-border); flex-wrap: wrap; } .ag-query-wrap { flex: 1 1 18rem; display: flex; align-items: center; gap: 0.5rem; min-width: 0; background: var(--ag-input); border: 1px solid var(--ag-input-border); border-radius: 0.5625rem; padding: 0 0.6rem; } -- 2.54.0 From bc9f55e2fa6152d010b5233488765e7f061690d1 Mon Sep 17 00:00:00 2001 From: Root At Skic Date: Wed, 21 Oct 2026 22:33:02 +0300 Subject: [PATCH 08/10] Model graph: decision and registry links on by default, placed near their elements --- CHANGELOG.md | 2 +- README.md | 2 +- src/graph/index.ts | 5 +++-- src/graph/layout.ts | 16 ++++++++++++---- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eda055d..2b3478f 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. 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. +- **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; the dotted decision and registry links are shown by default; **Links** switches them to the selected element only. 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 888b63f..b2cb24f 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. 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. +- **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, the dotted **Links** from decisions and registries to their elements are on by default and can be switched off, in which case only the selected element's 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 ecaf50c..5ad61e1 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -131,6 +131,7 @@ class GraphPage { this.saved = (host.getState().graphView as SavedView | undefined) ?? {}; if (initial.query !== undefined) this.saved.query = initial.query; if (this.saved.labels === undefined) this.saved.labels = initial.graph.edges.length <= LABELS_UP_TO; + if (this.saved.links === undefined) this.saved.links = true; this.hiddenTypes = new Set(this.saved.hidden ?? []); this.applyTheme(this.saved.theme ?? archifyThemePreference()); this.index(); @@ -261,7 +262,7 @@ class GraphPage { 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.title = 'Links from decisions and registries to their elements: on for all, off for the selected element only'; this.linksButton.addEventListener('click', () => { this.saved.links = !this.saved.links; this.save(); @@ -300,7 +301,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. 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.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 switches the dotted decision and registry links between all of them and 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); diff --git a/src/graph/layout.ts b/src/graph/layout.ts index 37ac34b..61c80d8 100644 --- a/src/graph/layout.ts +++ b/src/graph/layout.ts @@ -70,11 +70,19 @@ export function layeredLayout(nodes: GraphNode[], edges: GraphEdge[], options: L 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; + // Registries and decisions sit above or below the elements they link to: at the mean x of those + // elements, pushed apart only as far as needed so they do not overlap. 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 })); + const wanted = list.map((n) => { + const xs = (neighbours.get(n.id) ?? []).map((id) => positions.get(id)?.x).filter((x): x is number => x !== undefined); + return { n, x: xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : width / 2 }; + }); + wanted.sort((a, b) => a.x - b.x || a.n.label.localeCompare(b.n.label)); + for (let i = 1; i < wanted.length; i += 1) wanted[i].x = Math.max(wanted[i].x, wanted[i - 1].x + gap); + for (let i = wanted.length - 2; i >= 0; i -= 1) wanted[i].x = Math.min(wanted[i].x, wanted[i + 1].x - gap); + for (const { n, x } of wanted) positions.set(n.id, { x, 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)); + spread(registries, -options.rowGap * 1.6, 200); + spread(decisions, tallest * options.rowGap + options.rowGap * 0.6, 250); return positions; } -- 2.54.0 From 60ad7479fd8028fcb4d02a9c760ab5d53159700e Mon Sep 17 00:00:00 2001 From: Root At Skic Date: Wed, 21 Oct 2026 22:39:30 +0300 Subject: [PATCH 09/10] Model graph: decision titles on two lines, refit after resize --- src/graph/index.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/graph/index.ts b/src/graph/index.ts index 5ad61e1..742d049 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -156,7 +156,12 @@ 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 shorten = (text: string, max: number) => { + if (text.length <= max) return text; + const cut = text.slice(0, max - 1); + return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), max - 16))}\u2026`; + }; + const nodes = this.graph.nodes.map((n) => ({ group: 'nodes' as const, data: { id: n.id, label: n.kind === 'decision' ? shorten(n.label, 60) : n.label, type: n.type ?? '', kind: n.kind, shared: n.shared ? 1 : 0, color: nodeColor(n) } })); // In the layered layout a label sits at the end of the connection where fewer connections meet, so a // fan-out labels at its targets and a fan-in at its sources; what still shares an end is staggered. const outCount = new Map(); @@ -390,7 +395,7 @@ class GraphPage { return [ { selector: 'node', style: { shape: 'round-rectangle', width: (n: NodeSingular) => Math.min(240, Math.max(110, String(n.data('label')).length * 7.6 + 40)), height: 46, 'background-color': fill(dark ? 0.16 : 0.1), 'border-width': 1.5, 'border-color': 'data(color)', 'border-opacity': 0.9, label: 'data(label)', color: p.fg, 'font-size': 13, 'font-weight': 600, 'font-family': p.font, 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'ellipsis', 'text-max-width': '210px', 'overlay-opacity': 0, 'underlay-color': 'data(color)', 'underlay-padding': 6, 'underlay-opacity': 0, 'underlay-shape': 'round-rectangle', 'transition-property': 'underlay-opacity, border-width', 'transition-duration': 120 } }, { selector: 'node[shared = 0]', style: { 'border-style': 'dashed' } }, - { selector: 'node[kind = "decision"]', style: { shape: 'round-tag', height: 34, 'font-size': 11, 'font-weight': 500, 'background-color': fill(dark ? 0.12 : 0.08), 'text-max-width': '190px', width: (n: NodeSingular) => Math.min(220, Math.max(120, String(n.data('label')).length * 6.4 + 44)) } }, + { selector: 'node[kind = "decision"]', style: { shape: 'round-tag', height: (n: NodeSingular) => (String(n.data('label')).length > 30 ? 44 : 34), 'font-size': 11, 'font-weight': 500, 'background-color': fill(dark ? 0.12 : 0.08), 'text-wrap': 'wrap', 'text-max-width': '196px', 'line-height': 1.2, width: (n: NodeSingular) => Math.min(244, Math.max(120, String(n.data('label')).length * 6.4 + 48)) } }, { 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 } }, @@ -793,7 +798,12 @@ class GraphPage { this.help.hidden = true; } }); - window.addEventListener('resize', () => this.cy.resize()); + let resizeTimer: number | undefined; + window.addEventListener('resize', () => { + this.cy.resize(); + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => this.cy.fit(this.cy.elements().not('.hidden'), 40), 150); + }); new MutationObserver(() => this.restyle()).observe(document.body, { attributes: true, attributeFilter: ['class'] }); window.addEventListener('message', (event) => { const message = event.data || {}; -- 2.54.0 From 8398c44ca8ed4d4d402aaf8775f03dcea5fdc1e9 Mon Sep 17 00:00:00 2001 From: Root At Skic Date: Wed, 21 Oct 2026 22:47:40 +0300 Subject: [PATCH 10/10] Model graph: one head block with a consistent gutter and rhythm --- src/graph/index.ts | 4 +++- src/graph/style.ts | 21 ++++++++++++--------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/graph/index.ts b/src/graph/index.ts index 742d049..b10ed6e 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -308,7 +308,9 @@ class GraphPage { this.help.appendChild(table); this.help.appendChild(el('p', '', 'Matches stay bright, the rest dims. Connections show when both ends match. Click an element for its details; drag to move it; scroll to zoom. Press / to type a query. Layered puts clients on the left and data on the right, with registries before and decisions after; Links switches the dotted decision and registry links between all of them and the selected element\u2019s only.')); this.main.append(this.canvas, this.side, this.help); - this.root.append(top, bar, this.main); + const head = el('div', 'ag-head'); + head.append(top, bar); + this.root.append(head, this.main); document.body.appendChild(this.root); this.syncLabels(); } diff --git a/src/graph/style.ts b/src/graph/style.ts index 98455f3..57d6a97 100644 --- a/src/graph/style.ts +++ b/src/graph/style.ts @@ -10,10 +10,13 @@ export const GRAPH_PAGE_STYLE = ` :root[data-theme="light"] { --ag-bg: #ffffff; --ag-fg: #0f172a; --ag-muted: #64748b; --ag-panel: #f8fafc; --ag-border: rgba(15, 23, 42, 0.14); --ag-accent: #0e7490; --ag-input: #ffffff; --ag-input-fg: #0f172a; --ag-input-border: rgba(15, 23, 42, 0.2); --ag-error: #b91c1c; --ag-external: #64748b; --ag-registry: #94a3b8; color-scheme: light; } 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); } -.ag-kicker { font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ag-accent); } -.ag-name { font-weight: 700; font-size: 1.125rem; letter-spacing: -0.01em; } +:root { --ag-gutter: 1.5rem; } +.ag { display: grid; grid-template-rows: auto minmax(0, 1fr); height: 100%; } +/* One head block: title row and query row share a background and a single rule below. */ +.ag-head { background: color-mix(in srgb, var(--ag-panel) 55%, var(--ag-bg)); border-bottom: 1px solid var(--ag-border); } +.ag-top { display: flex; align-items: center; gap: 1rem; padding: 1.1rem var(--ag-gutter) 0.6rem; } +.ag-kicker { font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ag-accent); margin-bottom: 0.2rem; } +.ag-name { font-weight: 700; font-size: 1.25rem; line-height: 1.2; letter-spacing: -0.015em; } .ag-actions { margin-left: auto; display: flex; align-items: center; gap: 0.5rem; } .ag-bar .ag-button { min-height: 2rem; } /* Toolbar controls follow Archify's: quiet pills (0.5rem) inside 0.5625rem buttons, one filled primary action (0.6875rem), the 2D | 3D switch's twin. */ @@ -48,25 +51,25 @@ html, body { margin: 0; height: 100%; overflow: hidden; background: var(--ag-bg) .ag-theme-icon { position: absolute; z-index: 2; top: 50%; left: calc(0.6875rem + 0.1875rem); width: 0.75rem; height: 0.75rem; color: var(--ag-cloud); background: currentColor; transform: translateY(-50%); transition: left 0.22s cubic-bezier(0.22, 1, 0.36, 1); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round'%3E%3Ccircle cx='10' cy='10' r='3.1'/%3E%3Cpath d='M10 2.2v1.4M10 16.4v1.4M2.2 10h1.4M16.4 10h1.4M4.5 4.5l1 1M14.5 14.5l1 1M15.5 4.5l-1 1M5.5 14.5l-1 1'/%3E%3C/g%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round'%3E%3Ccircle cx='10' cy='10' r='3.1'/%3E%3Cpath d='M10 2.2v1.4M10 16.4v1.4M2.2 10h1.4M16.4 10h1.4M4.5 4.5l1 1M14.5 14.5l1 1M15.5 4.5l-1 1M5.5 14.5l-1 1'/%3E%3C/g%3E%3C/svg%3E") center / contain no-repeat; } .ag-theme[data-on="dark"] .ag-theme-icon { left: calc(100% - 0.6875rem - 1.125rem + 0.1875rem); color: #c7d2fe; -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M15.2 12.7A6.3 6.3 0 017.3 4.8a6.3 6.3 0 107.9 7.9Z' fill='none' stroke='black' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M15.2 12.7A6.3 6.3 0 017.3 4.8a6.3 6.3 0 107.9 7.9Z' fill='none' stroke='black' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E"); } .ag-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } -.ag-bar { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 1.25rem; border-bottom: 1px solid var(--ag-border); flex-wrap: wrap; } +.ag-bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem var(--ag-gutter) 0.9rem; flex-wrap: wrap; } .ag-query-wrap { flex: 1 1 18rem; display: flex; align-items: center; gap: 0.5rem; min-width: 0; background: var(--ag-input); border: 1px solid var(--ag-input-border); border-radius: 0.5625rem; padding: 0 0.6rem; } .ag-query-wrap:focus-within { border-color: var(--ag-accent); } -.ag-query { flex: 1; min-width: 0; height: 2rem; border: 0; background: transparent; color: var(--ag-input-fg); font-family: var(--ag-mono); font-size: 0.875rem; outline: none; } +.ag-query { flex: 1; min-width: 0; height: 2.125rem; border: 0; background: transparent; color: var(--ag-input-fg); font-family: var(--ag-mono); font-size: 0.875rem; outline: none; } .ag-query::placeholder { color: var(--ag-muted); opacity: 0.8; } .ag-clear { border: 0; background: transparent; color: var(--ag-muted); font-size: 1rem; cursor: pointer; padding: 0 0.1rem; } .ag-clear:hover { color: var(--ag-fg); } -.ag-count { font-family: var(--ag-mono); font-size: 0.8125rem; color: var(--ag-muted); white-space: nowrap; } +.ag-count { font-family: var(--ag-mono); font-size: 0.8125rem; color: var(--ag-muted); white-space: nowrap; margin: 0 0.35rem 0 0.25rem; } .ag-count.is-error { color: var(--ag-error); } .ag-main { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 20rem; min-height: 0; } .ag-main.is-side-hidden { grid-template-columns: minmax(0, 1fr); } .ag-canvas { position: relative; min-width: 0; min-height: 0; } .ag-cy { position: absolute; inset: 0; } -.ag-legend { position: absolute; left: 1rem; bottom: 0.9rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; z-index: 2; max-width: calc(100% - 2rem); } +.ag-legend { position: absolute; left: var(--ag-gutter); bottom: 1rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; z-index: 2; max-width: calc(100% - 2 * var(--ag-gutter)); } .ag-chip { display: inline-flex; align-items: center; gap: 0.4rem; font: inherit; font-size: 0.75rem; padding: 0.2rem 0.6rem; border-radius: 999px; border: 1px solid var(--ag-border); background: color-mix(in srgb, var(--ag-panel) 85%, transparent); color: var(--ag-fg); cursor: pointer; backdrop-filter: blur(6px); } .ag-chip::before { content: ""; width: 0.55rem; height: 0.55rem; border-radius: 2px; background: var(--c); } .ag-chip[aria-pressed="false"] { opacity: 0.45; text-decoration: line-through; } .ag-chip small { color: var(--ag-muted); font-family: var(--ag-mono); } -.ag-side { border-left: 1px solid var(--ag-border); background: var(--ag-panel); overflow: auto; padding: 1rem 1.1rem 1.5rem; min-width: 0; } +.ag-side { border-left: 1px solid var(--ag-border); background: var(--ag-panel); overflow: auto; padding: 1.1rem var(--ag-gutter) 1.5rem; min-width: 0; } .ag-side h2 { margin: 0; font-size: 1.0625rem; font-weight: 700; display: flex; align-items: center; gap: 0.5rem; } .ag-dot { display: inline-block; width: 0.6rem; height: 0.6rem; border-radius: 50%; background: var(--c, var(--ag-external)); flex: none; } .ag-id { font-family: var(--ag-mono); color: var(--ag-muted); font-size: 0.75rem; margin: 0.25rem 0 0.8rem; word-break: break-all; } -- 2.54.0