@humanforest/slidev-theme 0.1.0
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/LICENSE +67 -0
- package/README.md +577 -0
- package/assetUrl.js +38 -0
- package/components/Alert.vue +209 -0
- package/components/AreaBox.vue +152 -0
- package/components/AreaChart.vue +145 -0
- package/components/Avatar.vue +267 -0
- package/components/AvatarGroup.vue +94 -0
- package/components/BadgeStrip.vue +203 -0
- package/components/Bars.vue +207 -0
- package/components/BoroughShape.vue +127 -0
- package/components/BrowserFrame.vue +484 -0
- package/components/CalendarGrid.vue +480 -0
- package/components/Card.vue +70 -0
- package/components/Carousel.vue +266 -0
- package/components/ChartFrame.vue +426 -0
- package/components/ChordChart.vue +264 -0
- package/components/ComposedChart.vue +187 -0
- package/components/Contents.vue +86 -0
- package/components/DataTable.vue +244 -0
- package/components/DeckMap.vue +409 -0
- package/components/DeviceFrame.vue +331 -0
- package/components/DonutChart.vue +103 -0
- package/components/DottedMap.vue +126 -0
- package/components/Endorsement.vue +76 -0
- package/components/Grid.vue +169 -0
- package/components/GroupedBarChart.vue +141 -0
- package/components/IconTile.vue +116 -0
- package/components/Kpi.vue +188 -0
- package/components/LineChart.vue +133 -0
- package/components/Logo.vue +203 -0
- package/components/LondonMap.vue +348 -0
- package/components/MarkPair.vue +109 -0
- package/components/Marquee.vue +263 -0
- package/components/NestedDonutChart.vue +295 -0
- package/components/Placeholder.vue +88 -0
- package/components/SankeyChart.vue +223 -0
- package/components/ScatterChart.vue +142 -0
- package/components/StackedBarChart.vue +143 -0
- package/components/StatCard.vue +134 -0
- package/components/StatusTrack.vue +334 -0
- package/components/Timeline.vue +249 -0
- package/components/TimelineChart.vue +329 -0
- package/components/TreemapChart.vue +267 -0
- package/components/backgrounds.js +135 -0
- package/components/boroughGeometry.ts +59 -0
- package/components/chartData.ts +277 -0
- package/components/chartTip.ts +201 -0
- package/components/codeTheme.ts +239 -0
- package/components/deckPalette.ts +157 -0
- package/components/dottedMapGeometry.js +60 -0
- package/components/iconSlugs.js +55 -0
- package/components/iconUrl.js +51 -0
- package/components/mermaidTheme.ts +972 -0
- package/components/motionDuration.ts +41 -0
- package/components/tableRules.ts +257 -0
- package/confidential-mark.vue +151 -0
- package/custom-nav-controls.vue +79 -0
- package/endorsements/b-corp-black.svg +30 -0
- package/endorsements/b-corp-white.svg +30 -0
- package/global-top.vue +1662 -0
- package/icons/forest/bike-asterisk.svg +21 -0
- package/icons/forest/bike-down.svg +19 -0
- package/icons/forest/bike-off.svg +20 -0
- package/icons/forest/bike-up.svg +19 -0
- package/icons/forest/bike-x.svg +19 -0
- package/icons/forest/bike.svg +18 -0
- package/icons/forest/mark-fill.svg +6 -0
- package/icons/forest/mark.svg +6 -0
- package/icons/forest/parking.svg +3 -0
- package/icons/forest/star-fill.svg +4 -0
- package/icons/forest/star.svg +4 -0
- package/icons/forest/traffic-light-caution.svg +8 -0
- package/icons/forest/traffic-light-go.svg +8 -0
- package/icons/forest/traffic-light-lit.svg +8 -0
- package/icons/forest/traffic-light-stop.svg +8 -0
- package/icons/forest/traffic-light.svg +8 -0
- package/icons/lucide/battery.svg +1 -0
- package/icons/lucide/bike.svg +1 -0
- package/icons/lucide/chart-line.svg +1 -0
- package/icons/lucide/check.svg +1 -0
- package/icons/lucide/circle-check.svg +1 -0
- package/icons/lucide/clock.svg +1 -0
- package/icons/lucide/code.svg +1 -0
- package/icons/lucide/component.svg +1 -0
- package/icons/lucide/frame.svg +1 -0
- package/icons/lucide/layout-grid.svg +1 -0
- package/icons/lucide/map-pin.svg +1 -0
- package/icons/lucide/map.svg +1 -0
- package/icons/lucide/palette.svg +1 -0
- package/icons/lucide/table.svg +1 -0
- package/icons/lucide/triangle-alert.svg +1 -0
- package/icons/lucide/type.svg +1 -0
- package/icons/lucide/wrench.svg +1 -0
- package/layouts/bleed.vue +317 -0
- package/layouts/cover.vue +1514 -0
- package/layouts/default.vue +187 -0
- package/layouts/divider.vue +286 -0
- package/layouts/end.vue +384 -0
- package/layouts/quote.vue +218 -0
- package/layouts/split.vue +1875 -0
- package/layouts/stack.vue +944 -0
- package/layouts/statement.vue +67 -0
- package/manifest/chrome.ts +36 -0
- package/manifest/components.ts +49 -0
- package/manifest/index.ts +57 -0
- package/manifest/layouts.ts +37 -0
- package/manifest/manifest.json +6514 -0
- package/manifest/presets.ts +635 -0
- package/notch.js +458 -0
- package/notchPath.js +188 -0
- package/package.json +77 -0
- package/section.js +110 -0
- package/setup/mermaid-renderer.ts +2120 -0
- package/setup/mermaid.ts +30 -0
- package/setup/shiki.ts +44 -0
- package/setup/shortcuts.ts +28 -0
- package/slide-bottom.vue +305 -0
- package/slide-top.vue +268 -0
- package/styles/base.css +2611 -0
- package/styles/index.ts +71 -0
- package/styles/tokens.css +144 -0
- package/uno.config.ts +231 -0
- package/useNotchCuts.js +200 -0
|
@@ -0,0 +1,2120 @@
|
|
|
1
|
+
// A CUSTOM MERMAID RENDERER, and the reason it has to be a renderer rather than a config.
|
|
2
|
+
//
|
|
3
|
+
// Slidev offers two hooks. `setup/mermaid.ts` returns a config object — but @slidev/client's
|
|
4
|
+
// setup/mermaid.ts wraps it in `createSingletonPromise`, so it is resolved ONCE for the lifetime of
|
|
5
|
+
// the page. One config, one palette. This deck runs light and marks individual slides
|
|
6
|
+
// `class: deck-dark` (deck.css: "dark is punctuation, not the ground"), so a single frozen config
|
|
7
|
+
// can serve one of the two registers and gets the other wrong — which, on a diagram whose edges are
|
|
8
|
+
// drawn in the accent, means arrows a shade off the ground they sit on.
|
|
9
|
+
//
|
|
10
|
+
// `setup/mermaid-renderer.ts` is the other hook, and it is called PER RENDER
|
|
11
|
+
// (@slidev/client/modules/mermaid.ts: `for (const setup of mermaidRenderers) { const renderer =
|
|
12
|
+
// await setup(); if (renderer) { … } }`). That is what lets one deck hold both registers.
|
|
13
|
+
//
|
|
14
|
+
// ★ THE REGISTER TRAVELS AS `theme`, deliberately reusing mermaid's own prop rather than inventing
|
|
15
|
+
// one. Two things fall out for free. Slidev already passes `theme: isDark ? 'dark' : undefined`
|
|
16
|
+
// from the global toggle, so pressing `d` re-renders every diagram in the dark palette with no
|
|
17
|
+
// author involvement. And `options` is part of modules/mermaid.ts's own CACHE KEY
|
|
18
|
+
// (`lzEncoded + JSON.stringify(options)`), so the light and dark renders of the same diagram are
|
|
19
|
+
// two entries rather than one stale one — which a bespoke prop outside that key would not have been.
|
|
20
|
+
// A slide opts in per diagram with:
|
|
21
|
+
//
|
|
22
|
+
// ```mermaid {theme: 'dark'}
|
|
23
|
+
//
|
|
24
|
+
// ★★ AND `useMaxWidth` IS WHY THE SVG FITS. mermaid stamps a fixed `width`/`max-width` on its root
|
|
25
|
+
// <svg> from the diagram's own natural size. With labels set at the deck's 36px body rung — which
|
|
26
|
+
// is what makes a diagram legible from the back of a room in the first place — natural size is
|
|
27
|
+
// routinely wider than the slide, and the overflow leaves the canvas rather than scaling. Each
|
|
28
|
+
// diagram family has its own `useMaxWidth` switch (they do not share one), and the ones this deck
|
|
29
|
+
// draws are set in components/mermaidTheme.ts; the post-processing below is the backstop for the
|
|
30
|
+
// families that ignore it.
|
|
31
|
+
import type { MermaidRenderFn } from '@slidev/types';
|
|
32
|
+
import type { Register } from '../components/deckPalette';
|
|
33
|
+
import { MERMAID_FONT_FAMILY, MERMAID_FONT_PX, MERMAID_EDGE_LABEL_FONT_PX, MERMAID_INDEX_FONT_PX, MERMAID_TAG_FONT_PX, MERMAID_LEGEND_FONT_PX, DIAGRAM_STROKE_PX, mermaidConfig, mermaidInkRules, mermaidBackgroundRules } from '../components/mermaidTheme';
|
|
34
|
+
|
|
35
|
+
/** mermaid's ESM build, the same specifier @slidev/client resolves. */
|
|
36
|
+
async function loadMermaid() {
|
|
37
|
+
const mod = await import('mermaid/dist/mermaid.esm.mjs');
|
|
38
|
+
return (mod as { default: typeof import('mermaid').default }).default;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let counter = 0;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fit the rendered SVG to its column — SHRINK ONLY, never stretch.
|
|
45
|
+
*
|
|
46
|
+
* mermaid emits a hard `width` and a `style="max-width: …px"` from the diagram's natural size.
|
|
47
|
+
* Left alone, a diagram wider than its column paints past the slide edge, and it does so as an SVG
|
|
48
|
+
* overflow that scripts/deckAudit.ts cannot see, because the host element's own border box is still
|
|
49
|
+
* inside the column.
|
|
50
|
+
*
|
|
51
|
+
* ★★ AND THE OBVIOUS FIX IS WORSE THAN THE PROBLEM. A plain `width:100%` makes every diagram fill
|
|
52
|
+
* its column, including the ones that are narrower than it — measured on a five-state vertical
|
|
53
|
+
* `stateDiagram-v2` (viewBox 263 x 510, aspect 0.52): stretched across a 1160px column it came out
|
|
54
|
+
* **2247px tall**, four slides' worth of height for five boxes, with the first label alone taller
|
|
55
|
+
* than the slide heading. The cap has to be the diagram's OWN width, so `min(natural, 100%)`:
|
|
56
|
+
* a wide diagram shrinks to fit, a narrow one is left at the size mermaid laid it out at.
|
|
57
|
+
*
|
|
58
|
+
* ★★ AND THE HEIGHT IS A CAP TOO, because for one whole family it is the binding one. A sequence
|
|
59
|
+
* diagram grows a ROW per message and a COLUMN per actor, so it runs out of slide vertically long
|
|
60
|
+
* before it runs out of column. MEASURED: the specimen's own three-message sequence is 882x739 in a
|
|
61
|
+
* 1728px column — no width shrink at all, and 739 against the 720 the slide has between the
|
|
62
|
+
* diagram's top and the bottom inset. Three messages already over. A five-actor, sixteen-message
|
|
63
|
+
* one measured 1776x2380 and the audit put it 1500px past the bottom of the canvas.
|
|
64
|
+
*
|
|
65
|
+
* The cap is expressed as a THIRD TERM IN THE WIDTH, not as a max-height: the SVG is a replaced
|
|
66
|
+
* element whose height follows its width by the viewBox ratio, so `H_MAX x naturalW / naturalH` is
|
|
67
|
+
* the width at which the drawing is exactly H_MAX tall. min() then picks whichever bound bites.
|
|
68
|
+
* A max-height would need a definite height on a parent this function cannot reach.
|
|
69
|
+
*
|
|
70
|
+
* A diagram shorter than the budget is untouched — the third term is larger than its natural width,
|
|
71
|
+
* so min() never selects it.
|
|
72
|
+
*
|
|
73
|
+
* Rewritten in the string rather than styled: the SVG is inside a shadow root, so no stylesheet in
|
|
74
|
+
* this theme can reach it. The viewBox is left exactly as mermaid computed it, so nothing is
|
|
75
|
+
* re-laid-out — only the box the drawing is fitted into changes.
|
|
76
|
+
*/
|
|
77
|
+
/*
|
|
78
|
+
The height a diagram may occupy, in canvas px, and the CSS variable a layout overrides it with.
|
|
79
|
+
|
|
80
|
+
720 is the DEFAULT layout: 1080 canvas − the 264 a diagram's top sits at under a heading − the 96
|
|
81
|
+
bottom inset. MEASURED on slide 245, whose host box starts at 264 and whose safe area ends at 984.
|
|
82
|
+
|
|
83
|
+
★★ A LAYOUT THAT GIVES A DIAGRAM MORE HAS TO SAY SO, because this function cannot see where it is.
|
|
84
|
+
A split's figure column carries no heading above it, so its budget is 1080 − 96 − 96 = 888 — a
|
|
85
|
+
quarter more, which for a height-bound diagram is a quarter more of everything. split.vue sets the
|
|
86
|
+
variable; anything that does not, gets the default.
|
|
87
|
+
*/
|
|
88
|
+
export const DIAGRAM_MAX_H = 720;
|
|
89
|
+
export const DIAGRAM_MAX_H_VAR = '--deck-diagram-max-h';
|
|
90
|
+
|
|
91
|
+
export function fitToColumn(svg: string): string {
|
|
92
|
+
const viewBox = /viewBox="([^"]+)"/.exec(svg)?.[1]?.trim().split(/\s+/);
|
|
93
|
+
const naturalWidth = viewBox?.length === 4 ? Number(viewBox[2]) : Number.NaN;
|
|
94
|
+
const naturalHeight = viewBox?.length === 4 ? Number(viewBox[3]) : Number.NaN;
|
|
95
|
+
// No viewBox is not a case to guess at: without one there is no natural size to cap against, and
|
|
96
|
+
// 100% would be the stretch this function exists to avoid.
|
|
97
|
+
const sized = Number.isFinite(naturalWidth) && naturalWidth > 0;
|
|
98
|
+
const tall = sized && Number.isFinite(naturalHeight) && naturalHeight > DIAGRAM_MAX_H;
|
|
99
|
+
const ratio = naturalWidth / naturalHeight;
|
|
100
|
+
const terms = sized
|
|
101
|
+
? [`${Math.ceil(naturalWidth)}px`, '100%',
|
|
102
|
+
...(tall ? [`calc(var(${DIAGRAM_MAX_H_VAR}, ${DIAGRAM_MAX_H}px) * ${ratio.toFixed(4)})`] : [])]
|
|
103
|
+
: [];
|
|
104
|
+
const cap = terms.length ? `min(${terms.join(', ')})` : '100%';
|
|
105
|
+
return svg
|
|
106
|
+
.replace(/<svg([^>]*?)\swidth="[^"]*"/, '<svg$1')
|
|
107
|
+
.replace(/<svg([^>]*?)\sheight="[^"]*"/, '<svg$1')
|
|
108
|
+
.replace(/<svg([^>]*?)\sstyle="[^"]*"/, '<svg$1')
|
|
109
|
+
.replace(/<svg\b/, `<svg style="width:${cap};height:auto;display:block;margin:0 auto"`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/*
|
|
113
|
+
── THE ROOM-SCALE FLOOR ───────────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
Some of mermaid's own sizes are not configurable at all. They are literals inside the stylesheet
|
|
116
|
+
it generates, driven by no theme variable and no config key, and two of them are wrong for a deck:
|
|
117
|
+
|
|
118
|
+
· `.edgeLabel .label { font-size: 14px }` — the ER diagram's relationship labels ("contains",
|
|
119
|
+
"holds"). Measured: 14px beside 36px entity names, on a 1920px canvas. `er.fontSize` does not
|
|
120
|
+
move it, because nothing reads that key for this rule.
|
|
121
|
+
· `.slice { font-family: "trebuchet ms", verdana, arial, … }` — the pie chart's labels, hard-
|
|
122
|
+
coded past the `fontFamily` the rest of the diagram obeys.
|
|
123
|
+
|
|
124
|
+
★ THE SHADOW ROOT IS NOT THE OBSTACLE HERE, because this runs BEFORE the SVG becomes one. The
|
|
125
|
+
renderer returns a STRING, and mermaid's own <style> block is inside it, so appending rules to
|
|
126
|
+
that block puts them in the same shadow root at the same specificity — and later in source order,
|
|
127
|
+
which is what wins a tie. A stylesheet in this theme could never do this; a string rewrite can.
|
|
128
|
+
|
|
129
|
+
Deliberately a SHORT, NAMED list rather than a blanket `* { font-size: … }`: mermaid uses relative
|
|
130
|
+
sizes in places, and flattening all of them would break diagrams that are currently correct. Each
|
|
131
|
+
entry is a measured literal with the diagram family it belongs to.
|
|
132
|
+
*/
|
|
133
|
+
/*
|
|
134
|
+
★★ A STROKE IS IN THE DRAWING'S OWN UNITS, SO IT SCALES WITH IT.
|
|
135
|
+
|
|
136
|
+
fitToColumn caps a wide diagram at its column, which scales the whole SVG down — measured on the
|
|
137
|
+
specimen's flowchart, a 1603-unit viewBox drawn into 872px, a factor of 0.544. Mermaid's stroke
|
|
138
|
+
widths are in SVG units, so they shrink with it: the node border and every link, both set to 1px,
|
|
139
|
+
render at 0.54 canvas px. A half-pixel line is a line the back row cannot see, and because the
|
|
140
|
+
factor is per-diagram, two diagrams on consecutive slides carry visibly different weights.
|
|
141
|
+
|
|
142
|
+
This was `vector-effect: non-scaling-stroke`, which takes stroke width out of the scaled
|
|
143
|
+
coordinate space so the value below renders whatever the diagram's size. It cures the thin end by
|
|
144
|
+
making the line the ONE thing on a slide that does not scale, and the thick end is just as wrong:
|
|
145
|
+
at a fit of 0.37 — the sixteen-message sequence — every box, gap and letter shrinks while the ink
|
|
146
|
+
stays put, so the line reads thirteen times heavier relative to its diagram than at natural size.
|
|
147
|
+
|
|
148
|
+
Stated in UNITS instead, it scales like the corner radius, the type and the node it outlines. At
|
|
149
|
+
natural size it is still 4 canvas px, where every other strong edge in the deck sits; at 0.5 it is
|
|
150
|
+
2, and the diagram reads as the same drawing seen from further away rather than a different one.
|
|
151
|
+
|
|
152
|
+
⚠ The thin end is back in principle, and the height cap is what holds it off — a diagram cannot be
|
|
153
|
+
scaled arbitrarily small before fitToColumn stops it. At the smallest fit the specimen actually
|
|
154
|
+
produces this is 1.5 canvas px against mermaid's own 0.37. Past that the answer is fewer things on
|
|
155
|
+
the slide, not a heavier line.
|
|
156
|
+
|
|
157
|
+
The FILL is deliberately left alone. A mermaid node is a Card, and the deck draws a card as a quiet
|
|
158
|
+
--deck-surface fill read by its border and its corner — not by a loud ground. Fixing the border so
|
|
159
|
+
it renders is the system-consistent repair; reaching for a stronger fill would be treating the
|
|
160
|
+
symptom.
|
|
161
|
+
*/
|
|
162
|
+
/* ★ DECLARED IN mermaidTheme.ts AND RE-EXPORTED HERE. It moved the day radar needed it: that
|
|
163
|
+
family states its axis, graticule and curve weights as THEME VARIABLES, which are built in
|
|
164
|
+
mermaidTheme, and this file already imports from there — so declaring it here and importing it
|
|
165
|
+
back would have been a cycle. Re-exported rather than relocated in every caller, because the
|
|
166
|
+
rules below are where the number is most often read and the import list is not the story. */
|
|
167
|
+
export { DIAGRAM_STROKE_PX } from '../components/mermaidTheme';
|
|
168
|
+
/*
|
|
169
|
+
★ THE RADIUS IS IN SVG UNITS AND THOSE SCALE, which is why this is not one of the deck's own
|
|
170
|
+
corner steps. fitToColumn draws a wide diagram at a fraction of its natural size — 0.544 on the
|
|
171
|
+
specimen's flowchart — so the rendered corner is this value times that fraction. At the item step
|
|
172
|
+
(8) it came out at 4 canvas px, a square with the edge taken off rather than a rounded box. There
|
|
173
|
+
is no non-scaling equivalent for a corner the way there is for a stroke, so the number is chosen
|
|
174
|
+
for what it RENDERS at, and a diagram drawn at a different scale carries a proportionally different
|
|
175
|
+
corner — which is right for a radius, unlike for a line.
|
|
176
|
+
*/
|
|
177
|
+
const DIAGRAM_RADIUS = 28;
|
|
178
|
+
/*
|
|
179
|
+
★★ A POLYGON ROUNDS FAR LESS THAN A RECT, AND THE ARROWS ARE WHY. Mermaid computes an edge's
|
|
180
|
+
endpoint against the SHARP shape and leaves a fixed gap for the head to cover — 4 user units to a
|
|
181
|
+
rectangle. Rounding a corner pulls the silhouette back from the vertex the arrow was aimed at, and
|
|
182
|
+
on a 45-degree diamond point the tip retreats by roughly the radius times cos 45. At the rect's 28
|
|
183
|
+
that is ~20 units of daylight the head has no way to cross, because the path was laid out before
|
|
184
|
+
this rewrite and cannot be moved without redoing mermaid's own intersection maths.
|
|
185
|
+
|
|
186
|
+
Mermaid already leaves a diamond 14.4 units rather than 4 — measured on the specimen's flowchart —
|
|
187
|
+
so the gap starts wider and this only decides how much worse it gets. Small enough to read as a
|
|
188
|
+
softened point, not so large that the arrow stops in mid-air.
|
|
189
|
+
*/
|
|
190
|
+
const POLYGON_RADIUS = 10;
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
/*
|
|
194
|
+
The fragment keyword's size is mermaidTheme's, because the TAB it sits in is scaled from the same
|
|
195
|
+
number there. Set through CSS because mermaid's sequence font keys are all dead.
|
|
196
|
+
*/
|
|
197
|
+
const TAG_FONT_PX = MERMAID_TAG_FONT_PX;
|
|
198
|
+
|
|
199
|
+
/* The gap mermaid leaves between an autonumber's disc centre and the baseline it writes the digit
|
|
200
|
+
on — MEASURED off the rendered markup, a marker at y=175 against `<text y="179">`. It is half a
|
|
201
|
+
cap height at mermaid's own 12px, and a constant, so it does not follow the deck's type. */
|
|
202
|
+
const SEQUENCE_NUMBER_BASELINE_DROP = 4;
|
|
203
|
+
|
|
204
|
+
/* The same gap for a fragment keyword, and it comes from a different place: drawLabel puts the text
|
|
205
|
+
y at the tab's centre and drawText's valign-middle yfunc then adds boxTextMargin/2 on top of it,
|
|
206
|
+
so every keyword is laid out below its own tab's centre by half the tag scale (6/2, plus 0.5 from
|
|
207
|
+
the rounding). MEASURED off the rendered markup, not derived. */
|
|
208
|
+
const TAG_BASELINE_DROP = 3.5;
|
|
209
|
+
|
|
210
|
+
/* The disc's radius as a fraction of the index's type, which is left where it is — this number
|
|
211
|
+
moves the disc alone. mermaid's own is a flat half, r=6 against a 12px digit, and at deck size
|
|
212
|
+
that reads as a token the eye stops on rather than a marker on the line. Tightened twice: 0.44
|
|
213
|
+
still sat as a filled counter beside the message, 0.36 reads as a bullet the number happens to
|
|
214
|
+
be in. A two-digit index overhangs at any of these, mermaid's own included — the disc is a mark
|
|
215
|
+
on the line, not a container. */
|
|
216
|
+
const SEQUENCE_NUMBER_DISC_RATIO = 0.36;
|
|
217
|
+
|
|
218
|
+
// The pie legend's own size, the same 0.8 step mermaidTheme sets `pieLegendTextSize` from — one
|
|
219
|
+
// number, so the spacing pass and the text it is spacing cannot disagree.
|
|
220
|
+
const PIE_LEGEND_PX = Math.round(MERMAID_FONT_PX * 0.8);
|
|
221
|
+
|
|
222
|
+
/*
|
|
223
|
+
The radar legend's swatch, and the gap between it and the word it names. mermaid draws a 12x12
|
|
224
|
+
chip and puts the text 16 units to its right — both sized for its own ~12px legend, and both
|
|
225
|
+
presentation attributes, so a rule and a rewrite reach them. Stated as fractions of the legend
|
|
226
|
+
rung rather than as numbers, because the whole point of the pass below is that the three move
|
|
227
|
+
together.
|
|
228
|
+
*/
|
|
229
|
+
const RADAR_SWATCH_PX = Math.round(MERMAID_LEGEND_FONT_PX * 0.8);
|
|
230
|
+
const RADAR_SWATCH_GAP_PX = Math.round(MERMAID_LEGEND_FONT_PX * 0.4);
|
|
231
|
+
|
|
232
|
+
// How far a sequence message label drops so its INK, rather than its em box, is what sits between
|
|
233
|
+
// the two lines. A quarter of an em is half the descender depth this face reserves and never uses
|
|
234
|
+
// on these labels; see the rule below for the measurement it corrects.
|
|
235
|
+
export const MESSAGE_INK_DROP = 0.25;
|
|
236
|
+
|
|
237
|
+
// A journey's satisfaction face, as a multiple of the label beside it. mermaid draws it at r=15,
|
|
238
|
+
// sized for its own 14px type; this is what makes it a mark rather than a speck at the deck's rung.
|
|
239
|
+
export const FACE_DIAMETER_EM = 1.7;
|
|
240
|
+
|
|
241
|
+
/*
|
|
242
|
+
★★ A GANTT LABEL SITS LOW BECAUSE OF A CONSTANT THAT DOES NOT SCALE. mermaid places the baseline
|
|
243
|
+
from its own renderer as
|
|
244
|
+
|
|
245
|
+
text y = order * gap + barHeight / 2 + (fontSize / 2 - 2) + topPad
|
|
246
|
+
rect y = order * gap + topPad
|
|
247
|
+
|
|
248
|
+
so the baseline drops `fontSize / 2 - 2` below the bar's centre. At mermaid's own 11px that `- 2`
|
|
249
|
+
is 18% of the term and the result lands close to centring the CAP HEIGHT, which is what the eye
|
|
250
|
+
reads as centred. At this deck's 36px the same 2 is 5.5%, and the label drops low.
|
|
251
|
+
|
|
252
|
+
MEASURED against the shipped face: cap height 25.21 at 36px (0.70em), so cap-centring wants the
|
|
253
|
+
baseline 12.6 below the middle and mermaid puts it at 16 — 3.4 units low, uniform across every
|
|
254
|
+
row. The ratio is a property of the face and is pinned here rather than assumed; a face swap moves
|
|
255
|
+
it, which is why it is named.
|
|
256
|
+
*/
|
|
257
|
+
export const GANTT_CAP_RATIO = 0.7;
|
|
258
|
+
export const GANTT_LABEL_RISE = MERMAID_FONT_PX / 2 - 2 - (GANTT_CAP_RATIO * MERMAID_FONT_PX) / 2;
|
|
259
|
+
|
|
260
|
+
const ROOM_SCALE_RULES = [
|
|
261
|
+
// ER relationship labels — mermaid's hard 14px.
|
|
262
|
+
/*
|
|
263
|
+
★ A TRANSITION NAME IS AN ANNOTATION ON A LINE, not prose, and takes the same rung the autonumber
|
|
264
|
+
index does. mermaid's own is a hard 14px — sized for its 16px type — and this rule used to lift
|
|
265
|
+
it to the full body rung, which put the word on a transition at the same size as the state it
|
|
266
|
+
joins. It is already muted to 75% for the same reason; the size and the mute are one argument.
|
|
267
|
+
*/
|
|
268
|
+
`.edgeLabel .label{font-size:${MERMAID_EDGE_LABEL_FONT_PX}px;}`,
|
|
269
|
+
/*
|
|
270
|
+
★ THE GIT COMMIT ID IS ANOTHER HARD LITERAL. mermaid writes `.commit-label` at 10px — its own
|
|
271
|
+
16px-era size — with no config key that reaches it, the fourth instance of this pattern in the
|
|
272
|
+
family after the ER edge label's 14, the gantt title's 18 and the autonumber's 12. It names a
|
|
273
|
+
commit, so it takes the same rung a transition name does.
|
|
274
|
+
*/
|
|
275
|
+
`.commit-label{font-size:${MERMAID_EDGE_LABEL_FONT_PX}px;}`,
|
|
276
|
+
/*
|
|
277
|
+
★★ THE TIMELINE'S CONNECTORS ARE HARD-CODED PASTELS, and they are the last unthemed colour in
|
|
278
|
+
the family. mermaid draws the line from a period band down to its events as `line.node-line-N`
|
|
279
|
+
with a literal per-index pastel — MEASURED off the rendered slide, `rgb(165,206,255)` pale blue,
|
|
280
|
+
`rgb(255,207,183)` peach and `rgb(255,219,239)` pink. None is derived from anything the theme
|
|
281
|
+
sets, and on this deck's cream paper all three are very nearly invisible.
|
|
282
|
+
|
|
283
|
+
They carry the connection between a period and what happened in it, which is the same job every
|
|
284
|
+
other edge in the deck does, so they take the same accent. One colour rather than three: the
|
|
285
|
+
band above already says which period a line belongs to, and a second encoding of that in a tint
|
|
286
|
+
nobody can see is not an encoding.
|
|
287
|
+
*/
|
|
288
|
+
`line[class*="node-line"]{stroke:var(--deck-accent);}`,
|
|
289
|
+
`.branch-label{font-size:${MERMAID_EDGE_LABEL_FONT_PX}px;}`,
|
|
290
|
+
/*
|
|
291
|
+
★★ THE LABEL KNOCKS OUT THE LINE, SO ITS FILL MUST BE THE GROUND IT SITS ON — and mermaid's own
|
|
292
|
+
`edgeLabelBackground` cannot be trusted to be it. MEASURED across one page's renders: this theme
|
|
293
|
+
sets that variable to the page ground (#fff9f2 light), and mermaid honoured it on one diagram
|
|
294
|
+
and fell back to `mainBkg`/`tertiaryColor` on the next — which is --deck-surface, #f2ece5. A
|
|
295
|
+
grey box on cream paper, differing between two diagrams on the same slide. Same family of
|
|
296
|
+
defect as the dead `sequence.*FontSize` keys documented in mermaidTheme.
|
|
297
|
+
|
|
298
|
+
A custom property rather than a colour, because the ground is not always the page: a diagram
|
|
299
|
+
inside a named background has to knock out to THAT fill. The four backgrounds set this variable
|
|
300
|
+
in styles/base.css and it inherits into the SVG; the fallback is --deck-bg, which is already
|
|
301
|
+
per-register, so the dark register needs no second rule.
|
|
302
|
+
|
|
303
|
+
These are appended AFTER mermaid's own <style>, at the same specificity, so they win on order —
|
|
304
|
+
which is what `scope` and the append in applyRoomScale() are for.
|
|
305
|
+
*/
|
|
306
|
+
/*
|
|
307
|
+
★★ A COMPOSITE STATE DRAWS TWO RECTS, and `.cluster rect` in the stroke list gives BOTH the deck's
|
|
308
|
+
4px border — so the container came out double-ruled along the bottom, two lines a few units apart.
|
|
309
|
+
mermaid nests an inner rect inside `rect.outer`; the outer IS the container and the inner is
|
|
310
|
+
scaffolding. The corner rule already singles out `rect.outer`, so this makes the stroke agree.
|
|
311
|
+
*/
|
|
312
|
+
`.statediagram-cluster rect:not(.outer):not(.basic){stroke:none;fill:none;}`,
|
|
313
|
+
/*
|
|
314
|
+
★★ A MILESTONE IS A DIAMOND, AND THE DECK'S OWN CORNER WAS ERASING IT. mermaid draws one as a
|
|
315
|
+
square rotated 45°, sized from `barHeight` — 56 here — and `rect.task` is in the corner rule
|
|
316
|
+
above at 28px. A 56x56 square with a 28px radius IS A CIRCLE, and a circle rotated 45° is the
|
|
317
|
+
same circle: the diamond that distinguishes a milestone from a task never appeared. Measured on
|
|
318
|
+
this slide before the rule, and it is the one place in the family where the deck's own treatment
|
|
319
|
+
destroys a notation rather than restyling it.
|
|
320
|
+
|
|
321
|
+
Squared off, because the corner is what the shape means here.
|
|
322
|
+
*/
|
|
323
|
+
`rect.milestone,.milestone rect,rect.task.milestone{rx:0;ry:0;}`,
|
|
324
|
+
`.edgeLabel .label rect{fill:var(--deck-mermaid-label-bg,var(--deck-bg));}`,
|
|
325
|
+
/*
|
|
326
|
+
⚠ A LABEL INSIDE A CONTAINER STILL KNOCKS OUT TO THE PAGE, and nesting cannot fix it. An edge
|
|
327
|
+
label drawn inside a `subgraph` or a composite sits on the container's surface, so the
|
|
328
|
+
page-coloured knockout above is the wrong ground there. A descendant rule was tried and is DEAD:
|
|
329
|
+
mermaid collects every edge label into one `g.edgeLabels` at the ROOT and positions it by
|
|
330
|
+
transform, so no label is ever a descendant of the cluster it appears to sit in. Reaching it
|
|
331
|
+
would mean matching on coordinates, which a stylesheet cannot do.
|
|
332
|
+
*/
|
|
333
|
+
`.edgeLabel .label span{background:var(--deck-mermaid-label-bg,var(--deck-bg));}`,
|
|
334
|
+
/*
|
|
335
|
+
★★ AND INSIDE A CONTAINER THE GROUND IS THE CONTAINER. groundLabelsOnContainers marks the labels
|
|
336
|
+
a `subgraph` or a composite state encloses; this is what the mark does. Re-pointing the VARIABLE
|
|
337
|
+
rather than painting the rect is what makes one declaration enough:
|
|
338
|
+
|
|
339
|
+
· the knockout `rect` and the `span` are two different label forms — mermaid ignores
|
|
340
|
+
`htmlLabels: false` for edge labels in 11.17.2, so a flowchart's are HTML in a foreignObject
|
|
341
|
+
and a state diagram's are SVG — and a custom property inherits into both;
|
|
342
|
+
· the ink is a MIX TOWARD ITS OWN GROUND, so the word mutes toward the surface it sits on
|
|
343
|
+
instead of toward a page it does not, which painting the rect alone would have got wrong.
|
|
344
|
+
|
|
345
|
+
★ Its own variable, defaulting to the surface, so a named background can re-point the container
|
|
346
|
+
ground without touching the page ground beside it. `.marker.aggregation` reads
|
|
347
|
+
--deck-mermaid-label-bg too and must keep the PAGE value: it lives in <defs>, where a property
|
|
348
|
+
set on a group inside the drawing cannot reach it — which is exactly why this is scoped here and
|
|
349
|
+
not at the SVG root.
|
|
350
|
+
*/
|
|
351
|
+
`.edgeLabel .label[data-on-container]{--deck-mermaid-label-bg:var(--deck-mermaid-container-bg,var(--deck-surface));}`,
|
|
352
|
+
/*
|
|
353
|
+
── THE THREE FAMILIES THE TWELVE-SLOT CATEGORICAL WAS WRITTEN FOR ────────────────────────────
|
|
354
|
+
Each states weights and opacities mermaid writes as PRESENTATION ATTRIBUTES or as literals in
|
|
355
|
+
its own stylesheet, so a plain rule appended here beats them with no `!important` anywhere.
|
|
356
|
+
What is NOT here is as deliberate as what is — the exclusions are recorded on the slides.
|
|
357
|
+
*/
|
|
358
|
+
/* The radar legend's swatch is a 12x12 presentation attribute sized for mermaid's own ~12px
|
|
359
|
+
legend. `width`/`height` are SVG2 geometry properties on a rect, so a rule reaches them. */
|
|
360
|
+
/*
|
|
361
|
+
★★ THE GRATICULE IS A STACK OF FILLED DISCS, NOT RINGS, and no theme variable says so. mermaid
|
|
362
|
+
paints each tick circle with `graticuleColor` as BOTH fill and stroke, so `graticuleOpacity: 1`
|
|
363
|
+
— which is what a ring at the deck's border colour wants — turns the whole plot into one grey
|
|
364
|
+
disc with every tick invisible underneath it. Measured: four concentric circles, r 75 to 300,
|
|
365
|
+
every one of them fill-opacity 1.
|
|
366
|
+
|
|
367
|
+
Dropping the fill is the only way to get the scale back. The rings then read as what they are,
|
|
368
|
+
a measure, against the page — rather than as a coloured ground the data happens to sit on.
|
|
369
|
+
*/
|
|
370
|
+
`.radarGraticule{fill:none;}`,
|
|
371
|
+
/*
|
|
372
|
+
★★ AND THE WORD HANGS FROM ITS BASELINE, WHICH DEFEATS THE SEATING. mermaid's own stylesheet
|
|
373
|
+
carries `.radarLegendText{dominant-baseline:hanging}` — so the y a rule or a rewrite sets is the
|
|
374
|
+
text's TOP, not its baseline, and spaceRadarLegend's 0.35em cap-height nudge pushes it further
|
|
375
|
+
down instead of centring it. MEASURED: the word's box came out at y 4.27 for 36.54 units against
|
|
376
|
+
a chip spanning -11.5 to 11.5, i.e. 22.5 units low — the word sitting a row below the chip that
|
|
377
|
+
names it. Back to the alphabetic baseline, which is what the nudge is written against.
|
|
378
|
+
|
|
379
|
+
★ AND THE CHIP IS A KEY, NOT A SAMPLE OF THE WASH. It inherits the curve's own fill-opacity, so
|
|
380
|
+
at the 0.16 that makes two overlapping areas readable it renders as an all-but-empty outline at
|
|
381
|
+
a quarter of the size. A legend states WHICH COLOUR, once, at the size of a word — so it states
|
|
382
|
+
it solidly, and the stroke it already carries is the same hue.
|
|
383
|
+
*/
|
|
384
|
+
`.radarLegendText{dominant-baseline:auto;}`,
|
|
385
|
+
`[class^="radarLegendBox-"]{width:${RADAR_SWATCH_PX}px;height:${RADAR_SWATCH_PX}px;fill-opacity:1;}`,
|
|
386
|
+
/*
|
|
387
|
+
★★ A TREEMAP CELL IS A CARD, so it takes the deck's edge — mermaid draws the leaf at
|
|
388
|
+
stroke-width 3 and the section at 2, both presentation attributes, and both a shade under the
|
|
389
|
+
weight every other box in this deck carries. The fill opacities come with them because the two
|
|
390
|
+
are one reading: at mermaid's 0.3 leaf over 0.6 section the nesting is a wash of one hue, and
|
|
391
|
+
the deck's own answer to "a group of Cards is a Card" is a quiet surface read by its border.
|
|
392
|
+
|
|
393
|
+
⚠ AND THE CORNER RULE MUST NOT REACH THE CLIP RECTS. treemap nests a plain <rect> inside a
|
|
394
|
+
<clipPath> per cell; rounding those would crop every label to a rounded window it was never
|
|
395
|
+
laid out for. The corner rule below names `rect.treemapLeaf` and `rect.treemapSection` rather
|
|
396
|
+
than the family's rects in general, for that reason alone.
|
|
397
|
+
*/
|
|
398
|
+
`rect.treemapLeaf{stroke-width:${DIAGRAM_STROKE_PX};fill-opacity:0.18;}`,
|
|
399
|
+
/*
|
|
400
|
+
⚠ THE SECTION KEEPS A LIGHTER EDGE THAN THE LEAF, and it is the value in its corner that decides
|
|
401
|
+
it. mermaid anchors the section's total at `x = width - 10`, hard against the cell's right edge,
|
|
402
|
+
so a border at the deck's full 4 units runs UNDER the digits — measured by the audit's painted-
|
|
403
|
+
pixel pass, which scored three of them at 1.02:1 against the stroke rather than against the
|
|
404
|
+
fill. Nothing can move the text: the x is computed, not configured. So the border gives way.
|
|
405
|
+
*/
|
|
406
|
+
`rect.treemapSection{stroke-width:${DIAGRAM_STROKE_PX / 2};stroke-opacity:1;fill-opacity:0.5;}`,
|
|
407
|
+
`rect.treemapLeaf,rect.treemapSection{rx:${POLYGON_RADIUS}px;ry:${POLYGON_RADIUS}px;}`,
|
|
408
|
+
/*
|
|
409
|
+
★ THE SECTION HEADER'S TYPE IS AN INLINE STYLE at 12px, and `!important` is the only thing that
|
|
410
|
+
reaches it. Taken, unlike the treemap LEAF label below it, because a section header is not
|
|
411
|
+
fitted by a shrink loop — it is truncated against a width, so raising it costs an ellipsis at
|
|
412
|
+
worst rather than a culled label. Short section names keep it from ever truncating, which is
|
|
413
|
+
why the slide's are one word.
|
|
414
|
+
*/
|
|
415
|
+
/*
|
|
416
|
+
★★ THE SECTION HEADER TAKES THE INDEX RUNG, NOT THE TRANSITION ONE, AND THE BAND DECIDES THAT.
|
|
417
|
+
Its room is fixed at 35 units — mermaid's 25-unit header plus 10 of inner padding, which is what
|
|
418
|
+
d3 was handed as `paddingTop`, so growing it means moving every leaf the layout already placed.
|
|
419
|
+
MEASURED at the transition rung: the cap ink is 21 units, seated at 5 above and 9 below, i.e.
|
|
420
|
+
the word exactly fills its band and reads as jammed between the cell's top edge and the first
|
|
421
|
+
leaf. One rung down the ink is 18 and the air is even on both sides.
|
|
422
|
+
|
|
423
|
+
★ A section name is subordinate to the leaf labels it groups — those sit at the body rung, 38 by
|
|
424
|
+
mermaid's own leaf-count ladder — so being quieter than them is right on its own terms too.
|
|
425
|
+
*/
|
|
426
|
+
`text.treemapSectionLabel{font-size:${MERMAID_TAG_FONT_PX}px !important;}`,
|
|
427
|
+
`text.treemapSectionValue{font-size:${MERMAID_TAG_FONT_PX}px !important;font-style:normal;}`,
|
|
428
|
+
/*
|
|
429
|
+
★★ MINDMAP TAPERS ITS EDGES BY DEPTH — 17 - 3n, so 11, 8, 5, 2 and then NEGATIVE, which renders
|
|
430
|
+
as nothing at all past the fourth rank. The taper is a literal in mermaid's generated stylesheet
|
|
431
|
+
with no key behind it. The deck does not taper: a line is a line at the weight every other line
|
|
432
|
+
in the deck carries, and a mindmap that fades out by depth is telling the room its fifth rank
|
|
433
|
+
matters less than its first, which is not what a hierarchy means.
|
|
434
|
+
*/
|
|
435
|
+
`.edge,[class^="edge-depth-"]{stroke-width:${DIAGRAM_STROKE_PX};}`,
|
|
436
|
+
/*
|
|
437
|
+
★★ A KANBAN COLUMN IS A CONTAINER, NOT A CATEGORY, and mermaid colours it as though it were.
|
|
438
|
+
The family reuses mindmap's `.section-N` machinery for its columns, so each one arrives filled
|
|
439
|
+
with a categorical slot — and past the slots the deck states, with a HUE ROTATION of them:
|
|
440
|
+
measured on this slide, three columns came out bright blue, violet and magenta, none of which
|
|
441
|
+
is in any Forest scale.
|
|
442
|
+
|
|
443
|
+
Colour is the wrong encoding here whatever the hue. A column is a STAGE — backlog, in progress,
|
|
444
|
+
shipped — and stages are ordered, not categorical; painting them as three unrelated colours says
|
|
445
|
+
they are three kinds of thing. The deck's own answer to a group of Cards is a Card: one surface,
|
|
446
|
+
one border, one corner, which is what a flowchart `subgraph` and a composite state already take.
|
|
447
|
+
|
|
448
|
+
★ SCOPED TO `.cluster` SO MINDMAP IS UNTOUCHED. Both families draw `.section-N`, and on a
|
|
449
|
+
mindmap the colour IS the reading — a branch and everything under it share a slot, which is what
|
|
450
|
+
makes the picture legible by branch. Kanban's columns are the ones that are clusters.
|
|
451
|
+
*/
|
|
452
|
+
`.cluster[class*="section-"] > rect{fill:var(--deck-surface);stroke:var(--deck-border);}`,
|
|
453
|
+
/*
|
|
454
|
+
★ AND THE WORD ON THE LINE IS MUTED, because a transition NAME is subordinate to the states it
|
|
455
|
+
joins — at full ink it competes with the node labels for the same reading.
|
|
456
|
+
|
|
457
|
+
75%, and that number is a floor rather than a taste. The mute is a mix toward the label's own
|
|
458
|
+
ground, so how far it can go is set by whichever family has the least headroom. MEASURED, ink
|
|
459
|
+
against its ground at each step:
|
|
460
|
+
|
|
461
|
+
ground 100% 80% 75% 74% 72% 70%
|
|
462
|
+
page 17.19 9.33 7.74 7.50 6.96 6.52
|
|
463
|
+
warm 10.26 6.03 5.23 5.08 4.85 4.57
|
|
464
|
+
accent 13.07 7.30 6.26 6.07 5.71 5.37
|
|
465
|
+
vivid 8.51 5.40 4.76 4.63 4.44 4.20
|
|
466
|
+
neutral 15.76 8.82 7.42 7.19 6.67 6.27
|
|
467
|
+
|
|
468
|
+
★★ VIVID BINDS, AND IT BINDS AT 74. Below that it breaches the 4.5 body floor — 4.44 at 72 and
|
|
469
|
+
4.20 at 70 — while every other family still has room. 75 is the last step with a margin rather
|
|
470
|
+
than a rounding error, so the mute stops there and not at whatever looked right on a cream slide.
|
|
471
|
+
One number for all five grounds, which is why the mute lives here and the per-ground INK lives in
|
|
472
|
+
base.css beside the fill.
|
|
473
|
+
*/
|
|
474
|
+
`.edgeLabel .label text,.edgeLabel .label span{`
|
|
475
|
+
+ `fill:color-mix(in oklab,var(--deck-mermaid-label-ink,var(--deck-fg)) 62%,var(--deck-mermaid-label-bg,var(--deck-bg)));`
|
|
476
|
+
+ `color:color-mix(in oklab,var(--deck-mermaid-label-ink,var(--deck-fg)) 62%,var(--deck-mermaid-label-bg,var(--deck-bg)));}`,
|
|
477
|
+
/*
|
|
478
|
+
★★ `!important`, AND IT IS THE ONLY THING THAT REACHES THIS TEXT. mermaid writes the fragment
|
|
479
|
+
keyword with an INLINE STYLE — `<text class="labelText" style="font-family: …; font-size: 36px">`,
|
|
480
|
+
taken from the top-level `fontSize` — and an inline style beats a stylesheet rule at any
|
|
481
|
+
specificity. Without the flag this rule was inert: MEASURED off the rendered markup, the keyword
|
|
482
|
+
stayed at the body rung's 36px through three separate reductions of the number below, while
|
|
483
|
+
`labelBoxWidth` — which comes from config at layout time and IS scaled from it — shrank each
|
|
484
|
+
time. The visible result was the word overflowing its own tab, and the cause looked like the tab
|
|
485
|
+
being too small rather than the text never having moved.
|
|
486
|
+
|
|
487
|
+
Everything else in this list wins on source order, because mermaid states it in its own
|
|
488
|
+
stylesheet. This one it states on the element.
|
|
489
|
+
*/
|
|
490
|
+
`.labelText,.labelText>tspan{font-size:${TAG_FONT_PX}px!important;}`,
|
|
491
|
+
/*
|
|
492
|
+
★★ AND THE KEYWORD SITS LOW IN ITS OWN TAB — BUT `dominant-baseline` CANNOT TAKE IT BACK. mermaid
|
|
493
|
+
writes `alignment-baseline="middle"` as a PRESENTATION ATTRIBUTE, and in Chrome that beats a
|
|
494
|
+
`dominant-baseline` declaration at any importance, so the `central` this line used to carry
|
|
495
|
+
never rendered at all (measured: em box top 306.5 with the attribute, 308.5 without — `central`
|
|
496
|
+
would have pushed the word two units LOWER still).
|
|
497
|
+
|
|
498
|
+
The drop is mermaid's own arithmetic, so cancel it directly: the word was landing half on the
|
|
499
|
+
tab's own hairline stroke, and the audit scored its ink against that band at 4.39:1 rather than
|
|
500
|
+
against the fill. Translated back, every keyword clears both hairlines — worst case "critical",
|
|
501
|
+
3.1 units of daylight at the top — and every glyph pixel sits on the fill at 13.36:1. Same lever
|
|
502
|
+
`.sequenceNumber` below already uses, for the same class of bug.
|
|
503
|
+
*/
|
|
504
|
+
`.labelText{alignment-baseline:middle!important;transform:translateY(-${TAG_BASELINE_DROP}px);}`,
|
|
505
|
+
/*
|
|
506
|
+
★★ AUTONUMBER IS THREE HARD-CODED VALUES IN THE MARKUP, and no config key reaches any of them.
|
|
507
|
+
mermaid writes the index as
|
|
508
|
+
<text font-family="sans-serif" font-size="12px" class="sequenceNumber">1</text>
|
|
509
|
+
inside a `<circle r="6">` — 12px and a 6-unit disc, both sized for its own 16px era, beside this
|
|
510
|
+
deck's 36px type. Its own stylesheet sets only `.sequenceNumber { fill }`, so the size is not
|
|
511
|
+
something a theme variable can move.
|
|
512
|
+
|
|
513
|
+
They are PRESENTATION ATTRIBUTES, which any rule beats — the same lever the ER edge label above
|
|
514
|
+
needs. The number takes the fragment keyword's rung: it is an index on a line rather than part
|
|
515
|
+
of the diagram's prose, subordinate in exactly the same way. The disc follows the type at
|
|
516
|
+
mermaid's own ratio, r = half the font size, so a two-digit index still fits the way it does at
|
|
517
|
+
12px.
|
|
518
|
+
*/
|
|
519
|
+
`.sequenceNumber{font-family:${MERMAID_FONT_FAMILY};font-size:${MERMAID_INDEX_FONT_PX}px;`
|
|
520
|
+
/*
|
|
521
|
+
★★ AND THE NUMBER HAS TO BE RE-CENTRED, because mermaid positions it as a BASELINE. The disc is
|
|
522
|
+
a marker on a zero-length line at y, and the digit is a separate <text> at y + 4 — four units
|
|
523
|
+
below the centre, which is half a cap height at the 12px it wrote the markup for. Enlarge the
|
|
524
|
+
font and the glyph hangs: measured on the specimen, the digit sat about five units high in its
|
|
525
|
+
own disc.
|
|
526
|
+
|
|
527
|
+
`dominant-baseline: central` centres the glyph on its own y instead of sitting it on one, and
|
|
528
|
+
the translate takes back mermaid's baseline offset so that y is the disc's centre rather than
|
|
529
|
+
four below it. Both are needed: either alone leaves it off by the other's amount.
|
|
530
|
+
*/
|
|
531
|
+
+ `dominant-baseline:central;transform:translateY(-${SEQUENCE_NUMBER_BASELINE_DROP}px);}`,
|
|
532
|
+
`[id$="-sequencenumber"] circle{r:${Math.round(MERMAID_INDEX_FONT_PX * SEQUENCE_NUMBER_DISC_RATIO)}px;}`,
|
|
533
|
+
/*
|
|
534
|
+
★★ A HOLLOW DIAMOND IS GROUND-FILLED, NOT TRANSPARENT. UML's aggregation end is an OPEN diamond
|
|
535
|
+
against composition's solid one, and mermaid draws "open" as no fill at all — so the relationship
|
|
536
|
+
line, which is drawn along the whole gap between the two classes, runs straight through it. At
|
|
537
|
+
mermaid's own size that reads as a small lozenge on the line; at the size this theme draws it,
|
|
538
|
+
it reads as a diamond with a line through the middle, which is not the notation.
|
|
539
|
+
|
|
540
|
+
The ground rather than a colour, for the same reason the edge label knocks out to one: a diagram
|
|
541
|
+
inside a named background has to fill to THAT ground, and the variable already carries it.
|
|
542
|
+
|
|
543
|
+
Composition is deliberately untouched — its diamond is meant to be solid, and it takes the line
|
|
544
|
+
colour from mermaid's own `.marker` rule.
|
|
545
|
+
*/
|
|
546
|
+
`.marker.aggregation{fill:var(--deck-mermaid-label-bg,var(--deck-bg));}`,
|
|
547
|
+
// Pie labels — mermaid's hard-coded trebuchet stack.
|
|
548
|
+
`.slice{font-family:${MERMAID_FONT_FAMILY};}`,
|
|
549
|
+
`.pieTitleText,.legend text{font-family:${MERMAID_FONT_FAMILY};}`,
|
|
550
|
+
// Every drawn edge, named by family: flowchart nodes and links, sequence actors and messages,
|
|
551
|
+
// state, class and ER. A blanket `* { vector-effect }` would also catch the arrowhead fills and
|
|
552
|
+
// the pie slices, which are shapes rather than lines and read correctly as they are.
|
|
553
|
+
// ⚠ `ellipse` is listed because mermaid's OWN node rule lists it — `.node rect,.node circle,
|
|
554
|
+
// .node ellipse,.node polygon,.node path`. A shape family missing from this side is not styled
|
|
555
|
+
// half-way, it is left entirely on mermaid's 1px, which the diagram's own scale then draws at
|
|
556
|
+
// half a pixel.
|
|
557
|
+
`.node rect,.node polygon,.node circle,.node ellipse,.node path,.cluster rect,`
|
|
558
|
+
+ `.task-line,rect.task,rect.journey-section,rect.actor,circle[class^="actor-"],.face,.actor-line,.messageLine0,.messageLine1,.flowchart-link,.edgePath .path,`
|
|
559
|
+
/* ⚠ `.er.entityBox` AND `.er.relationshipLine` WERE BOTH DEAD, and censusing the rendered
|
|
560
|
+
classes is what found them. In 11.17.2 there is no `entityBox` class at all — an entity is a
|
|
561
|
+
`g.node.default` whose box rectifyOuterPaths turns into a <rect>, so `.node rect` above
|
|
562
|
+
already covers it — and the relationship line carries
|
|
563
|
+
`edge-thickness-normal edge-pattern-solid relationshipLine` with NO `er` beside it, so the
|
|
564
|
+
compound selector matched nothing and the line kept mermaid's own weight. */
|
|
565
|
+
+ `.transition,.stateGroup rect,.relationshipLine,.relation`
|
|
566
|
+
+ `{stroke-width:${DIAGRAM_STROKE_PX};}`,
|
|
567
|
+
// A round cap on the line so the end that meets the chevron is the same shape the chevron's own
|
|
568
|
+
// arms end in. A butt cap leaves a square nub inside the V at this weight.
|
|
569
|
+
`.flowchart-link,.edgePath .path,.messageLine0,.messageLine1,.relation,.transition`
|
|
570
|
+
+ `{stroke-linecap:round;stroke-linejoin:round;}`,
|
|
571
|
+
/*
|
|
572
|
+
★★ THE LINK'S WEIGHT NEEDS ONE MORE CLASS, and which one matters. Mermaid sets it through
|
|
573
|
+
`.edge-thickness-normal`, which ties the rule above on specificity, so the winner is whichever
|
|
574
|
+
the browser sees last — not something to depend on. Naming both classes settles it.
|
|
575
|
+
|
|
576
|
+
⚠ `.edge-thickness-thick` is deliberately NOT matched. That class is what an author asks for with
|
|
577
|
+
a `==>` arrow, and a rule flattening it would remove the one weight distinction mermaid offers.
|
|
578
|
+
*/
|
|
579
|
+
`.flowchart-link.edge-thickness-normal,.edgePath .path.edge-thickness-normal`
|
|
580
|
+
+ `{stroke-width:${DIAGRAM_STROKE_PX};}`,
|
|
581
|
+
/*
|
|
582
|
+
⚠ THE GANTT'S LAST TWO SIZES. `gantt.fontSize` moves the task and section text and the layout
|
|
583
|
+
with it, but the chart title and the axis ticks have no key at all — mermaid draws them at a
|
|
584
|
+
literal 18px and 10px, which on a 1920px canvas is 10 and 5 rendered pixels. Neither has a box
|
|
585
|
+
laid out around it, so unlike the task text they can be moved from CSS without the bars
|
|
586
|
+
disagreeing. The ticks take the 0.8 step a pie legend takes, because an axis is a caption.
|
|
587
|
+
*/
|
|
588
|
+
`.titleText{font-size:${MERMAID_FONT_PX}px;}`,
|
|
589
|
+
`.grid .tick text{font-size:${Math.round(MERMAID_FONT_PX * 0.8)}px;}`,
|
|
590
|
+
/*
|
|
591
|
+
★★ A DASH HAS TO CLEAR ITS OWN CAP. mermaid dashes a sequence reply at `stroke-dasharray: 2,2`
|
|
592
|
+
and re-states it inline as `3,3`; the deck then draws that line at 4px with a ROUND cap. A round
|
|
593
|
+
cap adds half the stroke width at each end, so a 3-unit dash is DRAWN 7 units long with 3 units
|
|
594
|
+
of gap after it — the dashes overlap and the line reads solid with a bumpy edge.
|
|
595
|
+
|
|
596
|
+
The pattern is therefore derived from the stroke rather than picked: dash 2x, gap 3x. That draws
|
|
597
|
+
a 12-unit dash with 8 units of daylight, which reads as dashed at any of the sizes this deck
|
|
598
|
+
renders a diagram at.
|
|
599
|
+
|
|
600
|
+
⚠ `!important` IS LOAD-BEARING HERE, and it is the only one in this list. mermaid puts the
|
|
601
|
+
sequence value in an INLINE style, which a stylesheet rule cannot outrank on specificity or on
|
|
602
|
+
source order. Every other rule in this file wins by being appended after mermaid's own.
|
|
603
|
+
*/
|
|
604
|
+
/* ★★ `.edge-pattern-dashed` BARE, NOT PER-FAMILY. It was named on the flowchart link and the
|
|
605
|
+
edgePath only, so a dashed CLASS relation (`..>`, `<|..`) and a dashed ER relationship (`..`)
|
|
606
|
+
took the 4px stroke and the round cap from the rules above and mermaid's own 3-unit dash —
|
|
607
|
+
which is the exact overlap this rule exists to prevent: half the stroke is added at each
|
|
608
|
+
end, so a 3-unit dash draws 7 long with 3 of gap and the line reads solid. Every family
|
|
609
|
+
spells the class the same way, so one bare selector is the honest scope. `.relation` is
|
|
610
|
+
named too: the class diagram sets its dash inline rather than through the pattern class. */
|
|
611
|
+
`.messageLine1,.loopLine,.task-line,.edge-pattern-dashed,.relation.edge-pattern-dashed`
|
|
612
|
+
+ `{stroke-dasharray:${DIAGRAM_STROKE_PX * 2}px ${DIAGRAM_STROKE_PX * 3}px !important;}`,
|
|
613
|
+
/*
|
|
614
|
+
A dot is the same mechanism with no dash at all — the round cap IS the dot, so the length is a
|
|
615
|
+
hair above zero and the gap carries the rhythm.
|
|
616
|
+
|
|
617
|
+
⚠ `-.->` EMITS `edge-pattern-DOTTED`, measured — not `dashed`, which is the name the arrow's
|
|
618
|
+
look suggests. So this rule is the one a flowchart actually exercises (the specimen draws it),
|
|
619
|
+
and `edge-pattern-dashed` above is matched but drawn by nothing here; the sequence reply is what
|
|
620
|
+
evidences that half.
|
|
621
|
+
*/
|
|
622
|
+
`.flowchart-link.edge-pattern-dotted,.edgePath .path.edge-pattern-dotted`
|
|
623
|
+
+ `{stroke-dasharray:0.01px ${DIAGRAM_STROKE_PX * 2.5}px !important;stroke-linecap:round;}`,
|
|
624
|
+
/*
|
|
625
|
+
★★ A SEQUENCE MESSAGE LABEL IS CENTRED ON ITS EM BOX, AND SITS UNDER THE WRONG LINE BECAUSE OF
|
|
626
|
+
IT. Measured on the specimen: 24.1 units of air above the label and 40.8 below, in a 110-unit
|
|
627
|
+
band — so the label is nearly twice as far from the line it NAMES as from the previous message's.
|
|
628
|
+
|
|
629
|
+
The em box itself is close to centred; what is not is the INK. These labels carry no descenders,
|
|
630
|
+
so the descender space the em box reserves is empty and reads as a gap the type does not fill.
|
|
631
|
+
Shifting by a fraction of an em corrects the ink rather than the box, which is why it is stated
|
|
632
|
+
in `em` and holds at any size — `dy` would have to be recomputed per font size.
|
|
633
|
+
*/
|
|
634
|
+
/*
|
|
635
|
+
An edge label knocks its line out rather than veiling it. mermaid gives the label's backing rect
|
|
636
|
+
`opacity: 0.5` — element opacity, not fill-opacity — so the edge runs through the word at half
|
|
637
|
+
strength and the label reads as printed on tracing paper. At this deck's weight, where that edge
|
|
638
|
+
is 4px rather than 1, the ghost line is thicker than the letterforms it crosses.
|
|
639
|
+
|
|
640
|
+
Only the opacity moves: the FILL is already right. mermaid takes it from `edgeLabelBackground`,
|
|
641
|
+
which this theme points at the page's own ground per register, so the rect is already the exact
|
|
642
|
+
colour of the paper behind it and going opaque simply finishes the knockout.
|
|
643
|
+
*/
|
|
644
|
+
`.edgeLabel rect,.edgeLabel .label rect,.icon-shape .label rect,.image-shape .label rect`
|
|
645
|
+
+ `{opacity:1;}`,
|
|
646
|
+
`.messageText{transform:translateY(${MESSAGE_INK_DROP}em);}`,
|
|
647
|
+
// Both placements, because both are laid out by the formula above — an outside label is moved
|
|
648
|
+
// sideways, not vertically, so it carries the same drop.
|
|
649
|
+
`.taskText,.taskTextOutsideLeft,.taskTextOutsideRight{transform:translateY(-${GANTT_LABEL_RISE}px);}`,
|
|
650
|
+
// A journey's actor dot is drawn at r=7 for 14px type. Beside the deck's rung it reads as a
|
|
651
|
+
// speck, so it takes a third of the label's size — `r` is a CSS geometry property, so no surgery
|
|
652
|
+
// is needed. Scoped to `circle` because a sequence diagram's `actor-line` shares the prefix.
|
|
653
|
+
`circle[class^="actor-"]{r:${Math.round(MERMAID_FONT_PX / 3)}px;}`,
|
|
654
|
+
// Rectangular nodes take the deck's corner.
|
|
655
|
+
`.node rect,.cluster rect,rect.actor,.stateGroup rect,`
|
|
656
|
+
+ `rect.task,rect.journey-section`
|
|
657
|
+
+ `{rx:${DIAGRAM_RADIUS}px;ry:${DIAGRAM_RADIUS}px;}`,
|
|
658
|
+
/*
|
|
659
|
+
★★ THE STATE DIAGRAM NEEDS ITS OWN NAMES, and without them it is the one family in this deck
|
|
660
|
+
that draws square. mermaid states its corner as `.statediagram-state rect.basic` — (1,2,1)
|
|
661
|
+
against the (1,1,1) of `.node rect` above, so the generic rule loses and a state box keeps
|
|
662
|
+
mermaid's 5. Repeating mermaid's own selectors TIES on specificity, and these rules are appended
|
|
663
|
+
to the stylesheet it generated, so source order settles it.
|
|
664
|
+
*/
|
|
665
|
+
`.statediagram-state rect.basic,.statediagram-state .title-state,`
|
|
666
|
+
+ `.statediagram-cluster rect.outer,.statediagram-note rect`
|
|
667
|
+
+ `{rx:${DIAGRAM_RADIUS}px;ry:${DIAGRAM_RADIUS}px;}`,
|
|
668
|
+
// The rounded rhombus is a <path> by the time it renders (see roundPolygons), and a path takes its
|
|
669
|
+
// corners from the geometry rather than from CSS. The join is still stated so the stroke follows
|
|
670
|
+
// the curve cleanly where two arcs meet a short edge.
|
|
671
|
+
`.node polygon,.node path{stroke-linejoin:round;}`,
|
|
672
|
+
];
|
|
673
|
+
|
|
674
|
+
/*
|
|
675
|
+
── THE ARROWHEAD ──────────────────────────────────────────────────────────────────────────────
|
|
676
|
+
|
|
677
|
+
Mermaid sizes its heads with `markerWidth`/`markerHeight` at `markerUnits="userSpaceOnUse"`, which
|
|
678
|
+
means two things: the head does NOT grow with stroke width the way a default marker would, and it
|
|
679
|
+
DOES shrink with the diagram, because user space is what fitToColumn scales. Measured on the
|
|
680
|
+
specimen's flowchart, an 8-unit head on a diagram drawn at 0.544 renders about 10 canvas px — a
|
|
681
|
+
tenth of the label beside it, against a line that is now three.
|
|
682
|
+
|
|
683
|
+
The box is scaled and the viewBox is not: the head's geometry is in viewBox units and is fitted
|
|
684
|
+
into the marker box, so multiplying the box enlarges the drawing.
|
|
685
|
+
|
|
686
|
+
★★ AND `refX` HAS TO MOVE WITH IT, WHICH IS THE WHOLE DIFFICULTY. `refX` names the point on the
|
|
687
|
+
marker that sits on the line's endpoint, and mermaid puts it mid-head — so the tip already extends
|
|
688
|
+
PAST the endpoint by design, and the line is drawn short to match. Scaling the box multiplies that
|
|
689
|
+
overhang too, and the tip lands inside the node's border rather than against it.
|
|
690
|
+
|
|
691
|
+
So the anchor is moved to hold the tip where it was and let the head grow BACKWARD along the line.
|
|
692
|
+
Which end is the tip comes from the marker's own name — mermaid names them `…End` and `…Start` —
|
|
693
|
+
and the arithmetic is the same both ways: keep the tip's offset from the anchor constant in
|
|
694
|
+
rendered units while the box grows by `k`.
|
|
695
|
+
|
|
696
|
+
End (tip at the viewBox's right edge) refX' = vbW - (vbW - refX) / k
|
|
697
|
+
Start (tip at the left edge) refX' = refX / k
|
|
698
|
+
|
|
699
|
+
A head already anchored at its own tip — mermaid's `pointEnd-margin`, refX 11.5 of 11.5 — comes out
|
|
700
|
+
unchanged, which is the arithmetic agreeing with the intent rather than a special case.
|
|
701
|
+
*/
|
|
702
|
+
export const ARROWHEAD_SCALE = 1.9;
|
|
703
|
+
|
|
704
|
+
/*
|
|
705
|
+
★ ONLY THE ARROW MARKERS. A mermaid diagram's <defs> holds every marker the family can use, and
|
|
706
|
+
most are not arrowheads: `sequencenumber` is a numbered badge, `circleEnd` and `crossEnd` are
|
|
707
|
+
terminators with their own meaning. Scaling all of them turned the sequence diagram's number badge
|
|
708
|
+
from 60 units to 114. The name is the filter, because mermaid names them plainly.
|
|
709
|
+
*/
|
|
710
|
+
/* ⚠ NO WORD BOUNDARIES AND NO /i. `\bpoint\b` does not match `pointEnd` — 't' and 'E' are both word
|
|
711
|
+
characters, so there is no boundary between them — and case matters: `crosshead` is the sequence
|
|
712
|
+
diagram's X terminator and must not be opened into a chevron, which a case-insensitive `head`
|
|
713
|
+
would catch. Each name is matched as mermaid spells it. */
|
|
714
|
+
/*
|
|
715
|
+
★★ SHAPE AND SIZE ARE TWO DIFFERENT QUESTIONS, AND THE DIAMOND ANSWERS THEM DIFFERENTLY.
|
|
716
|
+
|
|
717
|
+
`aggregationEnd` is a hollow diamond and `compositionEnd` a filled one. Opening either into a
|
|
718
|
+
chevron would say "arrow" where UML says "part of", so they are absent from the SHAPE list — but
|
|
719
|
+
they were absent from the SIZE list too, and nothing justified that. Every arrow in the deck is
|
|
720
|
+
grown to the deck's type while the diamond kept mermaid's stock 17-unit path, so `Bay o-- Vehicle`
|
|
721
|
+
drew a marker a third the weight of the arrowhead on the line beside it.
|
|
722
|
+
|
|
723
|
+
★★ AND THE DIAMOND CANNOT BORROW THE ARROWS' SIZING. viewBoxArrowheads reads markerWidth as the
|
|
724
|
+
size the content already had, which holds for an arrow — `pointEnd` declares 8 to 20 against a
|
|
725
|
+
10-unit path. mermaid declares `aggregationStart` at markerWidth=190, markerHeight=240 for the
|
|
726
|
+
SAME 17-unit path it gives `aggregationEnd` at 20x28. Handing that to the arrow path would map 17
|
|
727
|
+
units into 190 and draw a diamond eleven times too big. sizeDiamondMarkers reads the extent off
|
|
728
|
+
the path's own `d` instead, which is the one measurement mermaid is consistent about.
|
|
729
|
+
*/
|
|
730
|
+
/*
|
|
731
|
+
⚠ THE ER CROW'S FEET ARE NOT IN HERE, AND MUST NOT BE — `markerUnits` is why. MEASURED off the
|
|
732
|
+
rendered ER diagram:
|
|
733
|
+
|
|
734
|
+
<marker class="marker onlyOne er" refX="0" refY="9" markerWidth="18" markerHeight="18"
|
|
735
|
+
orient="auto"><path d="M9,0 L9,18 M15,0 L15,18"/></marker>
|
|
736
|
+
|
|
737
|
+
No `markerUnits` attribute, so it takes the SVG default of `strokeWidth` — the marker is scaled by
|
|
738
|
+
the line it ends, and this deck's line is 4 units, so it already tracks the deck's weight with no
|
|
739
|
+
help. The class diagram's diamond declares `markerUnits="userSpaceOnUse"`, which opts out of that
|
|
740
|
+
scaling, and is the only reason it needed sizing by hand.
|
|
741
|
+
|
|
742
|
+
Adding the crow's feet to this list multiplied an already-scaled marker by ARROWHEAD_SCALE again:
|
|
743
|
+
they rendered several times the height of the entity they pointed at. A marker belongs here only
|
|
744
|
+
if it declares `userSpaceOnUse`; check before adding one.
|
|
745
|
+
*/
|
|
746
|
+
const DIAMOND_MARKER = /(aggregationStart|aggregationEnd|compositionStart|compositionEnd)/;
|
|
747
|
+
/*
|
|
748
|
+
★★ EVERY FAMILY NAMES ITS ARROWHEAD SOMETHING ELSE, and a family missing from this list keeps
|
|
749
|
+
mermaid's own — a filled triangle at its 16px-era size, next to the deck's chevrons. CENSUSED off
|
|
750
|
+
the rendered SVG of every diagram in the specimen rather than guessed:
|
|
751
|
+
|
|
752
|
+
flowchart pointEnd / pointStart
|
|
753
|
+
sequence arrowhead / filled-head
|
|
754
|
+
state barbEnd ← was missing, and it is the same plain arrow
|
|
755
|
+
class extensionStart / extensionEnd, dependencyStart / dependencyEnd
|
|
756
|
+
|
|
757
|
+
The rest of the census is shapes, not arrows, and is listed under NOTATION_MARKER below.
|
|
758
|
+
*/
|
|
759
|
+
const ARROW_MARKER = /(pointEnd|pointStart|arrowhead|ArrowHead|filled-head|barbEnd|extensionStart|extensionEnd|dependencyStart|dependencyEnd)/;
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Size a class diagram's diamond ends to the deck's type, keeping their shape.
|
|
763
|
+
*
|
|
764
|
+
* refX/refY are left where mermaid put them: with a viewBox they are read in the path's own
|
|
765
|
+
* coordinates, which is exactly what they already were.
|
|
766
|
+
*/
|
|
767
|
+
/*
|
|
768
|
+
── THE SHAPE MARKERS THAT SCALE WITH THE LINE, AND THEREFORE WITH THE DECK'S 4px ──────────────
|
|
769
|
+
|
|
770
|
+
A <marker> with no `markerUnits` takes the SVG default, `strokeWidth`: everything it draws is
|
|
771
|
+
multiplied by the width of the line it sits on. mermaid draws the ER cardinalities and the class
|
|
772
|
+
lollipop for its own 1px line, so at this deck's 4px they render FOUR TIMES the size they were
|
|
773
|
+
drawn at — a crow's foot taller than the entity it points at.
|
|
774
|
+
|
|
775
|
+
MEASURED: `<marker class="marker onlyOne er" markerWidth="18" markerHeight="18">` with no
|
|
776
|
+
markerUnits, on a 4-unit line, is 72 units of marker.
|
|
777
|
+
|
|
778
|
+
The cure is to take them out of that space — `userSpaceOnUse` plus a viewBox, so the box is the
|
|
779
|
+
size and the content maps into it — and to draw them at the same scale the aggregation diamond
|
|
780
|
+
ends up: its 17-unit path at ARROWHEAD_SCALE is about 32, so an 18-unit crow's foot lands at 34
|
|
781
|
+
and the two families' line ends finally match each other.
|
|
782
|
+
|
|
783
|
+
⚠ THE viewBox IS NOT OPTIONAL. Without one, markerWidth sets the CLIP and not the scale, so the
|
|
784
|
+
marker would keep its 18 units inside a 34-unit window — the same trap viewBoxArrowheads exists
|
|
785
|
+
for on the arrowheads.
|
|
786
|
+
*/
|
|
787
|
+
const STROKE_SCALED_MARKER = /(onlyOne|zeroOrOne|oneOrMore|zeroOrMore|lollipop|circleEnd|circleStart|crossEnd|crossStart)/;
|
|
788
|
+
|
|
789
|
+
export function unscaleStrokeMarkers(svg: string): string {
|
|
790
|
+
return svg.replace(/<marker\b[^>]*>/g, (tag) => {
|
|
791
|
+
if (!STROKE_SCALED_MARKER.test(tag) || /markerUnits="userSpaceOnUse"/.test(tag) || /viewBox=/.test(tag)) return tag;
|
|
792
|
+
const w = Number(/\bmarkerWidth="([\d.]+)"/.exec(tag)?.[1]);
|
|
793
|
+
const h = Number(/\bmarkerHeight="([\d.]+)"/.exec(tag)?.[1]);
|
|
794
|
+
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return tag;
|
|
795
|
+
return tag
|
|
796
|
+
.replace(/<marker\b/, `<marker viewBox="0 0 ${w} ${h}"`)
|
|
797
|
+
.replace(/\bmarkerUnits="[^"]*"/, '')
|
|
798
|
+
.replace(/<marker\b/, '<marker markerUnits="userSpaceOnUse"')
|
|
799
|
+
.replace(/\bmarkerWidth="[\d.]+"/, `markerWidth="${(w * ARROWHEAD_SCALE).toFixed(2)}"`)
|
|
800
|
+
.replace(/\bmarkerHeight="[\d.]+"/, `markerHeight="${(h * ARROWHEAD_SCALE).toFixed(2)}"`);
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/*
|
|
805
|
+
── THE TIMELINE'S BANDS CARRY THEIR CORNER IN THE PATH ────────────────────────────────────────
|
|
806
|
+
|
|
807
|
+
Every other node in this deck takes its 28px corner from CSS, because it is a `<rect>` and `rx`
|
|
808
|
+
reaches it. A timeline band is not: mermaid emits `<path class="node-bkg">` whose `d` already
|
|
809
|
+
contains the curve — `M0 H v-(H-5) q0,-5,5,-5 h W q5,0,5,5 v H H0 Z` — a 5-unit radius on the TOP
|
|
810
|
+
two corners only, square along the bottom. No rule can move it, so a timeline sat among rounded
|
|
811
|
+
cards with square ones.
|
|
812
|
+
|
|
813
|
+
Rewritten as a full rounded rect over the same box. Guarded on the exact template mermaid emits:
|
|
814
|
+
anything else keeps whatever it drew, which is the same rule roundPolygons follows for the shapes
|
|
815
|
+
whose outline is their meaning.
|
|
816
|
+
*/
|
|
817
|
+
export function roundTimelineNodes(svg: string, radius: number): string {
|
|
818
|
+
return svg.replace(
|
|
819
|
+
/(<path\b[^>]*class="[^"]*\bnode-bkg\b[^"]*"[^>]*\bd=")M0 ([\d.]+) v-[\d.]+ q0,-5,5,-5 h([\d.]+) q5,0,5,5 v[\d.]+ H0 Z(")/g,
|
|
820
|
+
(whole, pre: string, hRaw: string, wRaw: string, post: string) => {
|
|
821
|
+
const h = Number(hRaw);
|
|
822
|
+
const w = Number(wRaw) + 10;
|
|
823
|
+
if (!Number.isFinite(h) || !Number.isFinite(w) || h <= 0 || w <= 0) return whole;
|
|
824
|
+
const r = Math.min(radius, w / 2, h / 2);
|
|
825
|
+
const f = (n: number) => Number(n.toFixed(2));
|
|
826
|
+
const d = `M ${f(r)},0 h ${f(w - r * 2)} a ${f(r)},${f(r)} 0 0 1 ${f(r)},${f(r)}`
|
|
827
|
+
+ ` v ${f(h - r * 2)} a ${f(r)},${f(r)} 0 0 1 ${f(-r)},${f(r)}`
|
|
828
|
+
+ ` h ${f(-(w - r * 2))} a ${f(r)},${f(r)} 0 0 1 ${f(-r)},${f(-r)}`
|
|
829
|
+
+ ` v ${f(-(h - r * 2))} a ${f(r)},${f(r)} 0 0 1 ${f(r)},${f(-r)} Z`;
|
|
830
|
+
return `${pre}${d}${post}`;
|
|
831
|
+
},
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
export function sizeDiamondMarkers(svg: string): string {
|
|
836
|
+
return svg.replace(/<marker\b[^>]*>[\s\S]*?<\/marker>/g, (whole) => {
|
|
837
|
+
const tag = /^<marker\b[^>]*>/.exec(whole)?.[0] ?? '';
|
|
838
|
+
if (!DIAMOND_MARKER.test(tag) || /viewBox=/.test(tag)) return whole;
|
|
839
|
+
const d = /<path\b[^>]*\bd="([^"]+)"/.exec(whole)?.[1];
|
|
840
|
+
const nums = d?.match(/-?\d*\.?\d+/g)?.map(Number);
|
|
841
|
+
if (!nums || nums.length < 4 || nums.length % 2 || nums.some(Number.isNaN)) return whole;
|
|
842
|
+
const xs = nums.filter((_, i) => i % 2 === 0), ys = nums.filter((_, i) => i % 2 === 1);
|
|
843
|
+
const minX = Math.min(...xs), minY = Math.min(...ys);
|
|
844
|
+
/* ★ THE BOX IS THE EXTENT PLUS THE PATH'S OWN INSET, NOT THE EXTENT. A viewBox tight to the
|
|
845
|
+
path puts all four vertices exactly on the edge, and the stroke straddles it — measured on
|
|
846
|
+
an export, the diamond came out as an open chevron with its right and bottom points shaved
|
|
847
|
+
off. mermaid already draws the shape inset by minX/minY inside its box; mirroring that inset
|
|
848
|
+
on the far side is what gives the stroke somewhere to sit. */
|
|
849
|
+
const w = Math.max(...xs) + minX, h = Math.max(...ys) + minY;
|
|
850
|
+
if (!(w > 0 && h > 0)) return whole;
|
|
851
|
+
/* ★★ AND THE ANCHOR HAS TO MOVE WITH IT, or the shape grows the wrong way. mermaid anchors the
|
|
852
|
+
Start diamond at its FAR vertex (refX=18 on an 18-wide path), so the body hangs backward off
|
|
853
|
+
the line's first point — which is the owning class's own edge. At mermaid's size that fits in
|
|
854
|
+
the gap; at 1.9x it does not, and the export showed the left half of the diamond painted over
|
|
855
|
+
by the node, leaving a shape that reads as an arrowhead. Anchoring the NEAR vertex instead
|
|
856
|
+
grows it along the line, into the gap that is already empty. */
|
|
857
|
+
const anchor = /\bid="[^"]*Start[^"]*"/.test(tag) ? minX : Math.max(...xs);
|
|
858
|
+
const sized = tag
|
|
859
|
+
.replace(/<marker\b/, `<marker viewBox="0 0 ${w} ${h}"`)
|
|
860
|
+
.replace(/\brefX="[\d.]+"/, `refX="${anchor}"`)
|
|
861
|
+
.replace(/\bmarkerWidth="[\d.]+"/, `markerWidth="${(w * ARROWHEAD_SCALE).toFixed(2)}"`)
|
|
862
|
+
.replace(/\bmarkerHeight="[\d.]+"/, `markerHeight="${(h * ARROWHEAD_SCALE).toFixed(2)}"`);
|
|
863
|
+
return sized + whole.slice(tag.length);
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/*
|
|
868
|
+
── THE HEAD IS A CHEVRON ──────────────────────────────────────────────────────────────────────
|
|
869
|
+
|
|
870
|
+
Mermaid draws a filled triangle: `M 0 0 L 10 5 L 0 10 z`, closed and filled. The same three points
|
|
871
|
+
without the close, stroked rather than filled, are a chevron — an open V that reads as a direction
|
|
872
|
+
rather than a blob, and that carries the line's weight into its tip instead of ending in a mass.
|
|
873
|
+
|
|
874
|
+
★★ AND THE HEAD IS STROKED IN UNITS LIKE THE LINE IS, which is the only way the two can agree.
|
|
875
|
+
A marker's contents live in viewBox units and the link's stroke now does too, so both are scaled
|
|
876
|
+
by the diagram together and the head is stroked at exactly the link's width, by construction
|
|
877
|
+
rather than by tuning. Measured when they disagreed: a 2.2-unit chevron rendered 1px against a
|
|
878
|
+
3px line, and the ratio moved with every diagram's scale.
|
|
879
|
+
*/
|
|
880
|
+
|
|
881
|
+
/*
|
|
882
|
+
── THE CHEVRON'S ANGLE ────────────────────────────────────────────────────────────────────────
|
|
883
|
+
|
|
884
|
+
Mermaid's head is a long, narrow triangle — `M 0 0 L 10 5 L 0 10`, arms 10 units back for 5 across,
|
|
885
|
+
which is a 53-degree V. Opened into a chevron that reads as a dart rather than an arrow.
|
|
886
|
+
|
|
887
|
+
Widening it is done in the shape's own terms rather than by substituting a hand-written path, so it
|
|
888
|
+
survives mermaid drawing a different triangle: the tip is the vertex furthest from the midpoint of
|
|
889
|
+
the other two, and each arm is shortened along the AXIS (tip to base-midpoint) while its
|
|
890
|
+
perpendicular spread is left alone. Less axial length for the same spread is a wider angle, and the
|
|
891
|
+
head keeps its width, its anchor and its tip exactly where they were — so none of the arithmetic
|
|
892
|
+
that lands the tip on the node's border has to change.
|
|
893
|
+
*/
|
|
894
|
+
const CHEVRON_AXIAL = 0.62;
|
|
895
|
+
|
|
896
|
+
/*
|
|
897
|
+
★ A TRIANGLE IS NOT ALWAYS SPELLED AS SIX NUMBERS. The flowchart writes its head the long way,
|
|
898
|
+
`M 0 0 L 10 5 L 0 10 z`, but the class diagram's inheritance end writes `M 1,7 L18,13 V 1 Z` —
|
|
899
|
+
the third point's x is implied by `V`, so a naive number count sees five and gives up. Expanding
|
|
900
|
+
the shorthand first is what lets one chevron routine serve both.
|
|
901
|
+
*/
|
|
902
|
+
function trianglePoints(d: string): [number, number][] | null {
|
|
903
|
+
const tokens = d.match(/[MLHVmlhv]|-?[\d.]+/g);
|
|
904
|
+
if (!tokens) return null;
|
|
905
|
+
const pts: [number, number][] = [];
|
|
906
|
+
let cmd = '';
|
|
907
|
+
let i = 0;
|
|
908
|
+
while (i < tokens.length) {
|
|
909
|
+
if (/[MLHVmlhv]/.test(tokens[i])) { cmd = tokens[i]; i += 1; continue; }
|
|
910
|
+
const last = pts[pts.length - 1] ?? [0, 0];
|
|
911
|
+
if (cmd === 'M' || cmd === 'L') {
|
|
912
|
+
pts.push([Number(tokens[i]), Number(tokens[i + 1])]); i += 2;
|
|
913
|
+
} else if (cmd === 'm' || cmd === 'l') {
|
|
914
|
+
pts.push([last[0] + Number(tokens[i]), last[1] + Number(tokens[i + 1])]); i += 2;
|
|
915
|
+
} else if (cmd === 'H') { pts.push([Number(tokens[i]), last[1]]); i += 1; }
|
|
916
|
+
else if (cmd === 'h') { pts.push([last[0] + Number(tokens[i]), last[1]]); i += 1; }
|
|
917
|
+
else if (cmd === 'V') { pts.push([last[0], Number(tokens[i])]); i += 1; }
|
|
918
|
+
else if (cmd === 'v') { pts.push([last[0], last[1] + Number(tokens[i])]); i += 1; }
|
|
919
|
+
else return null;
|
|
920
|
+
}
|
|
921
|
+
if (pts.length === 3) return pts;
|
|
922
|
+
/*
|
|
923
|
+
★★ AND THE STATE DIAGRAM'S HEAD IS A TRIANGLE WITH A NOTCH IN IT. `barbEnd` is
|
|
924
|
+
`M 19,7 L9,13 L14,7 L9,1 Z` — four points, where (14,7) sits INSIDE the other three and cuts the
|
|
925
|
+
concave barb out of the base. Rejecting it for having four points left the state diagram with
|
|
926
|
+
mermaid's own filled head beside the flowchart's chevrons, and adding it to the size transforms
|
|
927
|
+
alone just drew that head 1.9x bigger. Dropping the interior vertex leaves the same triangle
|
|
928
|
+
every other family spells directly, and one chevron routine serves them all.
|
|
929
|
+
*/
|
|
930
|
+
if (pts.length === 4) {
|
|
931
|
+
const inside = pts.findIndex((p, i) => {
|
|
932
|
+
const [a, b, c] = pts.filter((_, j) => j !== i);
|
|
933
|
+
const sign = (u: number[], v: number[], w: number[]) => (u[0] - w[0]) * (v[1] - w[1]) - (v[0] - w[0]) * (u[1] - w[1]);
|
|
934
|
+
const d1 = sign(p, a, b), d2 = sign(p, b, c), d3 = sign(p, c, a);
|
|
935
|
+
return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0));
|
|
936
|
+
});
|
|
937
|
+
if (inside >= 0) return pts.filter((_, i) => i !== inside) as [number, number][];
|
|
938
|
+
}
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
export function widenChevron(d: string): string {
|
|
943
|
+
const pts = trianglePoints(d);
|
|
944
|
+
if (!pts) return d;
|
|
945
|
+
/*
|
|
946
|
+
★★ THE TIP IS THE APEX OF THE ISOSCELES, NOT THE VERTEX FURTHEST FROM THE OTHER TWO.
|
|
947
|
+
|
|
948
|
+
An arrowhead is symmetric about the line it sits on, so its tip is the one vertex whose two
|
|
949
|
+
edges are equal. That was originally written as "furthest from the midpoint of the other two",
|
|
950
|
+
which is the same vertex ONLY while the triangle is longer than it is wide — and mermaid does
|
|
951
|
+
not guarantee that. MEASURED on `stateDiagram-barbEnd`, whose triangle is 10 long and 12 across:
|
|
952
|
+
|
|
953
|
+
tip (19,7) -> midpoint of the base (9,7) distance 10
|
|
954
|
+
base (9,13) -> midpoint of the others distance 10.3 ← wins, wrongly
|
|
955
|
+
|
|
956
|
+
So the chevron was built around a base corner and the state diagram drew backward ticks that sat
|
|
957
|
+
beside the line instead of on it. By edge symmetry the same barb gives 11.66 / 11.66 at the tip
|
|
958
|
+
against 11.66 / 12 at either base corner, and the flowchart's own head still resolves the same
|
|
959
|
+
way it always did.
|
|
960
|
+
*/
|
|
961
|
+
let tipIndex = 0, best = Infinity;
|
|
962
|
+
for (let i = 0; i < 3; i++) {
|
|
963
|
+
const [a, b] = [pts[(i + 1) % 3], pts[(i + 2) % 3]];
|
|
964
|
+
const skew = Math.abs(
|
|
965
|
+
Math.hypot(pts[i][0] - a[0], pts[i][1] - a[1]) - Math.hypot(pts[i][0] - b[0], pts[i][1] - b[1]),
|
|
966
|
+
);
|
|
967
|
+
if (skew < best) { best = skew; tipIndex = i; }
|
|
968
|
+
}
|
|
969
|
+
const tip = pts[tipIndex];
|
|
970
|
+
const arms = [pts[(tipIndex + 1) % 3], pts[(tipIndex + 2) % 3]];
|
|
971
|
+
const mid = [(arms[0][0] + arms[1][0]) / 2, (arms[0][1] + arms[1][1]) / 2];
|
|
972
|
+
const axisLen = Math.hypot(mid[0] - tip[0], mid[1] - tip[1]) || 1;
|
|
973
|
+
const axis = [(mid[0] - tip[0]) / axisLen, (mid[1] - tip[1]) / axisLen];
|
|
974
|
+
const moved = arms.map(([x, y]) => {
|
|
975
|
+
const v = [x - tip[0], y - tip[1]];
|
|
976
|
+
const along = v[0] * axis[0] + v[1] * axis[1];
|
|
977
|
+
const par = [axis[0] * along * CHEVRON_AXIAL, axis[1] * along * CHEVRON_AXIAL];
|
|
978
|
+
const perp = [v[0] - axis[0] * along, v[1] - axis[1] * along];
|
|
979
|
+
return [tip[0] + par[0] + perp[0], tip[1] + par[1] + perp[1]];
|
|
980
|
+
});
|
|
981
|
+
const f = (n: number) => n.toFixed(2);
|
|
982
|
+
// Drawn arm-tip-arm so the join at the point is a single stroke join rather than two ends meeting.
|
|
983
|
+
return `M ${f(moved[0][0])} ${f(moved[0][1])} L ${f(tip[0])} ${f(tip[1])} L ${f(moved[1][0])} ${f(moved[1][1])}`;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
export function chevronArrowheads(svg: string, strokePx = DIAGRAM_STROKE_PX): string {
|
|
987
|
+
return svg.replace(/<marker\b([^>]*)>([\s\S]*?)<\/marker>/g, (whole, attrs, body) => {
|
|
988
|
+
if (!ARROW_MARKER.test(attrs)) return whole;
|
|
989
|
+
// Only a closed triangle is convertible; anything else keeps whatever mermaid drew.
|
|
990
|
+
const opened = body.replace(/\sd="([^"]*?)\s*[zZ]"/g, (_m: string, d: string) => ` d="${widenChevron(d.trim())}"`);
|
|
991
|
+
if (opened === body) return whole;
|
|
992
|
+
/*
|
|
993
|
+
⚠ `overflow="visible"`, OR THE POINT IS CLIPPED FLAT. A <marker> clips to its own viewport by
|
|
994
|
+
default, and the tip sits ON the viewBox edge — mermaid's `M 0 0 L 10 5 L 0 10` in a 0..10 box.
|
|
995
|
+
A filled triangle is entirely inside that box, but a STROKED chevron is not: the stroke extends
|
|
996
|
+
half its width past the geometry, and at a non-scaling 4px that is far outside a box only 10
|
|
997
|
+
units wide. The join at the tip is cut off square, which reads as a chopped arrow.
|
|
998
|
+
*/
|
|
999
|
+
const opened2 = attrs.includes('overflow=') ? attrs : `${attrs} overflow="visible"`;
|
|
1000
|
+
/*
|
|
1001
|
+
★★ `fill` GOES IN THE STYLE, NOT IN AN ATTRIBUTE, AND THAT IS THE WHOLE BUG.
|
|
1002
|
+
|
|
1003
|
+
A presentation attribute is the LOWEST-priority way to set an SVG property — any stylesheet
|
|
1004
|
+
rule beats it. mermaid ships `.marker { fill: <lineColor>; stroke: <lineColor> }`, so
|
|
1005
|
+
`fill="none"` on the path was overridden and the chevron was filled back in: the arms and
|
|
1006
|
+
their round joins closed over the gap and the head read as a solid triangle again. Measured on
|
|
1007
|
+
the dark sequence slide at 260dpi — the V is there under the fill, which is why the geometry
|
|
1008
|
+
looked right in every unit test of widenChevron().
|
|
1009
|
+
|
|
1010
|
+
An inline style beats a plain rule, which is what this needs. Nothing here uses `!important`;
|
|
1011
|
+
if mermaid ever sets fill with one, the value it forces is `none` anyway.
|
|
1012
|
+
*/
|
|
1013
|
+
/*
|
|
1014
|
+
★★ AND THE HEAD'S WEIGHT IS DIVIDED BY THE MARKER'S OWN MAGNIFICATION. growArrowheads sets
|
|
1015
|
+
markerWidth to ARROWHEAD_SCALE times the viewBox, so everything the marker draws — the stroke
|
|
1016
|
+
included — is drawn that much larger. Under `non-scaling-stroke` that did not matter, because
|
|
1017
|
+
the width was pinned to canvas pixels whatever the marker did. In units it matters exactly:
|
|
1018
|
+
the head came out 1.9x the weight of the line it ends. Dividing here lands it back on the
|
|
1019
|
+
line's own width once the marker has scaled it.
|
|
1020
|
+
*/
|
|
1021
|
+
const stroked = opened.replace(/<path\b/g,
|
|
1022
|
+
'<path style="fill:none;stroke-width:'
|
|
1023
|
+
+ `${Number((strokePx / ARROWHEAD_SCALE).toFixed(3))}" stroke-linecap="round" stroke-linejoin="round"`);
|
|
1024
|
+
return `<marker${opened2}>${stroked}</marker>`;
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/*
|
|
1029
|
+
── EVERY ARROW MARKER GETS A viewBox, WHICH IS WHAT MAKES THEM ONE ARROWHEAD ──────────────────
|
|
1030
|
+
|
|
1031
|
+
A <marker> WITHOUT a viewBox does not scale its content: markerWidth/Height set the clip and
|
|
1032
|
+
nothing else, so growing the box grows the window and leaves the path the size it was. WITH one,
|
|
1033
|
+
the content is mapped into the box and grows with it.
|
|
1034
|
+
|
|
1035
|
+
Mermaid is inconsistent about this, and that inconsistency is the whole defect. MEASURED in a
|
|
1036
|
+
rendered deck:
|
|
1037
|
+
|
|
1038
|
+
flowchart pointEnd viewBox="0 0 10 10" markerWidth=8 -> 10 units drawn into 8px
|
|
1039
|
+
sequence arrowhead (none) markerWidth=12 -> 10 units drawn at 10px
|
|
1040
|
+
class extensionEnd (none) markerWidth=20 -> 10 units drawn at 10px
|
|
1041
|
+
|
|
1042
|
+
All three then take the same non-scaling 4px stroke. At the flowchart's scale that is a clean V;
|
|
1043
|
+
at 10px, with CHEVRON_AXIAL shortening the arms first, the two round caps meet at the point and
|
|
1044
|
+
the head reads as a blob rather than an arrow. Giving the bare ones a viewBox equal to the size
|
|
1045
|
+
they already had puts every family on the flowchart's footing before anything is grown, so one
|
|
1046
|
+
arrowhead is drawn deck-wide.
|
|
1047
|
+
*/
|
|
1048
|
+
export function viewBoxArrowheads(svg: string): string {
|
|
1049
|
+
return svg.replace(/<marker\b[^>]*>/g, (tag) => {
|
|
1050
|
+
if (!ARROW_MARKER.test(tag) || /viewBox=/.test(tag)) return tag;
|
|
1051
|
+
const w = Number(/\bmarkerWidth="([\d.]+)"/.exec(tag)?.[1]);
|
|
1052
|
+
const h = Number(/\bmarkerHeight="([\d.]+)"/.exec(tag)?.[1]);
|
|
1053
|
+
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return tag;
|
|
1054
|
+
return tag.replace(/<marker\b/, `<marker viewBox="0 0 ${w} ${h}"`);
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
export function growArrowheads(svg: string): string {
|
|
1059
|
+
return svg.replace(/<marker\b[^>]*>/g, (tag) => {
|
|
1060
|
+
if (!ARROW_MARKER.test(tag)) return tag;
|
|
1061
|
+
const vb = /viewBox="([^"]+)"/.exec(tag)?.[1]?.trim().split(/[\s,]+/).map(Number);
|
|
1062
|
+
const vbW = vb?.length === 4 ? vb[2] : Number.NaN;
|
|
1063
|
+
const grown = tag.replace(/\b(markerWidth|markerHeight)="([\d.]+)"/g,
|
|
1064
|
+
(_m, attr, value) => `${attr}="${(Number(value) * ARROWHEAD_SCALE).toFixed(2)}"`);
|
|
1065
|
+
// Without a viewBox there is no coordinate space to move the anchor in, and leaving it put is
|
|
1066
|
+
// the honest fallback: a bigger head in the same place beats one placed by guesswork.
|
|
1067
|
+
if (!Number.isFinite(vbW) || vbW <= 0) return grown;
|
|
1068
|
+
const pointsBackward = /\bid="[^"]*Start[^"]*"/.test(tag);
|
|
1069
|
+
return grown.replace(/\brefX="([\d.]+)"/, (_m, value) => {
|
|
1070
|
+
const refX = Number(value);
|
|
1071
|
+
const moved = pointsBackward ? refX / ARROWHEAD_SCALE : vbW - (vbW - refX) / ARROWHEAD_SCALE;
|
|
1072
|
+
return `refX="${moved.toFixed(3)}"`;
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/*
|
|
1078
|
+
── ROUNDING A POLYGON ─────────────────────────────────────────────────────────────────────────
|
|
1079
|
+
|
|
1080
|
+
SVG gives `rx` to <rect> and to nothing else, so mermaid's decision node — a rhombus, drawn as a
|
|
1081
|
+
<polygon> — has no corner radius available to it. `stroke-linejoin: round` rounds the OUTLINE's
|
|
1082
|
+
joins, and the radius that buys is half the stroke width: under 2px here, which is not a rounded
|
|
1083
|
+
shape, it is a slightly soft point.
|
|
1084
|
+
|
|
1085
|
+
So the points are rewritten as a path. At each vertex the two edges are trimmed by `r` and joined
|
|
1086
|
+
with a quadratic whose control point is the original corner — the standard construction, and the
|
|
1087
|
+
one whose curvature matches a rect's `rx` closely enough that a diamond and a box on the same slide
|
|
1088
|
+
read as the same family.
|
|
1089
|
+
|
|
1090
|
+
★ `r` IS CLAMPED PER CORNER to half the shorter adjoining edge. A rhombus node sized to a short
|
|
1091
|
+
label has edges shorter than the radius, and an untrimmed corner would overshoot into the next one
|
|
1092
|
+
and turn the shape inside out.
|
|
1093
|
+
|
|
1094
|
+
Every <polygon> mermaid emits is a node shape — rhombus, hexagon, parallelogram, trapezoid — so
|
|
1095
|
+
this applies to all of them rather than singling out the diamond. Pie slices and arrowheads are
|
|
1096
|
+
<path> already and are not touched.
|
|
1097
|
+
*/
|
|
1098
|
+
/*
|
|
1099
|
+
── THE EDGE LABEL ────────────────────────────────────────────────────────────────────────────
|
|
1100
|
+
|
|
1101
|
+
★★ AN EDGE LABEL IS CENTRED FROM A HEIGHT MEASURED AT THE WRONG FONT SIZE, and the deck is what
|
|
1102
|
+
makes it visible. mermaid nests the label three deep:
|
|
1103
|
+
|
|
1104
|
+
g.edgeLabel translate(x, y) the path's own midpoint — correct
|
|
1105
|
+
g.label translate(0, -h/2) mermaid's centring
|
|
1106
|
+
rect.background + text the label as drawn
|
|
1107
|
+
|
|
1108
|
+
`h` there is the height mermaid RECORDED for the label, and it records it against its own
|
|
1109
|
+
hard-coded `.edgeLabel .label { font-size: 14px }` — the exact rule ROOM_SCALE_RULES overrides,
|
|
1110
|
+
after the layout has run. So the translate is half a 14px line box while the glyphs and their
|
|
1111
|
+
background are a 36px one, and the label hangs below the line it belongs to.
|
|
1112
|
+
|
|
1113
|
+
MEASURED on the specimen's ER diagram: `g.label` translate(0, -8.75) against a background rect of
|
|
1114
|
+
y -2, height 49 — 13.75 user units, 7.48 canvas px, on a line the label is supposed to bisect.
|
|
1115
|
+
|
|
1116
|
+
The repair takes the height from the rect mermaid ACTUALLY EMITTED rather than from a font size
|
|
1117
|
+
this file would then have to keep in step: `createFormattedText` sizes that rect from the text's
|
|
1118
|
+
own getBBox plus 2 units of padding, so it is the drawn box by construction, at any size.
|
|
1119
|
+
|
|
1120
|
+
★ BOTH AXES, and x is deliberately included even though it is usually a no-op. A centred label's
|
|
1121
|
+
bbox is already symmetric about 0, so `-(x + w/2)` returns the translate unchanged — and where it
|
|
1122
|
+
is NOT symmetric (a start/end terminal label, which mermaid anchors differently) the same
|
|
1123
|
+
arithmetic centres it for the same reason. A rule that is inert exactly where mermaid is right is
|
|
1124
|
+
cheaper to trust than a second rule that decides when to fire.
|
|
1125
|
+
*/
|
|
1126
|
+
/*
|
|
1127
|
+
── THE PIE LEGEND ────────────────────────────────────────────────────────────────────────────
|
|
1128
|
+
|
|
1129
|
+
★★ THE SECOND INSTANCE OF THE SAME DEFECT AS THE EDGE LABEL, and worth naming as one rather than
|
|
1130
|
+
as two fixes. mermaid stacks legend rows on a pitch it hard-codes for its OWN text size — the rows
|
|
1131
|
+
come out at `translate(x, -55 | -33 | -11 | 11 | 33)`, a 22-unit step. `pieLegendTextSize` is the
|
|
1132
|
+
deck's body-sm rung times 0.8, i.e. 29, so five 29-unit labels are stacked 22 units apart and every
|
|
1133
|
+
row overlaps the one below it.
|
|
1134
|
+
|
|
1135
|
+
The pitch is READ from the rows mermaid emitted rather than assumed to be 22: two consecutive rows
|
|
1136
|
+
give it, whatever mermaid changes it to.
|
|
1137
|
+
|
|
1138
|
+
★ THE BLOCK'S CENTRE IS HELD, which is why the deviations are scaled and not the coordinates.
|
|
1139
|
+
mermaid centres this stack on the pie, and multiplying each `y` outright would drag the whole
|
|
1140
|
+
legend down by however much the spacing opened. Scaling each row's distance FROM THE MEAN opens
|
|
1141
|
+
the gaps symmetrically and leaves the block where it was placed.
|
|
1142
|
+
|
|
1143
|
+
A legend of one row has no pitch to read and no overlap to fix, so it is left alone.
|
|
1144
|
+
*/
|
|
1145
|
+
/*
|
|
1146
|
+
── THE JOURNEY'S SATISFACTION AXIS ───────────────────────────────────────────────────────────
|
|
1147
|
+
|
|
1148
|
+
★★ THE FACE'S POSITION IS ARITHMETIC MERMAID HARD-CODES, and it is the reason this family cannot
|
|
1149
|
+
simply be given bigger type. From its own renderer:
|
|
1150
|
+
|
|
1151
|
+
const maxHeight = 300 + 5 * 30;
|
|
1152
|
+
line ... .attr("y2", maxHeight)
|
|
1153
|
+
drawFace(g, { cy: 300 + (5 - task.score) * 30 })
|
|
1154
|
+
|
|
1155
|
+
A score is drawn 30 units below the one above it, the column starts at 300 and ends at 450, and no
|
|
1156
|
+
config key reaches any of those numbers. They are sized for mermaid's default 50-unit task box,
|
|
1157
|
+
whose bottom lands at 298 — just above the first face.
|
|
1158
|
+
|
|
1159
|
+
A box that holds 36px type is 112 units, so its bottom sits at 360, INSIDE that column: scores 5, 4
|
|
1160
|
+
and 3 are drawn behind the box they belong to. And a face big enough to read across a room is
|
|
1161
|
+
wider than the 30-unit step between two scores, so it collides on the other side too. Both are the
|
|
1162
|
+
same conflict — a fixed lattice under type it was never sized for.
|
|
1163
|
+
|
|
1164
|
+
So the lattice is rebuilt from the box that actually rendered: the first face clears the box by a
|
|
1165
|
+
fraction of its own radius, the step is the face's own size, and the column ends below the last
|
|
1166
|
+
one. The score is recovered from mermaid's formula rather than re-parsed from the diagram.
|
|
1167
|
+
|
|
1168
|
+
★ ONE TRANSFORM, NOT TWO PASSES. The face and its features are siblings, and the eyes carry
|
|
1169
|
+
absolute coordinates while the mouth carries its own translate — so moving the assembly by
|
|
1170
|
+
rewriting children would mean three different edits. Both elements instead take the SAME transform
|
|
1171
|
+
about the face's own centre, which moves and scales them together and cannot drift apart.
|
|
1172
|
+
*/
|
|
1173
|
+
export function placeJourneyFaces(svg: string, faceDiameter: number): string {
|
|
1174
|
+
const FACE = /<circle\b[^>]*\bclass="face"[^>]*>(?:<\/circle>)?(\s*)<g>/g;
|
|
1175
|
+
if (!FACE.test(svg)) return svg;
|
|
1176
|
+
FACE.lastIndex = 0;
|
|
1177
|
+
|
|
1178
|
+
const attr = (tag: string, name: string) => {
|
|
1179
|
+
const hit = new RegExp(`\\b${name}="([-\\d.]+)"`).exec(tag);
|
|
1180
|
+
return hit ? Number(hit[1]) : NaN;
|
|
1181
|
+
};
|
|
1182
|
+
const box = /<rect\b[^>]*\bclass="task task-type-\d+"[^>]*>/.exec(svg)?.[0];
|
|
1183
|
+
if (!box) return svg;
|
|
1184
|
+
const boxBottom = attr(box, 'y') + attr(box, 'height');
|
|
1185
|
+
if (!Number.isFinite(boxBottom)) return svg;
|
|
1186
|
+
|
|
1187
|
+
const radius = faceDiameter / 2;
|
|
1188
|
+
const gap = radius * 0.4;
|
|
1189
|
+
// mermaid's own lattice, named so the arithmetic below reads against it rather than against
|
|
1190
|
+
// three unexplained numbers.
|
|
1191
|
+
const MERMAID_TOP = 300;
|
|
1192
|
+
const MERMAID_STEP = 30;
|
|
1193
|
+
const MERMAID_SCORES = 5;
|
|
1194
|
+
const top = boxBottom + gap + radius;
|
|
1195
|
+
const step = faceDiameter * 0.9;
|
|
1196
|
+
const bottom = top + (MERMAID_SCORES - 1) * step + radius + gap;
|
|
1197
|
+
const remap = (cy: number) => top + ((cy - MERMAID_TOP) / MERMAID_STEP) * step;
|
|
1198
|
+
|
|
1199
|
+
let out = svg.replace(FACE, (whole, space: string) => {
|
|
1200
|
+
const face = whole.slice(0, whole.indexOf('>') + 1);
|
|
1201
|
+
const cx = attr(face, 'cx');
|
|
1202
|
+
const cy = attr(face, 'cy');
|
|
1203
|
+
const r = attr(face, 'r');
|
|
1204
|
+
if (![cx, cy, r].every(Number.isFinite) || r <= 0) return whole;
|
|
1205
|
+
const k = faceDiameter / (r * 2);
|
|
1206
|
+
const dy = remap(cy) - cy;
|
|
1207
|
+
const t = `transform="translate(0 ${dy}) translate(${cx} ${cy}) scale(${k}) translate(${-cx} ${-cy})"`;
|
|
1208
|
+
return `${face.replace(/>$/, ` ${t}>`)}</circle>${space}<g ${t}>`;
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
// The dashed guide each face slides along, and the rule the column stands on — an unclassed,
|
|
1212
|
+
// full-width line, which is what `y1 === y2` identifies it by.
|
|
1213
|
+
out = out.replace(/<line\b[^>]*\bclass="task-line"[^>]*>/g, (line) =>
|
|
1214
|
+
line.replace(/\by2="[-\d.]+"/, `y2="${bottom}"`));
|
|
1215
|
+
out = out.replace(/<line\b(?![^>]*\bclass=)[^>]*>/g, (line) => {
|
|
1216
|
+
const y1 = attr(line, 'y1');
|
|
1217
|
+
const y2 = attr(line, 'y2');
|
|
1218
|
+
if (!Number.isFinite(y1) || y1 !== y2) return line;
|
|
1219
|
+
return line.replace(/\by1="[-\d.]+"/, `y1="${bottom - 2}"`).replace(/\by2="[-\d.]+"/, `y2="${bottom - 2}"`);
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
// The column is now taller than the box mermaid measured, so the viewBox has to follow or the
|
|
1223
|
+
// lowest score is simply cropped off the diagram.
|
|
1224
|
+
return out.replace(/viewBox="([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+)"/, (vb, x, y, w, h) => {
|
|
1225
|
+
const minY = Number(y);
|
|
1226
|
+
const wanted = bottom - minY + radius + gap;
|
|
1227
|
+
return Number(h) >= wanted ? vb : `viewBox="${x} ${y} ${w} ${wanted}"`;
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/*
|
|
1232
|
+
── THE RADAR LEGEND, WHICH MERMAID GIVES NO HANDLE AT ALL ─────────────────────────────────────
|
|
1233
|
+
|
|
1234
|
+
★★ THE ROW IS AN UNCLASSED GROUP AND ITS PITCH IS A LITERAL 20. mermaid appends each legend entry
|
|
1235
|
+
as `g.append("g").attr("transform", translate(legendX, legendY + index * 20))` — no class, no id,
|
|
1236
|
+
no config key — so there is nothing for a stylesheet to select and nothing for `mermaidConfig` to
|
|
1237
|
+
set. `legendFontSize` is therefore capped at about 18 by a number no theme can reach, which is
|
|
1238
|
+
why the legend read as a footnote beside a diagram sized for a room.
|
|
1239
|
+
|
|
1240
|
+
A string rewrite CAN reach it, and the same pass fixes the two things the pitch was hiding.
|
|
1241
|
+
MEASURED off the render, in the plot group's own units:
|
|
1242
|
+
|
|
1243
|
+
row 0 translate(352.5, -292.5) rect 12x12 at (0,0) text x=16 y=0
|
|
1244
|
+
row 1 translate(352.5, -272.5) " "
|
|
1245
|
+
|
|
1246
|
+
· THE PITCH is 20 against a line box the deck's legend rung wants at 36.
|
|
1247
|
+
· THE SWATCH IS NOT CENTRED ON ITS WORD. The chip spans y 0..12 while the text sits on a BASELINE
|
|
1248
|
+
at y=0, so the word rides above the chip that names it — invisible at mermaid's size, plain at
|
|
1249
|
+
the deck's.
|
|
1250
|
+
· THE BLOCK IS NOT CENTRED ON ANYTHING. Both rows sit at the top of the right margin because the
|
|
1251
|
+
first is placed at a fixed `legendY` and the rest step down from it, so a two-curve legend hangs
|
|
1252
|
+
high and a six-curve one runs past the bottom of the frame.
|
|
1253
|
+
|
|
1254
|
+
All three come out of one rewrite: rows pitched at the deck's line box, stepped either side of the
|
|
1255
|
+
plot's own centre line, with the chip seated on the baseline it labels.
|
|
1256
|
+
*/
|
|
1257
|
+
/*
|
|
1258
|
+
── THE TREEMAP SECTION HEADER, SEATED FOR THE TYPE IT NOW CARRIES ─────────────────────────────
|
|
1259
|
+
|
|
1260
|
+
★★ THE BASELINE IS A LITERAL 12.5 AND THE THEME MOVED THE TYPE OUT FROM UNDER IT. mermaid writes
|
|
1261
|
+
a section's name and its total on one line at `y = SECTION_HEADER_HEIGHT / 2`, a number chosen for
|
|
1262
|
+
its own 12px label; this deck lifts that label to the transition rung and nothing re-seats it.
|
|
1263
|
+
MEASURED on the render: `Camden` at 29px bold sits on a baseline of 12.5 with its box reaching to
|
|
1264
|
+
y = -9 — nine units ABOVE the cell's own top edge, so the word crosses the border it is inside.
|
|
1265
|
+
|
|
1266
|
+
The same literal puts the name 6 units from the left edge and the total 10 from the right, both of
|
|
1267
|
+
which are inside the 4-unit border at the deck's weight rather than clear of it.
|
|
1268
|
+
|
|
1269
|
+
★ THERE IS ROOM WITHOUT MOVING ANYTHING ELSE. d3 is handed a `paddingTop` of 35 — the 25-unit
|
|
1270
|
+
header plus 10 of inner padding — so the leaves already start well below the band. Measured:
|
|
1271
|
+
a section 395 tall holding a 350-tall leaf, i.e. 35 above and 10 below. The seating below places
|
|
1272
|
+
the ink inside that reserve and leaves the layout exactly where mermaid computed it.
|
|
1273
|
+
*/
|
|
1274
|
+
/*
|
|
1275
|
+
The baseline that centres the header's ink in the band it has. Cap ink measures 0.72 of the type
|
|
1276
|
+
on this face (21 units at 29px, read off the render), so the ink is centred by putting half the
|
|
1277
|
+
slack above it and the rest below — derived rather than picked, because the rung above it moved
|
|
1278
|
+
once already and the seating has to move with it.
|
|
1279
|
+
*/
|
|
1280
|
+
const TREEMAP_HEADER_INK = MERMAID_TAG_FONT_PX * 0.72;
|
|
1281
|
+
const TREEMAP_HEADER_INSET = Math.round(MERMAID_EDGE_LABEL_FONT_PX * 0.48);
|
|
1282
|
+
/*
|
|
1283
|
+
The room a header actually has: mermaid hands d3 a `paddingTop` of the 25-unit header plus 10 of
|
|
1284
|
+
inner padding, so nothing is laid out above this line. Confirmed against the render rather than
|
|
1285
|
+
taken from the source — a section 395 tall holding a 350-tall leaf leaves 35 above and 10 below.
|
|
1286
|
+
*/
|
|
1287
|
+
const TREEMAP_HEADER_BAND = 35;
|
|
1288
|
+
/*
|
|
1289
|
+
★★ THE BAND IS SMALLER THAN IT LOOKS, because two borders eat into it. The section's own 4-unit
|
|
1290
|
+
edge is drawn on its top line, so half of it is inside; the first leaf carries the same edge, and
|
|
1291
|
+
half of THAT is above its top. The room the ink actually has is 2 to 33, not 0 to 35 — and
|
|
1292
|
+
centring against the nominal band is what left the word visibly nearer the leaf than the border.
|
|
1293
|
+
Measured before this: 9.5 clear above the cap, 7.7 below the ink, and the visible halves 7.5
|
|
1294
|
+
against 5.7.
|
|
1295
|
+
*/
|
|
1296
|
+
const TREEMAP_HEADER_EDGE = DIAGRAM_STROKE_PX / 2;
|
|
1297
|
+
const TREEMAP_HEADER_BASELINE = Math.round(
|
|
1298
|
+
TREEMAP_HEADER_EDGE + (TREEMAP_HEADER_BAND - 2 * TREEMAP_HEADER_EDGE - TREEMAP_HEADER_INK) / 2 + TREEMAP_HEADER_INK,
|
|
1299
|
+
);
|
|
1300
|
+
|
|
1301
|
+
export function seatTreemapHeaders(svg: string): string {
|
|
1302
|
+
/*
|
|
1303
|
+
★★ AND THE BAND CLIPS AT 25, WHICH IS WHAT ACTUALLY CUT THE WORD. Seating the baseline alone
|
|
1304
|
+
made it worse, not better: mermaid gives every header its own `<clipPath>` sized to the 25-unit
|
|
1305
|
+
band, so a label seated for the deck's type was cropped through the middle — measured, ink from
|
|
1306
|
+
4.5 to 40.7 against a clip ending at 25. The clip has a NAME rather than a number to match on:
|
|
1307
|
+
the header's is `clip-section-…` where a leaf's is `clip-…`, so this reaches the four that hold
|
|
1308
|
+
a title and none of the ten that hold a cell.
|
|
1309
|
+
*/
|
|
1310
|
+
const opened = svg.replace(/(<clipPath id="clip-section-[^"]*"><rect\b[^>]*)\sheight="[^"]*"/g,
|
|
1311
|
+
(whole: string, head: string) => `${head} height="${TREEMAP_HEADER_BAND}"`);
|
|
1312
|
+
return opened.replace(/<text\b([^>]*)>/g, (whole: string, attrs: string) => {
|
|
1313
|
+
const label = /\bclass="[^"]*\btreemapSectionLabel\b[^"]*"/.test(attrs);
|
|
1314
|
+
const value = /\bclass="[^"]*\btreemapSectionValue\b[^"]*"/.test(attrs);
|
|
1315
|
+
if (!label && !value) return whole;
|
|
1316
|
+
const held = /\sx="([-\d.eE+]+)"/.exec(attrs);
|
|
1317
|
+
if (!held) return whole;
|
|
1318
|
+
/*
|
|
1319
|
+
★ THE NAME IS ANCHORED LEFT AND THE TOTAL RIGHT, so one is set and the other is NUDGED. The
|
|
1320
|
+
total's x is `width - 10`, and the width is not in the tag — moving it in by the difference is
|
|
1321
|
+
the only way to inset it without re-deriving a number mermaid already computed.
|
|
1322
|
+
*/
|
|
1323
|
+
const x = label ? TREEMAP_HEADER_INSET : Number(held[1]) - (TREEMAP_HEADER_INSET - 10);
|
|
1324
|
+
if (!Number.isFinite(x)) return whole;
|
|
1325
|
+
const seated = attrs
|
|
1326
|
+
.replace(/\sx="[^"]*"/, ` x="${round4(x)}"`)
|
|
1327
|
+
.replace(/\sy="[^"]*"/, ` y="${TREEMAP_HEADER_BASELINE}"`);
|
|
1328
|
+
return `<text${seated}>`;
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
export function spaceRadarLegend(svg: string, textPx: number): string {
|
|
1333
|
+
/*
|
|
1334
|
+
★★ A DEPTH SCAN, AND THE NON-GREEDY VERSION OF THIS WAS WRITTEN FIRST AND WAS WRONG — the same
|
|
1335
|
+
trap recentreEdgeLabels documents two screens up, walked into anyway. `<g …>([\s\S]*?)</g>` on
|
|
1336
|
+
the PLOT group matches from its opening tag to the first `</g>` in the file, which is the end of
|
|
1337
|
+
legend row 0. That match is correctly rejected (its body contains a nested `<g`), but `replace`
|
|
1338
|
+
has already consumed the span — so row 0 never got a match of its own and only row 1 moved.
|
|
1339
|
+
Measured on the render: one row re-seated, one left exactly as mermaid drew it.
|
|
1340
|
+
|
|
1341
|
+
The scanner below counts opens and closes, so the group it edits is the group it matched.
|
|
1342
|
+
*/
|
|
1343
|
+
const OPEN = /<g transform="translate\(\s*([-\d.eE+]+)[\s,]+([-\d.eE+]+)\s*\)"\s*>/g;
|
|
1344
|
+
type Row = { open: number; bodyStart: number; end: number; x: string; body: string };
|
|
1345
|
+
const rows: Row[] = [];
|
|
1346
|
+
for (let m = OPEN.exec(svg); m; m = OPEN.exec(svg)) {
|
|
1347
|
+
const bodyStart = m.index + m[0].length;
|
|
1348
|
+
const end = groupEnd(svg, bodyStart);
|
|
1349
|
+
if (end < 0) continue;
|
|
1350
|
+
const body = svg.slice(bodyStart, end - 4);
|
|
1351
|
+
// A legend row is an unclassed group holding one swatch and one word, and nothing else.
|
|
1352
|
+
if (!/<rect\b[^>]*radarLegendBox-/.test(body) || /<g[\s>]/.test(body)) continue;
|
|
1353
|
+
rows.push({ open: m.index, bodyStart, end, x: m[1], body });
|
|
1354
|
+
}
|
|
1355
|
+
if (!rows.length) return svg;
|
|
1356
|
+
|
|
1357
|
+
// A line box, not the glyph height — the same 1.25 spaceLegendRows gives the pie's.
|
|
1358
|
+
const pitch = textPx * 1.25;
|
|
1359
|
+
const top = -((rows.length - 1) / 2) * pitch;
|
|
1360
|
+
|
|
1361
|
+
let out = '';
|
|
1362
|
+
let cursor = 0;
|
|
1363
|
+
rows.forEach((row, i) => {
|
|
1364
|
+
/*
|
|
1365
|
+
★★ THE CHIP IS SEATED ON THE BASELINE, NOT HUNG FROM IT. Its own y is half its height above
|
|
1366
|
+
the row's origin, which puts its centre on the line the word sits on — and the word then takes
|
|
1367
|
+
the deck's own answer to centring text in a box: an alphabetic baseline plus 0.35em, half a cap
|
|
1368
|
+
height for this face, rather than `dominant-baseline: central`, which centres the em box with
|
|
1369
|
+
its descender space included and leaves a word with no descenders riding high. The same
|
|
1370
|
+
correction recentreEdgeLabels makes, for the same reason.
|
|
1371
|
+
*/
|
|
1372
|
+
const seated = row.body
|
|
1373
|
+
.replace(/<rect\b([^>]*)>/, (rect: string, attrs: string) => (
|
|
1374
|
+
/radarLegendBox-/.test(attrs)
|
|
1375
|
+
? `<rect${attrs.replace(/\s(?:x|y)="[^"]*"/g, '')} x="0" y="${round4(-RADAR_SWATCH_PX / 2)}">`
|
|
1376
|
+
: rect
|
|
1377
|
+
))
|
|
1378
|
+
.replace(/<text\b([^>]*)>/, (whole2: string, attrs: string) => (
|
|
1379
|
+
`<text${attrs.replace(/\s(?:x|y|dy)="[^"]*"/g, '')} x="${RADAR_SWATCH_PX + RADAR_SWATCH_GAP_PX}" y="0" dy="0.35em">`
|
|
1380
|
+
));
|
|
1381
|
+
out += svg.slice(cursor, row.open)
|
|
1382
|
+
+ `<g transform="translate(${row.x}, ${round4(top + i * pitch)})">`
|
|
1383
|
+
+ seated
|
|
1384
|
+
+ '</g>';
|
|
1385
|
+
cursor = row.end;
|
|
1386
|
+
});
|
|
1387
|
+
return out + svg.slice(cursor);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
export function spaceLegendRows(svg: string, textPx: number): string {
|
|
1391
|
+
const ROW = /<g class="legend" transform="translate\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)"/g;
|
|
1392
|
+
const rows = [...svg.matchAll(ROW)];
|
|
1393
|
+
if (rows.length < 2) return svg;
|
|
1394
|
+
const ys = rows.map((m) => Number(m[2]));
|
|
1395
|
+
if (!ys.every(Number.isFinite)) return svg;
|
|
1396
|
+
const pitch = Math.abs(ys[1] - ys[0]);
|
|
1397
|
+
// A line box, not the glyph height — the same 1.25 the deck gives a label elsewhere.
|
|
1398
|
+
const wanted = textPx * 1.25;
|
|
1399
|
+
if (!(pitch > 0) || pitch >= wanted) return svg;
|
|
1400
|
+
const k = wanted / pitch;
|
|
1401
|
+
const mean = ys.reduce((a, b) => a + b, 0) / ys.length;
|
|
1402
|
+
let i = 0;
|
|
1403
|
+
return svg.replace(ROW, (whole, x: string) => {
|
|
1404
|
+
const y = mean + (ys[i++] - mean) * k;
|
|
1405
|
+
return whole.replace(/translate\([^)]*\)/, `translate(${x}, ${y})`);
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/*
|
|
1410
|
+
── THE JOURNEY'S ACTOR LEGEND ────────────────────────────────────────────────────────────────
|
|
1411
|
+
|
|
1412
|
+
The same defect as the pie legend, in different markup, which is why it needs its own pass rather
|
|
1413
|
+
than a wider regex. A pie stacks `<g class="legend" transform="translate(x,y)">`; a journey emits
|
|
1414
|
+
a BARE `<circle class="actor-N" cy>` followed by a `<text class="legend" y>`, both children of the
|
|
1415
|
+
root, on a hard-coded 20-unit pitch. Under 36px type the two rows print on top of each other.
|
|
1416
|
+
|
|
1417
|
+
★ THE DOT MOVES WITH ITS LABEL. They are separate elements with separate coordinates, so a pass
|
|
1418
|
+
that opened only the text spacing would leave every dot behind on the old pitch, beside the wrong
|
|
1419
|
+
name. The delta is computed once per row and applied to both.
|
|
1420
|
+
*/
|
|
1421
|
+
export function spaceJourneyLegend(svg: string, textPx: number): string {
|
|
1422
|
+
const PAIR = /(<circle\b[^>]*\bclass="actor-\d+"[^>]*>(?:<\/circle>)?)(\s*)(<text\b[^>]*\bclass="legend"[^>]*>)/g;
|
|
1423
|
+
const rows = [...svg.matchAll(PAIR)];
|
|
1424
|
+
if (rows.length < 2) return svg;
|
|
1425
|
+
const yOf = (tag: string, attr: string) => {
|
|
1426
|
+
const hit = new RegExp(`\\b${attr}="([-\\d.]+)"`).exec(tag);
|
|
1427
|
+
return hit ? Number(hit[1]) : NaN;
|
|
1428
|
+
};
|
|
1429
|
+
const ys = rows.map((m) => yOf(m[3], 'y'));
|
|
1430
|
+
if (!ys.every(Number.isFinite)) return svg;
|
|
1431
|
+
const pitch = Math.abs(ys[1] - ys[0]);
|
|
1432
|
+
const wanted = textPx * 1.25;
|
|
1433
|
+
if (!(pitch > 0) || pitch >= wanted) return svg;
|
|
1434
|
+
const k = wanted / pitch;
|
|
1435
|
+
const mean = ys.reduce((a, b) => a + b, 0) / ys.length;
|
|
1436
|
+
let i = 0;
|
|
1437
|
+
return svg.replace(PAIR, (whole, dot: string, gap: string, label: string) => {
|
|
1438
|
+
const was = ys[i++];
|
|
1439
|
+
const delta = mean + (was - mean) * k - was;
|
|
1440
|
+
const cy = yOf(dot, 'cy');
|
|
1441
|
+
const movedDot = Number.isFinite(cy)
|
|
1442
|
+
? dot.replace(/\bcy="[-\d.]+"/, `cy="${cy + delta}"`)
|
|
1443
|
+
: dot;
|
|
1444
|
+
const movedLabel = label.replace(/\by="[-\d.]+"/, `y="${was + delta}"`);
|
|
1445
|
+
return `${movedDot}${gap}${movedLabel}`;
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/*
|
|
1450
|
+
One canvas, reused: creating a 2D context per label on a diagram with a dozen edges is the kind of
|
|
1451
|
+
cost that shows up as a slow slide rather than as a wrong one. Returns 0 when there is no canvas to
|
|
1452
|
+
measure with — under a test runner, say — and the caller keeps mermaid's own box in that case.
|
|
1453
|
+
*/
|
|
1454
|
+
let labelMetrics: CanvasRenderingContext2D | null | undefined;
|
|
1455
|
+
function measureLabel(text: string): number {
|
|
1456
|
+
if (labelMetrics === undefined) {
|
|
1457
|
+
labelMetrics = typeof document === 'undefined' ? null : document.createElement('canvas').getContext('2d');
|
|
1458
|
+
if (labelMetrics) labelMetrics.font = `${MERMAID_EDGE_LABEL_FONT_PX}px ${MERMAID_FONT_FAMILY}`;
|
|
1459
|
+
}
|
|
1460
|
+
if (!labelMetrics || !text.trim()) return 0;
|
|
1461
|
+
return labelMetrics.measureText(text.trim()).width;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/*
|
|
1465
|
+
⚠ AN EDGE LABEL ALWAYS KNOCKS OUT TO THE PAGE, EVEN INSIDE A CONTAINER — a known miss, recorded
|
|
1466
|
+
because two different attempts at it have now failed and the next reader deserves the map.
|
|
1467
|
+
|
|
1468
|
+
A label knocks the line out behind itself, so its fill should BE the ground it sits on: the page on
|
|
1469
|
+
a plain slide, the container's surface inside a `subgraph` or a composite. Getting it wrong paints
|
|
1470
|
+
a page-coloured card onto a surface-coloured box, which is what happens today.
|
|
1471
|
+
|
|
1472
|
+
· A DESCENDANT RULE CANNOT REACH IT. mermaid collects every edge label into one `g.edgeLabels` at
|
|
1473
|
+
the ROOT and positions it by transform, so a label is never a descendant of the cluster it
|
|
1474
|
+
appears to sit in.
|
|
1475
|
+
· A POINT-IN-RECT TEST HERE DID NOT WORK EITHER. The geometry is all in the markup — clusters
|
|
1476
|
+
carry absolute `<rect x y width height>`, each `g.edgeLabel` an absolute `translate` — but the
|
|
1477
|
+
two are not in the same coordinate space by the time this runs, and a test built on them fired
|
|
1478
|
+
on the flowchart's `cluster` and never on the state diagram's `statediagram-cluster`.
|
|
1479
|
+
|
|
1480
|
+
Whoever picks this up: compare the two families' transform chains first. The answer is probably a
|
|
1481
|
+
parent transform this pass is not accounting for, not the test itself.
|
|
1482
|
+
*/
|
|
1483
|
+
export function recentreEdgeLabels(svg: string): string {
|
|
1484
|
+
/*
|
|
1485
|
+
★ A DEPTH SCAN, NOT A REGEX, and the difference is not stylistic. `g.label` wraps ANOTHER `<g>`
|
|
1486
|
+
around the rect and the text, so a non-greedy `([\s\S]*?)</g>` stops at the INNER close tag and
|
|
1487
|
+
a rewrite built on it emits markup with one `</g>` too few. The scanner below counts `<g` and
|
|
1488
|
+
`</g>` so the group it edits is the group it matched.
|
|
1489
|
+
*/
|
|
1490
|
+
const OPEN = /<g class="label"([^>]*)>/g;
|
|
1491
|
+
let out = '';
|
|
1492
|
+
let cursor = 0;
|
|
1493
|
+
for (let m = OPEN.exec(svg); m; m = OPEN.exec(svg)) {
|
|
1494
|
+
/*
|
|
1495
|
+
★★ A MATCH INSIDE A GROUP ALREADY EMITTED IS SKIPPED, or the splice eats itself. This loop now
|
|
1496
|
+
emits the body it rewrote and moves the cursor PAST the whole group, where it used to leave the
|
|
1497
|
+
body to the final copy. `OPEN.lastIndex` keeps scanning from inside that consumed region, so a
|
|
1498
|
+
nested or adjacent `g.label` matched at an index BEHIND the cursor: `svg.slice(cursor, m.index)`
|
|
1499
|
+
then returns empty and the group is written twice, with one `</g>` too many. Found on the class
|
|
1500
|
+
diagram the moment cardinality labels added a second label group — the whole diagram failed to
|
|
1501
|
+
render, which is what invalid markup looks like from the outside.
|
|
1502
|
+
*/
|
|
1503
|
+
if (m.index < cursor) continue;
|
|
1504
|
+
const bodyStart = m.index + m[0].length;
|
|
1505
|
+
let depth = 1;
|
|
1506
|
+
let i = bodyStart;
|
|
1507
|
+
while (depth > 0 && i < svg.length) {
|
|
1508
|
+
const nextOpen = svg.indexOf('<g', i);
|
|
1509
|
+
const nextClose = svg.indexOf('</g>', i);
|
|
1510
|
+
if (nextClose < 0) break;
|
|
1511
|
+
if (nextOpen >= 0 && nextOpen < nextClose) { depth += 1; i = nextOpen + 2; } else { depth -= 1; i = nextClose + 4; }
|
|
1512
|
+
}
|
|
1513
|
+
if (depth !== 0) continue;
|
|
1514
|
+
const body = svg.slice(bodyStart, i - 4);
|
|
1515
|
+
const rect = /<rect[^>]*class="background"[^>]*>/.exec(body)?.[0];
|
|
1516
|
+
// Only an edge label carries a background rect. A NODE label is the same `g.label` with a
|
|
1517
|
+
// foreignObject in it, and mermaid positions that one correctly — so it is left alone.
|
|
1518
|
+
if (!rect) continue;
|
|
1519
|
+
const num = (attr: string) => {
|
|
1520
|
+
const hit = new RegExp(`\\b${attr}="([-\\d.eE+]+)"`).exec(rect);
|
|
1521
|
+
return hit ? Number(hit[1]) : NaN;
|
|
1522
|
+
};
|
|
1523
|
+
const [x, y, w, h] = ['x', 'y', 'width', 'height'].map(num);
|
|
1524
|
+
// A rect mermaid has not sized yet leaves its label where it is rather than centred on NaN.
|
|
1525
|
+
if (![x, y, w, h].every(Number.isFinite)) continue;
|
|
1526
|
+
const attrs = m[1].replace(/\stransform="[^"]*"/, '');
|
|
1527
|
+
/*
|
|
1528
|
+
★★ AND THE TEXT INSIDE IS CENTRED ON THAT RECT, not left on mermaid's baseline. The transform
|
|
1529
|
+
above centres the BOX; the `<text>` in it keeps a `y` mermaid worked out for the size it laid
|
|
1530
|
+
the label out at, which is its own hard 14px. Re-point the size and the glyph hangs — the same
|
|
1531
|
+
defect the fragment keyword and the autonumber index each had, and the same cure: put the text
|
|
1532
|
+
at the rect's own vertical centre and let `central` centre the glyph box on it rather than
|
|
1533
|
+
sitting it on a baseline.
|
|
1534
|
+
|
|
1535
|
+
The rect's centre in this group's space is y + h/2, which is already in hand.
|
|
1536
|
+
*/
|
|
1537
|
+
/*
|
|
1538
|
+
★★ THE KNOCKOUT BOX IS MEASURED, NOT SCALED, AND ITS PADDING IS EQUAL ON BOTH AXES.
|
|
1539
|
+
|
|
1540
|
+
mermaid sizes this rect around the text at the size IT laid out — its own hard 14px — and this
|
|
1541
|
+
theme re-points the font in CSS afterwards, so the box keeps mermaid's proportions and its
|
|
1542
|
+
padding is whatever falls out of them: wider at the sides than above and below, and both wrong
|
|
1543
|
+
once the type has moved. Scaling the box by the type ratio fixed the size and kept the
|
|
1544
|
+
asymmetry.
|
|
1545
|
+
|
|
1546
|
+
This runs in the browser, during the render, so the text can simply be MEASURED at the size it
|
|
1547
|
+
will actually be drawn at — the same canvas trick the audit uses — and the box built around it
|
|
1548
|
+
with one padding in both directions.
|
|
1549
|
+
*/
|
|
1550
|
+
/*
|
|
1551
|
+
★ THE TWO PADDINGS ARE NOT THE SAME NUMBER, AND THAT IS THE POINT RATHER THAN AN OVERSIGHT.
|
|
1552
|
+
They started equal, which fixed mermaid's own lopsided box — but a knockout is not a card: it
|
|
1553
|
+
exists to break the line behind a word, so it wants enough air at the SIDES to separate the
|
|
1554
|
+
word from the line it interrupts, and only enough above and below to clear the glyph. Equal on
|
|
1555
|
+
both axes reads as a label with a box around it; tighter vertically reads as a gap in the line
|
|
1556
|
+
with a word in it, which is what an edge label is.
|
|
1557
|
+
*/
|
|
1558
|
+
const padX = Math.round(MERMAID_EDGE_LABEL_FONT_PX * 0.34);
|
|
1559
|
+
const padY = Math.round(MERMAID_EDGE_LABEL_FONT_PX * 0.16);
|
|
1560
|
+
const lineH = MERMAID_EDGE_LABEL_FONT_PX * 1.2;
|
|
1561
|
+
const words = /<text\b[^>]*>([\s\S]*?)<\/text>/.exec(body)?.[1]?.replace(/<[^>]*>/g, '') ?? '';
|
|
1562
|
+
const measured = measureLabel(words);
|
|
1563
|
+
const cx = x + w / 2, cy = y + h / 2;
|
|
1564
|
+
// A measurement that fails falls back to mermaid's own box rather than to a guess of zero.
|
|
1565
|
+
const sw = measured > 0 ? measured + padX * 2 : w;
|
|
1566
|
+
const sh = lineH + padY * 2;
|
|
1567
|
+
const setAttr = (attrs: string, name: string, value: number) => {
|
|
1568
|
+
const re = new RegExp(`\\b${name}="[-\\d.eE+]*"`);
|
|
1569
|
+
return re.test(attrs) ? attrs.replace(re, `${name}="${value}"`) : `${attrs} ${name}="${value}"`;
|
|
1570
|
+
};
|
|
1571
|
+
const scaledBody = body.replace(/<rect\b([^>]*)>/, (whole2: string, rectAttrs: string) => {
|
|
1572
|
+
if (!/class="background"/.test(rectAttrs)) return whole2;
|
|
1573
|
+
let next = rectAttrs;
|
|
1574
|
+
next = setAttr(next, 'x', cx - sw / 2);
|
|
1575
|
+
next = setAttr(next, 'y', cy - sh / 2);
|
|
1576
|
+
next = setAttr(next, 'width', sw);
|
|
1577
|
+
next = setAttr(next, 'height', sh);
|
|
1578
|
+
return `<rect${next}>`;
|
|
1579
|
+
});
|
|
1580
|
+
/*
|
|
1581
|
+
★★ AND THE TSPAN'S OWN `y`/`dy` HAVE TO GO, or none of the centring reaches the glyph. mermaid
|
|
1582
|
+
writes `<text y="-10.1"><tspan x="0" y="-0.1em" dy="1.1em">`, and a tspan's own `y` is ABSOLUTE
|
|
1583
|
+
in the text's coordinate space — it does not offset the parent's, it replaces it. So setting
|
|
1584
|
+
the <text> y centred the element and the tspan put the glyph back a line below it. Dropping
|
|
1585
|
+
both makes the tspan inherit the position and the baseline set on its parent.
|
|
1586
|
+
*/
|
|
1587
|
+
const strippedBody = scaledBody.replace(/<tspan\b[^>]*>/g, (tag: string) => (
|
|
1588
|
+
/text-outer-tspan|text-inner-tspan/.test(tag)
|
|
1589
|
+
? tag.replace(/\s(?:y|dy)="[^"]*"/g, '')
|
|
1590
|
+
: tag
|
|
1591
|
+
));
|
|
1592
|
+
/*
|
|
1593
|
+
★★ CENTRED ON CAP HEIGHT, NOT ON THE EM BOX. `dominant-baseline: central` centres the glyph box
|
|
1594
|
+
with its descender space included, so a word that has no descenders — `unlocked`, `returned` —
|
|
1595
|
+
rides visibly high in its own knockout. The typographic answer is the alphabetic baseline placed
|
|
1596
|
+
at the box's centre plus a 0.35em nudge, half a cap height for this face, which lands the visual
|
|
1597
|
+
middle of the word on the middle of the box whatever letters it happens to contain.
|
|
1598
|
+
*/
|
|
1599
|
+
const centredBody = strippedBody.replace(/<text\b([^>]*)>/, (_t: string, tagAttrs: string) => {
|
|
1600
|
+
const cleaned = tagAttrs
|
|
1601
|
+
.replace(/\s(?:y|dy)="[^"]*"/g, '')
|
|
1602
|
+
.replace(/\s(?:dominant|alignment)-baseline="[^"]*"/g, '');
|
|
1603
|
+
return `<text${cleaned} y="${cy}" dy="0.35em">`;
|
|
1604
|
+
});
|
|
1605
|
+
out += svg.slice(cursor, m.index)
|
|
1606
|
+
+ `<g class="label"${attrs} transform="translate(${-(x + w / 2)}, ${-(y + h / 2)})">`
|
|
1607
|
+
+ centredBody
|
|
1608
|
+
+ '</g>';
|
|
1609
|
+
cursor = i;
|
|
1610
|
+
}
|
|
1611
|
+
return out + svg.slice(cursor);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/*
|
|
1615
|
+
── WHAT A CONTAINER OWNS ──────────────────────────────────────────────────────────────────────
|
|
1616
|
+
|
|
1617
|
+
Two defects, one piece of geometry, so one scanner serves both:
|
|
1618
|
+
|
|
1619
|
+
· an edge label INSIDE a `subgraph` or a composite state knocked out to the PAGE, painting a
|
|
1620
|
+
cream card onto the container's surface;
|
|
1621
|
+
· a container's TITLE sat on its own top border and, in the state family, underneath the first
|
|
1622
|
+
label inside it.
|
|
1623
|
+
|
|
1624
|
+
★★ AND THE ANSWER TO THE ONE THAT HAD FAILED TWICE IS THAT CONTAINMENT IS STRUCTURAL, NOT
|
|
1625
|
+
GEOMETRIC. The note that used to sit here said a point-in-rect test "fired on the flowchart's
|
|
1626
|
+
`cluster` and never on the state diagram's `statediagram-cluster`", and guessed at an unaccounted
|
|
1627
|
+
parent transform. There is none. MEASURED off the live render of the specimen's nested-states
|
|
1628
|
+
slide, reading each element's nearest ancestor `g.root`:
|
|
1629
|
+
|
|
1630
|
+
label "released" / "below 20%" / "swapped" root: (the outer one, no transform)
|
|
1631
|
+
label "unlocked" / "returned" root: translate(448.875, 0)
|
|
1632
|
+
rect.outer (the Deployed container) root: translate(448.875, 0)
|
|
1633
|
+
|
|
1634
|
+
mermaid lays a composite state out as its OWN nested graph, and emits a nested `g.root` for it
|
|
1635
|
+
holding that graph's `g.clusters`, `g.edgeLabels` and `g.nodes` as siblings. So a label drawn
|
|
1636
|
+
inside a composite IS in the same coordinate space as the container's rect — they share a root —
|
|
1637
|
+
and a label from the outer graph is in a DIFFERENT one. The old test compared across those two
|
|
1638
|
+
spaces, where the numbers are meaningless but happen to overlap: "released" sits at x 351.7 in the
|
|
1639
|
+
OUTER root, and the Deployed rect spans x 8–641.9 in the INNER one, so a naive test called it
|
|
1640
|
+
contained. It is not, and no transform arithmetic was ever going to say so.
|
|
1641
|
+
|
|
1642
|
+
A flowchart has no nesting — one graph, one root, every subgraph a cluster in it — so scoping the
|
|
1643
|
+
test to a root is exactly right there too, and both families fall out of one rule:
|
|
1644
|
+
|
|
1645
|
+
A LABEL IS INSIDE A CONTAINER IFF THEY SHARE A ROOT AND THE LABEL'S ORIGIN IS IN ITS RECT.
|
|
1646
|
+
|
|
1647
|
+
Nothing here is a stylesheet's job, which is the other half of the old note that was true: mermaid
|
|
1648
|
+
puts every one of a graph's edge labels in ONE `g.edgeLabels`, so no label is a DESCENDANT of the
|
|
1649
|
+
cluster it sits in and no descendant selector can reach it. A string rewrite can.
|
|
1650
|
+
*/
|
|
1651
|
+
|
|
1652
|
+
/** End index (past `</g>`) of the group whose open tag ends at `bodyStart`, or -1 if unbalanced. */
|
|
1653
|
+
function groupEnd(svg: string, bodyStart: number): number {
|
|
1654
|
+
let depth = 1;
|
|
1655
|
+
let i = bodyStart;
|
|
1656
|
+
while (depth > 0 && i < svg.length) {
|
|
1657
|
+
const nextOpen = svg.indexOf('<g', i);
|
|
1658
|
+
const nextClose = svg.indexOf('</g>', i);
|
|
1659
|
+
if (nextClose < 0) return -1;
|
|
1660
|
+
if (nextOpen >= 0 && nextOpen < nextClose) { depth += 1; i = nextOpen + 2; } else { depth -= 1; i = nextClose + 4; }
|
|
1661
|
+
}
|
|
1662
|
+
return depth === 0 ? i : -1;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
type Span = { open: number; bodyStart: number; bodyEnd: number; end: number };
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* Every `<g>` in `[from, to)` carrying `cls` as one of its class tokens, with its span.
|
|
1669
|
+
*
|
|
1670
|
+
* ★ A DEPTH SCAN RATHER THAN A NON-GREEDY MATCH, for the reason recentreEdgeLabels already carries:
|
|
1671
|
+
* these groups nest, and `([\s\S]*?)</g>` stops at the first inner close tag.
|
|
1672
|
+
*/
|
|
1673
|
+
function groupsWithClass(svg: string, cls: string, from = 0, to = svg.length): Span[] {
|
|
1674
|
+
const OPEN = /<g\b([^>]*)>/g;
|
|
1675
|
+
OPEN.lastIndex = from;
|
|
1676
|
+
const out: Span[] = [];
|
|
1677
|
+
for (let m = OPEN.exec(svg); m && m.index < to; m = OPEN.exec(svg)) {
|
|
1678
|
+
const classes = /\bclass="([^"]*)"/.exec(m[1])?.[1]?.split(/\s+/) ?? [];
|
|
1679
|
+
if (!classes.includes(cls)) continue;
|
|
1680
|
+
const bodyStart = m.index + m[0].length;
|
|
1681
|
+
const end = groupEnd(svg, bodyStart);
|
|
1682
|
+
if (end < 0) continue;
|
|
1683
|
+
out.push({ open: m.index, bodyStart, bodyEnd: end - 4, end });
|
|
1684
|
+
}
|
|
1685
|
+
return out;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
const attrNum = (tag: string, name: string): number => {
|
|
1689
|
+
const hit = new RegExp(`\\b${name}="([-\\d.eE+]+)"`).exec(tag);
|
|
1690
|
+
return hit ? Number(hit[1]) : Number.NaN;
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
type Box = { x: number; y: number; w: number; h: number };
|
|
1694
|
+
|
|
1695
|
+
/** The container rects in one cluster group — the biggest wins, which is the one that is drawn. */
|
|
1696
|
+
function clusterBox(svg: string, cluster: Span): { tag: string; box: Box } | null {
|
|
1697
|
+
let best: { tag: string; box: Box } | null = null;
|
|
1698
|
+
const RECT = /<rect\b[^>]*>/g;
|
|
1699
|
+
RECT.lastIndex = cluster.bodyStart;
|
|
1700
|
+
for (let m = RECT.exec(svg); m && m.index < cluster.bodyEnd; m = RECT.exec(svg)) {
|
|
1701
|
+
const classes = /\bclass="([^"]*)"/.exec(m[0])?.[1]?.split(/\s+/) ?? [];
|
|
1702
|
+
// `inner` is scaffolding the deck already hides; `background` is the title's own knockout and
|
|
1703
|
+
// carries no geometry at all.
|
|
1704
|
+
if (classes.includes('inner') || classes.includes('background')) continue;
|
|
1705
|
+
const box = { x: attrNum(m[0], 'x'), y: attrNum(m[0], 'y'), w: attrNum(m[0], 'width'), h: attrNum(m[0], 'height') };
|
|
1706
|
+
if (!Object.values(box).every(Number.isFinite) || box.w <= 0 || box.h <= 0) continue;
|
|
1707
|
+
if (!best || box.w * box.h > best.box.w * best.box.h) best = { tag: m[0], box };
|
|
1708
|
+
}
|
|
1709
|
+
return best;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/** The `translate(x, y)` on a tag, or null. */
|
|
1713
|
+
function translateOf(tag: string): { x: number; y: number } | null {
|
|
1714
|
+
const m = /\btransform="translate\(\s*([-\d.eE+]+)[\s,]+([-\d.eE+]+)\s*\)"/.exec(tag);
|
|
1715
|
+
return m ? { x: Number(m[1]), y: Number(m[2]) } : null;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/**
|
|
1719
|
+
* One graph: a `g.root` and the `g.clusters` / `g.edgeLabels` that are ITS OWN, not a nested
|
|
1720
|
+
* graph's. Nested roots live inside `g.nodes`, so rejecting anything inside one is the whole test.
|
|
1721
|
+
*/
|
|
1722
|
+
type Graph = { root: Span; clusters: Span[]; edgeLabels: Span[] };
|
|
1723
|
+
|
|
1724
|
+
function graphsOf(svg: string): Graph[] {
|
|
1725
|
+
const roots = groupsWithClass(svg, 'root');
|
|
1726
|
+
return roots.map((root) => {
|
|
1727
|
+
const nested = roots.filter((r) => r !== root && r.open > root.bodyStart && r.end <= root.end);
|
|
1728
|
+
const own = (s: Span) => !nested.some((n) => s.open > n.open && s.end <= n.end);
|
|
1729
|
+
const holders = groupsWithClass(svg, 'clusters', root.bodyStart, root.bodyEnd).filter(own);
|
|
1730
|
+
const clusters = holders.flatMap((h) => {
|
|
1731
|
+
// The direct children of `g.clusters` ARE the clusters — `g.cluster` on a flowchart,
|
|
1732
|
+
// `g.statediagram-state.statediagram-cluster` on a state diagram. Take every group that
|
|
1733
|
+
// starts at the holder's own depth.
|
|
1734
|
+
const out: Span[] = [];
|
|
1735
|
+
let i = h.bodyStart;
|
|
1736
|
+
while (i < h.bodyEnd) {
|
|
1737
|
+
const open = svg.indexOf('<g', i);
|
|
1738
|
+
if (open < 0 || open >= h.bodyEnd) break;
|
|
1739
|
+
const close = svg.indexOf('>', open);
|
|
1740
|
+
if (close < 0) break;
|
|
1741
|
+
const end = groupEnd(svg, close + 1);
|
|
1742
|
+
if (end < 0) break;
|
|
1743
|
+
out.push({ open, bodyStart: close + 1, bodyEnd: end - 4, end });
|
|
1744
|
+
i = end;
|
|
1745
|
+
}
|
|
1746
|
+
return out;
|
|
1747
|
+
});
|
|
1748
|
+
return {
|
|
1749
|
+
root,
|
|
1750
|
+
clusters,
|
|
1751
|
+
edgeLabels: groupsWithClass(svg, 'edgeLabels', root.bodyStart, root.bodyEnd).filter(own),
|
|
1752
|
+
};
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/** Every edge label in `graph`, with the origin mermaid positioned it at. */
|
|
1757
|
+
function labelsOf(svg: string, graph: Graph): Array<Span & { at: { x: number; y: number } }> {
|
|
1758
|
+
return graph.edgeLabels.flatMap((holder) =>
|
|
1759
|
+
groupsWithClass(svg, 'edgeLabel', holder.bodyStart, holder.bodyEnd)
|
|
1760
|
+
.map((span) => {
|
|
1761
|
+
const at = translateOf(svg.slice(span.open, span.bodyStart));
|
|
1762
|
+
return at ? { ...span, at } : null;
|
|
1763
|
+
})
|
|
1764
|
+
.filter((v): v is Span & { at: { x: number; y: number } } => v !== null));
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
const inside = (at: { x: number; y: number }, b: Box) =>
|
|
1768
|
+
at.x >= b.x && at.x <= b.x + b.w && at.y >= b.y && at.y <= b.y + b.h;
|
|
1769
|
+
|
|
1770
|
+
/*
|
|
1771
|
+
A LABEL KNOCKS THE LINE OUT BEHIND ITSELF, SO ITS FILL HAS TO BE THE GROUND IT SITS ON — and
|
|
1772
|
+
inside a container that ground is the container's surface, not the page.
|
|
1773
|
+
|
|
1774
|
+
Marked with an ATTRIBUTE rather than a class, and the difference is load-bearing:
|
|
1775
|
+
recentreEdgeLabels matches `<g class="label"` exactly, so adding a second class to that attribute
|
|
1776
|
+
would silently take every marked label out of the pass that centres it. The rule this pairs with
|
|
1777
|
+
re-points `--deck-mermaid-label-bg` on the group, which INHERITS — so the knockout fill and the
|
|
1778
|
+
text's own mix-toward-the-ground both follow from one declaration.
|
|
1779
|
+
*/
|
|
1780
|
+
export const CONTAINER_GROUND_ATTR = 'data-on-container';
|
|
1781
|
+
|
|
1782
|
+
export function groundLabelsOnContainers(svg: string): string {
|
|
1783
|
+
const marks: number[] = [];
|
|
1784
|
+
for (const graph of graphsOf(svg)) {
|
|
1785
|
+
const boxes = graph.clusters.map((c) => clusterBox(svg, c)?.box).filter((b): b is Box => !!b);
|
|
1786
|
+
if (!boxes.length) continue;
|
|
1787
|
+
for (const label of labelsOf(svg, graph)) {
|
|
1788
|
+
if (!boxes.some((b) => inside(label.at, b))) continue;
|
|
1789
|
+
const inner = groupsWithClass(svg, 'label', label.bodyStart, label.bodyEnd)[0];
|
|
1790
|
+
if (inner) marks.push(inner.open);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
if (!marks.length) return svg;
|
|
1794
|
+
// Rewritten back to front so an earlier splice cannot move a later index.
|
|
1795
|
+
let out = svg;
|
|
1796
|
+
for (const at of marks.sort((a, b) => b - a)) {
|
|
1797
|
+
const close = out.indexOf('>', at);
|
|
1798
|
+
if (close < 0) continue;
|
|
1799
|
+
out = `${out.slice(0, close)} ${CONTAINER_GROUND_ATTR}=""${out.slice(close)}`;
|
|
1800
|
+
}
|
|
1801
|
+
return out;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/*
|
|
1805
|
+
── SEATING A CONTAINER'S TITLE ────────────────────────────────────────────────────────────────
|
|
1806
|
+
|
|
1807
|
+
★★ THE TITLE BAND IS SIZED AT LAYOUT TIME AND THE TYPE IS RE-POINTED AFTERWARDS, which is the same
|
|
1808
|
+
defect this file has already met on the fragment keyword, the autonumber index and the edge label:
|
|
1809
|
+
mermaid measures, this theme changes the size, and nothing reflows. MEASURED on the nested-states
|
|
1810
|
+
slide, in the container's own units:
|
|
1811
|
+
|
|
1812
|
+
rect.outer y 8 -> 196.5
|
|
1813
|
+
g.cluster-label y 5.81 -> 51.05 (2.19 units ABOVE the box it titles)
|
|
1814
|
+
"unlocked" text top 50.35 (the first thing inside)
|
|
1815
|
+
|
|
1816
|
+
So the word sat ON its own 4-unit border — which is what "cut off" looks like — and the gap to the
|
|
1817
|
+
first label was NEGATIVE. Both halves are one sum: the band a title needs is its own height plus a
|
|
1818
|
+
padding at each end, and the band it HAS is from the box's top edge to the first thing inside.
|
|
1819
|
+
|
|
1820
|
+
★ THE CLEARANCE IS MEASURED TO THE TEXT, NOT TO THE KNOCKOUT. An edge label's box is deliberately
|
|
1821
|
+
taller than its word (recentreEdgeLabels pads it), and now that the box paints the container's own
|
|
1822
|
+
surface it is invisible — so seating the title against the BOX leaves an optical hole above the
|
|
1823
|
+
title that reads as worse than the collision it fixed. Measured both ways on the live render.
|
|
1824
|
+
|
|
1825
|
+
★★ AND GROWING THE BOX MEANS GROWING THE viewBox. The container's top edge can rise above the
|
|
1826
|
+
diagram's own bounds, and the outermost <svg> clips there. fitToColumn reads the viewBox for the
|
|
1827
|
+
natural size it caps against and runs LAST, so extending it here is picked up for free — but only
|
|
1828
|
+
because of that ordering.
|
|
1829
|
+
*/
|
|
1830
|
+
/*
|
|
1831
|
+
★ PAD IS THE GAP FROM THE BOX'S TOP EDGE TO THE TITLE'S ORIGIN, and mermaid's own value for it is
|
|
1832
|
+
ZERO — `translate(x, clusterY - 2)` against a band that starts at clusterY. A sixth of the body
|
|
1833
|
+
rung is the smallest step that reads as deliberate air rather than a rounding error at room scale.
|
|
1834
|
+
*/
|
|
1835
|
+
const CLUSTER_TITLE_PAD = Math.round(MERMAID_FONT_PX * 0.17);
|
|
1836
|
+
|
|
1837
|
+
/*
|
|
1838
|
+
★★ THE TITLE'S HEIGHT IS ALREADY IN THE MARKUP, so nothing here has to measure anything. mermaid
|
|
1839
|
+
writes `rect.inner.y = clusterY + bbox.height + 2` (clusters.js, roundedWithTitle) where bbox is a
|
|
1840
|
+
LIVE measurement of the title text taken at layout time. Read backwards, the inner rect states the
|
|
1841
|
+
height mermaid measured — 45 on the nested-states slide, against 45.24 for the group's painted box
|
|
1842
|
+
— so this pass is pure arithmetic on the string and provable red in vitest without a canvas.
|
|
1843
|
+
|
|
1844
|
+
It is also the reason the pass is scoped to clusters that HAVE an inner rect. That is the state
|
|
1845
|
+
family's composite, which is where the collision is; a flowchart subgraph draws one rect and no
|
|
1846
|
+
content box, so there is no stated title height to reason from and its band is left alone.
|
|
1847
|
+
*/
|
|
1848
|
+
const TITLE_BAND_SLACK = 2;
|
|
1849
|
+
|
|
1850
|
+
export function seatClusterTitles(svg: string): string {
|
|
1851
|
+
const edits: Array<{ from: string; to: string }> = [];
|
|
1852
|
+
let rise = 0; // how far past the diagram's own top edge the tallest container now reaches
|
|
1853
|
+
|
|
1854
|
+
for (const graph of graphsOf(svg)) {
|
|
1855
|
+
const labels = labelsOf(svg, graph);
|
|
1856
|
+
for (const cluster of graph.clusters) {
|
|
1857
|
+
const found = clusterBox(svg, cluster);
|
|
1858
|
+
const title = groupsWithClass(svg, 'cluster-label', cluster.bodyStart, cluster.bodyEnd)[0];
|
|
1859
|
+
if (!found || !title) continue;
|
|
1860
|
+
const titleTag = svg.slice(title.open, title.bodyStart);
|
|
1861
|
+
const seat = translateOf(titleTag);
|
|
1862
|
+
if (!seat) continue;
|
|
1863
|
+
const { box } = found;
|
|
1864
|
+
|
|
1865
|
+
const innerRect = /<rect\b[^>]*\bclass="[^"]*\binner\b[^"]*"[^>]*>/.exec(svg.slice(cluster.bodyStart, cluster.bodyEnd));
|
|
1866
|
+
if (!innerRect) continue;
|
|
1867
|
+
const innerY = attrNum(innerRect[0], 'y');
|
|
1868
|
+
const titleHeight = innerY - box.y - TITLE_BAND_SLACK;
|
|
1869
|
+
if (!Number.isFinite(titleHeight) || titleHeight <= 0) continue;
|
|
1870
|
+
|
|
1871
|
+
/*
|
|
1872
|
+
★ THE CLEARANCE IS MEASURED TO THE INK, NOT TO THE KNOCKOUT. recentreEdgeLabels pads an edge
|
|
1873
|
+
label's box well past its word, and now that the box paints the container's own surface that
|
|
1874
|
+
padding is invisible — so seating the title against the BOX opens an optical hole above the
|
|
1875
|
+
title that reads worse than the collision it fixes. Measured both ways on the live render.
|
|
1876
|
+
*/
|
|
1877
|
+
const lineH = MERMAID_EDGE_LABEL_FONT_PX * 1.2;
|
|
1878
|
+
const contentTop = Math.min(
|
|
1879
|
+
innerY,
|
|
1880
|
+
...labels.filter((l) => inside(l.at, box)).map((l) => l.at.y - lineH / 2),
|
|
1881
|
+
);
|
|
1882
|
+
|
|
1883
|
+
const grow = Math.max(0, titleHeight + CLUSTER_TITLE_PAD * 2 - (contentTop - box.y));
|
|
1884
|
+
const top = box.y - grow;
|
|
1885
|
+
|
|
1886
|
+
if (grow > 0) {
|
|
1887
|
+
edits.push({
|
|
1888
|
+
from: found.tag,
|
|
1889
|
+
to: found.tag
|
|
1890
|
+
.replace(/\by="[-\d.eE+]*"/, `y="${round4(top)}"`)
|
|
1891
|
+
.replace(/\bheight="[-\d.eE+]*"/, `height="${round4(box.h + grow)}"`),
|
|
1892
|
+
});
|
|
1893
|
+
rise = Math.max(rise, grow - (box.y - viewBoxTop(svg)));
|
|
1894
|
+
}
|
|
1895
|
+
const seated = top + CLUSTER_TITLE_PAD;
|
|
1896
|
+
if (Math.abs(seated - seat.y) > 0.01) {
|
|
1897
|
+
edits.push({
|
|
1898
|
+
from: titleTag,
|
|
1899
|
+
to: titleTag.replace(/\btransform="translate\([^)]*\)"/, `transform="translate(${round4(seat.x)}, ${round4(seated)})"`),
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
if (!edits.length) return svg;
|
|
1905
|
+
|
|
1906
|
+
let out = svg;
|
|
1907
|
+
for (const e of edits) {
|
|
1908
|
+
const at = out.indexOf(e.from);
|
|
1909
|
+
if (at < 0) continue;
|
|
1910
|
+
out = out.slice(0, at) + e.to + out.slice(at + e.from.length);
|
|
1911
|
+
}
|
|
1912
|
+
return rise > 0 ? growViewBoxTop(out, rise) : out;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
const round4 = (n: number) => Number(n.toFixed(4));
|
|
1916
|
+
|
|
1917
|
+
function viewBoxTop(svg: string): number {
|
|
1918
|
+
const vb = /viewBox="([^"]+)"/.exec(svg)?.[1]?.trim().split(/\s+/);
|
|
1919
|
+
return vb?.length === 4 ? Number(vb[1]) : Number.NaN;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
/*
|
|
1923
|
+
★★ GROWING THE BOX MEANS GROWING THE viewBox, or the outermost <svg> clips the top edge off the
|
|
1924
|
+
container it was just given. fitToColumn reads the viewBox for the natural size it caps against
|
|
1925
|
+
and runs LAST in the pipeline, so the new numbers are picked up for free — but only because of
|
|
1926
|
+
that ordering.
|
|
1927
|
+
*/
|
|
1928
|
+
function growViewBoxTop(svg: string, by: number): string {
|
|
1929
|
+
return svg.replace(/viewBox="([^"]+)"/, (whole, v: string) => {
|
|
1930
|
+
const n = v.trim().split(/\s+/).map(Number);
|
|
1931
|
+
if (n.length !== 4 || n.some(Number.isNaN)) return whole;
|
|
1932
|
+
return `viewBox="${n[0]} ${round4(n[1] - by)} ${n[2]} ${round4(n[3] + by)}"`;
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/*
|
|
1937
|
+
── THE BOX THAT IS NOT A RECT ─────────────────────────────────────────────────────────────────
|
|
1938
|
+
mermaid draws a class box, a state NOTE and an ER entity through its unified shape registry, which
|
|
1939
|
+
hands them to rough.js. rough returns a <g> of two <path>s — a solid fill path and a double-stroked
|
|
1940
|
+
outline of eight open beziers — so the element is `g.basic.label-container.outer-path` and there is
|
|
1941
|
+
no <rect> anywhere in it. Every corner rule in DIAGRAM_RULES names `rect`, including the one that
|
|
1942
|
+
repeats mermaid's own `.statediagram-note rect`, so all of them match nothing and the boxes draw
|
|
1943
|
+
square. mermaid's own note rule is dead upstream for the same reason.
|
|
1944
|
+
|
|
1945
|
+
roundPolygons cannot reach these: it reads the `points` ATTRIBUTE of a <polygon> and walks one
|
|
1946
|
+
closed ring of straight segments. rough emits neither.
|
|
1947
|
+
|
|
1948
|
+
So restore the rect. THE RECTANGLE TEST IS THE SCOPING — `outer-path` is shared with the cylinder,
|
|
1949
|
+
the stadium and every other rough shape, and on those the outline IS the meaning. Their fill path
|
|
1950
|
+
opens with a curve; only a real box gives four axis-aligned corners. Colours travel as PRESENTATION
|
|
1951
|
+
attributes, exactly as rough wrote them, so the tone rules still override them.
|
|
1952
|
+
*/
|
|
1953
|
+
function axisAlignedRect(d: string): { x: number; y: number; w: number; h: number } | null {
|
|
1954
|
+
const tokens = d.trim().match(/[A-Za-z]|-?\d*\.?\d+/g);
|
|
1955
|
+
if (!tokens) return null;
|
|
1956
|
+
const letters = tokens.filter((t) => /[A-Za-z]/.test(t));
|
|
1957
|
+
/* ★ THE COMMAND LIST IS CHECKED BEFORE THE COORDINATE COUNT, and both are load-bearing. A curve
|
|
1958
|
+
is rejected here even when it happens to carry eight numbers — `M x y C x y, x y, x y` does,
|
|
1959
|
+
and a coordinate-count test alone would read it as a box. */
|
|
1960
|
+
if (letters.length < 4 || letters[0] !== 'M' || !letters.slice(1, 4).every((c) => c === 'L')) return null;
|
|
1961
|
+
if (letters.slice(4).some((c) => c !== 'Z' && c !== 'z')) return null;
|
|
1962
|
+
const nums = tokens.filter((t) => !/[A-Za-z]/.test(t)).map(Number);
|
|
1963
|
+
if (nums.length !== 8 || nums.some(Number.isNaN)) return null;
|
|
1964
|
+
const [x0, y0, x1, y1, x2, y2, x3, y3] = nums;
|
|
1965
|
+
if (y0 !== y1 || x1 !== x2 || y2 !== y3 || x3 !== x0) return null;
|
|
1966
|
+
const w = x1 - x0, h = y3 - y0;
|
|
1967
|
+
if (!(w > 0 && h > 0)) return null;
|
|
1968
|
+
return { x: x0, y: y0, w, h };
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
export function rectifyOuterPaths(svg: string): string {
|
|
1972
|
+
return svg.replace(/<g class="([^"]*\bouter-path\b[^"]*)">([\s\S]*?)<\/g>/g, (whole, cls: string, body: string) => {
|
|
1973
|
+
// A nested group would end the non-greedy match early and the replacement would eat live markup.
|
|
1974
|
+
if (body.includes('<g')) return whole;
|
|
1975
|
+
const paths = [...body.matchAll(/<path\b([^>]*?)\/?>/g)].map((m) => m[1]);
|
|
1976
|
+
if (!paths.length) return whole;
|
|
1977
|
+
const attr = (s: string, name: string) => (s.match(new RegExp(`\\b${name}="([^"]*)"`)) || [])[1];
|
|
1978
|
+
const box = axisAlignedRect(attr(paths[0], 'd') ?? '');
|
|
1979
|
+
if (!box) return whole;
|
|
1980
|
+
const stroke = paths[1] ?? '';
|
|
1981
|
+
const dash = attr(stroke, 'stroke-dasharray');
|
|
1982
|
+
const out = [
|
|
1983
|
+
`class="${cls.replace(/\bouter-path\b/, '').trim()}"`,
|
|
1984
|
+
`x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}"`,
|
|
1985
|
+
`fill="${attr(paths[0], 'fill') ?? 'none'}"`,
|
|
1986
|
+
`stroke="${attr(stroke, 'stroke') ?? 'none'}"`,
|
|
1987
|
+
`stroke-width="${attr(stroke, 'stroke-width') ?? '0'}"`,
|
|
1988
|
+
dash && dash !== '0 0' ? `stroke-dasharray="${dash}"` : '',
|
|
1989
|
+
].filter(Boolean).join(' ');
|
|
1990
|
+
return `<rect ${out}/>`;
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
export function roundPolygons(svg: string, radius: number): string {
|
|
1995
|
+
/* ⚠ THE CLOSING TAG IS PART OF THE MATCH. Replacing only `<polygon …>` leaves a `</polygon>` with
|
|
1996
|
+
no opening tag, and the parser then nests the node's LABEL inside the new <path> — the shape
|
|
1997
|
+
rounds correctly and the text disappears, which looks like a labelling bug rather than a markup
|
|
1998
|
+
one. Both the self-closing and the paired form are consumed. */
|
|
1999
|
+
return svg.replace(/<polygon([^>]*?)points="([^"]+)"([^>]*?)\/?>(?:<\/polygon>)?/g, (whole, pre, points, post) => {
|
|
2000
|
+
/*
|
|
2001
|
+
⚠ NOT EVERY POLYGON IS A NODE. The sequence fragment's tab is one — a five-point shape with a
|
|
2002
|
+
clipped bottom-right corner, `<polygon class="labelBox" points="351,390 407,390 407,406
|
|
2003
|
+
398.6,413 351,413">` — and that clip is the notation: it is what makes the tab read as a tab
|
|
2004
|
+
rather than a small box sitting on the frame. Rounded at the node radius it came out as a
|
|
2005
|
+
lozenge, and the clipped corner disappeared into the curve. mermaid's own shape is right here
|
|
2006
|
+
and the deck leaves it alone.
|
|
2007
|
+
*/
|
|
2008
|
+
if (/\blabelBox\b/.test(pre) || /\blabelBox\b/.test(post)) return whole;
|
|
2009
|
+
const pts = points.trim().split(/\s+/).map((pair: string) => pair.split(',').map(Number));
|
|
2010
|
+
if (pts.length < 3 || pts.some((q: number[]) => q.length !== 2 || q.some(Number.isNaN))) return whole;
|
|
2011
|
+
const n = pts.length;
|
|
2012
|
+
let d = '';
|
|
2013
|
+
for (let i = 0; i < n; i++) {
|
|
2014
|
+
const prev = pts[(i - 1 + n) % n], cur = pts[i], next = pts[(i + 1) % n];
|
|
2015
|
+
const seg = (a: number[], b: number[]) => {
|
|
2016
|
+
const dx = b[0] - a[0], dy = b[1] - a[1];
|
|
2017
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
2018
|
+
return { ux: dx / len, uy: dy / len, len };
|
|
2019
|
+
};
|
|
2020
|
+
const from = seg(cur, prev), to = seg(cur, next);
|
|
2021
|
+
const r = Math.min(radius, from.len / 2, to.len / 2);
|
|
2022
|
+
const a = [cur[0] + from.ux * r, cur[1] + from.uy * r];
|
|
2023
|
+
const b = [cur[0] + to.ux * r, cur[1] + to.uy * r];
|
|
2024
|
+
d += `${i === 0 ? 'M' : 'L'}${a[0].toFixed(2)},${a[1].toFixed(2)} Q${cur[0].toFixed(2)},${cur[1].toFixed(2)} ${b[0].toFixed(2)},${b[1].toFixed(2)} `;
|
|
2025
|
+
}
|
|
2026
|
+
return `<path${pre}d="${d.trim()}Z"${post.replace(/\/$/, '')}/>`;
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
/**
|
|
2031
|
+
* Append the floor to mermaid's own stylesheet, scoped to this diagram's id.
|
|
2032
|
+
*
|
|
2033
|
+
* Scoped rather than global for the same reason mermaid scopes its own: two diagrams on one slide
|
|
2034
|
+
* are two shadow roots today, but the id prefix is what makes that an implementation detail rather
|
|
2035
|
+
* than something these rules depend on.
|
|
2036
|
+
*/
|
|
2037
|
+
export function applyRoomScale(svg: string, id: string, register: Register = 'light'): string {
|
|
2038
|
+
/*
|
|
2039
|
+
★★ EVERY SELECTOR IN THE LIST IS SCOPED, NOT JUST THE FIRST. `#${id} ${rule}` prefixes the string,
|
|
2040
|
+
which reaches the first selector of a comma list and no other — so a rule written for five shapes
|
|
2041
|
+
arrived as one scoped selector at (1,1,1) and four unscoped ones at (0,1,1), and mermaid's own
|
|
2042
|
+
`#id .node …` beat every one of the four. The visible symptom was a rounded rhombus keeping a 1px
|
|
2043
|
+
border while the rectangle beside it took 3.
|
|
2044
|
+
*/
|
|
2045
|
+
const scope = (rule: string) => {
|
|
2046
|
+
const open = rule.indexOf('{');
|
|
2047
|
+
if (open < 0) return `#${id} ${rule}`;
|
|
2048
|
+
const selectors = rule.slice(0, open).split(',').map((sel) => `#${id} ${sel.trim()}`).join(',');
|
|
2049
|
+
return selectors + rule.slice(open);
|
|
2050
|
+
};
|
|
2051
|
+
// The background rules carry literal hex and so are the one part of this list that differs by
|
|
2052
|
+
// register — appended rather than interleaved, so a background always wins the tie against the
|
|
2053
|
+
// untoned rule above it.
|
|
2054
|
+
const rules = [...ROOM_SCALE_RULES, ...mermaidInkRules(register), ...mermaidBackgroundRules(register)].map(scope).join('');
|
|
2055
|
+
// If mermaid ever stops emitting a <style>, silently dropping the floor would look like the
|
|
2056
|
+
// fonts simply being wrong again — so the rules go in either way.
|
|
2057
|
+
return svg.includes('</style>')
|
|
2058
|
+
? svg.replace('</style>', `${rules}</style>`)
|
|
2059
|
+
: svg.replace(/(<svg[^>]*>)/, `$1<style>${rules}</style>`);
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
/*
|
|
2063
|
+
★★ RENDERS ARE SERIALISED, AND THIS IS THE ONE THING IN THIS FILE THAT ONLY EXPORT COULD FIND.
|
|
2064
|
+
|
|
2065
|
+
`mermaid.initialize()` does not configure a render — it mutates ONE piece of module-global state
|
|
2066
|
+
that the next `mermaid.render()` reads. This renderer therefore does initialize-then-render as a
|
|
2067
|
+
pair, and that pair has to be atomic.
|
|
2068
|
+
|
|
2069
|
+
In the browser it looked atomic for free: Slidev mounts one slide at a time, so no two diagrams
|
|
2070
|
+
are ever in flight together. `slidev export` does not work that way — it renders the WHOLE deck
|
|
2071
|
+
into one print container, so every diagram mounts at once and four `initialize` calls interleave
|
|
2072
|
+
with four `render` calls. The last configuration written wins for all of them.
|
|
2073
|
+
|
|
2074
|
+
MEASURED, on the exported PDF of this very deck: the specimen's four diagram slides are three
|
|
2075
|
+
light and one dark, and in the PDF all four came out in the DARK palette — near-black nodes with
|
|
2076
|
+
white labels, sitting on the deck's cream ground. The sequence diagram's message labels were
|
|
2077
|
+
near-white text on cream, i.e. invisible. Every one of them renders correctly in a browser, and
|
|
2078
|
+
the audit (which drives a browser) passed them, because the audit visits one slide at a time.
|
|
2079
|
+
|
|
2080
|
+
The queue is a plain promise chain: each call waits for the previous pair to finish before writing
|
|
2081
|
+
its own config. It costs nothing in the browser, where the queue is never more than one deep.
|
|
2082
|
+
*/
|
|
2083
|
+
let renderQueue: Promise<unknown> = Promise.resolve();
|
|
2084
|
+
|
|
2085
|
+
export default function setupMermaidRenderer(): MermaidRenderFn {
|
|
2086
|
+
return async (code, options) => {
|
|
2087
|
+
const register = options?.theme === 'dark' ? 'dark' : 'light';
|
|
2088
|
+
|
|
2089
|
+
// `theme` is consumed here — it names a REGISTER in this deck, not one of mermaid's own themes,
|
|
2090
|
+
// and passing it through would select mermaid's built-in 'dark' palette and discard every
|
|
2091
|
+
// themeVariable below it. `scale` belongs to Slidev's own component, not to mermaid.
|
|
2092
|
+
const { theme: _theme, scale: _scale, ...rest } = options ?? {};
|
|
2093
|
+
|
|
2094
|
+
const run = renderQueue.then(async () => {
|
|
2095
|
+
const mermaid = await loadMermaid();
|
|
2096
|
+
mermaid.initialize({ ...mermaidConfig(register), ...rest });
|
|
2097
|
+
|
|
2098
|
+
// Same container @slidev/client's own fallback path uses — mermaid measures text in a live
|
|
2099
|
+
// DOM node, and without one every label is laid out against a zero-width box.
|
|
2100
|
+
const container = document.getElementById('mermaid-rendering-container') ?? undefined;
|
|
2101
|
+
const id = `forest-mermaid-${register}-${counter++}`;
|
|
2102
|
+
const { svg } = await mermaid.render(id, code, container as Element);
|
|
2103
|
+
const shaped = recentreEdgeLabels(
|
|
2104
|
+
chevronArrowheads(growArrowheads(viewBoxArrowheads(unscaleStrokeMarkers(sizeDiamondMarkers(roundTimelineNodes(roundPolygons(rectifyOuterPaths(svg), POLYGON_RADIUS), DIAGRAM_RADIUS)))))),
|
|
2105
|
+
);
|
|
2106
|
+
/*
|
|
2107
|
+
★ BOTH CONTAINER PASSES RUN AFTER recentreEdgeLabels, AND FOR DIFFERENT REASONS. The ground
|
|
2108
|
+
pass adds an ATTRIBUTE to `g.label`, which recentre matches as `<g class="label"` and would
|
|
2109
|
+
still find — but seating reads where the labels ENDED UP, and recentre is what moves them.
|
|
2110
|
+
*/
|
|
2111
|
+
const contained = seatClusterTitles(groundLabelsOnContainers(shaped));
|
|
2112
|
+
const spaced = seatTreemapHeaders(spaceRadarLegend(spaceJourneyLegend(spaceLegendRows(contained, PIE_LEGEND_PX), PIE_LEGEND_PX), MERMAID_LEGEND_FONT_PX));
|
|
2113
|
+
const placed = placeJourneyFaces(spaced, MERMAID_FONT_PX * FACE_DIAMETER_EM);
|
|
2114
|
+
return fitToColumn(applyRoomScale(placed, id, register));
|
|
2115
|
+
});
|
|
2116
|
+
// The queue must survive a failed render, or one bad diagram wedges every one after it.
|
|
2117
|
+
renderQueue = run.catch(() => {});
|
|
2118
|
+
return run;
|
|
2119
|
+
};
|
|
2120
|
+
}
|