@nowline/renderer 0.8.5 → 0.9.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.
Files changed (88) hide show
  1. package/dist/image-size.d.ts +9 -0
  2. package/dist/image-size.d.ts.map +1 -0
  3. package/dist/image-size.js +104 -0
  4. package/dist/image-size.js.map +1 -0
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/logo.d.ts +24 -0
  10. package/dist/logo.d.ts.map +1 -0
  11. package/dist/logo.js +60 -0
  12. package/dist/logo.js.map +1 -0
  13. package/dist/stage.d.ts +78 -0
  14. package/dist/stage.d.ts.map +1 -0
  15. package/dist/stage.js +88 -0
  16. package/dist/stage.js.map +1 -0
  17. package/dist/svg/edges.d.ts +3 -0
  18. package/dist/svg/edges.d.ts.map +1 -0
  19. package/dist/svg/edges.js +144 -0
  20. package/dist/svg/edges.js.map +1 -0
  21. package/dist/svg/footnotes.d.ts +4 -0
  22. package/dist/svg/footnotes.d.ts.map +1 -0
  23. package/dist/svg/footnotes.js +81 -0
  24. package/dist/svg/footnotes.js.map +1 -0
  25. package/dist/svg/header.d.ts +6 -0
  26. package/dist/svg/header.d.ts.map +1 -0
  27. package/dist/svg/header.js +215 -0
  28. package/dist/svg/header.js.map +1 -0
  29. package/dist/svg/ids.d.ts.map +1 -1
  30. package/dist/svg/ids.js.map +1 -1
  31. package/dist/svg/include-region.d.ts +4 -0
  32. package/dist/svg/include-region.d.ts.map +1 -0
  33. package/dist/svg/include-region.js +157 -0
  34. package/dist/svg/include-region.js.map +1 -0
  35. package/dist/svg/item.d.ts +36 -0
  36. package/dist/svg/item.d.ts.map +1 -0
  37. package/dist/svg/item.js +549 -0
  38. package/dist/svg/item.js.map +1 -0
  39. package/dist/svg/markers.d.ts +7 -0
  40. package/dist/svg/markers.d.ts.map +1 -0
  41. package/dist/svg/markers.js +151 -0
  42. package/dist/svg/markers.js.map +1 -0
  43. package/dist/svg/nowline.d.ts +4 -0
  44. package/dist/svg/nowline.d.ts.map +1 -0
  45. package/dist/svg/nowline.js +133 -0
  46. package/dist/svg/nowline.js.map +1 -0
  47. package/dist/svg/primitives.d.ts +7 -0
  48. package/dist/svg/primitives.d.ts.map +1 -0
  49. package/dist/svg/primitives.js +70 -0
  50. package/dist/svg/primitives.js.map +1 -0
  51. package/dist/svg/render.d.ts +13 -7
  52. package/dist/svg/render.d.ts.map +1 -1
  53. package/dist/svg/render.js +64 -1701
  54. package/dist/svg/render.js.map +1 -1
  55. package/dist/svg/sanitize.d.ts +10 -0
  56. package/dist/svg/sanitize.d.ts.map +1 -1
  57. package/dist/svg/sanitize.js +264 -16
  58. package/dist/svg/sanitize.js.map +1 -1
  59. package/dist/svg/swimlanes.d.ts +5 -0
  60. package/dist/svg/swimlanes.d.ts.map +1 -0
  61. package/dist/svg/swimlanes.js +228 -0
  62. package/dist/svg/swimlanes.js.map +1 -0
  63. package/dist/svg/timeline.d.ts +24 -0
  64. package/dist/svg/timeline.d.ts.map +1 -0
  65. package/dist/svg/timeline.js +284 -0
  66. package/dist/svg/timeline.js.map +1 -0
  67. package/dist/svg/waves.d.ts +62 -0
  68. package/dist/svg/waves.d.ts.map +1 -0
  69. package/dist/svg/waves.js +397 -0
  70. package/dist/svg/waves.js.map +1 -0
  71. package/package.json +7 -7
  72. package/src/image-size.ts +113 -0
  73. package/src/index.ts +10 -1
  74. package/src/logo.ts +82 -0
  75. package/src/stage.ts +191 -0
  76. package/src/svg/edges.ts +154 -0
  77. package/src/svg/footnotes.ts +115 -0
  78. package/src/svg/header.ts +285 -0
  79. package/src/svg/include-region.ts +201 -0
  80. package/src/svg/item.ts +672 -0
  81. package/src/svg/markers.ts +178 -0
  82. package/src/svg/nowline.ts +152 -0
  83. package/src/svg/primitives.ts +86 -0
  84. package/src/svg/render.ts +94 -2110
  85. package/src/svg/sanitize.ts +286 -16
  86. package/src/svg/swimlanes.ts +317 -0
  87. package/src/svg/timeline.ts +343 -0
  88. package/src/svg/waves.ts +502 -0
