@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,285 @@
1
+ // The header layers: the title card, the "Powered by nowline" attribution mark,
2
+ // and the embedded logo that sits in the card, plus the base64 helper the logo
3
+ // path uses.
4
+
5
+ import type { PositionedHeader, PositionedLogo, PositionedRoadmap, Theme } from '@nowline/layout';
6
+ import {
7
+ ATTRIBUTION_BAR_LOGICAL_WIDTH,
8
+ ATTRIBUTION_BAR_LOGICAL_X,
9
+ ATTRIBUTION_INE_LOGICAL_X,
10
+ ATTRIBUTION_LINK,
11
+ ATTRIBUTION_NOW_LOGICAL_X,
12
+ ATTRIBUTION_PREFIX_FONT_SIZE,
13
+ ATTRIBUTION_SCALE,
14
+ ATTRIBUTION_TEXT,
15
+ ATTRIBUTION_WORDMARK_FONT_SIZE,
16
+ HEADER_AUTHOR_FONT_SIZE_PX,
17
+ HEADER_AUTHOR_LINE_HEIGHT_PX,
18
+ HEADER_CARD_PADDING_TOP,
19
+ HEADER_CARD_PADDING_X,
20
+ HEADER_TITLE_FONT_SIZE_PX,
21
+ HEADER_TITLE_LINE_HEIGHT_PX,
22
+ HEADER_TITLE_TO_AUTHOR_GAP_PX,
23
+ } from '@nowline/layout';
24
+ import type { AssetBytes, AssetResolver, FontFamilies, RenderOptions } from './render.js';
25
+ import { sanitizeSvg, svgIntrinsicSize } from './sanitize.js';
26
+ import { num, tag, textTag } from './xml.js';
27
+
28
+ export function renderHeader(
29
+ h: PositionedHeader,
30
+ idPrefix: string,
31
+ palette: Theme,
32
+ fonts: FontFamilies,
33
+ ): string {
34
+ // The layout has already sized the card to its (wrapped) text content
35
+ // and stashed the bounds in `h.cardBox`, with `h.titleLines` /
36
+ // `h.authorLines` ready to render line-by-line. See sizeBesideHeader
37
+ // in @nowline/layout.
38
+ const cardX = h.box.x + h.cardBox.x;
39
+ const cardY = h.box.y + h.cardBox.y;
40
+ const cardWidth = h.cardBox.width;
41
+ const cardHeight = h.cardBox.height;
42
+ const cardFill = h.style.bg === 'none' ? palette.surface.headerBox : h.style.bg;
43
+ const borderColor = palette.header.cardBorder;
44
+ const card = tag('rect', {
45
+ x: num(cardX),
46
+ y: num(cardY),
47
+ width: num(cardWidth),
48
+ height: num(cardHeight),
49
+ rx: 6,
50
+ ry: 6,
51
+ fill: cardFill,
52
+ stroke: borderColor,
53
+ 'stroke-width': 1,
54
+ filter: `url(#${idPrefix}-shadow-subtle)`,
55
+ });
56
+ // Title and author baselines come from `@nowline/layout`'s
57
+ // `header-card-geometry` module so the renderer paints with the
58
+ // exact metrics `sizeBesideHeader` sized the card to.
59
+ // A logo in the card shifts the text right of it (and down, to center
60
+ // it, when the logo made the card taller); see `h.textInset`.
61
+ const textX = cardX + HEADER_CARD_PADDING_X + (h.textInset?.x ?? 0);
62
+ const textTopY = cardY + (h.textInset?.y ?? 0);
63
+ const titleParts: string[] = [];
64
+ h.titleLines.forEach((line, i) => {
65
+ titleParts.push(
66
+ textTag(
67
+ {
68
+ x: num(textX),
69
+ y: num(textTopY + HEADER_CARD_PADDING_TOP + i * HEADER_TITLE_LINE_HEIGHT_PX),
70
+ 'font-family': fonts[h.style.font],
71
+ 'font-size': HEADER_TITLE_FONT_SIZE_PX,
72
+ 'font-weight': 600,
73
+ fill: h.style.text,
74
+ },
75
+ line,
76
+ ),
77
+ );
78
+ });
79
+ const titleText = titleParts.join('');
80
+ const lastTitleY =
81
+ textTopY +
82
+ HEADER_CARD_PADDING_TOP +
83
+ Math.max(0, h.titleLines.length - 1) * HEADER_TITLE_LINE_HEIGHT_PX;
84
+ const authorColor = palette.header.author;
85
+ const authorParts: string[] = [];
86
+ h.authorLines.forEach((line, j) => {
87
+ authorParts.push(
88
+ textTag(
89
+ {
90
+ x: num(textX),
91
+ y: num(
92
+ lastTitleY +
93
+ HEADER_TITLE_TO_AUTHOR_GAP_PX +
94
+ j * HEADER_AUTHOR_LINE_HEIGHT_PX,
95
+ ),
96
+ 'font-family': fonts[h.style.font],
97
+ 'font-size': HEADER_AUTHOR_FONT_SIZE_PX,
98
+ fill: authorColor,
99
+ },
100
+ line,
101
+ ),
102
+ );
103
+ });
104
+ const authorText = authorParts.join('');
105
+ return tag(
106
+ 'g',
107
+ { 'data-layer': 'header', 'data-id': `${idPrefix}-header` },
108
+ card + titleText + authorText,
109
+ );
110
+ }
111
+
112
+ // Paint the "Powered by nowline" attribution mark inside the
113
+ // layout-supplied `attributionBox`. The whole mark — prefix text,
114
+ // "now", red "l" bar, and "ine" — sits inside one <a href> so the
115
+ // entire string is clickable. Glyph anatomy (positions, widths, scale)
116
+ // lives in `themes/shared.ts` (`ATTRIBUTION_*`); the layout reserves a
117
+ // box of exactly that size at canvas-bottom-right.
118
+ export function renderAttributionMark(model: PositionedRoadmap, fonts: FontFamilies): string {
119
+ const muted = model.palette.attribution.mark;
120
+ const accent = model.palette.attribution.link;
121
+ if (model.swimlanes.length === 0) return '';
122
+ const tx = model.header.attributionBox.x;
123
+ const ty = model.header.attributionBox.y;
124
+ // Both texts share the wordmark's baseline (y = wordmark font size)
125
+ // so the smaller "Powered by" sits visually above the wordmark's
126
+ // baseline without bumping the bar's bottom up.
127
+ const baselineY = ATTRIBUTION_WORDMARK_FONT_SIZE;
128
+ const inner =
129
+ textTag(
130
+ {
131
+ x: '0',
132
+ y: baselineY,
133
+ 'font-family': fonts.sans,
134
+ 'font-size': ATTRIBUTION_PREFIX_FONT_SIZE,
135
+ 'font-weight': 400,
136
+ fill: muted,
137
+ },
138
+ ATTRIBUTION_TEXT,
139
+ ) +
140
+ textTag(
141
+ {
142
+ x: ATTRIBUTION_NOW_LOGICAL_X,
143
+ y: baselineY,
144
+ 'font-family': fonts.sans,
145
+ 'font-size': ATTRIBUTION_WORDMARK_FONT_SIZE,
146
+ 'font-weight': 700,
147
+ fill: muted,
148
+ },
149
+ 'now',
150
+ ) +
151
+ tag('rect', {
152
+ x: ATTRIBUTION_BAR_LOGICAL_X,
153
+ y: 12,
154
+ width: ATTRIBUTION_BAR_LOGICAL_WIDTH,
155
+ height: ATTRIBUTION_WORDMARK_FONT_SIZE,
156
+ fill: accent,
157
+ }) +
158
+ textTag(
159
+ {
160
+ x: ATTRIBUTION_INE_LOGICAL_X,
161
+ y: baselineY,
162
+ 'font-family': fonts.sans,
163
+ 'font-size': ATTRIBUTION_WORDMARK_FONT_SIZE,
164
+ 'font-weight': 400,
165
+ fill: muted,
166
+ },
167
+ 'ine',
168
+ );
169
+ const group = tag(
170
+ 'g',
171
+ { transform: `translate(${num(tx)} ${num(ty)}) scale(${num(ATTRIBUTION_SCALE)})` },
172
+ inner,
173
+ );
174
+ return tag(
175
+ 'a',
176
+ {
177
+ href: ATTRIBUTION_LINK,
178
+ target: '_blank',
179
+ rel: 'noopener',
180
+ 'aria-label': 'Powered by nowline',
181
+ },
182
+ tag('g', { 'data-layer': 'attribution' }, group),
183
+ );
184
+ }
185
+
186
+ // Draws the logo into its layout box. Layout sized the box to the asset's
187
+ // aspect ratio, so scaling the asset to the box fills it exactly whether
188
+ // the source is larger or smaller; `meet` (fit) only matters if the two
189
+ // disagree, and then it letterboxes rather than cropping or distorting.
190
+ export async function embedLogo(
191
+ logo: PositionedLogo,
192
+ resolver: AssetResolver | undefined,
193
+ idPrefix: string,
194
+ options: RenderOptions,
195
+ ): Promise<string> {
196
+ const logoRef = logo.assetRef ?? '';
197
+ const { x, y, width, height } = logo.box;
198
+ let asset: AssetBytes;
199
+ try {
200
+ if (options.logo && options.logo.ref === logoRef) {
201
+ asset = options.logo.asset;
202
+ } else if (resolver) {
203
+ asset = await resolver(logoRef);
204
+ } else {
205
+ return '';
206
+ }
207
+ } catch (err) {
208
+ const msg = `logo file not found: ${logoRef} (${err instanceof Error ? err.message : String(err)})`;
209
+ if (options.strict) throw err;
210
+ options.warn?.(msg);
211
+ return '';
212
+ }
213
+ const mime = (asset.mime ?? '').toLowerCase();
214
+ if (mime === 'image/svg+xml') {
215
+ const raw = new TextDecoder().decode(asset.bytes);
216
+ const intrinsic = logo.intrinsic ?? svgIntrinsicSize(raw) ?? { width, height };
217
+ // The root loses its own size so it fills this viewport; the
218
+ // viewport's viewBox then scales the asset's units to the box.
219
+ // One warning per logo listing what the sanitizer removed, not one
220
+ // per attribute; editor metadata it removes silently.
221
+ const removed = new Set<string>();
222
+ const cleaned = sanitizeSvg(raw, {
223
+ idPrefix: `${idPrefix}-logo`,
224
+ onWarn: (msg) => removed.add(msg.replace(/^sanitizer: dropping /, '')),
225
+ stripRootSize: true,
226
+ });
227
+ // A warning even under strict: callers promote warnings themselves
228
+ // (the export kernel throws, the browser pipeline returns rows).
229
+ if (removed.size > 0) {
230
+ options.warn?.(
231
+ `logo ${logoRef}: removed unsupported SVG content: ${[...removed].join(', ')}`,
232
+ );
233
+ }
234
+ return tag(
235
+ 'svg',
236
+ {
237
+ 'data-layer': 'logo',
238
+ x: num(x),
239
+ y: num(y),
240
+ width: num(width),
241
+ height: num(height),
242
+ viewBox: `0 0 ${num(intrinsic.width)} ${num(intrinsic.height)}`,
243
+ preserveAspectRatio: 'xMidYMid meet',
244
+ overflow: 'hidden',
245
+ },
246
+ cleaned,
247
+ );
248
+ }
249
+ if (
250
+ mime === 'image/png' ||
251
+ mime === 'image/jpeg' ||
252
+ mime === 'image/jpg' ||
253
+ mime === 'image/webp'
254
+ ) {
255
+ const b64 = bytesToBase64(asset.bytes);
256
+ return tag('image', {
257
+ 'data-layer': 'logo',
258
+ href: `data:${mime};base64,${b64}`,
259
+ x: num(x),
260
+ y: num(y),
261
+ width: num(width),
262
+ height: num(height),
263
+ preserveAspectRatio: 'xMidYMid meet',
264
+ });
265
+ }
266
+ const msg = `unsupported logo format: ${mime} (${logoRef})`;
267
+ if (options.strict) throw new Error(msg);
268
+ options.warn?.(msg);
269
+ return '';
270
+ }
271
+
272
+ // Base64 without depending on Node's Buffer (renderer stays browser-safe).
273
+ function bytesToBase64(bytes: Uint8Array): string {
274
+ if (typeof btoa !== 'undefined') {
275
+ let bin = '';
276
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
277
+ return btoa(bin);
278
+ }
279
+ // Node fallback without importing Buffer directly; use lazy dynamic require.
280
+ const g = globalThis as {
281
+ Buffer?: { from: (b: Uint8Array) => { toString: (enc: string) => string } };
282
+ };
283
+ if (g.Buffer) return g.Buffer.from(bytes).toString('base64');
284
+ throw new Error('renderer: no base64 encoder available');
285
+ }
@@ -0,0 +1,201 @@
1
+ // The include-region layer: the dashed border, frame tab, badge and source
2
+ // label drawn around the roadmap an `include` pulls in, with that roadmap's own
3
+ // swimlanes, edges, markers, non-working bands and wave layers nested inside.
4
+
5
+ import type {
6
+ BoundingBox,
7
+ PositionedIncludeRegion,
8
+ PositionedRoadmap,
9
+ Theme,
10
+ } from '@nowline/layout';
11
+ import {
12
+ ACCENT_DASH_PATTERN,
13
+ FRAME_TAB_HEIGHT_PX,
14
+ FRAME_TAB_LABEL_BASELINE_OFFSET_PX,
15
+ includeChromeGeometry,
16
+ } from '@nowline/layout';
17
+ import { renderEdge } from './edges.js';
18
+ import {
19
+ renderAnchor,
20
+ renderAnchorCutLine,
21
+ renderMilestone,
22
+ renderMilestoneCutLine,
23
+ } from './markers.js';
24
+ import type { FontFamilies, RenderOptions } from './render.js';
25
+ import { renderSwimlane } from './swimlanes.js';
26
+ import { renderNonWorkingBands } from './timeline.js';
27
+ import { renderWaveBoundaries, renderWaveCrossings, renderWaveTints } from './waves.js';
28
+ import { num, tag, textTag } from './xml.js';
29
+
30
+ export function renderIncludeRegion(
31
+ r: PositionedIncludeRegion,
32
+ options: RenderOptions,
33
+ idPrefix: string,
34
+ palette: Theme,
35
+ fonts: FontFamilies,
36
+ model?: PositionedRoadmap,
37
+ ): string {
38
+ const border = palette.includeRegion.border;
39
+ const fill = palette.includeRegion.fill;
40
+ const tabFill = palette.includeRegion.tabFill;
41
+ const tabStroke = palette.includeRegion.tabStroke;
42
+ const tabText = palette.includeRegion.tabText;
43
+ const badgeFill = palette.includeRegion.badgeFill;
44
+ const badgeStroke = palette.includeRegion.badgeStroke;
45
+ const badgeText = palette.includeRegion.badgeText;
46
+
47
+ const rx = r.box.x + 8;
48
+ const ry = r.box.y;
49
+ const rw = r.box.width - 16;
50
+ const rh = r.box.height;
51
+
52
+ const region = tag('rect', {
53
+ x: num(rx),
54
+ y: num(ry),
55
+ width: num(rw),
56
+ height: num(rh),
57
+ rx: 8,
58
+ ry: 8,
59
+ fill,
60
+ stroke: border,
61
+ 'stroke-width': 1,
62
+ 'stroke-dasharray': ACCENT_DASH_PATTERN,
63
+ });
64
+
65
+ // Chrome geometry — single source of truth shared with the layout
66
+ // (`buildIncludeRegions` calls the same helper to size the dashed
67
+ // bracket so the chrome always fits inside it). All placement Xs
68
+ // come from the helper directly so the renderer stays declarative.
69
+ const tabHeight = FRAME_TAB_HEIGHT_PX;
70
+ const chrome = includeChromeGeometry(r.box.x, r.label, r.sourcePath);
71
+ const tabY = ry - tabHeight / 2;
72
+ const tab = tag('rect', {
73
+ x: num(chrome.tabX),
74
+ y: num(tabY),
75
+ width: num(chrome.tabWidth),
76
+ height: tabHeight,
77
+ rx: 4,
78
+ ry: 4,
79
+ fill: tabFill,
80
+ stroke: tabStroke,
81
+ 'stroke-width': 1,
82
+ });
83
+ const tabLabel = textTag(
84
+ {
85
+ x: num(chrome.tabLabelX),
86
+ y: num(tabY + FRAME_TAB_LABEL_BASELINE_OFFSET_PX),
87
+ 'font-family': fonts.sans,
88
+ 'font-size': 11,
89
+ 'font-weight': 600,
90
+ fill: tabText,
91
+ },
92
+ r.label,
93
+ );
94
+
95
+ // Content badge to the right of the tab. The glyph here is the
96
+ // stacked-sheets icon, distinct from the item-level link-icon
97
+ // outbound-arrow: an `include` is a content pull (one document
98
+ // brings in another), conceptually different from a `link:` that
99
+ // navigates somewhere.
100
+ const badgeX = chrome.badgeX;
101
+ const badgeSize = chrome.badgeSize;
102
+ const badgeY = ry - badgeSize / 2;
103
+ const badge = tag('rect', {
104
+ x: num(badgeX),
105
+ y: num(badgeY),
106
+ width: badgeSize,
107
+ height: badgeSize,
108
+ rx: 4,
109
+ ry: 4,
110
+ fill: badgeFill,
111
+ stroke: badgeStroke,
112
+ 'stroke-width': 1,
113
+ });
114
+ // Glyph: stacked sheets — back rectangle peeking behind front
115
+ // rectangle. Sized for the 18×18 badge tile.
116
+ const glyph = tag('path', {
117
+ d:
118
+ `M${num(badgeX + 7)} ${num(badgeY + 4)} H${num(badgeX + 14)} V${num(badgeY + 11)}` +
119
+ ` M${num(badgeX + 4)} ${num(badgeY + 7)} H${num(badgeX + 11)} V${num(badgeY + 14)} H${num(badgeX + 4)} Z`,
120
+ stroke: badgeText,
121
+ 'stroke-width': 1.4,
122
+ fill: 'none',
123
+ 'stroke-linecap': 'round',
124
+ 'stroke-linejoin': 'round',
125
+ });
126
+ // Halo behind the source-path text. The text's baseline at `ry + 4`
127
+ // straddles the dashed region border (drawn at y = ry); without a
128
+ // backing rect the dashed stroke cuts through the text body. Matches
129
+ // how the tab and badge already mask the border where they cross it.
130
+ // Fill is `includeRegion.fill` — same cream/tint as the region, near
131
+ // the canvas surface above, so the halo disappears into both.
132
+ const sourceFontSize = 9;
133
+ const sourceTextY = ry + 4;
134
+ const sourceHalo = tag('rect', {
135
+ x: num(chrome.sourceHaloX),
136
+ y: num(sourceTextY - sourceFontSize),
137
+ width: num(chrome.sourceHaloWidth),
138
+ height: sourceFontSize + 6,
139
+ fill,
140
+ });
141
+ const sourceText = textTag(
142
+ {
143
+ x: num(chrome.sourceTextX),
144
+ y: num(sourceTextY),
145
+ 'font-family': fonts.mono,
146
+ 'font-size': sourceFontSize,
147
+ fill: badgeText,
148
+ },
149
+ r.sourcePath,
150
+ );
151
+
152
+ // The opaque region fill hides the global wave layers, so a roadmap
153
+ // with waves re-emits its styled tints and boundaries over it, clipped
154
+ // to the painted region rect by rect intersection (no clipPath, no new
155
+ // defs), and the region's own crossings over its nested lanes
156
+ // (specs/waves.md §9.8). The strip stays global.
157
+ const clip: BoundingBox = { x: rx, y: ry, width: rw, height: rh };
158
+ // The same opaque fill hides the global non-working bands, so they are
159
+ // re-emitted over it the same way, under the wave layers.
160
+ const bandsUnder = model
161
+ ? renderNonWorkingBands(model.timeline, model.chartBox.y, palette, clip)
162
+ : '';
163
+ const waveUnder = model?.waves
164
+ ? renderWaveTints(model.waves, clip) + renderWaveBoundaries(model.waveBoundaries, clip)
165
+ : '';
166
+ const waveOver = model?.waves ? renderWaveCrossings(r.waveCrossings) : '';
167
+
168
+ // Nested swimlanes (laid out by buildIncludeRegions against the parent's timeline).
169
+ const nested = r.nestedSwimlanes
170
+ .map((s) => renderSwimlane(s, options, idPrefix, palette, fonts))
171
+ .join('');
172
+ // The region's own dependency edges, behind its bars as on the host.
173
+ const edges = (r.edges ?? []).map((e) => renderEdge(e, palette)).join('');
174
+ // The region's own anchors and milestones: cut lines over its lanes,
175
+ // then the diamonds and labels of its marker row, as on the host.
176
+ const anchors = r.anchors ?? [];
177
+ const milestones = r.milestones ?? [];
178
+ const markers =
179
+ anchors.map((a) => renderAnchorCutLine(a, palette)).join('') +
180
+ milestones.map((m) => renderMilestoneCutLine(m, idPrefix, palette)).join('') +
181
+ anchors.map((a) => renderAnchor(a, palette, fonts)).join('') +
182
+ milestones.map((m) => renderMilestone(m, palette, fonts)).join('');
183
+
184
+ return tag(
185
+ 'g',
186
+ { 'data-layer': 'include' },
187
+ region +
188
+ bandsUnder +
189
+ waveUnder +
190
+ edges +
191
+ nested +
192
+ waveOver +
193
+ markers +
194
+ tab +
195
+ tabLabel +
196
+ badge +
197
+ glyph +
198
+ sourceHalo +
199
+ sourceText,
200
+ );
201
+ }