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 {