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

@@ -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>