@nowline/renderer 0.8.6 → 0.10.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 (86) 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/include-region.d.ts +4 -0
  30. package/dist/svg/include-region.d.ts.map +1 -0
  31. package/dist/svg/include-region.js +157 -0
  32. package/dist/svg/include-region.js.map +1 -0
  33. package/dist/svg/item.d.ts +36 -0
  34. package/dist/svg/item.d.ts.map +1 -0
  35. package/dist/svg/item.js +549 -0
  36. package/dist/svg/item.js.map +1 -0
  37. package/dist/svg/markers.d.ts +7 -0
  38. package/dist/svg/markers.d.ts.map +1 -0
  39. package/dist/svg/markers.js +151 -0
  40. package/dist/svg/markers.js.map +1 -0
  41. package/dist/svg/nowline.d.ts +4 -0
  42. package/dist/svg/nowline.d.ts.map +1 -0
  43. package/dist/svg/nowline.js +133 -0
  44. package/dist/svg/nowline.js.map +1 -0
  45. package/dist/svg/primitives.d.ts +7 -0
  46. package/dist/svg/primitives.d.ts.map +1 -0
  47. package/dist/svg/primitives.js +70 -0
  48. package/dist/svg/primitives.js.map +1 -0
  49. package/dist/svg/render.d.ts +13 -7
  50. package/dist/svg/render.d.ts.map +1 -1
  51. package/dist/svg/render.js +64 -1701
  52. package/dist/svg/render.js.map +1 -1
  53. package/dist/svg/sanitize.d.ts +10 -0
  54. package/dist/svg/sanitize.d.ts.map +1 -1
  55. package/dist/svg/sanitize.js +264 -16
  56. package/dist/svg/sanitize.js.map +1 -1
  57. package/dist/svg/swimlanes.d.ts +5 -0
  58. package/dist/svg/swimlanes.d.ts.map +1 -0
  59. package/dist/svg/swimlanes.js +228 -0
  60. package/dist/svg/swimlanes.js.map +1 -0
  61. package/dist/svg/timeline.d.ts +24 -0
  62. package/dist/svg/timeline.d.ts.map +1 -0
  63. package/dist/svg/timeline.js +284 -0
  64. package/dist/svg/timeline.js.map +1 -0
  65. package/dist/svg/waves.d.ts +62 -0
  66. package/dist/svg/waves.d.ts.map +1 -0
  67. package/dist/svg/waves.js +397 -0
  68. package/dist/svg/waves.js.map +1 -0
  69. package/package.json +6 -6
  70. package/src/image-size.ts +113 -0
  71. package/src/index.ts +10 -1
  72. package/src/logo.ts +82 -0
  73. package/src/stage.ts +191 -0
  74. package/src/svg/edges.ts +154 -0
  75. package/src/svg/footnotes.ts +115 -0
  76. package/src/svg/header.ts +285 -0
  77. package/src/svg/include-region.ts +201 -0
  78. package/src/svg/item.ts +672 -0
  79. package/src/svg/markers.ts +178 -0
  80. package/src/svg/nowline.ts +152 -0
  81. package/src/svg/primitives.ts +86 -0
  82. package/src/svg/render.ts +94 -2110
  83. package/src/svg/sanitize.ts +286 -16
  84. package/src/svg/swimlanes.ts +317 -0
  85. package/src/svg/timeline.ts +343 -0
  86. package/src/svg/waves.ts +502 -0
