feat(optimize): Diagram Doctor panel to declutter tangled flowcharts

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.
This commit is contained in:
2026-05-22 20:11:49 +03:00
parent b648ee904d
commit bbc12ee4be
11 changed files with 1249 additions and 8 deletions

View File

@@ -20,6 +20,14 @@ TypeScript** frontend using **CodeMirror 6** and **Mermaid 11**.
- **Git version control built in** — view the working-tree status, write commit - **Git version control built in** — view the working-tree status, write commit
messages, browse history, create branches and switch between them. Each branch messages, browse history, create branches and switch between them. Each branch
keeps its own set of diagrams, just like normal Git. 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). - **Export** the current diagram to **SVG** or **PNG** (2× scale).
- **Project registry** — recently opened projects are remembered in a local - **Project registry** — recently opened projects are remembered in a local
SQLite database so you can jump back in from the start screen. SQLite database so you can jump back in from the start screen.
@@ -35,9 +43,10 @@ Mermix/
│ ├─ lib/ │ ├─ lib/
│ │ ├─ api.ts # typed wrappers over Tauri commands │ │ ├─ api.ts # typed wrappers over Tauri commands
│ │ ├─ store.svelte.ts # central rune-based app state │ │ ├─ 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 │ │ ├─ export.ts # SVG / PNG export via save dialog
│ │ └─ components/ # Sidebar, Editor, Preview, GitPanel, Toolbar, … │ │ └─ components/ # Sidebar, Editor, Preview, GitPanel, OptimizePanel, …
│ └─ main.ts │ └─ main.ts
└─ src-tauri/ # Rust backend └─ src-tauri/ # Rust backend
├─ src/ ├─ src/

