@hagicode/hagilight-mermaid 0.0.0-stage → 0.15.2-dev.162.1.9e8dc1b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,155 @@
1
- # Temporary Holding Version
1
+ # @hagicode/hagilight-mermaid
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Framework-agnostic Mermaid rendering with the HagiCode look: a light and dark theme that follows the page, two appearance presets, a reveal that follows how each diagram type reads, hover and pinned path tracing, part-by-part focus for charts, and an enlarged viewer with zoom and pan. It runs in any site (Astro, Starlight, Docusaurus or plain HTML) with no React, store or i18n library.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @hagicode/hagilight-mermaid mermaid
9
+ ```
10
+
11
+ `mermaid` is a peer dependency (`^11`). The package never bundles it, and it is imported the first time a non-empty diagram renders.
12
+
13
+ ## Quick start
14
+
15
+ ```ts
16
+ import { mountMermaidDiagrams } from '@hagicode/hagilight-mermaid';
17
+ import '@hagicode/hagilight-mermaid/styles.css';
18
+
19
+ const mermaid = mountMermaidDiagrams(document, { mode: 'auto', preset: 'default' });
20
+ ```
21
+
22
+ `mountMermaidDiagrams` replaces each `pre.mermaid` and `code.language-mermaid` block under the root with a rendered diagram. Call `mermaid.destroy()` when the page goes away. For the archify preset, also import `@hagicode/hagilight-mermaid/styles/archify.css`.
23
+
24
+ To render one diagram into an element you already have, use `renderMermaid`:
25
+
26
+ ```ts
27
+ import { renderMermaid } from '@hagicode/hagilight-mermaid';
28
+
29
+ const handle = renderMermaid(element, 'flowchart LR; A --> B', { mode: 'dark' });
30
+ handle.update({ source: 'flowchart LR; B --> C' }); // new source or options
31
+ handle.replay(); // play the reveal again
32
+ handle.expand(); // open the enlarged viewer; handle.collapse() closes it
33
+ handle.destroy(); // discard pending renders and empty the element
34
+ ```
35
+
36
+ ## Options
37
+
38
+ | Option | Default | Meaning |
39
+ | --- | --- | --- |
40
+ | `mode` | `'auto'` | `'light'`, `'dark'` or `'auto'`. `auto` reads `data-theme` on `<html>`, then a `dark` class, then `prefers-color-scheme`, and follows later changes. |
41
+ | `preset` | `'default'` | `'default'` or `'archify'`. An unknown name uses `'default'`. |
42
+ | `labels` | English | Overrides for user-visible text. See below. |
43
+ | `tokens` | `--hagilight-mermaid-*` | Maps theme roles to your own custom property names. |
44
+ | `interaction` | `{ enabled: true }` | `enabled: false` renders a static diagram. `pin: false` leaves clicks to the host page (hover and keyboard focus still work). `reducedMotion` overrides the system setting. `onTraceChange` receives the trace or focus summary. |
45
+ | `entrance` | `true` | `false` skips the first-view reveal. |
46
+ | `viewer` | `true` | Adds an enlarge button to the toolbar and makes `handle.expand()` open the viewer. `false` removes both. |
47
+ | `fit` | `'width'` | `'width'` fills the host's width and lets the height follow. `'contain'` fits the whole diagram in the host's height, for fixed-height tiles: give the host a height. |
48
+ | `loadMermaid` | `import('mermaid')` | Supplies the `mermaid` module. Use it to control chunking or to test. |
49
+ | `className` | | Extra class names on the diagram container. |
50
+
51
+ ## What each diagram type does
52
+
53
+ Every Mermaid diagram type moves and responds, not only the ones with nodes.
54
+
55
+ | Kind | Types | Reveal | Hover, focus and pin |
56
+ | --- | --- | --- | --- |
57
+ | Graphs | flowchart, state, class, ER, requirement, mindmap, swimlane, Sankey | Nodes land in dependency order and edges draw in behind them | Path tracing: the nodes that reach a node and the ones it reaches light up, with hop badges and a travelling light |
58
+ | Charts and maps | pie, Gantt, XY chart, radar, quadrant, treemap, Venn, packet, timeline, journey, git graph, tree view, Wardley, Ishikawa, Cynefin, kanban, architecture, block, event modeling, C4, railroad | Each type reads its own way: pie slices swing in clockwise, Gantt bars and bar charts grow from their base, a line chart draws, radar rings open from the centre | The part under the pointer keeps its colour with its label and legend row, and the rest steps back |
59
+ | Sequence | sequence | Participants drop in, lifelines draw down, messages arrive from top to bottom with their arrowheads held back until the line lands | Hover a message or a participant to follow it |
60
+ | Info | info | Rises into place | Static |
61
+
62
+ A diagram type this version does not know still reveals: its top-level pieces arrive in order, or, past 160 pieces, it sweeps in as one. Keyboard users get one tab stop per diagram, arrow keys walk the parts, Enter pins, and Escape releases.
63
+
64
+ ## Enlarged viewer
65
+
66
+ The toolbar's enlarge button, or `handle.expand()`, opens the diagram in a modal viewer. The diagram lifts out of its place, and settles back when the viewer closes. The viewer has its own copy of the diagram, so tracing and focus work there too, at any scale.
67
+
68
+ - **Zoom:** scroll wheel or trackpad pinch (about the pointer), two-finger pinch on touch, the buttons, `+` and `-`.
69
+ - **Move:** drag; a released drag coasts. Arrow keys move too.
70
+ - **Fit and actual size:** the fit button or `0` fits the whole diagram, `1` shows it at its natural size. Double-click zooms in on a point, and again to return to fit.
71
+ - **Close:** the close button, `Esc` (first releases a pinned trace), or `handle.collapse()`. Focus returns to the enlarge button.
72
+
73
+ With reduced motion the viewer opens and closes with no travel and the camera jumps instead of easing.
74
+
75
+ ## Tokens
76
+
77
+ The theme reads these colors from the page. Define them on `:root` (or any ancestor) to match your site. Undefined tokens use built-in light and dark values.
78
+
79
+ | Token | Role | Light fallback | Dark fallback |
80
+ | --- | --- | --- | --- |
81
+ | `--hagilight-mermaid-background` | Canvas | `#ffffff` | `#111827` |
82
+ | `--hagilight-mermaid-foreground` | Text | `#17212b` | `#f8fafc` |
83
+ | `--hagilight-mermaid-primary` | Node borders, focus | `#2563eb` | `#60a5fa` |
84
+ | `--hagilight-mermaid-primary-foreground` | Core node text | `#ffffff` | `#111827` |
85
+ | `--hagilight-mermaid-accent` | Secondary accent, hover | `#0f766e` (theme), `#f1f5f9` (hover) | `#5eead4` (theme), `#334155` (hover) |
86
+ | `--hagilight-mermaid-card` | Chip and loading surface | `#ffffff` | `#1f2937` |
87
+ | `--hagilight-mermaid-border` | Lines and borders | `#e2e8f0` | `#334155` |
88
+ | `--hagilight-mermaid-muted`, `--hagilight-mermaid-muted-foreground` | Group fill and secondary text | `#f1f5f9`, `#64748b` | `#1e293b`, `#94a3b8` |
89
+ | `--hagilight-mermaid-destructive` | Error icon | `#dc2626` | `#f87171` |
90
+ | `--hagilight-mermaid-status-warning` | Upstream trace color | `#d97706` | `#fbbf24` |
91
+ | `--hagilight-mermaid-mm-up`, `--hagilight-mermaid-mm-down` | Trace colors override | primary and warning | primary and warning |
92
+ | `--hagilight-mermaid-app-font-mono` | Error source font | system monospace | system monospace |
93
+
94
+ To use a site's own names, pass `tokens` instead of redefining the package tokens:
95
+
96
+ ```ts
97
+ mountMermaidDiagrams(document, {
98
+ tokens: { background: '--background', foreground: '--foreground', primary: '--primary', accent: '--accent', primaryForeground: '--primary-foreground' },
99
+ });
100
+ ```
101
+
102
+ Colors in `oklch()` or `lab()` are converted to `rgb()` through a canvas before Mermaid reads them, because Mermaid's parser rejects those functions.
103
+
104
+ ## Labels
105
+
106
+ All visible text comes from `labels`. Unspecified keys keep the English defaults, and `{value}` is replaced by the count in trace labels.
107
+
108
+ ```ts
109
+ mountMermaidDiagrams(document, {
110
+ labels: {
111
+ rendering: 'Rendering diagram...',
112
+ errorTitle: 'Diagram rendering error',
113
+ trace: { upstream: 'Upstream {value}', downstream: 'Downstream {value}', pinned: 'Pinned', clear: 'Clear selection' },
114
+ expand: 'Enlarge diagram',
115
+ viewer: { title: 'Enlarged diagram', close: 'Close', zoomIn: 'Zoom in', zoomOut: 'Zoom out', fit: 'Fit to window', actualSize: 'Actual size', hint: 'Scroll to zoom, drag to move, Esc to close' },
116
+ },
117
+ });
118
+ ```
119
+
120
+ ## Docusaurus
121
+
122
+ Docusaurus renders in React, so wrap `renderMermaid` in an effect:
123
+
124
+ ```tsx
125
+ import { useEffect, useRef } from 'react';
126
+ import { renderMermaid } from '@hagicode/hagilight-mermaid';
127
+ import '@hagicode/hagilight-mermaid/styles.css';
128
+
129
+ export default function Mermaid({ chart }: { chart: string }) {
130
+ const ref = useRef<HTMLDivElement>(null);
131
+ useEffect(() => {
132
+ const handle = renderMermaid(ref.current!, chart);
133
+ return () => handle.destroy();
134
+ }, [chart]);
135
+ return <div ref={ref} />;
136
+ }
137
+ ```
138
+
139
+ ## Subpaths
140
+
141
+ `@hagicode/hagilight-mermaid/theme`, `/svg`, `/graph`, `/entrance`, `/interaction` and `/labels` expose the pure helpers. They do not touch the DOM at import time, so server builds can import them.
142
+
143
+ ## Known limits
144
+
145
+ With Mermaid 11.16.1, every diagram type the demo covers renders an SVG, and none shows an error panel. The browser checks keep an empty known-gaps list, so no type is expected to fail. These limits remain:
146
+
147
+ - **Railroad** (`railroad-beta`, and the ABNF, EBNF and PEG grammar variants): the first label can be clipped at the left edge of the diagram and is drawn larger than its box, because the diagram's own text sizing does not fit the container. The theme does not cause this.
148
+ - **Part focus and the reveal** read Mermaid 11.16.1's SVG structure, which has no shared shape across diagram types. A later Mermaid release can rename a class: the affected type then loses its part focus, and its reveal falls back to the generic one, until the table in `src/adapters.ts` is updated. The browser checks pin the installed version.
149
+ - **Thin lines** (a sequence message, a git branch) are hard to hit on their own, so the part includes its label: hover the label or the text beside the line.
150
+ - **Block**, **kanban**, **architecture** and **Wardley** have no dependency graph the tracing can read, so they get part focus rather than upstream and downstream paths.
151
+ - **Text halo**: the default preset draws a light halo behind diagram text in light mode and a dark halo in dark mode. Against a page background of the other colour, text can look outlined.
152
+
153
+ ## Parity
154
+
155
+ The theme, SVG post-processing, graph, entrance and interaction modules are ported from the HagiCode web app. The web app is not yet on this package, so the two copies can drift. Parity tests compare the Mermaid configuration and the rendered SVG with the web app's values. Adopting the package in the web app is a separate follow-up.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * What each Mermaid diagram type is made of. Mermaid's SVG has no shared structure across types, so
3
+ * this file is the one place that knows which elements are a pie slice, a Gantt bar or a sequence
4
+ * message. It answers two questions from the rendered SVG alone, without re-parsing the source:
5
+ *
6
+ * - `entranceBeats`: in what order, and how, do the parts of the diagram arrive?
7
+ * - `describeParts`: which elements belong together when one of them is hovered or focused?
8
+ *
9
+ * Diagrams that `buildMermaidGraph` can read (flowchart, state, class, ER, ...) arrive and trace
10
+ * through the graph model instead. Every selector here was read from Mermaid 11.16.1 output; a type
11
+ * the table does not know falls back to its top-level pieces, so a new Mermaid diagram still moves.
12
+ */
13
+ export type BeatKind = 'fade' | 'rise' | 'drop' | 'pop' | 'growX' | 'growY' | 'draw' | 'swing' | 'wipe';
14
+ export interface Beat {
15
+ els: Element[];
16
+ kind: BeatKind;
17
+ /** Start of the first element, in ms from the start of the reveal. */
18
+ at: number;
19
+ /** Gap between consecutive elements. Capped so one beat never outlasts the stagger budget. */
20
+ gap?: number;
21
+ /** Explicit start per element, in ms. Overrides `at` and `gap`. */
22
+ delays?: number[];
23
+ ms?: number;
24
+ /** Pivot for `pop` and `swing`, in the elements' parent coordinates. Defaults to each element's centre. */
25
+ origin?: [number, number];
26
+ }
27
+ /** One focusable thing in a chart: the element that takes focus, and every element that belongs to it. */
28
+ export interface PartFamily {
29
+ primary: Element;
30
+ parts: Element[];
31
+ label: string;
32
+ /** Families of the same group are numbered and walked together, for example every slice of a pie. */
33
+ group: string;
34
+ }
35
+ export declare function diagramType(svg: Element): string;
36
+ /** The reveal for a diagram the graph model cannot read. Always returns beats, so every type moves. */
37
+ export declare function entranceBeats(svg: Element): Beat[];
38
+ /**
39
+ * The focusable families of a chart. Message families label themselves from the message text, which
40
+ * is the second part, so they name it explicitly.
41
+ */
42
+ export declare function describeParts(svg: Element): PartFamily[];
@@ -0,0 +1,359 @@
1
+ /**
2
+ * What each Mermaid diagram type is made of. Mermaid's SVG has no shared structure across types, so
3
+ * this file is the one place that knows which elements are a pie slice, a Gantt bar or a sequence
4
+ * message. It answers two questions from the rendered SVG alone, without re-parsing the source:
5
+ *
6
+ * - `entranceBeats`: in what order, and how, do the parts of the diagram arrive?
7
+ * - `describeParts`: which elements belong together when one of them is hovered or focused?
8
+ *
9
+ * Diagrams that `buildMermaidGraph` can read (flowchart, state, class, ER, ...) arrive and trace
10
+ * through the graph model instead. Every selector here was read from Mermaid 11.16.1 output; a type
11
+ * the table does not know falls back to its top-level pieces, so a new Mermaid diagram still moves.
12
+ */
13
+ const SKIPPED_TOP_LEVEL = new Set(['defs', 'style', 'title', 'desc', 'marker']);
14
+ const GENERIC_PIECE_LIMIT = 160;
15
+ export function diagramType(svg) {
16
+ return svg.getAttribute('aria-roledescription') ?? '';
17
+ }
18
+ function all(root, selector) {
19
+ return Array.from(root.querySelectorAll(selector));
20
+ }
21
+ function textOf(el) {
22
+ return (el?.textContent ?? '').replace(/\s+/g, ' ').trim();
23
+ }
24
+ function rect(el) {
25
+ return el.getBoundingClientRect();
26
+ }
27
+ function byX(els) {
28
+ return [...els].sort((a, b) => rect(a).left - rect(b).left || rect(a).top - rect(b).top);
29
+ }
30
+ /** Start times that follow each element's position along an axis, mapped onto [at, at + span]. */
31
+ function along(els, axis, at, span, reference = els) {
32
+ const centre = (el) => {
33
+ const box = rect(el);
34
+ return axis === 'x' ? box.left + box.width / 2 : box.top + box.height / 2;
35
+ };
36
+ const marks = reference.map(centre);
37
+ const low = Math.min(...marks);
38
+ const range = Math.max(...marks) - low || 1;
39
+ return els.map((el) => at + ((centre(el) - low) / range) * span);
40
+ }
41
+ function beat(kind, els, at, gap, ms, extra = {}) {
42
+ return { kind, els, at, gap, ms, ...extra };
43
+ }
44
+ /** One beat per element, so a mixed list can keep its own order and give each element its own kind. */
45
+ function sequence(els, pick, at, gap, ms) {
46
+ return els.map((el, index) => beat(pick(el), [el], at + index * gap, undefined, ms));
47
+ }
48
+ /** The parents of elements, each once, in order. */
49
+ function lift(els) {
50
+ return [...new Set(els.map((el) => el.parentElement).filter((el) => el !== null))];
51
+ }
52
+ /** Centre of an element, in its parent's coordinates. */
53
+ function centreOf(el) {
54
+ const graphic = el;
55
+ if (!graphic || typeof graphic.getBBox !== 'function')
56
+ return undefined;
57
+ try {
58
+ const box = graphic.getBBox();
59
+ return [box.x + box.width / 2, box.y + box.height / 2];
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ /** The top-level pieces of a diagram, looking through a lone wrapper group. */
66
+ function pieces(svg) {
67
+ const visible = (parent) => Array.from(parent.children).filter((child) => !SKIPPED_TOP_LEVEL.has(child.tagName.toLowerCase()) && (child.children.length > 0 || textOf(child) !== '' || child.tagName.toLowerCase() !== 'g'));
68
+ let found = visible(svg);
69
+ while (found.length === 1 && found[0].tagName.toLowerCase() === 'g' && found[0].children.length > 1) {
70
+ found = visible(found[0]);
71
+ }
72
+ return found;
73
+ }
74
+ function genericKind(el) {
75
+ const tag = el.tagName.toLowerCase();
76
+ if (tag === 'path' || tag === 'line' || tag === 'polyline')
77
+ return 'draw';
78
+ if (tag === 'text')
79
+ return 'fade';
80
+ return 'pop';
81
+ }
82
+ function genericBeats(svg) {
83
+ const found = pieces(svg);
84
+ if (found.length === 0 || found.length > GENERIC_PIECE_LIMIT)
85
+ return [beat('wipe', [svg], 0)];
86
+ return sequence(found, genericKind, 0, Math.min(90, 900 / found.length));
87
+ }
88
+ const ENTRANCES = {
89
+ architecture: (svg) => [
90
+ beat('fade', all(svg, 'g.architecture-groups > g'), 0, 120, 420),
91
+ beat('pop', all(svg, 'g.architecture-service'), 160, 90),
92
+ beat('draw', all(svg, 'g.architecture-edges path.edge'), 380, 80, 360),
93
+ beat('fade', all(svg, 'g.architecture-edges polygon.arrow'), 620, 80, 240),
94
+ ],
95
+ block: (svg) => [
96
+ beat('pop', all(svg, 'g.block > g.node'), 0, 70),
97
+ beat('draw', all(svg, 'path[class*="edge-thickness"]'), 280, 60, 360),
98
+ ],
99
+ cynefin: (svg) => [
100
+ beat('pop', all(svg, 'rect.cynefinDomain'), 0, 110, 520),
101
+ beat('draw', all(svg, 'g.cynefin-boundaries path'), 260, 120, 520),
102
+ beat('fade', all(svg, 'path.cynefinConfusion'), 520, 0, 420),
103
+ beat('fade', all(svg, 'text.cynefinDomainLabel, text.cynefinSubtitle'), 300, 30, 360),
104
+ beat('pop', all(svg, 'rect.cynefinItem, text.cynefinItemText'), 560, 50),
105
+ ],
106
+ eventmodeling: (svg) => {
107
+ const boxes = byX(all(svg, 'g.em-box'));
108
+ return [
109
+ beat('growX', all(svg, 'g.em-swimlane'), 0, 90, 480),
110
+ beat('rise', boxes, 200, 80),
111
+ beat('draw', all(svg, 'path.em-relation'), 520, 70, 360),
112
+ ];
113
+ },
114
+ gantt: (svg) => [
115
+ beat('fade', all(svg, 'g.grid, text.titleText, text.sectionTitle'), 0, 0, 380),
116
+ beat('fade', all(svg, 'rect.section'), 40, 60, 360),
117
+ beat('growX', all(svg, 'rect.task'), 220, 90, 520),
118
+ beat('fade', all(svg, 'text[class*="taskText"]'), 420, 90, 320),
119
+ beat('draw', all(svg, 'g.today line'), 760, 0, 420),
120
+ ],
121
+ gitGraph: (svg) => {
122
+ const commits = byX(all(svg, 'circle.commit'));
123
+ return [
124
+ beat('draw', all(svg, 'line.branch'), 0, 140, 640),
125
+ beat('fade', all(svg, 'rect.branchLabelBkg, g.branchLabel'), 0, 0, 380),
126
+ beat('pop', commits, 220, 100),
127
+ beat('fade', byX(all(svg, 'rect.commit-label-bkg, text.commit-label, polygon.tag-label-bkg, circle.tag-hole')), 360, 70, 300),
128
+ beat('draw', all(svg, 'g.commit-arrows path'), 420, 70, 360),
129
+ ];
130
+ },
131
+ info: (svg) => [beat('rise', pieces(svg), 0, 80)],
132
+ ishikawa: (svg) => [
133
+ beat('draw', all(svg, 'line.ishikawa-spine'), 0, 0, 640),
134
+ beat('pop', all(svg, 'g.ishikawa-head-group'), 420, 0, 460),
135
+ beat('draw', all(svg, 'line.ishikawa-branch'), 260, 140, 420),
136
+ beat('rise', all(svg, 'g.ishikawa-label-group'), 440, 140),
137
+ beat('draw', all(svg, 'line.ishikawa-sub-branch'), 560, 90, 320),
138
+ beat('rise', all(svg, 'g.ishikawa-sub-group'), 620, 90),
139
+ ],
140
+ journey: (svg) => [
141
+ beat('fade', lift(all(svg, 'rect.journey-section')), 0, 80, 380),
142
+ beat('draw', all(svg, 'line.task-line'), 140, 80, 380),
143
+ beat('pop', all(svg, 'rect.task'), 220, 90),
144
+ beat('pop', all(svg, 'circle.face'), 360, 90),
145
+ beat('fade', all(svg, 'circle[class^="actor-"], text.legend'), 300, 20, 320),
146
+ ],
147
+ kanban: (svg) => [
148
+ beat('rise', all(svg, 'g.sections > g.cluster'), 0, 120, 460),
149
+ beat('rise', all(svg, 'g.items > g.node'), 240, 70),
150
+ ],
151
+ packet: (svg) => [
152
+ beat('fade', all(svg, 'text.packetTitle'), 0, 0, 320),
153
+ beat('rise', all(svg, 'rect.packetBlock'), 60, 45, 360),
154
+ beat('fade', all(svg, 'text.packetLabel'), 200, 45, 300),
155
+ beat('fade', all(svg, 'text.packetByte'), 260, 12, 260),
156
+ ],
157
+ pie: (svg) => [
158
+ beat('fade', all(svg, 'circle.pieOuterCircle, text.pieTitleText'), 0, 0, 420),
159
+ beat('swing', all(svg, 'path.pieCircle'), 60, 130, 600, { origin: [0, 0] }),
160
+ beat('fade', all(svg, 'text.slice'), 420, 130, 320),
161
+ beat('rise', all(svg, 'g.legend'), 300, 80),
162
+ ],
163
+ quadrantChart: (svg) => [
164
+ beat('fade', all(svg, 'g.quadrants g.quadrant'), 0, 100, 420),
165
+ beat('draw', all(svg, 'g.border line'), 140, 40, 420),
166
+ beat('fade', all(svg, 'g.labels text, g.title text'), 260, 30, 340),
167
+ beat('pop', all(svg, 'g.data-point'), 460, 90),
168
+ ],
169
+ radar: (svg) => {
170
+ const centre = centreOf(svg.querySelector('circle.radarGraticule:last-of-type') ?? svg.querySelector('circle.radarGraticule') ?? undefined);
171
+ return [
172
+ beat('pop', all(svg, 'circle.radarGraticule'), 0, 70, 460, { origin: centre }),
173
+ beat('draw', all(svg, 'line.radarAxisLine'), 160, 40, 360),
174
+ beat('fade', all(svg, 'text.radarAxisLabel, text.radarTitle'), 320, 40, 320),
175
+ beat('pop', all(svg, 'path[class^="radarCurve"]'), 460, 220, 760, { origin: centre }),
176
+ beat('rise', all(svg, 'rect[class^="radarLegendBox"], text.radarLegendText'), 700, 60),
177
+ ];
178
+ },
179
+ railroad: (svg) => [
180
+ beat('pop', all(svg, 'g.railroad-start, g.railroad-end'), 0, 0, 360),
181
+ beat('draw', all(svg, 'path.railroad-line'), 60, 60, 420),
182
+ beat('pop', all(svg, 'g.railroad-terminal, g.railroad-nonterminal'), 160, 70),
183
+ beat('fade', all(svg, 'text.railroad-rule-name'), 0, 0, 360),
184
+ ],
185
+ sequence: (svg) => {
186
+ const messages = all(svg, 'line.messageLine0, line.messageLine1, path.messageLine0, path.messageLine1');
187
+ const texts = all(svg, 'text.messageText');
188
+ // With autonumber, each number is a zero-length line whose marker draws the dot, plus a text for the digits.
189
+ const numbers = all(svg, 'text.sequenceNumber, line[marker-start*="sequencenumber"]');
190
+ const frames = all(svg, 'line.loopLine, polygon.labelBox, text.labelText, text.loopText, rect.note, text.noteText');
191
+ const timeline = [...messages, ...texts, ...frames];
192
+ const start = 520;
193
+ const span = 1000;
194
+ return [
195
+ beat('drop', lift(all(svg, 'rect.actor-top')), 0, 80, 420),
196
+ beat('draw', all(svg, 'line.actor-line'), 120, 60, 780),
197
+ beat('draw', messages, 0, 0, 300, { delays: along(messages, 'y', start, span, timeline) }),
198
+ beat('fade', texts, 0, 0, 300, { delays: along(texts, 'y', start + 80, span, timeline) }),
199
+ beat('fade', numbers, 0, 0, 300, { delays: along(numbers, 'y', start + 40, span, timeline) }),
200
+ beat('fade', frames, 0, 0, 300, { delays: along(frames, 'y', start, span, timeline) }),
201
+ beat('rise', lift(all(svg, 'rect.actor-bottom')), start + span, 70, 360),
202
+ ];
203
+ },
204
+ timeline: (svg) => {
205
+ const parts = all(svg, 'g.taskWrapper, g.lineWrapper, g.eventWrapper');
206
+ const kind = (el) => (el.matches('.lineWrapper') ? 'draw' : el.matches('.taskWrapper') ? 'pop' : 'rise');
207
+ return [
208
+ beat('fade', all(svg, 'g > g.timeline-node, text.titleText').filter((el) => !el.closest('.taskWrapper, .eventWrapper')), 0, 40, 360),
209
+ ...sequence(parts, kind, 120, Math.min(120, 1400 / Math.max(parts.length, 1)), 420),
210
+ ];
211
+ },
212
+ treeView: (svg) => {
213
+ const rows = Array.from(svg.querySelector('g.tree-view')?.children ?? []);
214
+ return sequence(rows, (el) => (el.tagName.toLowerCase() === 'line' ? 'draw' : 'rise'), 0, Math.min(60, 1200 / Math.max(rows.length, 1)), 380);
215
+ },
216
+ treemap: (svg) => [
217
+ beat('pop', all(svg, 'g.treemapSection'), 0, 110, 520),
218
+ beat('pop', all(svg, 'g.treemapLeafGroup'), 240, 60),
219
+ ],
220
+ venn: (svg) => [
221
+ beat('pop', all(svg, 'g.venn-circle'), 0, 140, 620),
222
+ beat('fade', all(svg, 'g.venn-intersection'), 480, 100, 420),
223
+ beat('fade', all(svg, 'text.venn-title'), 0, 0, 360),
224
+ ],
225
+ wardley: (svg) => [
226
+ beat('fade', all(svg, 'rect.wardley-background, text.wardley-title, text.wardley-axis-label, text.wardley-stage-label'), 0, 0, 380),
227
+ beat('draw', all(svg, 'g.wardley-axes line'), 0, 80, 420),
228
+ beat('fade', all(svg, 'g.wardley-stages line'), 120, 80, 360),
229
+ beat('pop', all(svg, 'g.wardley-node'), 260, 120),
230
+ beat('draw', all(svg, 'g.wardley-links line'), 400, 120, 360),
231
+ ],
232
+ xychart: (svg) => [
233
+ beat('fade', all(svg, 'g.chart-title, g.bottom-axis, g.left-axis'), 0, 80, 420),
234
+ beat('growY', all(svg, 'g[class^="bar-plot"] rect'), 220, 90, 600),
235
+ beat('draw', all(svg, 'g[class^="line-plot"] path'), 640, 0, 760),
236
+ ],
237
+ };
238
+ ENTRANCES.railroadAbnf = ENTRANCES.railroad;
239
+ ENTRANCES.railroadEbnf = ENTRANCES.railroad;
240
+ ENTRANCES.railroadPeg = ENTRANCES.railroad;
241
+ /** The reveal for a diagram the graph model cannot read. Always returns beats, so every type moves. */
242
+ export function entranceBeats(svg) {
243
+ const build = ENTRANCES[diagramType(svg)];
244
+ const beats = build ? build(svg).filter((item) => item.els.length > 0) : [];
245
+ return beats.length > 0 ? beats : genericBeats(svg);
246
+ }
247
+ /**
248
+ * Pairs the i-th element of each extra selector with the i-th primary, and drops an extra whose count
249
+ * does not match, so a part is never attached to the wrong family.
250
+ */
251
+ function zip(svg, group, primarySelector, extras = [], options = {}) {
252
+ const pick = (selector) => {
253
+ const found = all(svg, selector);
254
+ return options.lift ? lift(found) : found;
255
+ };
256
+ const primaries = pick(primarySelector);
257
+ // A column with extras past the last primary (a centre label, say) can be trimmed to fit.
258
+ const columns = extras
259
+ .map(pick)
260
+ .map((column) => (options.trim && column.length > primaries.length ? column.slice(0, primaries.length) : column))
261
+ .filter((column) => column.length === primaries.length);
262
+ const rows = primaries.map((primary, index) => ({ primary, parts: [primary, ...columns.map((column) => column[index])] }));
263
+ // Mermaid may emit parts in drawing order, which is not always reading order (sequence lists actors last to first).
264
+ if (options.leftToRight)
265
+ rows.sort((a, b) => rect(a.primary).left - rect(b.primary).left);
266
+ const inside = options.within ? all(svg, options.within) : [];
267
+ return rows.map(({ primary, parts }) => {
268
+ if (inside.length > 0) {
269
+ // Parts that sit over the primary (a domain's labels and items) belong to it, found by position.
270
+ const bounds = rect(primary);
271
+ parts = [...parts, ...inside.filter((part) => {
272
+ const box = rect(part);
273
+ const x = box.left + box.width / 2;
274
+ const y = box.top + box.height / 2;
275
+ return x >= bounds.left && x <= bounds.right && y >= bounds.top && y <= bounds.bottom;
276
+ })];
277
+ }
278
+ const labelled = options.labelFrom ? primary.querySelector(options.labelFrom) : null;
279
+ const named = options.labelPart === undefined ? undefined : textOf(parts[options.labelPart]);
280
+ const label = named || textOf(labelled) || parts.map(textOf).find((text) => text !== '') || '';
281
+ return { primary, parts, label, group };
282
+ });
283
+ }
284
+ function solo(svg, group, selector, options = {}) {
285
+ return zip(svg, group, selector, [], options);
286
+ }
287
+ /** A kanban column and the cards that sit inside it, matched by position. */
288
+ function kanbanColumns(svg) {
289
+ const cards = all(svg, 'g.items > g.node');
290
+ return all(svg, 'g.sections > g.cluster').map((column) => {
291
+ const bounds = rect(column);
292
+ const inside = cards.filter((card) => {
293
+ const box = rect(card);
294
+ const middle = box.left + box.width / 2;
295
+ return middle >= bounds.left && middle <= bounds.right;
296
+ });
297
+ return { primary: column, parts: [column, ...inside], label: textOf(column.querySelector('.cluster-label')), group: 'column' };
298
+ });
299
+ }
300
+ const PARTS = {
301
+ architecture: (svg) => solo(svg, 'service', 'g.architecture-service'),
302
+ block: (svg) => solo(svg, 'block', 'g.block > g.node'),
303
+ c4: (svg) => solo(svg, 'element', 'g.person-man'),
304
+ cynefin: (svg) => zip(svg, 'domain', 'rect.cynefinDomain', ['text.cynefinDomainLabel'], {
305
+ trim: true,
306
+ within: 'text.cynefinSubtitle, rect.cynefinItem, text.cynefinItemText',
307
+ }),
308
+ eventmodeling: (svg) => solo(svg, 'event', 'g.em-box'),
309
+ gantt: (svg) => zip(svg, 'task', 'rect.task', ['text[class*="taskText"]']),
310
+ gitGraph: (svg) => zip(svg, 'branch', 'rect.branchLabelBkg', ['g.branchLabel', 'line.branch'], { labelPart: 1 }),
311
+ ishikawa: (svg) => solo(svg, 'cause', 'g.ishikawa-pair', { labelFrom: '.ishikawa-label-group' }),
312
+ journey: (svg) => solo(svg, 'task', 'rect.task', { lift: true, labelFrom: 'div.task, text.task' }),
313
+ kanban: kanbanColumns,
314
+ packet: (svg) => zip(svg, 'field', 'rect.packetBlock', ['text.packetLabel', 'text.packetByte.start', 'text.packetByte.end'], { labelPart: 1 }),
315
+ pie: (svg) => zip(svg, 'slice', 'path.pieCircle', ['text.slice', 'g.legend'], { labelPart: 2 }),
316
+ quadrantChart: (svg) => [
317
+ ...solo(svg, 'point', 'g.data-point'),
318
+ ...solo(svg, 'quadrant', 'g.quadrants g.quadrant'),
319
+ ],
320
+ radar: (svg) => zip(svg, 'series', 'path[class^="radarCurve"]', ['rect[class^="radarLegendBox"]', 'text.radarLegendText']),
321
+ railroad: (svg) => solo(svg, 'token', 'g.railroad-terminal, g.railroad-nonterminal'),
322
+ sequence: (svg) => [
323
+ ...zip(svg, 'message', 'line.messageLine0, line.messageLine1, path.messageLine0, path.messageLine1', ['text.messageText', 'text.sequenceNumber']),
324
+ ...zip(svg, 'participant', 'line.actor-line', ['rect.actor-bottom'], { lift: true, leftToRight: true }),
325
+ ],
326
+ timeline: (svg) => solo(svg, 'period', 'g.taskWrapper, g.eventWrapper'),
327
+ treeView: (svg) => solo(svg, 'entry', 'text.treeView-node-label', { lift: true }),
328
+ treemap: (svg) => solo(svg, 'tile', 'g.treemapLeafGroup, g.treemapSection').map((family) => {
329
+ const name = textOf(family.primary.querySelector('text.treemapSectionLabel, text.treemapLabel'));
330
+ const value = textOf(family.primary.querySelector('text.treemapSectionValue, text.treemapValue'));
331
+ return { ...family, label: [name, value].filter(Boolean).join(' ') };
332
+ }),
333
+ venn: (svg) => solo(svg, 'area', 'g.venn-area'),
334
+ wardley: (svg) => solo(svg, 'component', 'g.wardley-node'),
335
+ xychart: (svg) => zip(svg, 'bar', 'g[class^="bar-plot"] rect', ['g.bottom-axis g.label text']),
336
+ };
337
+ PARTS.railroadAbnf = PARTS.railroad;
338
+ PARTS.railroadEbnf = PARTS.railroad;
339
+ PARTS.railroadPeg = PARTS.railroad;
340
+ /**
341
+ * The focusable families of a chart. Message families label themselves from the message text, which
342
+ * is the second part, so they name it explicitly.
343
+ */
344
+ export function describeParts(svg) {
345
+ const build = PARTS[diagramType(svg)];
346
+ if (!build)
347
+ return [];
348
+ return build(svg)
349
+ // Mermaid sometimes draws a hidden root (the treemap's first section); nothing can hover it.
350
+ .filter((family) => {
351
+ const box = rect(family.primary);
352
+ return box.width > 0 || box.height > 0;
353
+ })
354
+ .map((family) => {
355
+ if (family.group === 'message' && family.parts[1])
356
+ return { ...family, label: textOf(family.parts[1]) };
357
+ return family;
358
+ });
359
+ }
package/dist/chip.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { MermaidLabels, MermaidTraceSummary } from './types.js';
2
+ /**
3
+ * Fills a live region with the trace or focus summary: the name, then either the upstream and
4
+ * downstream counts of a traced node or the place of a focused chart part. A pinned summary carries a
5
+ * clear button.
6
+ */
7
+ export declare function renderTraceChip(status: HTMLElement, summary: MermaidTraceSummary | null, labels: MermaidLabels, onClear: () => void): void;
package/dist/chip.js ADDED
@@ -0,0 +1,35 @@
1
+ import { el, icon } from './dom.js';
2
+ import { formatLabel } from './labels.js';
3
+ /**
4
+ * Fills a live region with the trace or focus summary: the name, then either the upstream and
5
+ * downstream counts of a traced node or the place of a focused chart part. A pinned summary carries a
6
+ * clear button.
7
+ */
8
+ export function renderTraceChip(status, summary, labels, onClear) {
9
+ status.replaceChildren();
10
+ if (!summary)
11
+ return;
12
+ const { trace } = labels;
13
+ const chip = el('div', 'mermaid-trace-chip');
14
+ chip.dataset.pinned = String(summary.pinned);
15
+ if (summary.pinned)
16
+ chip.appendChild(icon('pin', 'mermaid-trace-chip-pin', trace.pinned));
17
+ chip.appendChild(el('span', 'mermaid-trace-chip-name', summary.label));
18
+ if (summary.position) {
19
+ chip.appendChild(el('span', 'mermaid-trace-chip-count mermaid-trace-chip-place', `${summary.position.index} / ${summary.position.total}`));
20
+ }
21
+ else {
22
+ chip.appendChild(el('span', 'mermaid-trace-chip-count mermaid-trace-chip-up', formatLabel(trace.upstream, { value: summary.upstream })));
23
+ chip.appendChild(el('span', 'mermaid-trace-chip-count mermaid-trace-chip-down', formatLabel(trace.downstream, { value: summary.downstream })));
24
+ }
25
+ if (summary.pinned) {
26
+ const clear = el('button', 'mermaid-trace-chip-clear');
27
+ clear.type = 'button';
28
+ clear.setAttribute('aria-label', trace.clear);
29
+ clear.title = trace.clear;
30
+ clear.appendChild(icon('x'));
31
+ clear.addEventListener('click', onClear);
32
+ chip.appendChild(clear);
33
+ }
34
+ status.appendChild(chip);
35
+ }
@@ -0,0 +1,9 @@
1
+ import { type Beat } from './adapters.js';
2
+ export interface MermaidChoreographyHandle {
3
+ cancel: () => void;
4
+ finished: Promise<void>;
5
+ }
6
+ /** Plays a list of beats on an SVG. Each element follows its beat's kind, delay and the shared easing. */
7
+ export declare function playBeats(svg: SVGSVGElement, beats: Beat[]): MermaidChoreographyHandle | null;
8
+ /** The reveal for a diagram that has no graph: its parts arrive in the order its type reads. */
9
+ export declare function playTypeEntrance(svg: SVGSVGElement): MermaidChoreographyHandle | null;