From bbc12ee4be4f0f2a9de2a6b946d12a3f0536930d Mon Sep 17 00:00:00 2001 From: Aleksey Shakhmatov Date: Fri, 22 May 2026 20:11:49 +0300 Subject: [PATCH] feat(optimize): Diagram Doctor panel to declutter tangled flowcharts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an Optimize side panel that analyses the active flowchart and applies one-click, reversible source rewrites to make dense graphs readable. - optimize.ts: pure, label-aware flowchart parser + metrics, a 0-100 readability score, and transforms — ELK layered layout, node/rank spacing + curved edges, de-emphasising cross-cutting hub edges (event bus / audit / cost-style fan-ins), duplicate-edge removal, direction toggle, and a non-destructive focus variant. - mermaid.ts: register @mermaid-js/layout-elk so `layout: elk` renders. - OptimizePanel.svelte + Toolbar toggle; panels are mutually exclusive. - Preview: render the focus variant when a node is spotlighted, with a clear-focus pill. - store/Editor: applySource() bumps a revision so programmatic rewrites re-sync CodeMirror and stay undoable with the editor's history. --- README.md | 13 +- package-lock.json | 20 + package.json | 1 + src/App.svelte | 4 + src/lib/components/Editor.svelte | 8 +- src/lib/components/OptimizePanel.svelte | 431 +++++++++++++++ src/lib/components/Preview.svelte | 35 +- src/lib/components/Toolbar.svelte | 15 +- src/lib/mermaid.ts | 5 + src/lib/optimize.ts | 703 ++++++++++++++++++++++++ src/lib/store.svelte.ts | 22 + 11 files changed, 1249 insertions(+), 8 deletions(-) create mode 100644 src/lib/components/OptimizePanel.svelte create mode 100644 src/lib/optimize.ts diff --git a/README.md b/README.md index 9ad6b8a..084b323 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,14 @@ TypeScript** frontend using **CodeMirror 6** and **Mermaid 11**. - **Git version control built in** — view the working-tree status, write commit messages, browse history, create branches and switch between them. Each branch keeps its own set of diagrams, just like normal Git. +- **Optimize panel (Diagram Doctor)** — analyse a tangled flowchart and declutter + it in one click: a readability score and metrics (hubs, density, cross-group + edges), plus source rewrites for the **ELK layered layout**, extra + spacing/curved edges, **de-emphasising cross-cutting hub edges** (event bus, + audit, cost-style fan-ins), and removing duplicate edges. A non-destructive + **focus mode** spotlights any node and its neighbours so you can read a dense + graph without changing it. Every rewrite lands in the editor and is reversible + with ⌘Z. - **Export** the current diagram to **SVG** or **PNG** (2× scale). - **Project registry** — recently opened projects are remembered in a local SQLite database so you can jump back in from the start screen. @@ -35,9 +43,10 @@ Mermix/ │ ├─ lib/ │ │ ├─ api.ts # typed wrappers over Tauri commands │ │ ├─ store.svelte.ts # central rune-based app state -│ │ ├─ mermaid.ts # render + error capture +│ │ ├─ mermaid.ts # render + error capture (registers ELK layout) +│ │ ├─ optimize.ts # flowchart analysis + declutter transforms (pure) │ │ ├─ export.ts # SVG / PNG export via save dialog -│ │ └─ components/ # Sidebar, Editor, Preview, GitPanel, Toolbar, … +│ │ └─ components/ # Sidebar, Editor, Preview, GitPanel, OptimizePanel, … │ └─ main.ts └─ src-tauri/ # Rust backend ├─ src/ diff --git a/package-lock.json b/package-lock.json index 9373f3b..a899f49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@codemirror/state": "^6.4.1", "@codemirror/view": "^6.28.0", "@lezer/highlight": "^1.2.0", + "@mermaid-js/layout-elk": "^0.2.1", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0", "codemirror": "^6.0.1", @@ -625,6 +626,19 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "node_modules/@mermaid-js/layout-elk": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/layout-elk/-/layout-elk-0.2.1.tgz", + "integrity": "sha512-MX9jwhMyd5zDcFsYcl3duDUkKhjVRUCGEQrdCeNV5hCIR6+3FuDDbRbFmvVbAu15K1+juzsYGG+K8MDvCY1Amg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "elkjs": "^0.9.3" + }, + "peerDependencies": { + "mermaid": "^11.0.2" + } + }, "node_modules/@mermaid-js/parser": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", @@ -2216,6 +2230,12 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, "node_modules/es-toolkit": { "version": "1.46.1", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", diff --git a/package.json b/package.json index ce8542a..7ba4029 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@codemirror/state": "^6.4.1", "@codemirror/view": "^6.28.0", "@lezer/highlight": "^1.2.0", + "@mermaid-js/layout-elk": "^0.2.1", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0", "codemirror": "^6.0.1", diff --git a/src/App.svelte b/src/App.svelte index 25598f9..8b4efda 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -7,6 +7,7 @@ import Editor from './lib/components/Editor.svelte'; import Preview from './lib/components/Preview.svelte'; import GitPanel from './lib/components/GitPanel.svelte'; + import OptimizePanel from './lib/components/OptimizePanel.svelte'; import Toasts from './lib/components/Toasts.svelte'; // Editor/preview split ratio (fraction taken by the editor). @@ -112,6 +113,9 @@
Pick one from the sidebar, or create a new diagram with +.
{/if} + {#if store.showOptimize} + + {/if} {#if store.showGit} {/if} diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 64e13eb..8882d41 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -128,10 +128,12 @@ return () => view?.destroy(); }); - // When a different diagram is loaded, replace the whole document. Depend only - // on activeId; read content untracked so typing doesn't reset the editor. + // Replace the whole document when a different diagram is loaded (activeId) or + // when content is swapped programmatically (revision, e.g. an applied + // optimization). Read content untracked so plain typing never resets it. $effect(() => { - void store.activeId; // re-run only when the selected diagram changes + void store.activeId; // re-run when the selected diagram changes… + void store.revision; // …or when an optimization rewrites the source. if (!view) return; untrack(() => { const incoming = store.content; diff --git a/src/lib/components/OptimizePanel.svelte b/src/lib/components/OptimizePanel.svelte new file mode 100644 index 0000000..34b3669 --- /dev/null +++ b/src/lib/components/OptimizePanel.svelte @@ -0,0 +1,431 @@ + + + + + diff --git a/src/lib/components/Preview.svelte b/src/lib/components/Preview.svelte index 85fa2eb..473d840 100644 --- a/src/lib/components/Preview.svelte +++ b/src/lib/components/Preview.svelte @@ -2,6 +2,7 @@ import { tick } from 'svelte'; import { store } from '../store.svelte'; import { renderMermaid } from '../mermaid'; + import { applyFocus } from '../optimize'; let svg = $state(''); let error = $state(''); @@ -12,12 +13,16 @@ let timer: ReturnType | undefined; let fittedFor = ''; - // Debounced re-render whenever the source or theme changes. + // Debounced re-render whenever the source, theme or focus target changes. + // Focus mode renders a transient spotlighted variant without touching the + // saved source. $effect(() => { const code = store.content; const theme = store.theme; + const focus = store.focusNode; clearTimeout(timer); - timer = setTimeout(() => void run(code, theme), 220); + const toRender = focus ? applyFocus(code, focus) : code; + timer = setTimeout(() => void run(toRender, theme), focus ? 60 : 220); return () => clearTimeout(timer); }); @@ -102,6 +107,11 @@
+ {#if store.focusNode} + + {/if}
{#if rendering}{/if} @@ -153,6 +163,27 @@ var(--bg); overflow: hidden; } + .focus-pill { + position: absolute; + top: 10px; + left: 12px; + z-index: 6; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 12px; + border-radius: 999px; + background: var(--accent-soft); + border: 1px solid var(--accent); + color: var(--text); + font-size: 12px; + } + .focus-pill .x { + color: var(--text-faint); + } + .focus-pill:hover .x { + color: var(--red); + } .ctrls { position: absolute; top: 10px; diff --git a/src/lib/components/Toolbar.svelte b/src/lib/components/Toolbar.svelte index 2bd19f3..3050762 100644 --- a/src/lib/components/Toolbar.svelte +++ b/src/lib/components/Toolbar.svelte @@ -65,11 +65,24 @@
+ + { + store.showGit = !store.showGit; + if (store.showGit) store.showOptimize = false; + }}>⎇
diff --git a/src/lib/mermaid.ts b/src/lib/mermaid.ts index e237cbb..f441b28 100644 --- a/src/lib/mermaid.ts +++ b/src/lib/mermaid.ts @@ -1,4 +1,9 @@ import mermaid from 'mermaid'; +import elkLayouts from '@mermaid-js/layout-elk'; + +// Register the ELK layout engine so diagrams (and the Optimize panel's "Switch +// to ELK layout" fix) can request `layout: elk` for far cleaner dense graphs. +mermaid.registerLayoutLoaders(elkLayouts); let currentTheme = ''; diff --git a/src/lib/optimize.ts b/src/lib/optimize.ts new file mode 100644 index 0000000..77b2ed1 --- /dev/null +++ b/src/lib/optimize.ts @@ -0,0 +1,703 @@ +/** + * Diagram Doctor — analysis and decluttering for Mermaid flowcharts. + * + * Everything here is pure (no DOM, no Svelte, no Mermaid runtime) so it can be + * reasoned about and tested in isolation. The module does three things: + * + * 1. `parseFlowchart` — turn flowchart/graph source into a small graph model. + * 2. `analyze` — derive metrics, a readability score, and a list of + * concrete fixes (each a `source -> source` transform). + * 3. transforms — the rewrites the panel applies: ELK layout, spacing, + * dimming busy hub edges, removing duplicate edges, and + * a non-destructive "focus" variant for reading. + * + * Parsing is line-based and label-aware (see `maskLabels`) rather than a full + * grammar; it is deliberately forgiving and degrades gracefully on syntax it + * does not recognise. The layout/spacing transforms never depend on edge + * parsing, so they stay correct even when an exotic edge confuses the parser. + */ + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +export interface FlowNode { + id: string; + label: string; + subgraph: string | null; // id of the enclosing subgraph, if any + inDeg: number; + outDeg: number; + /** Number of bidirectional (`<-->`) edges touching this node. */ + bidirDeg: number; +} + +export interface FlowEdge { + from: string; + to: string; + bidir: boolean; + /** 0-based line in the source where the edge was declared. */ + line: number; +} + +export interface SubgraphInfo { + id: string; + title: string; +} + +export interface FlowGraph { + kind: 'flowchart' | 'graph'; + direction: string; + /** 0-based index of the header line (`flowchart TB`). */ + headerLine: number; + nodes: Map; + /** Edges in Mermaid link order (matches `linkStyle` indexing). */ + edges: FlowEdge[]; + subgraphs: SubgraphInfo[]; + usesElk: boolean; + hasSpacing: boolean; +} + +export interface Metrics { + nodes: number; + edges: number; + subgraphs: number; + /** edges / nodes — how "wired" the graph is. */ + density: number; + maxDegree: number; + /** Nodes ranked by total degree (hubs first). */ + hubs: FlowNode[]; + /** High in-degree sinks fed from several places (Audit/Cost-style). */ + sinkHubs: FlowNode[]; + /** + * Cross-cutting infrastructure worth fading: pure sinks (Audit) and shared + * buses (EventBus) — not forward-flow orchestrators like OMS. + */ + dimHubs: FlowNode[]; + /** Edges whose endpoints live in different groups — a crossing proxy. */ + interGroupEdges: number; + duplicateEdges: number; +} + +export type FixSeverity = 'high' | 'medium' | 'low'; + +export interface Fix { + id: string; + title: string; + detail: string; + severity: FixSeverity; + /** When present, applying the fix returns rewritten source. */ + apply?: (src: string) => string; +} + +export interface Analysis { + supported: boolean; + /** Set when `supported` is false (non-flowchart diagram). */ + kind?: string; + graph?: FlowGraph; + metrics?: Metrics; + score: number; + rating: 'Clear' | 'Readable' | 'Busy' | 'Tangled'; + fixes: Fix[]; +} + +const HUB_DEGREE = 6; // total degree at/above which a node is a "hub" +const SINK_INDEG = 4; // in-degree at/above which a sink is "high traffic" + +// --------------------------------------------------------------------------- +// Lexical helpers +// --------------------------------------------------------------------------- + +/** + * Return a same-length copy of `line` with arrow/separator characters that sit + * *inside* a node label (`[...]`, `(...)`, `{...}`, `"..."`) neutralised to + * `_`. This lets us split a line on real link operators and `&` separators + * without being fooled by punctuation in labels like `[Public & Partner API]` + * or `[ETA / Promise Engine]`. + */ +export function maskLabels(line: string): string { + const out = line.split(''); + let depth = 0; + let inQuote = false; + const NEUTRAL = /[-=.<>|&~]/; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (inQuote) { + if (c === '"') inQuote = false; + else if (NEUTRAL.test(c)) out[i] = '_'; + continue; + } + if (c === '"') { + inQuote = true; + continue; + } + if (c === '[' || c === '(' || c === '{') { + depth++; + continue; + } + if (c === ']' || c === ')' || c === '}') { + if (depth > 0) depth--; + continue; + } + if (depth > 0 && NEUTRAL.test(c)) out[i] = '_'; + } + return out.join(''); +} + +// Matches a flowchart link operator: -->, ---, -.->, ==>, --o, x--x, <-->, ~~~, +// with arbitrary length. Used on a *masked* line so label text never matches. +const LINK_RE = /<{0,2}[ox]?[-=.~]{2,}[->ox]{0,2}/g; + +// Lines that start with one of these keywords are statements, not node defs. +const STATEMENT_RE = + /^(?:subgraph|end|direction|style|classDef|class|click|linkStyle|%%)\b/; + +function isBidir(arrow: string): boolean { + if (arrow.includes('<')) return true; + const head = arrow[0]; + const tail = arrow[arrow.length - 1]; + return (head === 'o' || head === 'x') && (tail === 'o' || tail === 'x'); +} + +/** Parse a single node reference (`OMS[OMS / Orchestrator]`) into id + label. */ +function parseNodeRef(token: string): { id: string; label: string } | null { + const m = token.trim().match(/^([A-Za-z0-9_][\w.-]*)\s*(.*)$/); + if (!m) return null; + const id = m[1]; + let label = ''; + const rest = m[2].trim(); + if (rest) { + // Strip the outermost shape delimiters and quotes to recover the text. + label = rest + .replace(/^[[({<>/\\]+/, '') + .replace(/[\])}>/\\]+$/, '') + .replace(/^"|"$/g, '') + .trim(); + } + return { id, label }; +} + +/** Split a segment like `A[x] & B(y)` into its `&`-separated node references. */ +function splitNodeList(segment: string): string[] { + const masked = maskLabels(segment); + const parts: string[] = []; + let start = 0; + for (let i = 0; i < masked.length; i++) { + if (masked[i] === '&') { + parts.push(segment.slice(start, i)); + start = i + 1; + } + } + parts.push(segment.slice(start)); + return parts.map((p) => p.trim()).filter(Boolean); +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/** Length (in lines) of a leading YAML frontmatter block, or 0 if none. */ +function frontmatterLines(src: string): number { + const m = src.match(/^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/); + if (!m) return 0; + return m[0].replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n').length; +} + +/** + * Parse flowchart/graph source into a {@link FlowGraph}. Returns `null` for any + * other diagram type (sequence, class, …) or empty input. + */ +export function parseFlowchart(src: string): FlowGraph | null { + const lines = src.replace(/\r\n/g, '\n').split('\n'); + const fmEnd = frontmatterLines(src); + + // Locate the header line. + let headerLine = -1; + let kind: 'flowchart' | 'graph' | null = null; + let direction = 'TB'; + for (let i = fmEnd; i < lines.length; i++) { + const t = lines[i].trim(); + if (!t || t.startsWith('%%')) continue; + const h = t.match(/^(flowchart|graph)\b\s*([A-Za-z]{2})?/); + if (h) { + headerLine = i; + kind = h[1] as 'flowchart' | 'graph'; + if (h[2]) direction = h[2].toUpperCase(); + break; + } + // First meaningful line isn't a flowchart header → not our diagram. + return null; + } + if (kind === null) return null; + + const usesElk = + /(?:layout|defaultRenderer)["']?\s*[:=]\s*["']?elk/i.test(src); + const hasSpacing = /\b(?:nodeSpacing|rankSpacing)\b/.test(src); + + const nodes = new Map(); + const edges: FlowEdge[] = []; + const subgraphs: SubgraphInfo[] = []; + const stack: string[] = []; // current subgraph nesting + + const touchNode = (id: string, label: string) => { + let n = nodes.get(id); + if (!n) { + n = { + id, + label: label || id, + subgraph: stack.length ? stack[stack.length - 1] : null, + inDeg: 0, + outDeg: 0, + bidirDeg: 0, + }; + nodes.set(id, n); + } else if (label && (n.label === n.id || !n.label)) { + n.label = label; + } + return n; + }; + + for (let i = headerLine + 1; i < lines.length; i++) { + const raw = lines[i]; + const t = raw.trim(); + if (!t || t.startsWith('%%')) continue; + + const sg = t.match(/^subgraph\s+(.+)$/); + if (sg) { + const ref = parseNodeRef(sg[1]); + const id = ref ? ref.id : `sg${subgraphs.length}`; + const title = ref && ref.label ? ref.label : id; + subgraphs.push({ id, title }); + stack.push(id); + continue; + } + if (/^end\b/.test(t)) { + stack.pop(); + continue; + } + if (STATEMENT_RE.test(t)) continue; // style/class/click/linkStyle/direction + + // Edge statement? Strip pipe labels first, then look for link operators. + const noPipes = t.replace(/\|[^|]*\|/g, ' '); + const masked = maskLabels(noPipes); + const ops = [...masked.matchAll(LINK_RE)]; + + if (ops.length === 0) { + // Standalone node declaration line (possibly several, space separated). + const ref = parseNodeRef(t); + if (ref) touchNode(ref.id, ref.label); + continue; + } + + // Slice the original line at the masked operator positions to recover the + // node-list segments between successive arrows. + const segments: string[] = []; + const arrows: string[] = []; + let cursor = 0; + for (const op of ops) { + const idx = op.index ?? 0; + segments.push(noPipes.slice(cursor, idx)); + arrows.push(op[0]); + cursor = idx + op[0].length; + } + segments.push(noPipes.slice(cursor)); + + // Register every node, then connect consecutive segments (a-chain), + // expanding `&` lists in source × target order (Mermaid's link order). + const nodeLists = segments.map(splitNodeList); + for (const list of nodeLists) { + for (const ref of list) { + const p = parseNodeRef(ref); + if (p) touchNode(p.id, p.label); + } + } + for (let s = 0; s < arrows.length; s++) { + const bidir = isBidir(arrows[s]); + for (const aRef of nodeLists[s]) { + for (const bRef of nodeLists[s + 1]) { + const a = parseNodeRef(aRef); + const b = parseNodeRef(bRef); + if (!a || !b) continue; + edges.push({ from: a.id, to: b.id, bidir, line: i }); + } + } + } + } + + // Degree counts. + for (const e of edges) { + const f = nodes.get(e.from); + const t = nodes.get(e.to); + if (f) { + f.outDeg++; + if (e.bidir) { + f.inDeg++; + f.bidirDeg++; + } + } + if (t) { + t.inDeg++; + if (e.bidir) { + t.outDeg++; + t.bidirDeg++; + } + } + } + + return { + kind, + direction, + headerLine, + nodes, + edges, + subgraphs, + usesElk, + hasSpacing, + }; +} + +// --------------------------------------------------------------------------- +// Metrics & scoring +// --------------------------------------------------------------------------- + +function groupOf(g: FlowGraph, id: string): string { + return g.nodes.get(id)?.subgraph ?? '∅'; +} + +export function computeMetrics(g: FlowGraph): Metrics { + const all = [...g.nodes.values()]; + const byDegree = (n: FlowNode) => n.inDeg + n.outDeg; + const hubs = all + .filter((n) => byDegree(n) >= HUB_DEGREE) + .sort((a, b) => byDegree(b) - byDegree(a)); + const sinkHubs = all + .filter((n) => n.inDeg >= SINK_INDEG) + .sort((a, b) => b.inDeg - a.inDeg); + // Fade-worthy infrastructure: heavy receivers (in ≥ 2× out) or shared buses + // (many bidirectional links). Orchestrators that mostly *emit* are excluded. + const dimHubs = sinkHubs.filter( + (n) => n.outDeg * 2 <= n.inDeg || n.bidirDeg >= 5 + ); + + let interGroupEdges = 0; + for (const e of g.edges) { + if (groupOf(g, e.from) !== groupOf(g, e.to)) interGroupEdges++; + } + + // Exact duplicate edges (same direction, same endpoints) beyond the first. + const seen = new Set(); + let duplicateEdges = 0; + for (const e of g.edges) { + const key = `${e.from}${e.to}${e.bidir ? 1 : 0}`; + if (seen.has(key)) duplicateEdges++; + else seen.add(key); + } + + const nodes = g.nodes.size; + return { + nodes, + edges: g.edges.length, + subgraphs: g.subgraphs.length, + density: nodes ? g.edges.length / nodes : 0, + maxDegree: all.reduce((m, n) => Math.max(m, byDegree(n)), 0), + hubs, + sinkHubs, + dimHubs, + interGroupEdges, + duplicateEdges, + }; +} + +const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); + +export function scoreOf(g: FlowGraph, m: Metrics): number { + let score = 100; + score -= clamp((m.density - 1.2) * 18, 0, 30); // dense wiring + score -= clamp((m.maxDegree - HUB_DEGREE) * 4, 0, 24); // oversized hubs + if (m.edges > 0) { + score -= clamp((m.interGroupEdges / m.edges - 0.4) * 50, 0, 20); // crossings + } + if (!g.usesElk) score -= clamp((m.edges - 25) * 0.5, 0, 16); // dense + no ELK + score -= clamp(m.duplicateEdges * 3, 0, 12); + return Math.round(clamp(score, 0, 100)); +} + +function ratingOf(score: number): Analysis['rating'] { + if (score >= 80) return 'Clear'; + if (score >= 60) return 'Readable'; + if (score >= 40) return 'Busy'; + return 'Tangled'; +} + +// --------------------------------------------------------------------------- +// Top-level analysis +// --------------------------------------------------------------------------- + +export function analyze(src: string): Analysis { + const g = parseFlowchart(src); + if (!g) { + const head = src.replace(/^---[\s\S]*?---\s*/, '').trim().split(/\s+/)[0] || ''; + return { supported: false, kind: head, score: 0, rating: 'Clear', fixes: [] }; + } + + const m = computeMetrics(g); + const score = scoreOf(g, m); + const fixes: Fix[] = []; + + if (!g.usesElk && (m.nodes >= 12 || m.edges >= 20)) { + fixes.push({ + id: 'elk', + title: 'Switch to the ELK layout engine', + detail: + 'ELK is a layered auto-layout that untangles dense graphs far better ' + + 'than the default. Usually the single biggest readability win.', + severity: 'high', + apply: applyElkLayout, + }); + } + + if (!g.hasSpacing && (m.density >= 1.3 || m.maxDegree >= HUB_DEGREE)) { + fixes.push({ + id: 'spacing', + title: 'Add breathing room & smooth edges', + detail: + 'Increase node/rank spacing and use curved edges so lines are easier ' + + 'to follow where they bunch up.', + severity: 'medium', + apply: applySpacing, + }); + } + + if (m.dimHubs.length > 0) { + const names = m.dimHubs.slice(0, 4).map((n) => n.label).join(', '); + fixes.push({ + id: 'dim-hubs', + title: `De-emphasise ${m.dimHubs.length} cross-cutting hub${ + m.dimHubs.length > 1 ? 's' : '' + }`, + detail: + `Shared infrastructure (${names}) wires up to everything and dominates ` + + 'the picture. Fading those edges lets the main flow read clearly while ' + + 'keeping every connection intact.', + severity: m.dimHubs.length >= 3 ? 'high' : 'medium', + apply: (src) => dimHubEdges(src, m.dimHubs.map((n) => n.id)), + }); + } + + if (m.duplicateEdges > 0) { + fixes.push({ + id: 'dedupe', + title: `Remove ${m.duplicateEdges} duplicate edge${ + m.duplicateEdges > 1 ? 's' : '' + }`, + detail: 'Identical connections are declared more than once.', + severity: 'low', + apply: removeDuplicateEdges, + }); + } + + if (m.maxDegree >= 10) { + const worst = m.hubs[0]; + fixes.push({ + id: 'split-advice', + title: `"${worst.label}" connects to ${m.maxDegree} nodes`, + detail: + 'A hub this large is hard to lay out cleanly. Consider splitting the ' + + 'diagram by concern (e.g. a separate data-flow view) or routing ' + + 'through an intermediate node.', + severity: 'low', + }); + } + + return { supported: true, graph: g, metrics: m, score, rating: ratingOf(score), fixes }; +} + +// --------------------------------------------------------------------------- +// Source transforms +// --------------------------------------------------------------------------- + +function deepMerge(a: Record, b: Record): Record { + const out: Record = { ...a }; + for (const [k, v] of Object.entries(b)) { + out[k] = v && typeof v === 'object' && !Array.isArray(v) && typeof out[k] === 'object' + ? deepMerge(out[k], v) + : v; + } + return out; +} + +/** Best-effort parse of a Mermaid init directive's relaxed-JSON body. */ +function parseLooseObject(body: string): Record | null { + try { + return JSON.parse(body); + } catch { + try { + // Quote bare keys and convert single quotes; good enough for our own output. + const normalised = body + .replace(/'/g, '"') + .replace(/([{,]\s*)([A-Za-z_][\w-]*)\s*:/g, '$1"$2":'); + return JSON.parse(normalised); + } catch { + return null; + } + } +} + +/** + * Merge `patch` into the diagram's `%%{init: …}%%` config directive, creating + * one (just above the header) if absent. Used by the ELK and spacing fixes so + * they compose instead of fighting each other. + */ +export function mergeInit(src: string, patch: Record): string { + const lines = src.replace(/\r\n/g, '\n').split('\n'); + const initIdx = lines.findIndex((l) => /%%\{\s*init\s*:/.test(l)); + + if (initIdx >= 0) { + const m = lines[initIdx].match(/%%\{\s*init\s*:\s*([\s\S]*?)\}%%/); + const existing = (m && parseLooseObject(m[1])) || {}; + const merged = deepMerge(existing, patch); + lines[initIdx] = `%%{init: ${JSON.stringify(merged)}}%%`; + return lines.join('\n'); + } + + const g = parseFlowchart(src); + const at = g ? g.headerLine : frontmatterLines(src); + lines.splice(at, 0, `%%{init: ${JSON.stringify(patch)}}%%`); + return lines.join('\n'); +} + +export function applyElkLayout(src: string): string { + return mergeInit(src, { layout: 'elk' }); +} + +export function applySpacing(src: string): string { + return mergeInit(src, { + flowchart: { nodeSpacing: 55, rankSpacing: 70, curve: 'basis' }, + }); +} + +export function setDirection(src: string, dir: string): string { + const lines = src.replace(/\r\n/g, '\n').split('\n'); + const g = parseFlowchart(src); + if (!g) return src; + lines[g.headerLine] = lines[g.headerLine].replace( + /^(\s*)(flowchart|graph)\b\s*([A-Za-z]{2})?/, + `$1$2 ${dir}` + ); + return lines.join('\n'); +} + +const DIM_MARK = '%% ─ Mermix: de-emphasised hub edges (re-run Optimize to refresh) ─'; + +/** Strip any Mermix-managed dim block so transforms stay idempotent. */ +function stripDimBlock(src: string): string { + return src + .replace( + new RegExp(`\\n*${escapeRe(DIM_MARK)}\\n(?:linkStyle[^\\n]*\\n?)+`, 'g'), + '\n' + ) + .replace(/\n{3,}/g, '\n\n'); +} + +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Append a `linkStyle` rule that fades every edge touching one of `hubIds`, + * so the primary flow stands out. Re-applying refreshes the same block. + */ +export function dimHubEdges(src: string, hubIds: string[]): string { + const base = stripDimBlock(src); + const g = parseFlowchart(base); + if (!g) return src; + const targets = new Set(hubIds); + const idx: number[] = []; + g.edges.forEach((e, i) => { + if (targets.has(e.from) || targets.has(e.to)) idx.push(i); + }); + if (idx.length === 0) return base; + const rule = `linkStyle ${idx.join(',')} stroke:#3a3f4b,stroke-width:1px,stroke-opacity:0.28;`; + return `${base.replace(/\n+$/, '')}\n\n${DIM_MARK}\n${rule}\n`; +} + +/** + * Drop edge statements that repeat an earlier identical one (whitespace and + * pipe-label–insensitive). Only whole single-edge lines are removed; lines + * with `&` lists or multiple chained arrows are left untouched. + */ +export function removeDuplicateEdges(src: string): string { + const lines = src.replace(/\r\n/g, '\n').split('\n'); + const seen = new Set(); + const out: string[] = []; + for (const line of lines) { + const t = line.trim(); + const masked = maskLabels(t.replace(/\|[^|]*\|/g, ' ')); + const ops = [...masked.matchAll(LINK_RE)]; + const single = ops.length === 1 && !masked.includes('&'); + if (single && !STATEMENT_RE.test(t)) { + const key = t.replace(/\s+/g, ' '); + if (seen.has(key)) continue; // skip the duplicate line entirely + seen.add(key); + } + out.push(line); + } + return out.join('\n'); +} + +/** Run the safe fixes (ELK + spacing + dim hubs + dedupe) in one pass. */ +export function autoOptimize(src: string): string { + const a = analyze(src); + if (!a.supported || !a.metrics) return src; + let out = src; + if (!a.graph!.usesElk) out = applyElkLayout(out); + if (!a.graph!.hasSpacing) out = applySpacing(out); + if (a.metrics.duplicateEdges > 0) out = removeDuplicateEdges(out); + if (a.metrics.dimHubs.length > 0) { + out = dimHubEdges(out, a.metrics.dimHubs.map((n) => n.id)); + } + return out; +} + +// --------------------------------------------------------------------------- +// Focus mode (non-destructive — used only for the preview render) +// --------------------------------------------------------------------------- + +/** + * Produce a transient variant of `src` that spotlights `nodeId` and its direct + * neighbours, fading everything else. Reuses the parse + linkStyle machinery so + * it survives any flowchart the analyzer understands. Returns `src` unchanged + * if the node can't be found. + */ +export function applyFocus(src: string, nodeId: string): string { + const base = stripDimBlock(src); + const g = parseFlowchart(base); + if (!g || !g.nodes.has(nodeId)) return src; + + const neighbours = new Set([nodeId]); + const incident: number[] = []; + const other: number[] = []; + g.edges.forEach((e, i) => { + if (e.from === nodeId || e.to === nodeId) { + incident.push(i); + neighbours.add(e.from); + neighbours.add(e.to); + } else { + other.push(i); + } + }); + + const dimNodes = [...g.nodes.keys()].filter((id) => !neighbours.has(id)); + + const parts = [base.replace(/\n+$/, ''), '', `%% ─ Mermix focus: ${nodeId} ─`]; + parts.push('classDef mmxDim fill:#161922,stroke:#262b36,color:#4a5263;'); + parts.push('classDef mmxFocus fill:#2c2350,stroke:#a78bfa,color:#fff,stroke-width:2px;'); + if (dimNodes.length) parts.push(`class ${dimNodes.join(',')} mmxDim;`); + parts.push(`class ${nodeId} mmxFocus;`); + if (other.length) { + parts.push(`linkStyle ${other.join(',')} stroke:#1e222b,stroke-opacity:0.1;`); + } + if (incident.length) { + parts.push(`linkStyle ${incident.join(',')} stroke:#a78bfa,stroke-width:2px;`); + } + return parts.join('\n') + '\n'; +} diff --git a/src/lib/store.svelte.ts b/src/lib/store.svelte.ts index fe463b1..2f75ccf 100644 --- a/src/lib/store.svelte.ts +++ b/src/lib/store.svelte.ts @@ -40,6 +40,15 @@ class Store { lastSvg = $state(''); /** Whether the Git side panel is visible. */ showGit = $state(true); + /** Whether the Optimize (Diagram Doctor) side panel is visible. */ + showOptimize = $state(false); + /** Node id spotlighted in the preview by focus mode, or null. */ + focusNode = $state(null); + /** + * Bumped whenever `content` is replaced programmatically (e.g. an applied + * optimization). The editor watches this to re-sync its document. + */ + revision = $state(0); /** Workspace layout: editor only, side-by-side, or preview only. */ layout = $state<'code' | 'split' | 'preview'>('split'); /** Quick-edit drawer over the preview (viewer mode). */ @@ -125,6 +134,7 @@ class Store { this.history = []; this.branches = []; this.changes = []; + this.focusNode = null; this.view = 'start'; } @@ -152,6 +162,7 @@ class Store { this.activeId = rel; this.content = content; this.savedContent = content; + this.focusNode = null; } catch (e) { this.fail(e); } @@ -201,6 +212,17 @@ class Store { } } + /** + * Replace the editor content programmatically (used by the Optimize panel). + * Bumps `revision` so the CodeMirror view re-syncs and the change lands in + * its undo history — applying a fix stays reversible with ⌘Z. + */ + applySource(next: string) { + if (next === this.content) return; + this.content = next; + this.revision++; + } + async save(silent = false) { if (!this.path || !this.activeId) return; try {