feat(optimize): support state diagrams in the Optimize panel

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.
This commit is contained in:
2026-05-22 22:25:51 +03:00
parent e1b5f31f87
commit 6488acc7b9
3 changed files with 297 additions and 51 deletions

View File

@@ -25,14 +25,15 @@ TypeScript** frontend using **CodeMirror 6** and **Mermaid 11**.
counts. Network operations shell out to your system `git`, so they reuse your
existing credentials (SSH agent, keychain / credential helpers) — no separate
login.
- **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.
- **Optimize panel (Diagram Doctor)** — analyse a tangled **flowchart or state
diagram** and declutter it in one click: a readability score and metrics
(hubs, density, cross-group edges), plus source rewrites for the **ELK layered
layout** (flowcharts), extra spacing, a layout-direction toggle,
**de-emphasising cross-cutting hubs** (event-bus / audit fan-ins on flowcharts;
faded sink states like CANCELLED on state diagrams), and removing duplicate
edges. A non-destructive **focus mode** spotlights any node/state 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), or **copy a
PNG straight to the clipboard**. (PNG/clipboard re-render flowcharts with
text labels so the bitmap rasterizes cleanly across platforms.)

View File

@@ -57,10 +57,10 @@
<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>
<div class="muted">Decluttering is built for flowcharts & state diagrams.</div>
<p class="faint">
This looks like a <code>{a.kind || 'different'}</code> diagram, so only
flowchart / graph sources can be analysed here.
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}

View File