@@ -75,7 +75,6 @@ const ALLOWED_ATTRIBUTES = new Set([
75
75
  'transform',
76
76
  'points',
77
77
  'id',
78
- 'class',
79
78
  'clip-path',
80
79
  'mask',
81
80
  'filter',
@@ -89,7 +88,6 @@ const ALLOWED_ATTRIBUTES = new Set([
89
88
  'rotate',
90
89
  'letter-spacing',
91
90
  'word-spacing',
92
- 'style',
93
91
  'xmlns',
94
92
  'href',
95
93
  'xlink:href',
@@ -114,8 +112,129 @@ const ALLOWED_ATTRIBUTES = new Set([
114
112
  'patternContentUnits',
115
113
  'role',
116
114
  'aria-label',
115
+ // Presentation attributes beyond the ones above, so the same set can
116
+ // arrive from a `style` declaration (see PRESENTATION_PROPERTIES).
117
+ 'display',
118
+ 'visibility',
119
+ 'clip-rule',
120
+ 'color',
121
+ 'font-variant',
122
+ 'font-stretch',
123
+ 'text-decoration',
124
+ 'dominant-baseline',
125
+ 'alignment-baseline',
126
+ 'baseline-shift',
127
+ 'writing-mode',
128
+ 'paint-order',
129
+ 'vector-effect',
130
+ 'shape-rendering',
131
+ 'text-rendering',
132
+ 'image-rendering',
133
+ 'color-interpolation',
134
+ 'color-interpolation-filters',
117
135
  ]);
118
136
 
137
+ // CSS properties a `style` attribute or `<style>` class rule may set. Each
138
+ // becomes the presentation attribute of the same name, then goes through
139
+ // the same attribute checks as one written directly. Editors (Inkscape,
140
+ // Illustrator, Figma) put a logo's colors here, so dropping them would draw
141
+ // the logo black.
142
+ const PRESENTATION_PROPERTIES = new Set([
143
+ 'fill',
144
+ 'fill-opacity',
145
+ 'fill-rule',
146
+ 'stroke',
147
+ 'stroke-width',
148
+ 'stroke-opacity',
149
+ 'stroke-linecap',
150
+ 'stroke-linejoin',
151
+ 'stroke-miterlimit',
152
+ 'stroke-dasharray',
153
+ 'stroke-dashoffset',
154
+ 'opacity',
155
+ 'clip-path',
156
+ 'clip-rule',
157
+ 'mask',
158
+ 'filter',
159
+ 'font-family',
160
+ 'font-size',
161
+ 'font-weight',
162
+ 'font-style',
163
+ 'font-variant',
164
+ 'font-stretch',
165
+ 'text-anchor',
166
+ 'text-decoration',
167
+ 'letter-spacing',
168
+ 'word-spacing',
169
+ 'dominant-baseline',
170
+ 'alignment-baseline',
171
+ 'baseline-shift',
172
+ 'writing-mode',
173
+ 'stop-color',
174
+ 'stop-opacity',
175
+ 'flood-color',
176
+ 'flood-opacity',
177
+ 'display',
178
+ 'visibility',
179
+ 'color',
180
+ 'paint-order',
181
+ 'vector-effect',
182
+ 'shape-rendering',
183
+ 'text-rendering',
184
+ 'image-rendering',
185
+ 'color-interpolation',
186
+ 'color-interpolation-filters',
187
+ ]);
188
+
189
+ // CSS properties dropped without a warning: they have no effect on how an
190
+ // SVG 1.1 renderer draws the logo (editor hints, typographic settings for
191
+ // features we do not draw, text layout that `text-anchor` already carries).
192
+ const IGNORED_STYLE_PROPERTIES = new Set([
193
+ 'line-height',
194
+ 'text-align',
195
+ 'font-variant-ligatures',
196
+ 'font-variant-caps',
197
+ 'font-variant-numeric',
198
+ 'font-variant-east-asian',
199
+ 'font-variant-position',
200
+ 'font-variant-alternates',
201
+ 'font-feature-settings',
202
+ 'font-variation-settings',
203
+ 'font-kerning',
204
+ 'white-space',
205
+ 'text-orientation',
206
+ 'overflow',
207
+ 'isolation',
208
+ 'solid-color',
209
+ 'solid-opacity',
210
+ 'enable-background',
211
+ 'color-rendering',
212
+ ]);
213
+
214
+ // Values that leave an unsupported property at its default, so dropping
215
+ // it changes nothing (`mix-blend-mode:normal`, `marker:none`).
216
+ const NO_OP_VALUES = new Set(['none', 'normal', 'auto', 'initial', 'inherit', 'unset']);
217
+
218
+ // Attributes dropped without a warning: editor and document bookkeeping
219
+ // that never draws (`inkscape:label`, `sodipodi:docname`, `xmlns:*`,
220
+ // `xml:space`, `data-name`, `version`).
221
+ function isMetadataAttribute(key: string): boolean {
222
+ if (key === 'xlink:href') return false;
223
+ return (
224
+ key.includes(':') ||
225
+ key.startsWith('data-') ||
226
+ key === 'version' ||
227
+ key === 'baseProfile' ||
228
+ key === 'enable-background'
229
+ );
230
+ }
231
+
232
+ // Elements dropped without a warning: `<metadata>` and editor-namespaced
233
+ // elements (`<sodipodi:namedview>`, `<inkscape:page>`, `<rdf:RDF>`).
234
+ function isMetadataElement(name: string): boolean {
235
+ return name === 'metadata' || (name.includes(':') && !name.startsWith('svg:'));
236
+ }
237
+
119
238
  export interface SanitizeOptions {
120
239
  // Rewrite internal ids under this prefix to avoid collisions with the
121
240
  // host document. The rewrite is both on `id=` and on references
@@ -123,8 +242,13 @@ export interface SanitizeOptions {
123
242
  idPrefix?: string;
124
243
  // Called when the sanitizer rejects a construct, for test/diagnostic use.
125
244
  onWarn?: (message: string) => void;
245
+ // Drop `x`, `y`, `width` and `height` from the root `<svg>` so it fills
246
+ // whatever viewport the host wraps it in (logo scaling).
247
+ stripRootSize?: boolean;
126
248
  }
127
249
 
250
+ const ROOT_SIZE_ATTRIBUTES = new Set(['x', 'y', 'width', 'height']);
251
+
128
252
  interface Token {
129
253
  kind: 'open' | 'close' | 'self' | 'text' | 'cdata' | 'comment' | 'decl';
130
254
  name?: string;
@@ -282,6 +406,17 @@ export function sanitizeSvg(input: string, options: SanitizeOptions = {}): strin
282
406
  const warn = options.onWarn ?? ((): void => {});
283
407
  const tokens = tokenize(input);
284
408
 
409
+ // Class rules from every `<style>` element, applied below as attributes.
410
+ // The `<style>` element itself is never emitted.
411
+ let sheet = '';
412
+ let styleDepth = 0;
413
+ for (const t of tokens) {
414
+ if (t.kind === 'open' && t.name === 'style') styleDepth++;
415
+ else if (t.kind === 'close' && t.name === 'style') styleDepth = Math.max(0, styleDepth - 1);
416
+ else if (styleDepth > 0 && (t.kind === 'text' || t.kind === 'cdata')) sheet += t.text ?? '';
417
+ }
418
+ const classRules = parseClassRules(sheet, (what) => warn(`sanitizer: dropping ${what}`));
419
+
285
420
  // First pass: discover ids so the second pass can rewrite references.
286
421
  const idMap = new Map<string, string>();
287
422
  let idCounter = 0;
@@ -329,19 +464,31 @@ export function sanitizeSvg(input: string, options: SanitizeOptions = {}): strin
329
464
  if (t.kind === 'open') stack.push(false);
330
465
  continue;
331
466
  }
467
+ if (name === 'style' || isMetadataElement(name)) {
468
+ if (t.kind === 'open') stack.push(false);
469
+ continue;
470
+ }
332
471
  if (!ALLOWED_ELEMENTS.has(name)) {
333
472
  warn(`sanitizer: dropping unknown element <${name}>`);
334
473
  if (t.kind === 'open') stack.push(false);
335
474
  continue;
336
475
  }
337
476
  const parts: string[] = [`<${name}`];
338
- const keys = Object.keys(t.attrs ?? {}).sort();
477
+ const isRoot = name === 'svg' && stack.length === 0;
478
+ const merged = applyStyles(t.attrs ?? {}, classRules, warn);
479
+ const keys = Object.keys(merged).sort();
339
480
  for (const key of keys) {
340
- const value = (t.attrs as Record<string, string>)[key];
481
+ const value = merged[key];
482
+ if (isRoot && options.stripRootSize && ROOT_SIZE_ATTRIBUTES.has(key)) continue;
341
483
  if (/^on/i.test(key)) {
342
484
  warn(`sanitizer: dropping event handler ${key}`);
343
485
  continue;
344
486
  }
487
+ if (isMetadataAttribute(key)) continue;
488
+ // `applyStyles` has folded the `<style>` rules into attributes,
489
+ // so `class` is spent bookkeeping. Keeping it would let CSS on
490
+ // the host page (another inline SVG's `.cls-1`) restyle the logo.
491
+ if (key === 'class') continue;
345
492
  if (!ALLOWED_ATTRIBUTES.has(key)) {
346
493
  warn(`sanitizer: dropping attribute ${key}`);
347
494
  continue;
@@ -355,30 +502,33 @@ export function sanitizeSvg(input: string, options: SanitizeOptions = {}): strin
355
502
  parts.push(` ${key}="${escAttrValue(rewritten)}"`);
356
503
  continue;
357
504
  }
358
- if (key === 'style') {
359
- // CSS can smuggle `expression()` or url() — strip entirely.
360
- warn('sanitizer: dropping inline style');
361
- continue;
362
- }
363
505
  if (key === 'id') {
364
506
  const mapped = idMap.get(value);
365
507
  if (mapped) parts.push(` id="${escAttrValue(mapped)}"`);
366
508
  continue;
367
509
  }
510
+ // Reject raster data URIs, script URLs, any url() that is not
511
+ // a local fragment (an external fetch), and CSS escapes or
512
+ // constructs a style value could smuggle in. Local
513
+ // `url(#foo)` references are checked out of the value first.
514
+ const localRef = /url\(#([^)]+)\)/gi;
515
+ if (
516
+ /data:|javascript:|vbscript:|url\(|expression\s*\(|[\\{}]/i.test(
517
+ value.replace(localRef, ''),
518
+ )
519
+ ) {
520
+ warn(`sanitizer: dropping suspicious value at ${key}`);
521
+ continue;
522
+ }
368
523
  // Rewrite any `url(#foo)` embedded inside fill/stroke/etc.
369
- if (/\burl\(#/i.test(value)) {
370
- const rewritten = value.replace(/url\(#([^)]+)\)/gi, (_m, id) => {
524
+ if (/url\(#/i.test(value)) {
525
+ const rewritten = value.replace(localRef, (_m, id) => {
371
526
  const mapped = idMap.get(id);
372
527
  return mapped ? `url(#${mapped})` : 'none';
373
528
  });
374
529
  parts.push(` ${key}="${escAttrValue(rewritten)}"`);
375
530
  continue;
376
531
  }
377
- // Reject raster data URIs at non-href attributes.
378
- if (/data:|javascript:|vbscript:/i.test(value)) {
379
- warn(`sanitizer: dropping suspicious value at ${key}`);
380
- continue;
381
- }
382
532
  parts.push(` ${key}="${escAttrValue(value)}"`);
383
533
  }
384
534
  parts.push(t.kind === 'self' ? '/>' : '>');
@@ -393,3 +543,123 @@ export function sanitizeSvg(input: string, options: SanitizeOptions = {}): strin
393
543
  }
394
544
  return output.join('');
395
545
  }
546
+
547
+ // A unitless or `px` length, else undefined (percentages and absolute units
548
+ // carry no intrinsic pixel size).
549
+ function parsePxLength(raw: string | undefined): number | undefined {
550
+ if (raw === undefined) return undefined;
551
+ const m = /^\s*([0-9]*\.?[0-9]+(?:e[+-]?[0-9]+)?)\s*(px)?\s*$/i.exec(raw);
552
+ if (!m) return undefined;
553
+ const n = Number(m[1]);
554
+ return n > 0 && Number.isFinite(n) ? n : undefined;
555
+ }
556
+
557
+ /**
558
+ * Intrinsic size of an SVG document from its root `<svg>`: `width` and
559
+ * `height` when both are plain lengths, else the `viewBox` size. Undefined
560
+ * when neither gives a positive size (no root, `%` sizes and no viewBox).
561
+ */
562
+ export function svgIntrinsicSize(input: string): { width: number; height: number } | undefined {
563
+ const root = tokenize(input).find(
564
+ (t) => (t.kind === 'open' || t.kind === 'self') && t.name === 'svg',
565
+ );
566
+ if (!root) return undefined;
567
+ const attrs = root.attrs ?? {};
568
+ const width = parsePxLength(attrs.width);
569
+ const height = parsePxLength(attrs.height);
570
+ if (width !== undefined && height !== undefined) return { width, height };
571
+ const vb = (attrs.viewBox ?? '')
572
+ .trim()
573
+ .split(/[\s,]+/)
574
+ .map(Number);
575
+ if (vb.length === 4 && vb.every(Number.isFinite) && vb[2] > 0 && vb[3] > 0) {
576
+ return { width: vb[2], height: vb[3] };
577
+ }
578
+ return undefined;
579
+ }
580
+
581
+ interface StyleDeclaration {
582
+ property: string;
583
+ value: string;
584
+ }
585
+
586
+ // `a: b; c: d` into declarations, `!important` stripped. Malformed parts
587
+ // (no colon) are skipped.
588
+ function parseDeclarations(text: string): StyleDeclaration[] {
589
+ const out: StyleDeclaration[] = [];
590
+ for (const part of text.split(';')) {
591
+ const colon = part.indexOf(':');
592
+ if (colon < 0) continue;
593
+ const property = part.slice(0, colon).trim().toLowerCase();
594
+ const value = part
595
+ .slice(colon + 1)
596
+ .replace(/!\s*important\s*$/i, '')
597
+ .trim();
598
+ if (property && value) out.push({ property, value });
599
+ }
600
+ return out;
601
+ }
602
+
603
+ interface ClassRule {
604
+ classes: string[];
605
+ declarations: StyleDeclaration[];
606
+ }
607
+
608
+ // The subset of a `<style>` sheet we apply: rules whose selectors are all
609
+ // plain class selectors (`.cls-1, .cls-2 { ... }`), the shape Illustrator
610
+ // and Figma export. Anything else (element, id or compound selectors,
611
+ // at-rules) is reported through `onUnsupported` and not applied.
612
+ function parseClassRules(sheet: string, onUnsupported: (what: string) => void): ClassRule[] {
613
+ const rules: ClassRule[] = [];
614
+ const text = sheet.replace(/\/\*[\s\S]*?\*\//g, '');
615
+ const rulePattern = /([^{}]+)\{([^{}]*)\}/g;
616
+ for (const m of text.matchAll(rulePattern)) {
617
+ const selectorText = m[1].trim();
618
+ const selectors = selectorText.split(',').map((sel) => sel.trim());
619
+ if (
620
+ selectorText.startsWith('@') ||
621
+ !selectors.every((sel) => /^\.[A-Za-z_][\w-]*$/.test(sel))
622
+ ) {
623
+ onUnsupported(`<style> rule ${selectorText}`);
624
+ continue;
625
+ }
626
+ rules.push({
627
+ classes: selectors.map((sel) => sel.slice(1)),
628
+ declarations: parseDeclarations(m[2]),
629
+ });
630
+ }
631
+ if (/@[a-z-]+[^{;]*;/i.test(text)) onUnsupported('<style> at-rule');
632
+ return rules;
633
+ }
634
+
635
+ // An element's attributes with its styles folded in, in CSS cascade order:
636
+ // presentation attributes, then matching class rules in sheet order, then
637
+ // the inline `style`. Only PRESENTATION_PROPERTIES survive as attributes;
638
+ // other properties are dropped, with a warning unless ignorable.
639
+ function applyStyles(
640
+ attrs: Record<string, string>,
641
+ classRules: ClassRule[],
642
+ warn: (message: string) => void,
643
+ ): Record<string, string> {
644
+ const classes = new Set((attrs.class ?? '').split(/\s+/).filter(Boolean));
645
+ const declarations: StyleDeclaration[] = [];
646
+ for (const rule of classRules) {
647
+ if (rule.classes.some((c) => classes.has(c))) declarations.push(...rule.declarations);
648
+ }
649
+ if (attrs.style !== undefined) declarations.push(...parseDeclarations(attrs.style));
650
+ if (declarations.length === 0 && attrs.style === undefined) return attrs;
651
+ const merged: Record<string, string> = { ...attrs };
652
+ delete merged.style;
653
+ for (const { property, value } of declarations) {
654
+ if (PRESENTATION_PROPERTIES.has(property)) {
655
+ merged[property] = value;
656
+ } else if (
657
+ !property.startsWith('-') &&
658
+ !IGNORED_STYLE_PROPERTIES.has(property) &&
659
+ !NO_OP_VALUES.has(value.toLowerCase())
660
+ ) {
661
+ warn(`sanitizer: dropping style property ${property}`);
662
+ }
663
+ }
664
+ return merged;
665
+ }
@@ -0,0 +1,317 @@
1
+ // The swimlane layers: the lane background tint and tri-state utilization
2
+ // underline, the lane frame tab and badges, and the group and parallel
3
+ // containers that wrap a lane's items.
4
+
5
+ import type {
6
+ PositionedGroup,
7
+ PositionedParallel,
8
+ PositionedSwimlane,
9
+ PositionedTrackChild,
10
+ Theme,
11
+ } from '@nowline/layout';
12
+ import {
13
+ CONTAINER_BODY_FILL_OPACITY,
14
+ CONTAINER_BRACKET_LINE_OFFSET_PX,
15
+ CONTAINER_HEADER_TITLE_BASELINE_PX,
16
+ CONTAINER_HEADER_TITLE_FONT_SIZE_PX,
17
+ CONTAINER_MAX_FILLED_DEPTH,
18
+ CONTAINER_NESTED_BODY_FILL_OPACITY,
19
+ containerHeaderTitleX,
20
+ FRAME_TAB_HEIGHT_PX,
21
+ FRAME_TAB_LABEL_BASELINE_OFFSET_PX,
22
+ FRAME_TAB_OWNER_GLYPH_PX,
23
+ } from '@nowline/layout';
24
+ import { CAPACITY_ICON_SVG } from './icons.js';
25
+ import { hasAfterPin, renderCapacitySuffix, renderInlineDatePins, renderItem } from './item.js';
26
+ import { fontAttrs } from './primitives.js';
27
+ import type { FontFamilies, RenderOptions } from './render.js';
28
+ import { escAttr, num, tag, textTag } from './xml.js';
29
+
30
+ /**
31
+ * A group or parallel, drawn above and around its content
32
+ * (specs/true-width-layout.md §3.3): a body shaded with the container's `bg`
33
+ * spanning exactly its dates, a header bracket over those dates (solid for
34
+ * a group, dashed for a parallel by default; `bracket:` styles it), the
35
+ * title at the container's start with any inline-date glyphs beside it,
36
+ * then the children. A wave-only group draws only its children.
37
+ *
38
+ * `depth` is the container's nesting depth inside its lane, 1 for a
39
+ * top-level container. A nested body takes a lighter tint, and bodies deeper
40
+ * than `CONTAINER_MAX_FILLED_DEPTH` are outlined instead of filled, so tints
41
+ * never compound three deep (§8 O4).
42
+ */
43
+ function renderContainer(
44
+ c: PositionedGroup | PositionedParallel,
45
+ depth: number,
46
+ options: RenderOptions,
47
+ idPrefix: string,
48
+ palette: Theme,
49
+ fonts: FontFamilies,
50
+ ): string {
51
+ const parts: string[] = [];
52
+ const isDrawn = !(c.kind === 'group' && c.waveOnly);
53
+ if (isDrawn) {
54
+ const bodyY = c.box.y + c.headerHeight;
55
+ const bodyHeight = c.box.height - c.headerHeight;
56
+ if (c.style.bg !== 'none') {
57
+ const isFilled = depth <= CONTAINER_MAX_FILLED_DEPTH;
58
+ parts.push(
59
+ tag('rect', {
60
+ x: num(c.box.x),
61
+ y: num(bodyY),
62
+ width: num(c.box.width),
63
+ height: num(bodyHeight),
64
+ fill: isFilled ? c.style.bg : 'none',
65
+ 'fill-opacity': isFilled
66
+ ? depth === 1
67
+ ? CONTAINER_BODY_FILL_OPACITY
68
+ : CONTAINER_NESTED_BODY_FILL_OPACITY
69
+ : null,
70
+ stroke: isFilled ? null : c.style.bg,
71
+ 'stroke-opacity': isFilled ? null : 0.6,
72
+ 'stroke-width': isFilled ? null : 1,
73
+ }),
74
+ );
75
+ }
76
+ // The title and the bracket share one ink, the header's: a group's
77
+ // `fg`, a parallel's bracket stroke. The title sits on the lane band,
78
+ // never on the body, so a `text:` colour chosen to read on `bg`
79
+ // (white on a filled chiclet, before the header) would vanish.
80
+ const ink = c.kind === 'group' ? c.style.fg : palette.parallel.bracketStroke;
81
+ if (c.headerHeight > 0 && c.style.bracket !== 'none') {
82
+ const lineY = c.box.y + CONTAINER_BRACKET_LINE_OFFSET_PX;
83
+ const x0 = c.box.x;
84
+ const x1 = c.box.x + c.box.width;
85
+ parts.push(
86
+ tag('path', {
87
+ d: `M${num(x0)} ${num(bodyY)} V${num(lineY)} H${num(x1)} V${num(bodyY)}`,
88
+ fill: 'none',
89
+ stroke: ink,
90
+ 'stroke-width': 1.2,
91
+ 'stroke-dasharray': c.style.bracket === 'dashed' ? '3 2' : null,
92
+ }),
93
+ );
94
+ }
95
+ if (c.title && c.headerHeight > 0) {
96
+ parts.push(
97
+ textTag(
98
+ {
99
+ // Past the `after:` glyph when there is one; layout
100
+ // clamps the `before:` glyph past this text.
101
+ x: num(containerHeaderTitleX(c.box.x, hasAfterPin(c.inlineDatePins))),
102
+ y: num(c.box.y + CONTAINER_HEADER_TITLE_BASELINE_PX),
103
+ ...fontAttrs(c.style, fonts, CONTAINER_HEADER_TITLE_FONT_SIZE_PX),
104
+ 'font-weight': 600,
105
+ fill: ink,
106
+ },
107
+ c.title,
108
+ ),
109
+ );
110
+ }
111
+ // The glyphs sit in the header's title row, never on a child, so
112
+ // painting them before the children is safe.
113
+ parts.push(renderInlineDatePins(c.inlineDatePins, c.style.fg));
114
+ }
115
+ for (const child of c.children) {
116
+ parts.push(renderTrackChild(child, options, idPrefix, palette, fonts, depth));
117
+ }
118
+ return tag('g', { 'data-layer': c.kind, 'data-id': c.id ?? null }, parts.join(''));
119
+ }
120
+
121
+ function renderTrackChild(
122
+ c: PositionedTrackChild,
123
+ options: RenderOptions,
124
+ idPrefix: string,
125
+ palette: Theme,
126
+ fonts: FontFamilies,
127
+ parentDepth = 0,
128
+ ): string {
129
+ if (c.kind === 'item') return renderItem(c, options, idPrefix, palette, fonts);
130
+ return renderContainer(c, parentDepth + 1, options, idPrefix, palette, fonts);
131
+ }
132
+
133
+ // Renders only the swimlane's background tint rect. Emitted before the
134
+ // chart-body grid lines so those lines visibly span the full chart width.
135
+ // The frame tab (chiclet at top-left) and item content are emitted later,
136
+ // in renderSwimlaneContent, so they appear on top of the grid.
137
+ // Tri-state lane utilization underline. Painted along the bottom edge of
138
+ // the band when the lane has `capacity:` AND at least one item contributing
139
+ // load AND has not opted out of every color band via `utilization-*-at:none`.
140
+ // Geometry per specs/rendering.md § Lane utilization underline:
141
+ // - height: 2px (matches the milestone-line stroke weight)
142
+ // - y: flush with the bottom edge of the band, fully inside it
143
+ // - x: aligned to the segment boundaries the layout already pre-coalesced
144
+ // One <rect> per coalesced segment; classification → palette token mapping
145
+ // is the only renderer-side decision.
146
+ const LANE_UTILIZATION_HEIGHT_PX = 2;
147
+
148
+ function utilizationColor(classification: 'green' | 'yellow' | 'red', palette: Theme): string {
149
+ switch (classification) {
150
+ case 'green':
151
+ return palette.swimlane.utilizationOk;
152
+ case 'yellow':
153
+ return palette.swimlane.utilizationWarn;
154
+ case 'red':
155
+ return palette.swimlane.utilizationOver;
156
+ }
157
+ }
158
+
159
+ function renderLaneUtilization(s: PositionedSwimlane, palette: Theme): string {
160
+ if (!s.utilization || s.utilization.segments.length === 0) return '';
161
+ const y = s.box.y + s.box.height - LANE_UTILIZATION_HEIGHT_PX;
162
+ const rects = s.utilization.segments.map((seg) => {
163
+ const width = seg.endX - seg.startX;
164
+ if (width <= 0) return '';
165
+ return tag('rect', {
166
+ x: num(seg.startX),
167
+ y: num(y),
168
+ width: num(width),
169
+ height: LANE_UTILIZATION_HEIGHT_PX,
170
+ fill: utilizationColor(seg.classification, palette),
171
+ 'data-utilization': seg.classification,
172
+ 'data-load': num(seg.load),
173
+ });
174
+ });
175
+ return tag(
176
+ 'g',
177
+ {
178
+ 'data-layer': 'lane-utilization',
179
+ 'data-id': s.id ?? null,
180
+ },
181
+ rects.join(''),
182
+ );
183
+ }
184
+
185
+ export function renderSwimlaneBg(s: PositionedSwimlane, palette: Theme): string {
186
+ const tint = s.bandIndex % 2 === 0 ? palette.swimlane.rowTintEven : palette.swimlane.rowTintOdd;
187
+ const borderColor = palette.swimlane.border;
188
+ return tag(
189
+ 'g',
190
+ { 'data-layer': 'swimlane-bg', 'data-id': s.id ?? null },
191
+ tag('rect', {
192
+ x: num(s.box.x),
193
+ y: num(s.box.y),
194
+ width: num(s.box.width),
195
+ height: num(s.box.height),
196
+ fill: tint,
197
+ stroke: borderColor,
198
+ 'stroke-width': 1,
199
+ }),
200
+ );
201
+ }
202
+
203
+ export function renderSwimlane(
204
+ s: PositionedSwimlane,
205
+ options: RenderOptions,
206
+ idPrefix: string,
207
+ palette: Theme,
208
+ fonts: FontFamilies,
209
+ ): string {
210
+ const tabFill = palette.swimlane.tabFill;
211
+ const tabStroke = palette.swimlane.tabStroke;
212
+ const tabText = palette.swimlane.tabText;
213
+ const ownerText = palette.swimlane.ownerText;
214
+ const footnoteColor = palette.swimlane.footnoteIndicator;
215
+ const parts: string[] = [];
216
+ // Frame-tab chiclet at the top-left of the band, auto-sized to fit
217
+ // title, owner, capacity badge and footnote indicators. Layout sized it
218
+ // (`PositionedSwimlane.tab`, the same rectangle it reserved for the
219
+ // row packer) and placed each element inside it; this only draws.
220
+ const tab = s.tab;
221
+ if (tab) {
222
+ const LANE_BADGE_FONT_SIZE_PX = 10;
223
+ const footnoteIndicatorText =
224
+ s.footnoteIndicators.length > 0 ? s.footnoteIndicators.join(',') : '';
225
+ const tabH = FRAME_TAB_HEIGHT_PX;
226
+ const tabY = s.box.y + 10;
227
+ const labelY = tabY + FRAME_TAB_LABEL_BASELINE_OFFSET_PX;
228
+ parts.push(
229
+ tag('rect', {
230
+ x: num(tab.tabX),
231
+ y: num(tabY),
232
+ width: num(tab.tabW),
233
+ height: num(tabH),
234
+ rx: 4,
235
+ ry: 4,
236
+ fill: tabFill,
237
+ stroke: tabStroke,
238
+ 'stroke-width': 1,
239
+ }),
240
+ );
241
+ parts.push(
242
+ textTag(
243
+ {
244
+ x: num(tab.titleX),
245
+ y: num(labelY),
246
+ 'font-family': fonts[s.style.font],
247
+ 'font-size': 12,
248
+ 'font-weight': 600,
249
+ fill: tabText,
250
+ },
251
+ s.title,
252
+ ),
253
+ );
254
+ if (s.owner) {
255
+ // A person or people glyph leads the owner name, lifted onto
256
+ // the text's x-height like the capacity badge's glyph.
257
+ const glyph = CAPACITY_ICON_SVG[s.ownerKind === 'person' ? 'person' : 'people'];
258
+ const glyphSize = FRAME_TAB_OWNER_GLYPH_PX;
259
+ parts.push(
260
+ `<svg x="${num(tab.ownerX)}" y="${num(labelY - glyphSize * 0.85)}" width="${num(glyphSize)}" height="${num(glyphSize)}" viewBox="${glyph.viewBox}" style="color:${escAttr(ownerText)}" aria-hidden="true">${glyph.body}</svg>`,
261
+ );
262
+ parts.push(
263
+ textTag(
264
+ {
265
+ x: num(tab.ownerTextX),
266
+ y: num(labelY),
267
+ 'font-family': fonts[s.style.font],
268
+ 'font-size': 10,
269
+ fill: ownerText,
270
+ },
271
+ s.owner,
272
+ ),
273
+ );
274
+ }
275
+ if (s.capacity) {
276
+ // Re-uses the same `renderCapacitySuffix` helper that paints
277
+ // item-level suffixes (m6) so multiplier / built-in SVG /
278
+ // inline literal / dereferenced-custom-glyph paths stay
279
+ // consistent across both contexts.
280
+ parts.push(
281
+ renderCapacitySuffix(
282
+ s.capacity,
283
+ tab.badgeX,
284
+ labelY,
285
+ LANE_BADGE_FONT_SIZE_PX,
286
+ fonts[s.style.font],
287
+ ownerText,
288
+ ),
289
+ );
290
+ }
291
+ if (footnoteIndicatorText) {
292
+ parts.push(
293
+ textTag(
294
+ {
295
+ x: num(tab.footnoteRightX),
296
+ y: num(tabY + 14),
297
+ 'font-family': fonts.sans,
298
+ 'font-size': LANE_BADGE_FONT_SIZE_PX,
299
+ 'font-weight': 700,
300
+ fill: footnoteColor,
301
+ 'text-anchor': 'end',
302
+ },
303
+ footnoteIndicatorText,
304
+ ),
305
+ );
306
+ }
307
+ }
308
+ for (const c of s.children) {
309
+ parts.push(renderTrackChild(c, options, idPrefix, palette, fonts));
310
+ }
311
+ // m13: tri-state utilization underline along the band's bottom edge.
312
+ // Painted after items so it overlays any item that happens to extend
313
+ // to the band's bottom; under cut-lines / now-line which run as
314
+ // separate top-level passes.
315
+ parts.push(renderLaneUtilization(s, palette));
316
+ return tag('g', { 'data-layer': 'swimlane', 'data-id': s.id ?? null }, parts.join(''));
317
+ }