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'; }