@@ -44,10 +44,12 @@ export interface SubgraphInfo {
title: string;
}
export type DiagramKind = 'flowchart' | 'graph' | 'stateDiagram';
export interface FlowGraph {
kind: 'flowchart' | 'graph';
kind: DiagramKind;
direction: string;
/** 0-based index of the header line (`flowchart TB`). */
/** 0-based index of the header line (`flowchart TB` / `stateDiagram-v2`). */
headerLine: number;
nodes: Map<string, FlowNode>;
/** Edges in Mermaid link order (matches `linkStyle` indexing). */
@@ -355,6 +357,169 @@ export function parseFlowchart(src: string): FlowGraph | null {
};
}
// ---------------------------------------------------------------------------
// State diagrams
// ---------------------------------------------------------------------------
const STATE_ARROW = /--+>/;
/** Strip a `:::class` suffix and whitespace from a transition endpoint token. */
function stateToken(tok: string): string {
const t = tok.trim();
if (t === '[*]') return '[*]';
return t.replace(/:::.*$/, '').trim();
}
/** Parse a state declaration body: `"Label" as ID`, `ID`, or `ID: ...`. */
function parseStateDecl(s: string): { id: string; label: string } {
const quotedAs = s.match(/^"([^"]*)"\s+as\s+([A-Za-z0-9_]\S*)/);
if (quotedAs) return { id: quotedAs[2], label: quotedAs[1] };
const idM = s.match(/^([A-Za-z0-9_]\S*)/);
return { id: idM ? idM[1] : s.trim(), label: '' };
}
/**
* Parse `stateDiagram` / `stateDiagram-v2` source. States become nodes and
* transitions become edges in source order — including `[*]` pseudo-state
* transitions, which keeps `linkStyle` indices aligned (verified: Mermaid emits
* one link per transition, `[*]` included). `[*]` itself is never a node.
*/
export function parseStateDiagram(src: string): FlowGraph | null {
const lines = src.replace(/\r\n/g, '\n').split('\n');
const fmEnd = frontmatterLines(src);
let headerLine = -1;
for (let i = fmEnd; i < lines.length; i++) {
const t = lines[i].trim();
if (!t || t.startsWith('%%')) continue;
if (/^stateDiagram(?:-v2)?\b/.test(t)) {
headerLine = i;
break;
}
return null;
}
if (headerLine < 0) return null;
const hasSpacing = /\b(?:nodeSpacing|rankSpacing)\b/.test(src);
let direction = 'TB';
const nodes = new Map<string, FlowNode>();
const edges: FlowEdge[] = [];
const subgraphs: SubgraphInfo[] = [];
const stack: string[] = []; // composite-state nesting
let inNote = false;
const touch = (id: string, label?: string) => {
if (id === '[*]' || !id) return;
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;
}
};
for (let i = headerLine + 1; i < lines.length; i++) {
const t = lines[i].trim();
if (!t || t.startsWith('%%')) continue;
// Notes (single-line `note … : text`, or a block until `end note`).
if (inNote) {
if (/^end\s+note\b/.test(t)) inNote = false;
continue;
}
if (/^note\b/.test(t)) {
if (!t.includes(':')) inNote = true;
continue;
}
const dirM = t.match(/^direction\s+([A-Za-z]{2})\b/);
if (dirM) {
direction = dirM[1].toUpperCase();
continue;
}
// Composite state open: `state Name {` / `state "Label" as Name {`.
const compM = t.match(/^state\s+(.+?)\s*\{$/);
if (compM) {
const ref = parseStateDecl(compM[1]);
touch(ref.id, ref.label);
subgraphs.push({ id: ref.id, title: ref.label || ref.id });
stack.push(ref.id);
continue;
}
if (t === '}') {
stack.pop();
continue;
}
if (/^(?:classDef|class|style|click|linkStyle)\b/.test(t)) continue;
if (t.includes(':::') && !STATE_ARROW.test(t)) continue; // `X:::cls`
if (STATE_ARROW.test(t)) {
const arrow = t.match(STATE_ARROW)![0];
const ai = t.indexOf(arrow);
const from = stateToken(t.slice(0, ai));
let rhs = t.slice(ai + arrow.length).trim();
const ci = rhs.indexOf(':');
if (ci >= 0) rhs = rhs.slice(0, ci); // drop the transition label
const to = stateToken(rhs);
touch(from);
touch(to);
edges.push({ from, to, bidir: false, line: i });
continue;
}
// `state "Label" as ID` / `state ID`.
const stateDeclM = t.match(/^state\s+(.+)$/);
if (stateDeclM) {
const ref = parseStateDecl(stateDeclM[1]);
touch(ref.id, ref.label);
continue;
}
// `ID : description`.
const labelM = t.match(/^([A-Za-z0-9_]\S*)\s*:\s*(.+)$/);
if (labelM) {
touch(labelM[1], labelM[2].trim());
continue;
}
// Bare state name.
const bare = t.match(/^([A-Za-z0-9_]\S*)$/);
if (bare) touch(bare[1]);
}
for (const e of edges) {
const f = nodes.get(e.from);
const tt = nodes.get(e.to);
if (f) f.outDeg++;
if (tt) tt.inDeg++;
}
return {
kind: 'stateDiagram',
direction,
headerLine,
nodes,
edges,
subgraphs,
usesElk: false,
hasSpacing,
};
}
/** Parse any supported diagram (flowchart/graph or state diagram). */
export function parseDiagram(src: string): FlowGraph | null {
return parseFlowchart(src) ?? parseStateDiagram(src);
}
// ---------------------------------------------------------------------------
// Metrics & scoring
// ---------------------------------------------------------------------------
@@ -416,7 +581,10 @@ export function scoreOf(g: FlowGraph, m: Metrics): number {
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
// The ELK penalty only applies where ELK is actually available (flowcharts).
if (g.kind !== 'stateDiagram' && !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));
}
@@ -433,17 +601,19 @@ function ratingOf(score: number): Analysis['rating'] {
// ---------------------------------------------------------------------------
export function analyze(src: string): Analysis {
const g = parseFlowchart(src);
const g = parseDiagram(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 isFlow = g.kind === 'flowchart' || g.kind === 'graph';
const m = computeMetrics(g);
const score = scoreOf(g, m);
const fixes: Fix[] = [];
if (!g.usesElk && (m.nodes >= 12 || m.edges >= 20)) {
// ELK is a flowchart-only layout engine in Mermaid; state diagrams reject it.
if (isFlow && !g.usesElk && (m.nodes >= 12 || m.edges >= 20)) {
fixes.push({
id: 'elk',
title: 'Switch to the ELK layout engine',
@@ -458,29 +628,42 @@ export function analyze(src: string): Analysis {
if (!g.hasSpacing && (m.density >= 1.3 || m.maxDegree >= HUB_DEGREE)) {
fixes.push({
id: 'spacing',
title: 'Add breathing room & smooth edges',
title: 'Add breathing room between nodes',
detail:
'Increase node/rank spacing and use curved edges so lines are easier ' +
'to follow where they bunch up.',
'Increase node/rank spacing so transitions are easier to follow where ' +
'they bunch up.',
severity: 'medium',
apply: applySpacing,
});
}
if (m.dimHubs.length > 0) {
const ids = m.dimHubs.map((n) => n.id);
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)),
});
const plural = m.dimHubs.length > 1;
fixes.push(
isFlow
? {
id: 'dim-hubs',
title: `De-emphasise ${m.dimHubs.length} cross-cutting hub${plural ? '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, ids),
}
: {
id: 'dim-hubs',
title: `Fade ${m.dimHubs.length} sink state${plural ? 's' : ''}`,
detail:
`Terminal states (${names}) collect transitions from everywhere. ` +
'Greying them out pulls focus onto the main flow. (State diagrams ' +
"can't style individual transitions, so the states are faded.)",
severity: 'medium',
apply: (src) => dimHubStates(src, ids),
}
);
}
if (m.duplicateEdges > 0) {
@@ -559,7 +742,7 @@ export function mergeInit(src: string, patch: Record<string, any>): string {
return lines.join('\n');
}
const g = parseFlowchart(src);
const g = parseDiagram(src);
const at = g ? g.headerLine : frontmatterLines(src);
lines.splice(at, 0, `%%{init: ${JSON.stringify(patch)}}%%`);
return lines.join('\n');
@@ -570,15 +753,34 @@ export function applyElkLayout(src: string): string {
}
export function applySpacing(src: string): string {
return mergeInit(src, {
flowchart: { nodeSpacing: 55, rankSpacing: 70, curve: 'basis' },
});
// Spacing lives under a per-diagram config key; state diagrams also lack a
// `curve` option.
const patch =
parseDiagram(src)?.kind === 'stateDiagram'
? { state: { nodeSpacing: 60, rankSpacing: 80 } }
: { flowchart: { nodeSpacing: 55, rankSpacing: 70, curve: 'basis' } };
return mergeInit(src, patch);
}
export function setDirection(src: string, dir: string): string {
const lines = src.replace(/\r\n/g, '\n').split('\n');
const g = parseFlowchart(src);
const g = parseDiagram(src);
if (!g) return src;
const lines = src.replace(/\r\n/g, '\n').split('\n');
if (g.kind === 'stateDiagram') {
// State diagrams set direction with a `direction XX` statement, not on the
// header. Update an existing one or insert just after the header.
const di = lines.findIndex(
(l, idx) => idx > g.headerLine && /^\s*direction\s+[A-Za-z]{2}\b/.test(l)
);
if (di >= 0) {
lines[di] = lines[di].replace(/(direction\s+)[A-Za-z]{2}/, `$1${dir}`);
} else {
lines.splice(g.headerLine + 1, 0, ` direction ${dir}`);
}
return lines.join('\n');
}
lines[g.headerLine] = lines[g.headerLine].replace(
/^(\s*)(flowchart|graph)\b\s*([A-Za-z]{2})?/,
`$1$2 ${dir}`
@@ -608,7 +810,7 @@ function escapeRe(s: string): string {
*/
export function dimHubEdges(src: string, hubIds: string[]): string {
const base = stripDimBlock(src);
const g = parseFlowchart(base);
const g = parseDiagram(base);
if (!g) return src;
const targets = new Set(hubIds);
const idx: number[] = [];
@@ -620,6 +822,43 @@ export function dimHubEdges(src: string, hubIds: string[]): string {
return `${base.replace(/\n+$/, '')}\n\n${DIM_MARK}\n${rule}\n`;
}
const STATE_DIM_MARK = '%% ─ Mermix: faded sink states (re-run Optimize to refresh) ─';
/** Strip any Mermix-managed state-dim block so transforms stay idempotent. */
function stripStateDimBlock(src: string): string {
return src
.replace(
new RegExp(
`\\n*${escapeRe(STATE_DIM_MARK)}\\n(?:classDef mmxHubDim[^\\n]*\\n?|class [^\\n]*mmxHubDim[^\\n]*\\n?)+`,
'g'
),
'\n'
)
.replace(/\n{3,}/g, '\n\n');
}
/**
* Fade sink states via `classDef`/`class`. State diagrams have no per-transition
* styling (and a `linkStyle` line renders as a stray node), so for them we
* de-emphasise the busy *states* — e.g. a CANCELLED everything funnels into.
*/
export function dimHubStates(src: string, ids: string[]): string {
const base = stripStateDimBlock(src);
const g = parseDiagram(base);
if (!g) return src;
const valid = ids.filter((id) => g.nodes.has(id));
if (valid.length === 0) return base;
return (
[
base.replace(/\n+$/, ''),
'',
STATE_DIM_MARK,
'classDef mmxHubDim fill:#1b1f2a,stroke:#2a2f3a,color:#5b6573',
`class ${valid.join(',')} mmxHubDim`,
].join('\n') + '\n'
);
}
/**
* Drop edge statements that repeat an earlier identical one (whitespace and
* pipe-labelinsensitive). Only whole single-edge lines are removed; lines
@@ -649,11 +888,13 @@ 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);
const isFlow = a.graph!.kind === 'flowchart' || a.graph!.kind === 'graph';
if (isFlow && !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));
const ids = a.metrics.dimHubs.map((n) => n.id);
out = isFlow ? dimHubEdges(out, ids) : dimHubStates(out, ids);
}
return out;
}
@@ -669,8 +910,8 @@ export function autoOptimize(src: string): string {
* if the node can't be found.
*/
export function applyFocus(src: string, nodeId: string): string {
const base = stripDimBlock(src);
const g = parseFlowchart(base);
const base = stripStateDimBlock(stripDimBlock(src));
const g = parseDiagram(base);
if (!g || !g.nodes.has(nodeId)) return src;
const neighbours = new Set<string>([nodeId]);
@@ -689,15 +930,19 @@ export function applyFocus(src: string, nodeId: string): string {
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;`);
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`);
// State diagrams can't style transitions (a `linkStyle` line renders as a
// stray node), so fade by state only there; flowcharts also fade the edges.
if (g.kind !== 'stateDiagram') {
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';
}