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:
13
README.md
13
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/
|
||||
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 @@
|
||||
<div class="faint">Pick one from the sidebar, or create a new diagram with +.</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if store.showOptimize}
|
||||
<OptimizePanel />
|
||||
{/if}
|
||||
{#if store.showGit}
|
||||
<GitPanel />
|
||||
{/if}
|
||||
|
||||
@@ -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;
|
||||
|
||||
431
src/lib/components/OptimizePanel.svelte
Normal file
431
src/lib/components/OptimizePanel.svelte
Normal 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>
|
||||
@@ -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<typeof setTimeout> | 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 @@
|
||||
</script>
|
||||
|
||||
<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">
|
||||
{#if rendering}<span class="dot" title="Rendering…"></span>{/if}
|
||||
<button class="ghost icon" title="Zoom out" onclick={() => zoomBy(0.83)}>−</button>
|
||||
@@ -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;
|
||||
|
||||
@@ -65,11 +65,24 @@
|
||||
<button onclick={() => doExport('png')} disabled={!store.lastSvg}>PNG</button>
|
||||
</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
|
||||
class="ghost icon"
|
||||
class:on={store.showGit}
|
||||
title="Toggle Git panel"
|
||||
onclick={() => (store.showGit = !store.showGit)}>⎇</button
|
||||
onclick={() => {
|
||||
store.showGit = !store.showGit;
|
||||
if (store.showGit) store.showOptimize = false;
|
||||
}}>⎇</button
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
|
||||
703
src/lib/optimize.ts
Normal file
703
src/lib/optimize.ts
Normal 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} | ||||