@@ -0,0 +1,502 @@
1
+ // The waves layer (specs/waves.md section 9): the hatch patterns that mark
2
+ // background work, the wave strip, tints, boundaries, labels, crossings and the
3
+ // legend. Every wave layer is emitted only when it has content, so a roadmap
4
+ // without waves renders byte-identically.
5
+
6
+ import type {
7
+ BoundingBox,
8
+ PositionedRoadmap,
9
+ PositionedSwimlane,
10
+ PositionedTimelineScale,
11
+ PositionedTrackChild,
12
+ PositionedWave,
13
+ PositionedWaveBoundary,
14
+ PositionedWaveCrossing,
15
+ PositionedWaveLegend,
16
+ ResolvedStyle,
17
+ Theme,
18
+ WaveHatchKind,
19
+ } from '@nowline/layout';
20
+ import {
21
+ CORNER_RADIUS_PX,
22
+ ITEM_FOOTNOTE_INDICATOR_STEP_PX,
23
+ WAVE_BOUNDARY_WIDTH_PX,
24
+ WAVE_CELL_INSET_PX,
25
+ WAVE_CROSS_DASH,
26
+ WAVE_HATCH_OPACITY,
27
+ WAVE_HATCH_STROKE_PX,
28
+ WAVE_HATCH_TILE_PX,
29
+ WAVE_STRIP_LABEL_FONT_SIZE_PX,
30
+ WAVE_STYLED_STRIP_MIX_OPACITY,
31
+ WAVE_STYLED_TINT_OPACITY,
32
+ } from '@nowline/layout';
33
+ import { renderInlineDatePins } from './item.js';
34
+ import { intersectBox } from './primitives.js';
35
+ import type { FontFamilies } from './render.js';
36
+ import { escText, num, tag, textTag } from './xml.js';
37
+
38
+ // --- Waves (specs/waves.md §9) ---------------------------------------------
39
+ //
40
+ // Every wave layer is emitted only when it has content, so a roadmap without
41
+ // waves renders byte-identically: no `wave-*` layers and no hatch `<defs>`.
42
+
43
+ /** Strip label baseline below the strip's vertical centre (10 px text). */
44
+ const WAVE_LABEL_BASELINE_BELOW_CENTER_PX = 4;
45
+
46
+ /** Footnote superscripts in the strip: size and baseline below the strip top. */
47
+ const WAVE_SUPERSCRIPT_FONT_SIZE_PX = 8;
48
+ const WAVE_SUPERSCRIPT_BASELINE_OFFSET_PX = 10;
49
+
50
+ function waveHatchUrl(idPrefix: string, kind: WaveHatchKind): string {
51
+ return `url(#${idPrefix}-wave-hatch-${kind})`;
52
+ }
53
+
54
+ /**
55
+ * A hatch `<pattern>`: a `WAVE_HATCH_TILE_PX` tile rotated 45°, one
56
+ * `WAVE_HATCH_STROKE_PX` stroke down its middle (so the tile never clips
57
+ * it) at `WAVE_HATCH_OPACITY`. The id carries the SVG's prefix, like the
58
+ * shadow filters, so two SVGs on one page never share it.
59
+ *
60
+ * The line carries `opacity`, not `stroke-opacity`. Browsers and resvg draw
61
+ * the two the same (one stroke, nothing overlaps), but svg-to-pdfkit turns
62
+ * `stroke-opacity` into a stroke-only `CA` inside the pattern cell, which
63
+ * poppler ignores, so the PDF hatch drew opaque. With `opacity` it sets the
64
+ * fill alpha `ca` as well, which poppler and cairo both honour.
65
+ */
66
+ export function waveHatchPatternDef(idPrefix: string, kind: WaveHatchKind, palette: Theme): string {
67
+ const tile = WAVE_HATCH_TILE_PX;
68
+ return tag(
69
+ 'pattern',
70
+ {
71
+ id: `${idPrefix}-wave-hatch-${kind}`,
72
+ width: tile,
73
+ height: tile,
74
+ patternUnits: 'userSpaceOnUse',
75
+ patternTransform: 'rotate(45)',
76
+ },
77
+ tag('line', {
78
+ x1: num(tile / 2),
79
+ y1: 0,
80
+ x2: num(tile / 2),
81
+ y2: tile,
82
+ stroke: kind === 'dark' ? palette.wave.hatch : palette.wave.hatchOnDark,
83
+ 'stroke-width': WAVE_HATCH_STROKE_PX,
84
+ opacity: WAVE_HATCH_OPACITY,
85
+ }),
86
+ );
87
+ }
88
+
89
+ /**
90
+ * The hatch patterns the SVG uses, in a fixed order: one per kind that a
91
+ * background bar (main lanes or isolated regions) or the legend's hatch
92
+ * swatch needs, as layout resolved them (`waveHatch`, `hatch`). Empty
93
+ * without waves.
94
+ */
95
+ export function usedWaveHatchKinds(model: PositionedRoadmap): WaveHatchKind[] {
96
+ if (!model.waves) return [];
97
+ const used = new Set<WaveHatchKind>();
98
+ const walk = (children: PositionedTrackChild[]): void => {
99
+ for (const c of children) {
100
+ if (c.kind === 'item') {
101
+ if (c.waveRole === 'background' && c.waveHatch) used.add(c.waveHatch);
102
+ } else {
103
+ walk(c.children);
104
+ }
105
+ }
106
+ };
107
+ const walkLanes = (lanes: PositionedSwimlane[]): void => {
108
+ for (const lane of lanes) {
109
+ walk(lane.children);
110
+ walkLanes(lane.nested);
111
+ }
112
+ };
113
+ walkLanes(model.swimlanes);
114
+ for (const r of model.includes) walkLanes(r.nestedSwimlanes);
115
+ for (const e of model.waveLegend?.entries ?? []) {
116
+ used.add(e.hatch);
117
+ }
118
+ return (['dark', 'light'] as const).filter((k) => used.has(k));
119
+ }
120
+
121
+ /**
122
+ * The hatch overlay over a background bar: the bar's rect inset by half its
123
+ * 1 px stroke, with the same corner radius, so it never covers the stroke.
124
+ * `kind` is the bar's hatch kind as layout resolved it.
125
+ */
126
+ export function hatchOverlay(
127
+ box: BoundingBox,
128
+ style: ResolvedStyle,
129
+ kind: WaveHatchKind,
130
+ idPrefix: string,
131
+ ): string {
132
+ const inset = 0.5;
133
+ const rx = Math.min(CORNER_RADIUS_PX[style.cornerRadius] ?? 4, box.height / 2);
134
+ return tag('rect', {
135
+ x: num(box.x + inset),
136
+ y: num(box.y + inset),
137
+ width: num(Math.max(0, box.width - 2 * inset)),
138
+ height: num(Math.max(0, box.height - 2 * inset)),
139
+ rx: num(rx),
140
+ ry: num(rx),
141
+ fill: waveHatchUrl(idPrefix, kind),
142
+ });
143
+ }
144
+
145
+ /** The strip's backing panel corner radius (matches the tick panel's). */
146
+ const WAVE_STRIP_PANEL_RADIUS_PX = 4;
147
+
148
+ /**
149
+ * Corner radii of a cell's left and right ends: a cell that reaches an end
150
+ * of the backing panel rounds its outer corners there to the panel's radius
151
+ * less the inset, so it never covers the panel's rounded corner or border.
152
+ */
153
+ interface WaveCellCorners {
154
+ left: number;
155
+ right: number;
156
+ }
157
+
158
+ function waveCellCorners(cell: BoundingBox, t: PositionedTimelineScale): WaveCellCorners {
159
+ const r = Math.min(
160
+ WAVE_STRIP_PANEL_RADIUS_PX - WAVE_CELL_INSET_PX,
161
+ cell.width / 2,
162
+ cell.height / 2,
163
+ );
164
+ const eps = 1e-6;
165
+ return {
166
+ left: cell.x <= t.box.x + WAVE_CELL_INSET_PX + eps ? r : 0,
167
+ right: cell.x + cell.width >= t.box.x + t.box.width - WAVE_CELL_INSET_PX - eps ? r : 0,
168
+ };
169
+ }
170
+
171
+ /** A box as a path with the given left / right corner radii. */
172
+ function roundedEndsPath(box: BoundingBox, c: WaveCellCorners): string {
173
+ const { x, y, width: w, height: h } = box;
174
+ const arc = (r: number, ex: number, ey: number): string =>
175
+ r > 0 ? ` A${num(r)} ${num(r)} 0 0 1 ${num(ex)} ${num(ey)}` : '';
176
+ return (
177
+ `M${num(x + c.left)} ${num(y)}` +
178
+ ` H${num(x + w - c.right)}` +
179
+ arc(c.right, x + w, y + c.right) +
180
+ ` V${num(y + h - c.right)}` +
181
+ arc(c.right, x + w - c.right, y + h) +
182
+ ` H${num(x + c.left)}` +
183
+ arc(c.left, x, y + h - c.left) +
184
+ ` V${num(y + c.left)}` +
185
+ arc(c.left, x + c.left, y) +
186
+ ' Z'
187
+ );
188
+ }
189
+
190
+ /**
191
+ * A cell's fill: the strip fill, then the styled `bg` overlay when set.
192
+ * Square rects, unless `corners` rounds an end that meets the panel's.
193
+ */
194
+ function waveCellFill(w: PositionedWave, box: BoundingBox, corners?: WaveCellCorners): string {
195
+ const shape = (fill: Record<string, string | number>): string =>
196
+ corners && (corners.left > 0 || corners.right > 0)
197
+ ? tag('path', { d: roundedEndsPath(box, corners), ...fill })
198
+ : tag('rect', {
199
+ x: num(box.x),
200
+ y: num(box.y),
201
+ width: num(box.width),
202
+ height: num(box.height),
203
+ ...fill,
204
+ });
205
+ const base = shape({ fill: w.style.stripFill });
206
+ if (!w.style.tint) return base;
207
+ return (
208
+ base +
209
+ shape({
210
+ fill: w.style.tint,
211
+ 'fill-opacity': WAVE_STYLED_STRIP_MIX_OPACITY,
212
+ })
213
+ );
214
+ }
215
+
216
+ /**
217
+ * `wave-strip` (§9.1): the backing panel and one cell per non-empty wave,
218
+ * each with its tooltip. Drawn right after the timeline panels, so the
219
+ * major grid lines cross the cells as they cross the tick panel.
220
+ */
221
+ export function renderWaveStrip(model: PositionedRoadmap, palette: Theme): string {
222
+ const t = model.timeline;
223
+ const strip = t.waveStrip;
224
+ if (!model.waves || !strip) return '';
225
+ const parts: string[] = [
226
+ tag('rect', {
227
+ x: num(t.box.x),
228
+ y: num(strip.y),
229
+ width: num(t.box.width),
230
+ height: num(strip.height),
231
+ rx: WAVE_STRIP_PANEL_RADIUS_PX,
232
+ ry: WAVE_STRIP_PANEL_RADIUS_PX,
233
+ fill: palette.timeline.panelFill,
234
+ stroke: palette.timeline.border,
235
+ 'stroke-width': 1,
236
+ }),
237
+ ];
238
+ for (const w of model.waves) {
239
+ // The cell layout clipped to the panel; none for an empty or
240
+ // off-screen wave.
241
+ const box = w.strip.cell;
242
+ if (!box) continue;
243
+ parts.push(
244
+ tag(
245
+ 'g',
246
+ { 'data-id': w.id },
247
+ `<title>${escText(w.strip.tooltip)}</title>${waveCellFill(w, box, waveCellCorners(box, t))}`,
248
+ ),
249
+ );
250
+ }
251
+ return tag('g', { 'data-layer': 'wave-strip' }, parts.join(''));
252
+ }
253
+
254
+ /**
255
+ * `wave-bg` (§9.4): a column tint over each styled wave's `columnBox`,
256
+ * clipped to `clip` when given (an include region's painted rect).
257
+ */
258
+ export function renderWaveTints(waves: PositionedWave[], clip?: BoundingBox): string {
259
+ const parts: string[] = [];
260
+ for (const w of waves) {
261
+ if (!w.style.tint || w.empty) continue;
262
+ const box = clip ? intersectBox(w.columnBox, clip) : w.columnBox;
263
+ if (!box || box.width <= 0 || box.height <= 0) continue;
264
+ parts.push(
265
+ tag('rect', {
266
+ x: num(box.x),
267
+ y: num(box.y),
268
+ width: num(box.width),
269
+ height: num(box.height),
270
+ fill: w.style.tint,
271
+ 'fill-opacity': WAVE_STYLED_TINT_OPACITY,
272
+ }),
273
+ );
274
+ }
275
+ if (parts.length === 0) return '';
276
+ return tag('g', { 'data-layer': 'wave-bg' }, parts.join(''));
277
+ }
278
+
279
+ /**
280
+ * `wave-boundary` (§9.2): the 2 px boundary lines, from the strip top to the
281
+ * last lane. With `clip`, only the lines strictly inside it, cut to its
282
+ * vertical extent.
283
+ */
284
+ export function renderWaveBoundaries(
285
+ boundaries: PositionedWaveBoundary[] | undefined,
286
+ clip?: BoundingBox,
287
+ ): string {
288
+ const parts: string[] = [];
289
+ for (const b of boundaries ?? []) {
290
+ let topY = b.topY;
291
+ let bottomY = b.bottomY;
292
+ if (clip) {
293
+ if (b.x <= clip.x || b.x >= clip.x + clip.width) continue;
294
+ topY = Math.max(topY, clip.y);
295
+ bottomY = Math.min(bottomY, clip.y + clip.height);
296
+ if (bottomY <= topY) continue;
297
+ }
298
+ parts.push(
299
+ tag('line', {
300
+ x1: num(b.x),
301
+ y1: num(topY),
302
+ x2: num(b.x),
303
+ y2: num(bottomY),
304
+ stroke: b.stroke,
305
+ 'stroke-width': WAVE_BOUNDARY_WIDTH_PX,
306
+ 'stroke-dasharray': b.dash,
307
+ }),
308
+ );
309
+ }
310
+ if (parts.length === 0) return '';
311
+ return tag('g', { 'data-layer': 'wave-boundary' }, parts.join(''));
312
+ }
313
+
314
+ /**
315
+ * `wave-labels` (§9.1): strip labels on halos in their cell's fill,
316
+ * right-aligned footnote superscripts, empty-wave diamonds, gap floor
317
+ * labels, and the all-empty placeholder. Drawn after the boundaries.
318
+ */
319
+ export function renderWaveLabels(
320
+ model: PositionedRoadmap,
321
+ palette: Theme,
322
+ fonts: FontFamilies,
323
+ ): string {
324
+ const t = model.timeline;
325
+ const strip = t.waveStrip;
326
+ if (!model.waves || !strip) return '';
327
+ const baselineY = strip.y + strip.height / 2 + WAVE_LABEL_BASELINE_BELOW_CENTER_PX;
328
+ const parts: string[] = [];
329
+ // Muted italics on a halo in the backing panel's fill, so the major
330
+ // grid lines drawn later never cut through the text (§9.1). Layout
331
+ // placed the halo.
332
+ const mutedText = (x: number, text: string, halo: BoundingBox): string => {
333
+ return (
334
+ tag('rect', {
335
+ x: num(halo.x),
336
+ y: num(halo.y),
337
+ width: num(halo.width),
338
+ height: num(halo.height),
339
+ fill: palette.timeline.panelFill,
340
+ }) +
341
+ textTag(
342
+ {
343
+ x: num(x),
344
+ y: num(baselineY),
345
+ 'font-family': fonts.sans,
346
+ 'font-size': WAVE_STRIP_LABEL_FONT_SIZE_PX,
347
+ 'font-style': 'italic',
348
+ fill: palette.wave.labelMuted,
349
+ 'text-anchor': 'middle',
350
+ },
351
+ text,
352
+ )
353
+ );
354
+ };
355
+
356
+ for (const w of model.waves) {
357
+ // A gap opened by a start floor: no cell, the floor reference in
358
+ // muted italics on a halo in the backing panel's fill.
359
+ if (w.strip.gapLabel) {
360
+ const { text, x, halo } = w.strip.gapLabel;
361
+ parts.push(mutedText(x, text, halo));
362
+ }
363
+
364
+ // Layout placed the label and superscript halos in the painted
365
+ // cell; a wave without one draws neither.
366
+ if (!w.strip.cell) continue;
367
+ const { labelDraw, superscripts, inlineDatePin } = w.strip;
368
+ if (inlineDatePin) parts.push(renderInlineDatePins([inlineDatePin], w.style.text));
369
+ if (superscripts) {
370
+ // The superscripts' own halo in the cell's fill, first so the
371
+ // label's halo never covers them.
372
+ parts.push(waveCellFill(w, superscripts.halo));
373
+ }
374
+ if (w.strip.label !== undefined && labelDraw) {
375
+ // Centred on the visible span, but never under the superscripts.
376
+ parts.push(waveCellFill(w, labelDraw.halo));
377
+ parts.push(
378
+ textTag(
379
+ {
380
+ x: num(labelDraw.x),
381
+ y: num(baselineY),
382
+ 'font-family': fonts.sans,
383
+ 'font-size': WAVE_STRIP_LABEL_FONT_SIZE_PX,
384
+ 'font-weight': 600,
385
+ fill: w.style.text,
386
+ 'text-anchor': 'middle',
387
+ },
388
+ w.strip.label,
389
+ ),
390
+ );
391
+ }
392
+ if (superscripts) {
393
+ // Right-aligned in the cell, walking left like an item's.
394
+ let fx = superscripts.rightX;
395
+ for (let k = w.footnoteIndicators.length - 1; k >= 0; k--) {
396
+ parts.push(
397
+ textTag(
398
+ {
399
+ x: num(fx),
400
+ y: num(strip.y + WAVE_SUPERSCRIPT_BASELINE_OFFSET_PX),
401
+ 'font-family': fonts.sans,
402
+ 'font-size': WAVE_SUPERSCRIPT_FONT_SIZE_PX,
403
+ 'font-weight': 700,
404
+ fill: w.style.text,
405
+ 'text-anchor': 'end',
406
+ },
407
+ String(w.footnoteIndicators[k]),
408
+ ),
409
+ );
410
+ fx -= ITEM_FOOTNOTE_INDICATOR_STEP_PX;
411
+ }
412
+ }
413
+ }
414
+ if (parts.length === 0) return '';
415
+ return tag('g', { 'data-layer': 'wave-labels' }, parts.join(''));
416
+ }
417
+
418
+ /**
419
+ * `wave-cross` (§9.3): a 1 px dashed boundary over each background bar a
420
+ * boundary runs through, showing the barrier does not hold that work.
421
+ */
422
+ export function renderWaveCrossings(crossings: PositionedWaveCrossing[] | undefined): string {
423
+ if (!crossings || crossings.length === 0) return '';
424
+ const parts = crossings.map((c) =>
425
+ tag('line', {
426
+ x1: num(c.x),
427
+ y1: num(c.topY),
428
+ x2: num(c.x),
429
+ y2: num(c.bottomY),
430
+ stroke: c.stroke,
431
+ 'stroke-width': 1,
432
+ 'stroke-dasharray': WAVE_CROSS_DASH,
433
+ }),
434
+ );
435
+ return tag('g', { 'data-layer': 'wave-cross' }, parts.join(''));
436
+ }
437
+
438
+ /** The legend's hatch swatch fill: a default item bar. */
439
+ function legendSwatchFill(palette: Theme): string {
440
+ const bg = palette.entities.item.bg;
441
+ return bg === 'none' ? 'transparent' : bg;
442
+ }
443
+
444
+ /**
445
+ * `wave-legend` (§9.5): the hatched bar swatch for background work and its
446
+ * text, as the layout placed them.
447
+ */
448
+ export function renderWaveLegend(
449
+ legend: PositionedWaveLegend | undefined,
450
+ idPrefix: string,
451
+ palette: Theme,
452
+ fonts: FontFamilies,
453
+ ): string {
454
+ if (!legend) return '';
455
+ const parts: string[] = [];
456
+ for (const entry of legend.entries) {
457
+ const s = entry.swatch;
458
+ const fill = legendSwatchFill(palette);
459
+ const geometry = {
460
+ x: num(s.x),
461
+ y: num(s.y),
462
+ width: num(s.width),
463
+ height: num(s.height),
464
+ };
465
+ parts.push(
466
+ tag('rect', {
467
+ ...geometry,
468
+ rx: 2,
469
+ ry: 2,
470
+ fill,
471
+ stroke: palette.entities.item.fg,
472
+ 'stroke-width': 1,
473
+ }),
474
+ );
475
+ parts.push(
476
+ tag('rect', {
477
+ x: num(s.x + 0.5),
478
+ y: num(s.y + 0.5),
479
+ width: num(s.width - 1),
480
+ height: num(s.height - 1),
481
+ rx: 2,
482
+ ry: 2,
483
+ fill: waveHatchUrl(idPrefix, entry.hatch),
484
+ }),
485
+ );
486
+ for (const run of entry.runs) {
487
+ parts.push(
488
+ textTag(
489
+ {
490
+ x: num(run.x),
491
+ y: num(run.y),
492
+ 'font-family': fonts.sans,
493
+ 'font-size': WAVE_STRIP_LABEL_FONT_SIZE_PX,
494
+ fill: palette.wave.labelText,
495
+ },
496
+ run.text,
497
+ ),
498
+ );
499
+ }
500
+ }
501
+ return tag('g', { 'data-layer': 'wave-legend' }, parts.join(''));
502
+ }