diff --git a/README.md b/README.md
index 116568d..55d73fe 100644
--- a/README.md
+++ b/README.md
@@ -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.)
diff --git a/src/lib/components/OptimizePanel.svelte b/src/lib/components/OptimizePanel.svelte
index 34b3669..d0bcfdd 100644
--- a/src/lib/components/OptimizePanel.svelte
+++ b/src/lib/components/OptimizePanel.svelte
@@ -57,10 +57,10 @@
Open a diagram to analyse it.
{:else if !a.supported}
-
Decluttering is built for flowcharts.
+
Decluttering is built for flowcharts & state diagrams.
- This looks like a {a.kind || 'different'} diagram, so only
- flowchart / graph sources can be analysed here.
+ This looks like a {a.kind || 'different'} diagram, which
+ can't be analysed here yet.
{:else if a.metrics && a.graph}
diff --git a/src/lib/optimize.ts b/src/lib/optimize.ts
index 77b2ed1..cdaf9fd 100644
--- a/src/lib/optimize.ts
+++ b/src/lib/optimize.ts
@@ -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;
/** 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();
+ 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 {
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-label–insensitive). 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([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';
}