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:
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>
|
||||
Reference in New Issue
Block a user