@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.
- package/dist/image-size.d.ts +9 -0
- package/dist/image-size.d.ts.map +1 -0
- package/dist/image-size.js +104 -0
- package/dist/image-size.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/logo.d.ts +24 -0
- package/dist/logo.d.ts.map +1 -0
- package/dist/logo.js +60 -0
- package/dist/logo.js.map +1 -0
- package/dist/stage.d.ts +78 -0
- package/dist/stage.d.ts.map +1 -0
- package/dist/stage.js +88 -0
- package/dist/stage.js.map +1 -0
- package/dist/svg/edges.d.ts +3 -0
- package/dist/svg/edges.d.ts.map +1 -0
- package/dist/svg/edges.js +144 -0
- package/dist/svg/edges.js.map +1 -0
- package/dist/svg/footnotes.d.ts +4 -0
- package/dist/svg/footnotes.d.ts.map +1 -0
- package/dist/svg/footnotes.js +81 -0
- package/dist/svg/footnotes.js.map +1 -0
- package/dist/svg/header.d.ts +6 -0
- package/dist/svg/header.d.ts.map +1 -0
- package/dist/svg/header.js +215 -0
- package/dist/svg/header.js.map +1 -0
- package/dist/svg/include-region.d.ts +4 -0
- package/dist/svg/include-region.d.ts.map +1 -0
- package/dist/svg/include-region.js +157 -0
- package/dist/svg/include-region.js.map +1 -0
- package/dist/svg/item.d.ts +36 -0
- package/dist/svg/item.d.ts.map +1 -0
- package/dist/svg/item.js +549 -0
- package/dist/svg/item.js.map +1 -0
- package/dist/svg/markers.d.ts +7 -0
- package/dist/svg/markers.d.ts.map +1 -0
- package/dist/svg/markers.js +151 -0
- package/dist/svg/markers.js.map +1 -0
- package/dist/svg/nowline.d.ts +4 -0
- package/dist/svg/nowline.d.ts.map +1 -0
- package/dist/svg/nowline.js +133 -0
- package/dist/svg/nowline.js.map +1 -0
- package/dist/svg/primitives.d.ts +7 -0
- package/dist/svg/primitives.d.ts.map +1 -0
- package/dist/svg/primitives.js +70 -0
- package/dist/svg/primitives.js.map +1 -0
- package/dist/svg/render.d.ts +13 -7
- package/dist/svg/render.d.ts.map +1 -1
- package/dist/svg/render.js +64 -1701
- package/dist/svg/render.js.map +1 -1
- package/dist/svg/sanitize.d.ts +10 -0
- package/dist/svg/sanitize.d.ts.map +1 -1
- package/dist/svg/sanitize.js +264 -16
- package/dist/svg/sanitize.js.map +1 -1
- package/dist/svg/swimlanes.d.ts +5 -0
- package/dist/svg/swimlanes.d.ts.map +1 -0
- package/dist/svg/swimlanes.js +228 -0
- package/dist/svg/swimlanes.js.map +1 -0
- package/dist/svg/timeline.d.ts +24 -0
- package/dist/svg/timeline.d.ts.map +1 -0
- package/dist/svg/timeline.js +284 -0
- package/dist/svg/timeline.js.map +1 -0
- package/dist/svg/waves.d.ts +62 -0
- package/dist/svg/waves.d.ts.map +1 -0
- package/dist/svg/waves.js +397 -0
- package/dist/svg/waves.js.map +1 -0
- package/package.json +6 -6
- package/src/image-size.ts +113 -0
- package/src/index.ts +10 -1
- package/src/logo.ts +82 -0
- package/src/stage.ts +191 -0
- package/src/svg/edges.ts +154 -0
- package/src/svg/footnotes.ts +115 -0
- package/src/svg/header.ts +285 -0
- package/src/svg/include-region.ts +201 -0
- package/src/svg/item.ts +672 -0
- package/src/svg/markers.ts +178 -0
- package/src/svg/nowline.ts +152 -0
- package/src/svg/primitives.ts +86 -0
- package/src/svg/render.ts +94 -2110
- package/src/svg/sanitize.ts +286 -16
- package/src/svg/swimlanes.ts +317 -0
- package/src/svg/timeline.ts +343 -0
- 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
|
+
}
|