Extend the Diagram Doctor beyond flowcharts to stateDiagram / stateDiagram-v2.
- New parseStateDiagram: states → nodes, transitions → edges in source order
(including `[*]` pseudo-transitions, so linkStyle indices stay aligned);
`[*]` itself is never a node. Composite states map to groups, notes are
skipped. A parseDiagram() dispatcher feeds analyze/transforms.
- Fixes are tailored per kind (verified empirically against Mermaid 11):
* ELK is flowchart-only (state diagrams reject `layout: elk`), so it's not
offered and the no-ELK score penalty is skipped for state diagrams.
* Spacing uses the `state` config key; direction is set via a `direction`
statement rather than the header.
* State diagrams can't style individual transitions — a `linkStyle` line
renders as a stray node — so the dim-hubs fix and focus mode fade the
busy *states* (e.g. CANCELLED) via classDef/class instead of dimming edges.
Flowcharts keep edge dimming via linkStyle.
- Panel notice now covers flowcharts & state diagrams.
Verified in-browser: state-diagram dim/focus render with zero junk nodes;
flowchart ELK + linkStyle dimming still intact.
432 lines
9.9 KiB
Svelte
432 lines
9.9 KiB
Svelte
<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 & state diagrams.</div>
|
|
<p class="faint">
|
|
This looks like a <code>{a.kind || 'different'}</code> diagram, which
|
|
can't be analysed here yet.
|
|
</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>
|