20
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@codemirror/state": "^6.4.1", "@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.28.0", "@codemirror/view": "^6.28.0",
"@lezer/highlight": "^1.2.0", "@lezer/highlight": "^1.2.0",
"@mermaid-js/layout-elk": "^0.2.1",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
@@ -625,6 +626,19 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT" "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": { "node_modules/@mermaid-js/parser": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
@@ -2216,6 +2230,12 @@
"@types/trusted-types": "^2.0.7" "@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": { "node_modules/es-toolkit": {
"version": "1.46.1", "version": "1.46.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",

View File

@@ -20,6 +20,7 @@
"@codemirror/state": "^6.4.1", "@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.28.0", "@codemirror/view": "^6.28.0",
"@lezer/highlight": "^1.2.0", "@lezer/highlight": "^1.2.0",
"@mermaid-js/layout-elk": "^0.2.1",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",

View File

@@ -7,6 +7,7 @@
import Editor from './lib/components/Editor.svelte'; import Editor from './lib/components/Editor.svelte';
import Preview from './lib/components/Preview.svelte'; import Preview from './lib/components/Preview.svelte';
import GitPanel from './lib/components/GitPanel.svelte'; import GitPanel from './lib/components/GitPanel.svelte';
import OptimizePanel from './lib/components/OptimizePanel.svelte';
import Toasts from './lib/components/Toasts.svelte'; import Toasts from './lib/components/Toasts.svelte';
// Editor/preview split ratio (fraction taken by the editor). // Editor/preview split ratio (fraction taken by the editor).
@@ -112,6 +113,9 @@
<div class="faint">Pick one from the sidebar, or create a new diagram with .</div> <div class="faint">Pick one from the sidebar, or create a new diagram with .</div>
</div> </div>
{/if} {/if}
{#if store.showOptimize}
<OptimizePanel />
{/if}
{#if store.showGit} {#if store.showGit}
<GitPanel /> <GitPanel />
{/if} {/if}

View File

@@ -128,10 +128,12 @@
return () => view?.destroy(); return () => view?.destroy();
}); });
// When a different diagram is loaded, replace the whole document. Depend only // Replace the whole document when a different diagram is loaded (activeId) or
// on activeId; read content untracked so typing doesn't reset the editor. // when content is swapped programmatically (revision, e.g. an applied
// optimization). Read content untracked so plain typing never resets it.
$effect(() => { $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; if (!view) return;
untrack(() => { untrack(() => {
const incoming = store.content; const incoming = store.content;

View File

@@ -0,0 +1,431 @@
<script lang="ts">
import { store } from '../store.svelte';
import {
analyze,
autoOptimize,
setDirection,
type Fix,
type FlowNode,
} from '../optimize';
// Re-analyse whenever the source changes; pure & cheap.
const a = $derived(analyze(store.content));
const DIRECTIONS = ['TB', 'LR', 'BT', 'RL'];
// Nodes for the focus picker, hubs first.
const focusable = $derived.by<FlowNode[]>(() => {
if (!a.graph) return [];
return [...a.graph.nodes.values()].sort(
(x, y) => y.inDeg + y.outDeg - (x.inDeg + x.outDeg)
);
});
function applyFix(f: Fix) {
if (!f.apply) return;
store.applySource(f.apply(store.content));
store.notify(`Applied — ${f.title}`, 'success');
}
function runAuto() {
store.applySource(autoOptimize(store.content));
store.notify('Applied all safe optimizations', 'success');
}
function setDir(d: string) {
if (a.graph?.direction === d) return;
store.applySource(setDirection(store.content, d));
}
function focus(id: string) {
store.focusNode = store.focusNode === id ? null : id;
if (store.focusNode && store.layout === 'code') store.layout = 'split';
}
</script>
<aside class="optimize">
<header class="head">
<span class="title">✨ Optimize</span>
<button
class="ghost icon"
title="Close"
onclick={() => (store.showOptimize = false)}>✕</button
>
</header>
{#if !store.activeId || !store.content.trim()}
<div class="notice faint">Open a diagram to analyse it.</div>
{:else if !a.supported}
<div class="notice">
<div class="muted">Decluttering is built for flowcharts.</div>
<p class="faint">
This looks like a <code>{a.kind || 'different'}</code> diagram, so only
flowchart / graph sources can be analysed here.
</p>
</div>
{:else if a.metrics && a.graph}
<!-- Readability score -->
<div class="score-row">
<div class="gauge" data-rating={a.rating.toLowerCase()}>
<span class="num">{a.score}</span>
</div>
<div class="score-meta">
<div class="rating" data-rating={a.rating.toLowerCase()}>{a.rating}</div>
<div class="faint">readability score</div>
{#if a.graph.usesElk}<div class="elk-badge">ELK layout on</div>{/if}
</div>
</div>
<button
class="primary auto"
onclick={runAuto}
disabled={a.fixes.filter((f) => f.apply).length === 0}
>
Auto-optimize ✦
</button>
<!-- Metrics -->
<div class="metrics">
<div class="metric"><b>{a.metrics.nodes}</b><span>nodes</span></div>
<div class="metric"><b>{a.metrics.edges}</b><span>edges</span></div>
<div class="metric"><b>{a.metrics.density.toFixed(1)}</b><span>edges/node</span></div>
<div class="metric"><b>{a.metrics.maxDegree}</b><span>busiest hub</span></div>
<div class="metric"><b>{a.metrics.interGroupEdges}</b><span>cross-group</span></div>
<div class="metric"><b>{a.metrics.subgraphs}</b><span>groups</span></div>
</div>
<!-- Layout direction -->
<div class="dir">
<span class="lbl faint">Direction</span>
<div class="seg">
{#each DIRECTIONS as d}
<button class:on={a.graph.direction === d} onclick={() => setDir(d)}>{d}</button>
{/each}
</div>
</div>
<!-- Suggested fixes -->
<div class="section-head">Suggestions</div>
<div class="fixes">
{#each a.fixes as f (f.id)}
<div class="fix" data-sev={f.severity}>
<div class="fix-head">
<span class="dot"></span>
<span class="fix-title">{f.title}</span>
{#if f.apply}
<button class="apply" onclick={() => applyFix(f)}>Apply</button>
{:else}
<span class="tag">tip</span>
{/if}
</div>
<p class="fix-detail">{f.detail}</p>
</div>
{/each}
{#if a.fixes.length === 0}
<div class="clean faint">✓ Nothing to declutter — this reads cleanly.</div>
{/if}
</div>
<!-- Focus reader -->
<div class="section-head">
Focus a node
{#if store.focusNode}
<button class="ghost mini clear" onclick={() => (store.focusNode = null)}>
clear
</button>
{/if}
</div>
<p class="hint faint">Spotlight one node and its neighbours in the preview.</p>
<div class="focus-list">
{#each focusable.slice(0, 12) as n (n.id)}
<button
class="chip"
class:on={store.focusNode === n.id}
title={n.label}
onclick={() => focus(n.id)}
>
{n.label}<span class="deg">{n.inDeg + n.outDeg}</span>
</button>
{/each}
</div>
{/if}
</aside>
<style>
.optimize {
width: 312px;
flex-shrink: 0;
background: var(--bg-elev);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 10px 10px 14px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--bg-elev);
z-index: 2;
}
.title {
font-weight: 600;
font-size: 13px;
}
.notice {
padding: 18px 14px;
font-size: 13px;
line-height: 1.5;
}
.notice code {
font-family: var(--mono);
color: var(--accent);
}
/* Score */
.score-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px;
}
.gauge {
width: 60px;
height: 60px;
border-radius: 50%;
display: grid;
place-items: center;
flex-shrink: 0;
border: 3px solid var(--border-strong);
background: var(--bg-input);
}
.gauge .num {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.gauge[data-rating='clear'] {
border-color: var(--green);
}
.gauge[data-rating='readable'] {
border-color: var(--accent);
}
.gauge[data-rating='busy'] {
border-color: var(--amber);
}
.gauge[data-rating='tangled'] {
border-color: var(--red);
}
.rating {
font-weight: 600;
font-size: 15px;
}
.rating[data-rating='clear'] {
color: var(--green);
}
.rating[data-rating='readable'] {
color: var(--accent);
}
.rating[data-rating='busy'] {
color: var(--amber);
}
.rating[data-rating='tangled'] {
color: var(--red);
}
.elk-badge {
margin-top: 4px;
display: inline-block;
font-size: 10px;
color: var(--green);
border: 1px solid var(--green);
border-radius: 3px;
padding: 1px 5px;
}
.auto {
margin: 0 14px 6px;
width: calc(100% - 28px);
}
/* Metrics */
.metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding: 10px 14px 6px;
}
.metric {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 6px;
text-align: center;
}
.metric b {
display: block;
font-size: 17px;
font-variant-numeric: tabular-nums;
}
.metric span {
font-size: 10.5px;
color: var(--text-faint);
}
/* Direction */
.dir {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
}
.lbl {
font-size: 12px;
}
.seg {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.seg button {
border: none;
border-radius: 0;
background: transparent;
padding: 5px 9px;
font-size: 11.5px;
color: var(--text-dim);
font-family: var(--mono);
}
.seg button:hover {
background: var(--bg-elev-2);
}
.seg button.on {
background: var(--accent-soft);
color: var(--text);
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px 4px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
}
.hint {
margin: 0;
padding: 0 14px 8px;
font-size: 11.5px;
line-height: 1.4;
}
/* Fixes */
.fixes {
display: flex;
flex-direction: column;
gap: 7px;
padding: 2px 14px 6px;
}
.fix {
background: var(--bg-input);
border: 1px solid var(--border);
border-left-width: 3px;
border-radius: var(--radius-sm);
padding: 9px 10px;
}
.fix[data-sev='high'] {
border-left-color: var(--red);
}
.fix[data-sev='medium'] {
border-left-color: var(--amber);
}
.fix[data-sev='low'] {
border-left-color: var(--text-faint);
}
.fix-head {
display: flex;
align-items: center;
gap: 7px;
}
.fix-title {
flex: 1;
font-size: 12.5px;
font-weight: 600;
line-height: 1.3;
}
.apply {
padding: 3px 10px;
font-size: 11.5px;
flex-shrink: 0;
}
.tag {
font-size: 10px;
color: var(--text-faint);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 5px;
flex-shrink: 0;
}
.fix-detail {
margin: 6px 0 0;
font-size: 11.5px;
line-height: 1.45;
color: var(--text-dim);
}
.clean {
padding: 10px 0;
font-size: 12.5px;
}
/* Focus chips */
.focus-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
padding: 0 14px 16px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
padding: 4px 6px 4px 9px;
font-size: 11.5px;
border-radius: 999px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip .deg {
font-size: 10px;
color: var(--text-faint);
background: var(--bg);
border-radius: 999px;
padding: 0 5px;
min-width: 16px;
text-align: center;
}
.chip.on {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.chip.on .deg {
color: #fff;
background: rgba(255, 255, 255, 0.2);
}
.mini {
padding: 1px 6px;
font-size: 10px;
text-transform: none;
letter-spacing: 0;
}
.clear {
color: var(--accent);
}
</style>

View File

@@ -2,6 +2,7 @@
import { tick } from 'svelte'; import { tick } from 'svelte';
import { store } from '../store.svelte'; import { store } from '../store.svelte';
import { renderMermaid } from '../mermaid'; import { renderMermaid } from '../mermaid';
import { applyFocus } from '../optimize';
let svg = $state(''); let svg = $state('');
let error = $state(''); let error = $state('');
@@ -12,12 +13,16 @@
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
let fittedFor = ''; 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(() => { $effect(() => {
const code = store.content; const code = store.content;
const theme = store.theme; const theme = store.theme;
const focus = store.focusNode;
clearTimeout(timer); 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); return () => clearTimeout(timer);
}); });
@@ -102,6 +107,11 @@
</script> </script>
<div class="preview"> <div class="preview">
{#if store.focusNode}
<button class="focus-pill" title="Clear focus" onclick={() => (store.focusNode = null)}>
◎ Focus: {store.focusNode} <span class="x"></span>
</button>
{/if}
<div class="ctrls"> <div class="ctrls">
{#if rendering}<span class="dot" title="Rendering…"></span>{/if} {#if rendering}<span class="dot" title="Rendering…"></span>{/if}
<button class="ghost icon" title="Zoom out" onclick={() => zoomBy(0.83)}></button> <button class="ghost icon" title="Zoom out" onclick={() => zoomBy(0.83)}></button>
@@ -153,6 +163,27 @@
var(--bg); var(--bg);
overflow: hidden; 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 { .ctrls {
position: absolute; position: absolute;
top: 10px; top: 10px;

View File

@@ -65,11 +65,24 @@
<button onclick={() => doExport('png')} disabled={!store.lastSvg}>PNG</button> <button onclick={() => doExport('png')} disabled={!store.lastSvg}>PNG</button>
</div> </div>
<button
class="ghost icon"
class:on={store.showOptimize}
title="Optimize diagram"
onclick={() => {
store.showOptimize = !store.showOptimize;
if (store.showOptimize) store.showGit = false;
}}>✨</button
>
<button <button
class="ghost icon" class="ghost icon"
class:on={store.showGit} class:on={store.showGit}
title="Toggle Git panel" title="Toggle Git panel"
onclick={() => (store.showGit = !store.showGit)}>⎇</button onclick={() => {
store.showGit = !store.showGit;
if (store.showGit) store.showOptimize = false;
}}>⎇</button
> >
</div> </div>
</header> </header>

View File

@@ -1,4 +1,9 @@
import mermaid from 'mermaid'; 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 = ''; let currentTheme = '';

703
src/lib/optimize.ts Normal file
View File

@@ -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<string, FlowNode>;
/** 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<string, FlowNode>();
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<string>();
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<string, any>, b: Record<string, any>): Record<string, any> {
const out: Record<string, any> = { ...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<string, any> | 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, any>): 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-labelinsensitive). 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<string>();
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<string>([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';
}

View File

@@ -40,6 +40,15 @@ class Store {
lastSvg = $state(''); lastSvg = $state('');
/** Whether the Git side panel is visible. */ /** Whether the Git side panel is visible. */
showGit = $state(true); 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<string | null>(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. */ /** Workspace layout: editor only, side-by-side, or preview only. */
layout = $state<'code' | 'split' | 'preview'>('split'); layout = $state<'code' | 'split' | 'preview'>('split');
/** Quick-edit drawer over the preview (viewer mode). */ /** Quick-edit drawer over the preview (viewer mode). */
@@ -125,6 +134,7 @@ class Store {
this.history = []; this.history = [];
this.branches = []; this.branches = [];
this.changes = []; this.changes = [];
this.focusNode = null;
this.view = 'start'; this.view = 'start';
} }
@@ -152,6 +162,7 @@ class Store {
this.activeId = rel; this.activeId = rel;
this.content = content; this.content = content;
this.savedContent = content; this.savedContent = content;
this.focusNode = null;
} catch (e) { } catch (e) {
this.fail(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) { async save(silent = false) {
if (!this.path || !this.activeId) return; if (!this.path || !this.activeId) return;
try { try {