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:
17
README.md
17
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
|
counts. Network operations shell out to your system `git`, so they reuse your
|
||||||
existing credentials (SSH agent, keychain / credential helpers) — no separate
|
existing credentials (SSH agent, keychain / credential helpers) — no separate
|
||||||
login.
|
login.
|
||||||
- **Optimize panel (Diagram Doctor)** — analyse a tangled flowchart and declutter
|
- **Optimize panel (Diagram Doctor)** — analyse a tangled **flowchart or state
|
||||||
it in one click: a readability score and metrics (hubs, density, cross-group
|
diagram** and declutter it in one click: a readability score and metrics
|
||||||
edges), plus source rewrites for the **ELK layered layout**, extra
|
(hubs, density, cross-group edges), plus source rewrites for the **ELK layered
|
||||||
spacing/curved edges, **de-emphasising cross-cutting hub edges** (event bus,
|
layout** (flowcharts), extra spacing, a layout-direction toggle,
|
||||||
audit, cost-style fan-ins), and removing duplicate edges. A non-destructive
|
**de-emphasising cross-cutting hubs** (event-bus / audit fan-ins on flowcharts;
|
||||||
**focus mode** spotlights any node and its neighbours so you can read a dense
|
faded sink states like CANCELLED on state diagrams), and removing duplicate
|
||||||
graph without changing it. Every rewrite lands in the editor and is reversible
|
edges. A non-destructive **focus mode** spotlights any node/state and its
|
||||||
with ⌘Z.
|
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
|
- **Export** the current diagram to **SVG** or **PNG** (2× scale), or **copy a
|
||||||
PNG straight to the clipboard**. (PNG/clipboard re-render flowcharts with
|
PNG straight to the clipboard**. (PNG/clipboard re-render flowcharts with
|
||||||
text labels so the bitmap rasterizes cleanly across platforms.)
|
text labels so the bitmap rasterizes cleanly across platforms.)
|
||||||
|
|||||||
@@ -57,10 +57,10 @@
|
|||||||
<div class="notice faint">Open a diagram to analyse it.</div>
|
<div class="notice faint">Open a diagram to analyse it.</div>
|
||||||
{:else if !a.supported}
|
{:else if !a.supported}
|
||||||
<div class="notice">
|
<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">
|
<p class="faint">
|
||||||
This looks like a <code>{a.kind || 'different'}</code> diagram, so only
|
This looks like a <code>{a.kind || 'different'}</code> diagram, which
|
||||||
flowchart / graph sources can be analysed here.
|
can't be analysed here yet.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if a.metrics && a.graph}
|
{:else if a.metrics && a.graph}
|
||||||
|
|||||||
@@ -44,10 +44,12 @@ export interface SubgraphInfo {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DiagramKind = 'flowchart' | 'graph' | 'stateDiagram';
|
||||||
|
|
||||||
export interface FlowGraph {
|
export interface FlowGraph {
|
||||||
kind: 'flowchart' | 'graph';
|
kind: DiagramKind;
|
||||||
direction: string;
|
direction: string;
|
||||||
/** 0-based index of the header line (`flowchart TB`). */
|
/** 0-based index of the header line (`flowchart TB` / `stateDiagram-v2`). */
|
||||||
headerLine: number;
|
headerLine: number;
|
||||||
nodes: Map<string, FlowNode>;
|
nodes: Map<string, FlowNode>;
|
||||||
/** Edges in Mermaid link order (matches `linkStyle` indexing). */
|
/** 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
|
// Metrics & scoring
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -416,7 +581,10 @@ export function scoreOf(g: FlowGraph, m: Metrics): number {
|
|||||||
if (m.edges > 0) {
|
if (m.edges > 0) {
|
||||||
score -= clamp((m.interGroupEdges / m.edges - 0.4) * 50, 0, 20); // crossings
|
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);
|
score -= clamp(m.duplicateEdges * 3, 0, 12);
|
||||||
return Math.round(clamp(score, 0, 100));
|
return Math.round(clamp(score, 0, 100));
|
||||||
}
|
}
|
||||||
@@ -433,17 +601,19 @@ function ratingOf(score: number): Analysis['rating'] {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function analyze(src: string): Analysis {
|
export function analyze(src: string): Analysis {
|
||||||
const g = parseFlowchart(src);
|
const g = parseDiagram(src);
|
||||||
if (!g) {
|
if (!g) {
|
||||||
const head = src.replace(/^---[\s\S]*?---\s*/, '').trim().split(/\s+/)[0] || '';
|
const head = src.replace(/^---[\s\S]*?---\s*/, '').trim().split(/\s+/)[0] || '';
|
||||||
return { supported: false, kind: head, score: 0, rating: 'Clear', fixes: [] };
|
return { supported: false, kind: head, score: 0, rating: 'Clear', fixes: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFlow = g.kind === 'flowchart' || g.kind === 'graph';
|
||||||
const m = computeMetrics(g);
|
const m = computeMetrics(g);
|
||||||
const score = scoreOf(g, m);
|
const score = scoreOf(g, m);
|
||||||
const fixes: Fix[] = [];
|
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({
|
fixes.push({
|
||||||
id: 'elk',
|
id: 'elk',
|
||||||
title: 'Switch to the ELK layout engine',
|
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)) {
|
if (!g.hasSpacing && (m.density >= 1.3 || m.maxDegree >= HUB_DEGREE)) {
|
||||||
fixes.push({
|
fixes.push({
|
||||||
id: 'spacing',
|
id: 'spacing',
|
||||||
title: 'Add breathing room & smooth edges',
|
title: 'Add breathing room between nodes',
|
||||||
detail:
|
detail:
|
||||||
'Increase node/rank spacing and use curved edges so lines are easier ' +
|
'Increase node/rank spacing so transitions are easier to follow where ' +
|
||||||
'to follow where they bunch up.',
|
'they bunch up.',
|
||||||
severity: 'medium',
|
severity: 'medium',
|
||||||
apply: applySpacing,
|
apply: applySpacing,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m.dimHubs.length > 0) {
|
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(', ');
|
const names = m.dimHubs.slice(0, 4).map((n) => n.label).join(', ');
|
||||||
fixes.push({
|
const plural = m.dimHubs.length > 1;
|
||||||
|
fixes.push(
|
||||||
|
isFlow
|
||||||
|
? {
|
||||||
id: 'dim-hubs',
|
id: 'dim-hubs',
|
||||||
title: `De-emphasise ${m.dimHubs.length} cross-cutting hub${
|
title: `De-emphasise ${m.dimHubs.length} cross-cutting hub${plural ? 's' : ''}`,
|
||||||
m.dimHubs.length > 1 ? 's' : ''
|
|
||||||
}`,
|
|
||||||
detail:
|
detail:
|
||||||
`Shared infrastructure (${names}) wires up to everything and dominates ` +
|
`Shared infrastructure (${names}) wires up to everything and ` +
|
||||||
'the picture. Fading those edges lets the main flow read clearly while ' +
|
'dominates the picture. Fading those edges lets the main flow read ' +
|
||||||
'keeping every connection intact.',
|
'clearly while keeping every connection intact.',
|
||||||
severity: m.dimHubs.length >= 3 ? 'high' : 'medium',
|
severity: m.dimHubs.length >= 3 ? 'high' : 'medium',
|
||||||
apply: (src) => dimHubEdges(src, m.dimHubs.map((n) => n.id)),
|
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) {
|
if (m.duplicateEdges > 0) {
|
||||||
@@ -559,7 +742,7 @@ export function mergeInit(src: string, patch: Record<string, any>): string {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
const g = parseFlowchart(src);
|
const g = parseDiagram(src);
|
||||||
const at = g ? g.headerLine : frontmatterLines(src);
|
const at = g ? g.headerLine : frontmatterLines(src);
|
||||||
lines.splice(at, 0, `%%{init: ${JSON.stringify(patch)}}%%`);
|
lines.splice(at, 0, `%%{init: ${JSON.stringify(patch)}}%%`);
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
@@ -570,15 +753,34 @@ export function applyElkLayout(src: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function applySpacing(src: string): string {
|
export function applySpacing(src: string): string {
|
||||||
return mergeInit(src, {
|
// Spacing lives under a per-diagram config key; state diagrams also lack a
|
||||||
flowchart: { nodeSpacing: 55, rankSpacing: 70, curve: 'basis' },
|
// `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 {
|
export function setDirection(src: string, dir: string): string {
|
||||||
const lines = src.replace(/\r\n/g, '\n').split('\n');
|
const g = parseDiagram(src);
|
||||||
const g = parseFlowchart(src);
|
|
||||||
if (!g) return 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(
|
lines[g.headerLine] = lines[g.headerLine].replace(
|
||||||
/^(\s*)(flowchart|graph)\b\s*([A-Za-z]{2})?/,
|
/^(\s*)(flowchart|graph)\b\s*([A-Za-z]{2})?/,
|
||||||
`$1$2 ${dir}`
|
`$1$2 ${dir}`
|
||||||
@@ -608,7 +810,7 @@ function escapeRe(s: string): string {
|
|||||||
*/
|
*/
|
||||||
export function dimHubEdges(src: string, hubIds: string[]): string {
|
export function dimHubEdges(src: string, hubIds: string[]): string {
|
||||||
const base = stripDimBlock(src);
|
const base = stripDimBlock(src);
|
||||||
const g = parseFlowchart(base);
|
const g = parseDiagram(base);
|
||||||
if (!g) return src;
|
if (!g) return src;
|
||||||
const targets = new Set(hubIds);
|
const targets = new Set(hubIds);
|
||||||
const idx: number[] = [];
|
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`;
|
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
|
* Drop edge statements that repeat an earlier identical one (whitespace and
|
||||||
* pipe-label–insensitive). Only whole single-edge lines are removed; lines
|
* 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);
|
const a = analyze(src);
|
||||||
if (!a.supported || !a.metrics) return src;
|
if (!a.supported || !a.metrics) return src;
|
||||||
let out = 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.graph!.hasSpacing) out = applySpacing(out);
|
||||||
if (a.metrics.duplicateEdges > 0) out = removeDuplicateEdges(out);
|
if (a.metrics.duplicateEdges > 0) out = removeDuplicateEdges(out);
|
||||||
if (a.metrics.dimHubs.length > 0) {
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -669,8 +910,8 @@ export function autoOptimize(src: string): string {
|
|||||||
* if the node can't be found.
|
* if the node can't be found.
|
||||||
*/
|
*/
|
||||||
export function applyFocus(src: string, nodeId: string): string {
|
export function applyFocus(src: string, nodeId: string): string {
|
||||||
const base = stripDimBlock(src);
|
const base = stripStateDimBlock(stripDimBlock(src));
|
||||||
const g = parseFlowchart(base);
|
const g = parseDiagram(base);
|
||||||
if (!g || !g.nodes.has(nodeId)) return src;
|
if (!g || !g.nodes.has(nodeId)) return src;
|
||||||
|
|
||||||
const neighbours = new Set<string>([nodeId]);
|
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 dimNodes = [...g.nodes.keys()].filter((id) => !neighbours.has(id));
|
||||||
|
|
||||||
const parts = [base.replace(/\n+$/, ''), '', `%% ─ Mermix focus: ${nodeId} ─`];
|
const parts = [base.replace(/\n+$/, ''), '', `%% ─ Mermix focus: ${nodeId} ─`];
|
||||||
parts.push('classDef mmxDim fill:#161922,stroke:#262b36,color:#4a5263;');
|
parts.push('classDef mmxDim fill:#161922,stroke:#262b36,color:#4a5263');
|
||||||
parts.push('classDef mmxFocus fill:#2c2350,stroke:#a78bfa,color:#fff,stroke-width:2px;');
|
parts.push('classDef mmxFocus fill:#2c2350,stroke:#a78bfa,color:#fff,stroke-width:2px');
|
||||||
if (dimNodes.length) parts.push(`class ${dimNodes.join(',')} mmxDim;`);
|
if (dimNodes.length) parts.push(`class ${dimNodes.join(',')} mmxDim`);
|
||||||
parts.push(`class ${nodeId} mmxFocus;`);
|
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) {
|
if (other.length) {
|
||||||
parts.push(`linkStyle ${other.join(',')} stroke:#1e222b,stroke-opacity:0.1;`);
|
parts.push(`linkStyle ${other.join(',')} stroke:#1e222b,stroke-opacity:0.1;`);
|
||||||
}
|
}
|
||||||
if (incident.length) {
|
if (incident.length) {
|
||||||
parts.push(`linkStyle ${incident.join(',')} stroke:#a78bfa,stroke-width:2px;`);
|
parts.push(`linkStyle ${incident.join(',')} stroke:#a78bfa,stroke-width:2px;`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return parts.join('\n') + '\n';
|
return parts.join('\n') + '\n';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user