@nowline/renderer 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +94 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/svg/icons.d.ts +26 -0
- package/dist/svg/icons.d.ts.map +1 -0
- package/dist/svg/icons.js +61 -0
- package/dist/svg/icons.js.map +1 -0
- package/dist/svg/ids.d.ts +7 -0
- package/dist/svg/ids.d.ts.map +1 -0
- package/dist/svg/ids.js +15 -0
- package/dist/svg/ids.js.map +1 -0
- package/dist/svg/render.d.ts +28 -0
- package/dist/svg/render.d.ts.map +1 -0
- package/dist/svg/render.js +1737 -0
- package/dist/svg/render.js.map +1 -0
- package/dist/svg/sanitize.d.ts +6 -0
- package/dist/svg/sanitize.d.ts.map +1 -0
- package/dist/svg/sanitize.js +396 -0
- package/dist/svg/sanitize.js.map +1 -0
- package/dist/svg/shadow.d.ts +5 -0
- package/dist/svg/shadow.d.ts.map +1 -0
- package/dist/svg/shadow.js +27 -0
- package/dist/svg/shadow.js.map +1 -0
- package/dist/svg/xml.d.ts +8 -0
- package/dist/svg/xml.d.ts.map +1 -0
- package/dist/svg/xml.js +54 -0
- package/dist/svg/xml.js.map +1 -0
- package/package.json +35 -0
- package/src/index.ts +7 -0
- package/src/svg/icons.ts +107 -0
- package/src/svg/ids.ts +12 -0
- package/src/svg/render.ts +2154 -0
- package/src/svg/sanitize.ts +395 -0
- package/src/svg/shadow.ts +38 -0
- package/src/svg/xml.ts +58 -0
|
@@ -0,0 +1,2154 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Point,
|
|
3
|
+
PositionedAnchor,
|
|
4
|
+
PositionedCapacity,
|
|
5
|
+
PositionedDependencyEdge,
|
|
6
|
+
PositionedFootnoteArea,
|
|
7
|
+
PositionedGroup,
|
|
8
|
+
PositionedHeader,
|
|
9
|
+
PositionedIncludeRegion,
|
|
10
|
+
PositionedItem,
|
|
11
|
+
PositionedMilestone,
|
|
12
|
+
PositionedNowline,
|
|
13
|
+
PositionedParallel,
|
|
14
|
+
PositionedRoadmap,
|
|
15
|
+
PositionedSwimlane,
|
|
16
|
+
PositionedTimelineScale,
|
|
17
|
+
PositionedTrackChild,
|
|
18
|
+
ResolvedStyle,
|
|
19
|
+
Theme,
|
|
20
|
+
} from '@nowline/layout';
|
|
21
|
+
import {
|
|
22
|
+
ACCENT_DASH_PATTERN,
|
|
23
|
+
ATTRIBUTION_BAR_LOGICAL_WIDTH,
|
|
24
|
+
ATTRIBUTION_BAR_LOGICAL_X,
|
|
25
|
+
ATTRIBUTION_INE_LOGICAL_X,
|
|
26
|
+
ATTRIBUTION_LINK,
|
|
27
|
+
ATTRIBUTION_NOW_LOGICAL_X,
|
|
28
|
+
ATTRIBUTION_PREFIX_FONT_SIZE,
|
|
29
|
+
ATTRIBUTION_SCALE,
|
|
30
|
+
ATTRIBUTION_TEXT,
|
|
31
|
+
ATTRIBUTION_WORDMARK_FONT_SIZE,
|
|
32
|
+
CORNER_RADIUS_PX,
|
|
33
|
+
EDGE_CORNER_RADIUS,
|
|
34
|
+
estimateCapacitySuffixWidth,
|
|
35
|
+
FONT_STACK,
|
|
36
|
+
FOOTNOTE_HEADER_BASELINE_OFFSET_PX,
|
|
37
|
+
FOOTNOTE_HEADER_HEIGHT_PX,
|
|
38
|
+
FOOTNOTE_PANEL_PADDING_PX,
|
|
39
|
+
FOOTNOTE_ROW_HEIGHT,
|
|
40
|
+
FRAME_TAB_HEIGHT_PX,
|
|
41
|
+
FRAME_TAB_LABEL_BASELINE_OFFSET_PX,
|
|
42
|
+
frameTabGeometry,
|
|
43
|
+
GROUP_BRACKET_LABEL_OVERHANG_PX,
|
|
44
|
+
GROUP_TITLE_TAB_CHAR_WIDTH_PX,
|
|
45
|
+
GROUP_TITLE_TAB_HEIGHT_PX,
|
|
46
|
+
GROUP_TITLE_TAB_LABEL_BASELINE_OFFSET_PX,
|
|
47
|
+
GROUP_TITLE_TAB_LABEL_FONT_SIZE_PX,
|
|
48
|
+
GROUP_TITLE_TAB_PAD_X_PX,
|
|
49
|
+
HEADER_AUTHOR_FONT_SIZE_PX,
|
|
50
|
+
HEADER_AUTHOR_LINE_HEIGHT_PX,
|
|
51
|
+
HEADER_CARD_PADDING_TOP,
|
|
52
|
+
HEADER_CARD_PADDING_X,
|
|
53
|
+
HEADER_TITLE_FONT_SIZE_PX,
|
|
54
|
+
HEADER_TITLE_LINE_HEIGHT_PX,
|
|
55
|
+
HEADER_TITLE_TO_AUTHOR_GAP_PX,
|
|
56
|
+
ITEM_CAPTION_INSET_X_PX,
|
|
57
|
+
ITEM_CAPTION_META_BASELINE_OFFSET_PX,
|
|
58
|
+
ITEM_CAPTION_META_FONT_SIZE_PX,
|
|
59
|
+
ITEM_CAPTION_SPILL_GAP_PX,
|
|
60
|
+
ITEM_CAPTION_TITLE_BASELINE_OFFSET_PX,
|
|
61
|
+
ITEM_CAPTION_TITLE_FONT_SIZE_PX,
|
|
62
|
+
ITEM_DECORATION_SPILL_GAP_PX,
|
|
63
|
+
ITEM_FOOTNOTE_INDICATOR_BASELINE_OFFSET_PX,
|
|
64
|
+
ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX,
|
|
65
|
+
ITEM_FOOTNOTE_INDICATOR_STEP_PX,
|
|
66
|
+
ITEM_LINK_ICON_INSET_PX,
|
|
67
|
+
ITEM_LINK_ICON_TILE_SIZE_PX,
|
|
68
|
+
ITEM_STATUS_DOT_INSET_RIGHT_PX,
|
|
69
|
+
ITEM_STATUS_DOT_INSET_TOP_PX,
|
|
70
|
+
ITEM_STATUS_DOT_RADIUS_PX,
|
|
71
|
+
includeChromeGeometry,
|
|
72
|
+
NOW_PILL_CORNER_RADIUS_PX,
|
|
73
|
+
NOW_PILL_HEIGHT_PX,
|
|
74
|
+
NOW_PILL_LABEL_BASELINE_OFFSET_PX,
|
|
75
|
+
NOW_PILL_LABEL_FONT_SIZE_PX,
|
|
76
|
+
NOW_PILL_LABEL_INSET_X_PX,
|
|
77
|
+
NOWLINE_STROKE_WIDTH_PX,
|
|
78
|
+
PROGRESS_STRIP_HEIGHT_PX,
|
|
79
|
+
TEXT_SIZE_PX,
|
|
80
|
+
TIMELINE_TICK_LABEL_BASELINE_OFFSET_PX,
|
|
81
|
+
} from '@nowline/layout';
|
|
82
|
+
import { CAPACITY_ICON_SVG } from './icons.js';
|
|
83
|
+
import { IdGenerator } from './ids.js';
|
|
84
|
+
import { sanitizeSvg } from './sanitize.js';
|
|
85
|
+
import { allShadowDefs, shadowFilterUrl } from './shadow.js';
|
|
86
|
+
import { attrs, escAttr, escText, num, tag, textTag } from './xml.js';
|
|
87
|
+
|
|
88
|
+
// Browser-safe types. The renderer never touches `fs`, `path`, or `Buffer`.
|
|
89
|
+
// Callers inject an AssetResolver when they want logos embedded.
|
|
90
|
+
export interface AssetBytes {
|
|
91
|
+
bytes: Uint8Array;
|
|
92
|
+
mime: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type AssetResolver = (ref: string) => Promise<AssetBytes>;
|
|
96
|
+
|
|
97
|
+
export interface RenderOptions {
|
|
98
|
+
assetResolver?: AssetResolver;
|
|
99
|
+
noLinks?: boolean;
|
|
100
|
+
strict?: boolean;
|
|
101
|
+
warn?: (message: string) => void;
|
|
102
|
+
// Override the deterministic id prefix (defaults to 'nl').
|
|
103
|
+
idPrefix?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// `TEXT_SIZE_PX`, `CORNER_RADIUS_PX`, `FONT_STACK` come from
|
|
107
|
+
// `@nowline/layout` (themes/shared) so a typography or radius change
|
|
108
|
+
// flows from one place to both layout and renderer.
|
|
109
|
+
//
|
|
110
|
+
// `WEIGHT_NUM` lives here only because no DSL `weight` table exists in
|
|
111
|
+
// shared yet. If/when it does, hoist this alongside `FONT_STACK`.
|
|
112
|
+
const WEIGHT_NUM: Record<string, number> = {
|
|
113
|
+
thin: 100,
|
|
114
|
+
light: 300,
|
|
115
|
+
normal: 400,
|
|
116
|
+
bold: 700,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// `style.textSize` is `SizeBucket` which includes `'full'`; the shared
|
|
120
|
+
// `TEXT_SIZE_PX` table only carries the size buckets (no `'full'` —
|
|
121
|
+
// that's a corner-radius-only value). Widen the lookup so a stray
|
|
122
|
+
// `'full'` falls through to the `?? 14` fallback instead of compiling.
|
|
123
|
+
function textSizePx(bucket: ResolvedStyle['textSize']): number {
|
|
124
|
+
return (TEXT_SIZE_PX as Record<string, number>)[bucket] ?? 14;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function fontAttrs(style: ResolvedStyle, overrideSize?: number): Record<string, string | number> {
|
|
128
|
+
return {
|
|
129
|
+
'font-family': FONT_STACK[style.font],
|
|
130
|
+
'font-size': overrideSize ?? textSizePx(style.textSize),
|
|
131
|
+
'font-weight': WEIGHT_NUM[style.weight] ?? 400,
|
|
132
|
+
'font-style': style.italic ? 'italic' : 'normal',
|
|
133
|
+
fill: style.text,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function strokeDash(style: ResolvedStyle): string | undefined {
|
|
138
|
+
if (style.border === 'dashed') return '4 3';
|
|
139
|
+
if (style.border === 'dotted') return '1 3';
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* sRGB → relative luminance per WCAG 2.x. Input may be `#rrggbb`,
|
|
145
|
+
* `#rgb`, or a non-hex token like `none` / `transparent`. Non-hex
|
|
146
|
+
* inputs return 1 (treated as light) so a transparent bar reuses
|
|
147
|
+
* the chart's light bg. Mostly used to choose between two
|
|
148
|
+
* status-dot palettes (`onLight` vs `onDark`) so the dot reads on
|
|
149
|
+
* any bar fill — see `pickStatusDotPalette` and
|
|
150
|
+
* `specs/rendering.md`'s status-dot section.
|
|
151
|
+
*/
|
|
152
|
+
function relativeLuminance(hex: string): number {
|
|
153
|
+
if (!hex || hex === 'none' || hex === 'transparent') return 1;
|
|
154
|
+
let h = hex.startsWith('#') ? hex.slice(1) : hex;
|
|
155
|
+
if (h.length === 3) {
|
|
156
|
+
h = h
|
|
157
|
+
.split('')
|
|
158
|
+
.map((c) => c + c)
|
|
159
|
+
.join('');
|
|
160
|
+
}
|
|
161
|
+
if (h.length !== 6) return 1;
|
|
162
|
+
const r = parseInt(h.slice(0, 2), 16) / 255;
|
|
163
|
+
const g = parseInt(h.slice(2, 4), 16) / 255;
|
|
164
|
+
const b = parseInt(h.slice(4, 6), 16) / 255;
|
|
165
|
+
const lin = (c: number): number => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
166
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Pick the status-dot palette whose tone contrasts best with the
|
|
171
|
+
* given bar bg.
|
|
172
|
+
*
|
|
173
|
+
* The crossover threshold is the bar luminance at which a deep dot
|
|
174
|
+
* (avg luminance ≈ 0.045 across `onLight` palette entries) and a
|
|
175
|
+
* pale dot (avg ≈ 0.85 across `onDark` entries) give equal WCAG
|
|
176
|
+
* contrast. Solving `(L_bar + 0.05)² ≈ 0.86 × 0.095` gives
|
|
177
|
+
* `L_bar ≈ 0.24`, so:
|
|
178
|
+
* - bars with luminance ≥ 0.24 (most label-driven mid-tones AND
|
|
179
|
+
* all pale status-tint bars) → `onLight` deep dot
|
|
180
|
+
* - bars with luminance < 0.24 (dark status-tint bars in dark
|
|
181
|
+
* theme, e.g. `#172554`) → `onDark` pale dot
|
|
182
|
+
*/
|
|
183
|
+
function pickStatusDotPalette(bg: string, palette: Theme): Theme['statusDot']['onLight'] {
|
|
184
|
+
return relativeLuminance(bg) >= 0.24 ? palette.statusDot.onLight : palette.statusDot.onDark;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Approx. rendered width (px) of `text` at `fontSizePx`. Mirrors the
|
|
189
|
+
* `0.58 em / char` heuristic the layout uses for spill detection so the
|
|
190
|
+
* renderer's positioning of the capacity suffix lines up with what the
|
|
191
|
+
* layout reserved.
|
|
192
|
+
*/
|
|
193
|
+
function estimateCaptionWidthPx(text: string, fontSizePx: number): number {
|
|
194
|
+
return text.length * fontSizePx * 0.58;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Paint an item / lane capacity suffix starting at `(x0, baselineY)`. The
|
|
199
|
+
* capacity model arrives pre-resolved from layout (`PositionedCapacity`):
|
|
200
|
+
* `text` is the formatted number, `icon` is either `null` (no glyph), a
|
|
201
|
+
* built-in name, or an inline literal string.
|
|
202
|
+
*
|
|
203
|
+
* Three rendering paths:
|
|
204
|
+
*
|
|
205
|
+
* 1. `icon === null` (the resolved `capacity-icon` was `'none'`): paint
|
|
206
|
+
* the bare number as a single `<text>` node.
|
|
207
|
+
* 2. `icon.kind === 'builtin'` and `name === 'multiplier'`: paint
|
|
208
|
+
* `5×` as a single `<text>` node — the multiplication sign is a
|
|
209
|
+
* typographic operator with consistent rendering across system
|
|
210
|
+
* fonts and built-in side bearing, so no `<tspan dx>` separator.
|
|
211
|
+
* 3. `icon.kind === 'builtin'` and `name` ∈ {person, people, points,
|
|
212
|
+
* time}: paint the number as `<text>`, then drop the curated SVG
|
|
213
|
+
* icon at the next column (`0.1em` gap, sized to one em). The
|
|
214
|
+
* icon's `style="color:..."` propagates through the
|
|
215
|
+
* `currentColor`-bound paths in the icon library.
|
|
216
|
+
* 4. `icon.kind === 'literal'` (inline Unicode literal or
|
|
217
|
+
* dereferenced custom `symbol`): paint number + glyph in a single
|
|
218
|
+
* `<text>`, with a `<tspan dx="0.1em">` separator before the
|
|
219
|
+
* glyph payload.
|
|
220
|
+
*
|
|
221
|
+
* `precedingText` is the existing meta-line text (or `undefined` when the
|
|
222
|
+
* suffix is standalone). When present, the suffix's left edge starts one
|
|
223
|
+
* space's width past the meta text's estimated right edge so `2w 5×`
|
|
224
|
+
* reads as a single caption rather than running text together.
|
|
225
|
+
*/
|
|
226
|
+
function renderCapacitySuffix(
|
|
227
|
+
capacity: PositionedCapacity,
|
|
228
|
+
precedingText: string | undefined,
|
|
229
|
+
x0: number,
|
|
230
|
+
baselineY: number,
|
|
231
|
+
fontSize: number,
|
|
232
|
+
fontFamily: string,
|
|
233
|
+
color: string,
|
|
234
|
+
): string {
|
|
235
|
+
const charWidthPx = fontSize * 0.58;
|
|
236
|
+
const precedingWidthPx = precedingText ? estimateCaptionWidthPx(precedingText, fontSize) : 0;
|
|
237
|
+
const separatorPx = precedingText ? charWidthPx : 0;
|
|
238
|
+
const numberX = x0 + precedingWidthPx + separatorPx;
|
|
239
|
+
const { text: numberStr, icon } = capacity;
|
|
240
|
+
const numberWidthPx = estimateCaptionWidthPx(numberStr, fontSize);
|
|
241
|
+
const baseAttrs = {
|
|
242
|
+
'font-family': fontFamily,
|
|
243
|
+
'font-size': fontSize,
|
|
244
|
+
fill: color,
|
|
245
|
+
} as const;
|
|
246
|
+
|
|
247
|
+
if (!icon) {
|
|
248
|
+
return textTag({ x: num(numberX), y: num(baselineY), ...baseAttrs }, numberStr);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (icon.kind === 'builtin' && icon.name === 'multiplier') {
|
|
252
|
+
return textTag({ x: num(numberX), y: num(baselineY), ...baseAttrs }, `${numberStr}\u00D7`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (icon.kind === 'builtin') {
|
|
256
|
+
const def = CAPACITY_ICON_SVG[icon.name];
|
|
257
|
+
// Render `<text>5</text>` followed by the curated SVG icon. The
|
|
258
|
+
// icon sits at one font-em wide and tall, with a 0.1em separator
|
|
259
|
+
// gap. Vertical positioning lifts the icon so its visual center
|
|
260
|
+
// sits on the text x-height (`baselineY - fontSize * 0.85`); this
|
|
261
|
+
// matches how Lucide-style outline icons read in inline text.
|
|
262
|
+
const numberSvg = textTag({ x: num(numberX), y: num(baselineY), ...baseAttrs }, numberStr);
|
|
263
|
+
const gapPx = fontSize * 0.1;
|
|
264
|
+
const iconSize = fontSize;
|
|
265
|
+
const iconX = numberX + numberWidthPx + gapPx;
|
|
266
|
+
const iconY = baselineY - fontSize * 0.85;
|
|
267
|
+
const iconSvg = `<svg x="${num(iconX)}" y="${num(iconY)}" width="${num(iconSize)}" height="${num(iconSize)}" viewBox="${def.viewBox}" style="color:${escAttr(color)}" aria-hidden="true">${def.body}</svg>`;
|
|
268
|
+
return numberSvg + iconSvg;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Literal glyph (inline Unicode literal or dereferenced custom glyph).
|
|
272
|
+
// Single <text> node with the number + a tspan-separated glyph.
|
|
273
|
+
const dx = num(fontSize * 0.1);
|
|
274
|
+
return (
|
|
275
|
+
`<text x="${num(numberX)}" y="${num(baselineY)}" font-family="${escAttr(fontFamily)}"` +
|
|
276
|
+
` font-size="${fontSize}" fill="${escAttr(color)}">` +
|
|
277
|
+
`${escText(numberStr)}<tspan dx="${dx}">${escText(icon.text)}</tspan>` +
|
|
278
|
+
'</text>'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Em-distance the renderer reserves between an item's metaText and its
|
|
284
|
+
* capacity suffix (or any inline trailing token). Half a space's worth
|
|
285
|
+
* — wide enough to read as a separator, narrow enough not to look like
|
|
286
|
+
* a stray gap. SVG `<tspan dx>` honors this exactly so we don't depend
|
|
287
|
+
* on per-character estimation for the gap itself.
|
|
288
|
+
*/
|
|
289
|
+
const META_SUFFIX_DX_EM = 0.6;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Tighter em-per-char width estimate used inside `renderItemMetaLine`
|
|
293
|
+
* for positioning a built-in SVG icon after a metaText + number. Layout
|
|
294
|
+
* still uses 0.58 em/char to pessimistically reserve bar width for
|
|
295
|
+
* spill detection (so `metaText 5×` always fits its row); the renderer
|
|
296
|
+
* paints the icon at a tighter offset so there's no visible
|
|
297
|
+
* dead-space between the rendered number and its glyph. Lands inside
|
|
298
|
+
* the layout reservation either way (`0.5 < 0.58`).
|
|
299
|
+
*/
|
|
300
|
+
const META_ICON_EM_PER_CHAR = 0.5;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Paint the item meta line (metaText plus optional capacity suffix) as
|
|
304
|
+
* a single SVG fragment. Call sites used to emit metaText and the
|
|
305
|
+
* suffix as two unrelated `<text>` nodes whose horizontal offsets came
|
|
306
|
+
* from `estimateCaptionWidthPx` — fine for short metas like `2w`, but
|
|
307
|
+
* the per-character estimate over-reserves for longer captions like
|
|
308
|
+
* `L 1w — 1w remaining`, leaving a visible gap before `2×`.
|
|
309
|
+
*
|
|
310
|
+
* The fix flows the suffix INSIDE the same `<text>` element via
|
|
311
|
+
* `<tspan dx>` whenever the suffix is pure inline text (multiplier,
|
|
312
|
+
* literal glyph, or no glyph). Browsers compute the dx anchor relative
|
|
313
|
+
* to the previously rendered glyph so the gap matches the spec
|
|
314
|
+
* regardless of how wide metaText actually rendered.
|
|
315
|
+
*
|
|
316
|
+
* Built-in SVG icons (person/people/points/time) still need an `<svg>`
|
|
317
|
+
* sibling outside `<text>`, so the icon's x is computed via the
|
|
318
|
+
* tighter `META_ICON_EM_PER_CHAR` constant — which still lands inside
|
|
319
|
+
* the layout's pessimistic spill reservation.
|
|
320
|
+
*/
|
|
321
|
+
function renderItemMetaLine(opts: {
|
|
322
|
+
metaText: string | undefined;
|
|
323
|
+
capacity: PositionedCapacity | null;
|
|
324
|
+
x: number;
|
|
325
|
+
baselineY: number;
|
|
326
|
+
fontSize: number;
|
|
327
|
+
fontFamily: string;
|
|
328
|
+
color: string;
|
|
329
|
+
}): string {
|
|
330
|
+
const { metaText, capacity, x, baselineY, fontSize, fontFamily, color } = opts;
|
|
331
|
+
if (!metaText && !capacity) return '';
|
|
332
|
+
const baseAttrs = {
|
|
333
|
+
'font-family': fontFamily,
|
|
334
|
+
'font-size': fontSize,
|
|
335
|
+
fill: color,
|
|
336
|
+
} as const;
|
|
337
|
+
if (!capacity) {
|
|
338
|
+
return textTag({ x: num(x), y: num(baselineY), ...baseAttrs }, metaText!);
|
|
339
|
+
}
|
|
340
|
+
if (!metaText) {
|
|
341
|
+
return renderCapacitySuffix(capacity, undefined, x, baselineY, fontSize, fontFamily, color);
|
|
342
|
+
}
|
|
343
|
+
const sepDx = num(fontSize * META_SUFFIX_DX_EM);
|
|
344
|
+
const openText =
|
|
345
|
+
`<text x="${num(x)}" y="${num(baselineY)}" font-family="${escAttr(fontFamily)}"` +
|
|
346
|
+
` font-size="${fontSize}" fill="${escAttr(color)}">`;
|
|
347
|
+
const { text: numberStr, icon } = capacity;
|
|
348
|
+
if (!icon) {
|
|
349
|
+
return (
|
|
350
|
+
openText +
|
|
351
|
+
escText(metaText) +
|
|
352
|
+
`<tspan dx="${sepDx}">${escText(numberStr)}</tspan>` +
|
|
353
|
+
'</text>'
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
if (icon.kind === 'builtin' && icon.name === 'multiplier') {
|
|
357
|
+
return (
|
|
358
|
+
openText +
|
|
359
|
+
escText(metaText) +
|
|
360
|
+
`<tspan dx="${sepDx}">${escText(numberStr)}\u00D7</tspan>` +
|
|
361
|
+
'</text>'
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
if (icon.kind === 'literal') {
|
|
365
|
+
const glyphDx = num(fontSize * 0.1);
|
|
366
|
+
return (
|
|
367
|
+
openText +
|
|
368
|
+
escText(metaText) +
|
|
369
|
+
`<tspan dx="${sepDx}">${escText(numberStr)}</tspan>` +
|
|
370
|
+
`<tspan dx="${glyphDx}">${escText(icon.text)}</tspan>` +
|
|
371
|
+
'</text>'
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
// Built-in SVG icon — text element holds metaText + tspan-separated
|
|
375
|
+
// number, then the icon SVG sits at an estimated position. Tighter
|
|
376
|
+
// per-character estimate than layout's spill detector so the icon
|
|
377
|
+
// visually hugs the number.
|
|
378
|
+
const def = CAPACITY_ICON_SVG[icon.name];
|
|
379
|
+
const tightWidth = (s: string) => s.length * fontSize * META_ICON_EM_PER_CHAR;
|
|
380
|
+
const numberStartX = x + tightWidth(metaText) + fontSize * META_SUFFIX_DX_EM;
|
|
381
|
+
const iconGapPx = fontSize * 0.1;
|
|
382
|
+
const iconSize = fontSize;
|
|
383
|
+
const iconX = numberStartX + tightWidth(numberStr) + iconGapPx;
|
|
384
|
+
const iconY = baselineY - fontSize * 0.85;
|
|
385
|
+
const textPart =
|
|
386
|
+
openText +
|
|
387
|
+
escText(metaText) +
|
|
388
|
+
`<tspan dx="${sepDx}">${escText(numberStr)}</tspan>` +
|
|
389
|
+
'</text>';
|
|
390
|
+
const iconPart = `<svg x="${num(iconX)}" y="${num(iconY)}" width="${num(iconSize)}" height="${num(iconSize)}" viewBox="${def.viewBox}" style="color:${escAttr(color)}" aria-hidden="true">${def.body}</svg>`;
|
|
391
|
+
return textPart + iconPart;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function rectFrame(
|
|
395
|
+
x: number,
|
|
396
|
+
y: number,
|
|
397
|
+
w: number,
|
|
398
|
+
h: number,
|
|
399
|
+
style: ResolvedStyle,
|
|
400
|
+
extra: Record<string, string | number | undefined | null | boolean> = {},
|
|
401
|
+
): string {
|
|
402
|
+
const rx = Math.min(CORNER_RADIUS_PX[style.cornerRadius] ?? 4, h / 2);
|
|
403
|
+
return tag('rect', {
|
|
404
|
+
x: num(x),
|
|
405
|
+
y: num(y),
|
|
406
|
+
width: num(w),
|
|
407
|
+
height: num(h),
|
|
408
|
+
rx: num(rx),
|
|
409
|
+
ry: num(rx),
|
|
410
|
+
fill: style.bg === 'none' ? 'transparent' : style.bg,
|
|
411
|
+
stroke: style.fg,
|
|
412
|
+
'stroke-width': 1,
|
|
413
|
+
'stroke-dasharray': strokeDash(style) ?? null,
|
|
414
|
+
...extra,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function renderHeader(h: PositionedHeader, idPrefix: string, palette: Theme): string {
|
|
419
|
+
// The layout has already sized the card to its (wrapped) text content
|
|
420
|
+
// and stashed the bounds in `h.cardBox`, with `h.titleLines` /
|
|
421
|
+
// `h.authorLines` ready to render line-by-line. See sizeBesideHeader
|
|
422
|
+
// in @nowline/layout.
|
|
423
|
+
const cardX = h.box.x + h.cardBox.x;
|
|
424
|
+
const cardY = h.box.y + h.cardBox.y;
|
|
425
|
+
const cardWidth = h.cardBox.width;
|
|
426
|
+
const cardHeight = h.cardBox.height;
|
|
427
|
+
const cardFill = h.style.bg === 'none' ? palette.surface.headerBox : h.style.bg;
|
|
428
|
+
const borderColor = palette.header.cardBorder;
|
|
429
|
+
const card = tag('rect', {
|
|
430
|
+
x: num(cardX),
|
|
431
|
+
y: num(cardY),
|
|
432
|
+
width: num(cardWidth),
|
|
433
|
+
height: num(cardHeight),
|
|
434
|
+
rx: 6,
|
|
435
|
+
ry: 6,
|
|
436
|
+
fill: cardFill,
|
|
437
|
+
stroke: borderColor,
|
|
438
|
+
'stroke-width': 1,
|
|
439
|
+
filter: `url(#${idPrefix}-shadow-subtle)`,
|
|
440
|
+
});
|
|
441
|
+
// Title and author baselines come from `@nowline/layout`'s
|
|
442
|
+
// `header-card-geometry` module so the renderer paints with the
|
|
443
|
+
// exact metrics `sizeBesideHeader` sized the card to.
|
|
444
|
+
const titleParts: string[] = [];
|
|
445
|
+
h.titleLines.forEach((line, i) => {
|
|
446
|
+
titleParts.push(
|
|
447
|
+
textTag(
|
|
448
|
+
{
|
|
449
|
+
x: num(cardX + HEADER_CARD_PADDING_X),
|
|
450
|
+
y: num(cardY + HEADER_CARD_PADDING_TOP + i * HEADER_TITLE_LINE_HEIGHT_PX),
|
|
451
|
+
'font-family': FONT_STACK[h.style.font],
|
|
452
|
+
'font-size': HEADER_TITLE_FONT_SIZE_PX,
|
|
453
|
+
'font-weight': 600,
|
|
454
|
+
fill: h.style.text,
|
|
455
|
+
},
|
|
456
|
+
line,
|
|
457
|
+
),
|
|
458
|
+
);
|
|
459
|
+
});
|
|
460
|
+
const titleText = titleParts.join('');
|
|
461
|
+
const lastTitleY =
|
|
462
|
+
cardY +
|
|
463
|
+
HEADER_CARD_PADDING_TOP +
|
|
464
|
+
Math.max(0, h.titleLines.length - 1) * HEADER_TITLE_LINE_HEIGHT_PX;
|
|
465
|
+
const authorColor = palette.header.author;
|
|
466
|
+
const authorParts: string[] = [];
|
|
467
|
+
h.authorLines.forEach((line, j) => {
|
|
468
|
+
authorParts.push(
|
|
469
|
+
textTag(
|
|
470
|
+
{
|
|
471
|
+
x: num(cardX + HEADER_CARD_PADDING_X),
|
|
472
|
+
y: num(
|
|
473
|
+
lastTitleY +
|
|
474
|
+
HEADER_TITLE_TO_AUTHOR_GAP_PX +
|
|
475
|
+
j * HEADER_AUTHOR_LINE_HEIGHT_PX,
|
|
476
|
+
),
|
|
477
|
+
'font-family': FONT_STACK[h.style.font],
|
|
478
|
+
'font-size': HEADER_AUTHOR_FONT_SIZE_PX,
|
|
479
|
+
fill: authorColor,
|
|
480
|
+
},
|
|
481
|
+
line,
|
|
482
|
+
),
|
|
483
|
+
);
|
|
484
|
+
});
|
|
485
|
+
const authorText = authorParts.join('');
|
|
486
|
+
return tag(
|
|
487
|
+
'g',
|
|
488
|
+
{ 'data-layer': 'header', 'data-id': `${idPrefix}-header` },
|
|
489
|
+
card + titleText + authorText,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Renders the chart-body vertical grid lines (major dotted at every
|
|
494
|
+
// labeled tick, plus optional faint minor lines at every tick when
|
|
495
|
+
// `minorGrid` is set). Emitted as its own layer drawn AFTER the
|
|
496
|
+
// swimlane backgrounds so the lines actually span the chart body
|
|
497
|
+
// instead of being occluded — without this layer the lines would only
|
|
498
|
+
// be visible inside the timeline header strip. Grid lines stay BEHIND
|
|
499
|
+
// items, edges, anchor/milestone cuts, and the now-line so item bars
|
|
500
|
+
// sit cleanly on top of the ruled-paper backdrop.
|
|
501
|
+
function renderGridLines(t: PositionedTimelineScale, swimlaneTopY: number, palette: Theme): string {
|
|
502
|
+
const gridColor = palette.timeline.gridLine;
|
|
503
|
+
const minorGridColor = palette.timeline.minorGridLine;
|
|
504
|
+
// Major lines thread through the FULL timeline strip — they start
|
|
505
|
+
// at the top of the top date-label panel (when present) and run
|
|
506
|
+
// all the way through the bottom date-label panel (when one is
|
|
507
|
+
// mirrored at the chart bottom via `timeline-position:both` or
|
|
508
|
+
// `timeline-position:bottom`). This ties date labels at both ends
|
|
509
|
+
// to their column boundaries.
|
|
510
|
+
//
|
|
511
|
+
// Minor lines stay quieter: they start at the TOP OF THE TOPMOST
|
|
512
|
+
// SWIMLANE (i.e. below the top date panel AND below the marker
|
|
513
|
+
// row, so they don't streak through anchor/milestone diamonds in
|
|
514
|
+
// the header) and stop ABOVE the bottom date panel.
|
|
515
|
+
//
|
|
516
|
+
// Anchor diamonds, milestone markers, and date label text are
|
|
517
|
+
// rendered later in the orchestrator so they sit on top of any
|
|
518
|
+
// crossing line.
|
|
519
|
+
const bottomTickPanelHeight = t.bottomTickPanelHeight ?? 0;
|
|
520
|
+
const hasBottomTickPanel = t.bottomTickPanelY !== undefined && bottomTickPanelHeight > 0;
|
|
521
|
+
const majorTopY = t.tickPanelY;
|
|
522
|
+
const majorBottomY = hasBottomTickPanel
|
|
523
|
+
? t.bottomTickPanelY! + bottomTickPanelHeight
|
|
524
|
+
: t.box.y + t.box.height;
|
|
525
|
+
// Use the topmost swimlane's top edge directly — `markerRow.height`
|
|
526
|
+
// alone misses the small gap (`timelineHeightBudget` slack) between
|
|
527
|
+
// the marker row and the swimlane area, which would leave the minor
|
|
528
|
+
// lines short and floating in dead space above the swimlane.
|
|
529
|
+
const minorTopY = swimlaneTopY;
|
|
530
|
+
const minorBottomY = t.box.y + t.box.height;
|
|
531
|
+
const parts: string[] = [];
|
|
532
|
+
for (const tick of t.ticks) {
|
|
533
|
+
if (tick.major) {
|
|
534
|
+
// Solid major line at every labeled tick — the dominant
|
|
535
|
+
// column boundary, drawn in the stronger gridLine color.
|
|
536
|
+
parts.push(
|
|
537
|
+
tag('line', {
|
|
538
|
+
x1: num(tick.x),
|
|
539
|
+
y1: num(majorTopY),
|
|
540
|
+
x2: num(tick.x),
|
|
541
|
+
y2: num(majorBottomY),
|
|
542
|
+
stroke: gridColor,
|
|
543
|
+
'stroke-width': 1,
|
|
544
|
+
}),
|
|
545
|
+
);
|
|
546
|
+
} else if (t.minorGrid) {
|
|
547
|
+
// Solid faint minor line at every non-major tick boundary.
|
|
548
|
+
// Hierarchy is established by color (lighter than the major)
|
|
549
|
+
// rather than by texture. Skip the very last tick since it
|
|
550
|
+
// has no following column — a line at the chart's right edge
|
|
551
|
+
// just doubles up the chart border.
|
|
552
|
+
if (tick.labelX === undefined) continue;
|
|
553
|
+
parts.push(
|
|
554
|
+
tag('line', {
|
|
555
|
+
x1: num(tick.x),
|
|
556
|
+
y1: num(minorTopY),
|
|
557
|
+
x2: num(tick.x),
|
|
558
|
+
y2: num(minorBottomY),
|
|
559
|
+
stroke: minorGridColor,
|
|
560
|
+
'stroke-width': 1,
|
|
561
|
+
}),
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return tag('g', { 'data-layer': 'grid' }, parts.join(''));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function renderTimeline(t: PositionedTimelineScale, palette: Theme): string {
|
|
569
|
+
const panelFill = palette.timeline.panelFill;
|
|
570
|
+
const borderColor = palette.timeline.border;
|
|
571
|
+
const labelColor = palette.timeline.labelText;
|
|
572
|
+
const parts: string[] = [];
|
|
573
|
+
// Header layout from top: now-pill row → tick-label panel → marker row.
|
|
574
|
+
// The pill row owns its space (no panel rect); the now-line crosses it
|
|
575
|
+
// visually. Marker row is omitted entirely when empty. The top tick
|
|
576
|
+
// panel is also omitted when the roadmap requested
|
|
577
|
+
// `timeline-position:bottom` (height 0). The optional bottom tick
|
|
578
|
+
// panel mirrors the top panel directly above the footnote area.
|
|
579
|
+
const tickPanelY = t.tickPanelY;
|
|
580
|
+
const tickPanelHeight = t.tickPanelHeight;
|
|
581
|
+
const hasTopTickPanel = tickPanelHeight > 0;
|
|
582
|
+
const hasMarkerRow = t.markerRow.height > 0;
|
|
583
|
+
const markerRowY = tickPanelY + tickPanelHeight;
|
|
584
|
+
const markerRowHeight = t.markerRow.height;
|
|
585
|
+
const bottomTickPanelY = t.bottomTickPanelY;
|
|
586
|
+
const bottomTickPanelHeight = t.bottomTickPanelHeight ?? 0;
|
|
587
|
+
const hasBottomTickPanel = bottomTickPanelY !== undefined && bottomTickPanelHeight > 0;
|
|
588
|
+
|
|
589
|
+
if (hasTopTickPanel) {
|
|
590
|
+
parts.push(
|
|
591
|
+
tag('rect', {
|
|
592
|
+
x: num(t.box.x),
|
|
593
|
+
y: num(tickPanelY),
|
|
594
|
+
width: num(t.box.width),
|
|
595
|
+
height: num(tickPanelHeight),
|
|
596
|
+
rx: 4,
|
|
597
|
+
ry: 4,
|
|
598
|
+
fill: panelFill,
|
|
599
|
+
stroke: borderColor,
|
|
600
|
+
'stroke-width': 1,
|
|
601
|
+
}),
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
if (hasMarkerRow) {
|
|
605
|
+
parts.push(
|
|
606
|
+
tag('rect', {
|
|
607
|
+
x: num(t.box.x),
|
|
608
|
+
y: num(markerRowY),
|
|
609
|
+
width: num(t.box.width),
|
|
610
|
+
height: num(markerRowHeight),
|
|
611
|
+
rx: 4,
|
|
612
|
+
ry: 4,
|
|
613
|
+
fill: panelFill,
|
|
614
|
+
stroke: borderColor,
|
|
615
|
+
'stroke-width': 1,
|
|
616
|
+
}),
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
if (hasBottomTickPanel) {
|
|
620
|
+
parts.push(
|
|
621
|
+
tag('rect', {
|
|
622
|
+
x: num(t.box.x),
|
|
623
|
+
y: num(bottomTickPanelY!),
|
|
624
|
+
width: num(t.box.width),
|
|
625
|
+
height: num(bottomTickPanelHeight),
|
|
626
|
+
rx: 4,
|
|
627
|
+
ry: 4,
|
|
628
|
+
fill: panelFill,
|
|
629
|
+
stroke: borderColor,
|
|
630
|
+
'stroke-width': 1,
|
|
631
|
+
}),
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
// Header-only labels — the chart-body grid lines themselves are
|
|
635
|
+
// emitted by `renderGridLines` after the swimlane backgrounds so
|
|
636
|
+
// they actually span the chart body rather than being occluded.
|
|
637
|
+
for (const tick of t.ticks) {
|
|
638
|
+
if (!tick.major) continue;
|
|
639
|
+
if (!tick.label || tick.labelX === undefined) continue;
|
|
640
|
+
// Label sits at the COLUMN CENTER (tick.labelX), not at the
|
|
641
|
+
// tick boundary. The last tick has no following column → no
|
|
642
|
+
// label.
|
|
643
|
+
if (hasTopTickPanel) {
|
|
644
|
+
parts.push(
|
|
645
|
+
textTag(
|
|
646
|
+
{
|
|
647
|
+
x: num(tick.labelX),
|
|
648
|
+
y: num(tickPanelY + TIMELINE_TICK_LABEL_BASELINE_OFFSET_PX),
|
|
649
|
+
'font-family': FONT_STACK.sans,
|
|
650
|
+
'font-size': 10,
|
|
651
|
+
fill: labelColor,
|
|
652
|
+
'text-anchor': 'middle',
|
|
653
|
+
},
|
|
654
|
+
tick.label,
|
|
655
|
+
),
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
if (hasBottomTickPanel) {
|
|
659
|
+
parts.push(
|
|
660
|
+
textTag(
|
|
661
|
+
{
|
|
662
|
+
x: num(tick.labelX),
|
|
663
|
+
y: num(bottomTickPanelY! + TIMELINE_TICK_LABEL_BASELINE_OFFSET_PX),
|
|
664
|
+
'font-family': FONT_STACK.sans,
|
|
665
|
+
'font-size': 10,
|
|
666
|
+
fill: labelColor,
|
|
667
|
+
'text-anchor': 'middle',
|
|
668
|
+
},
|
|
669
|
+
tick.label,
|
|
670
|
+
),
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return tag('g', { 'data-layer': 'timeline' }, parts.join(''));
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function renderNowline(n: PositionedNowline | null, palette: Theme): string {
|
|
678
|
+
if (!n) return '';
|
|
679
|
+
const color = palette.nowline.stroke;
|
|
680
|
+
const labelTextColor = palette.nowline.labelText;
|
|
681
|
+
// Line drops from `topY` (just below the pill / top of date headers)
|
|
682
|
+
// through the headers into the chart, ending at `bottomY`.
|
|
683
|
+
const line = tag('line', {
|
|
684
|
+
x1: num(n.x),
|
|
685
|
+
y1: num(n.topY),
|
|
686
|
+
x2: num(n.x),
|
|
687
|
+
y2: num(n.bottomY),
|
|
688
|
+
stroke: color,
|
|
689
|
+
'stroke-width': NOWLINE_STROKE_WIDTH_PX,
|
|
690
|
+
});
|
|
691
|
+
// Pill — sits above the date headers at `pillTopY`. Three modes
|
|
692
|
+
// (decided by layout in `buildNowline`):
|
|
693
|
+
// - center → rounded rect centered on the line, label `middle`
|
|
694
|
+
// - flag-right → squared LEFT, rounded RIGHT, line at left edge,
|
|
695
|
+
// label `start` past the line
|
|
696
|
+
// - flag-left → rounded LEFT, squared RIGHT, line at right edge,
|
|
697
|
+
// label `end` before the line
|
|
698
|
+
// The squared edge IS the line; the rounded edge points into the
|
|
699
|
+
// chart, so the pill always hugs the line and never overflows.
|
|
700
|
+
const pillBg = renderNowPillBg(n, color);
|
|
701
|
+
const label = renderNowPillLabel(n, labelTextColor);
|
|
702
|
+
return tag('g', { 'data-layer': 'nowline' }, line + pillBg + label);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* X coordinate of the pill's squared edge in flag modes. SVG strokes
|
|
707
|
+
* are centered on their geometry, so a 2.25 px line at `n.x` actually
|
|
708
|
+
* paints from `n.x - 1.125` to `n.x + 1.125`. To make the pill's
|
|
709
|
+
* squared edge line up with the OUTER edge of the line stroke (so
|
|
710
|
+
* the line and the pill share a single continuous edge instead of
|
|
711
|
+
* the line peeking past the pill by half-stroke), we offset by
|
|
712
|
+
* `NOWLINE_STROKE_WIDTH_PX / 2` on the side the line is on.
|
|
713
|
+
*
|
|
714
|
+
* flag-right: line on the LEFT → squared edge at n.x - half-stroke
|
|
715
|
+
* flag-left: line on the RIGHT → squared edge at n.x + half-stroke
|
|
716
|
+
*
|
|
717
|
+
* Center mode doesn't apply — the line passes through the pill's
|
|
718
|
+
* vertical center, so a half-stroke offset would make things worse.
|
|
719
|
+
*/
|
|
720
|
+
function squaredEdgeX(n: PositionedNowline): number {
|
|
721
|
+
const halfStroke = NOWLINE_STROKE_WIDTH_PX / 2;
|
|
722
|
+
if (n.pillMode === 'flag-right') return n.x - halfStroke;
|
|
723
|
+
if (n.pillMode === 'flag-left') return n.x + halfStroke;
|
|
724
|
+
return n.x;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function renderNowPillBg(n: PositionedNowline, color: string): string {
|
|
728
|
+
const top = n.pillTopY;
|
|
729
|
+
const bottom = n.pillTopY + NOW_PILL_HEIGHT_PX;
|
|
730
|
+
const r = NOW_PILL_CORNER_RADIUS_PX;
|
|
731
|
+
// Pill width comes from the layout (`n.pillWidth`) — locale-aware,
|
|
732
|
+
// floored at `NOW_PILL_WIDTH_PX` so en-US output stays byte-stable
|
|
733
|
+
// and longer locale strings (e.g. fr `'maint.'`) grow the pill
|
|
734
|
+
// instead of clipping. See `buildNowline` in `@nowline/layout`.
|
|
735
|
+
const width = n.pillWidth;
|
|
736
|
+
if (n.pillMode === 'center') {
|
|
737
|
+
return tag('rect', {
|
|
738
|
+
x: num(n.x - width / 2),
|
|
739
|
+
y: num(top),
|
|
740
|
+
width: num(width),
|
|
741
|
+
height: num(NOW_PILL_HEIGHT_PX),
|
|
742
|
+
rx: r,
|
|
743
|
+
ry: r,
|
|
744
|
+
fill: color,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
const edgeX = squaredEdgeX(n);
|
|
748
|
+
if (n.pillMode === 'flag-right') {
|
|
749
|
+
// Squared LEFT edge aligns with line's left outer stroke edge,
|
|
750
|
+
// rounded corners on the RIGHT.
|
|
751
|
+
const right = edgeX + width;
|
|
752
|
+
const d = [
|
|
753
|
+
`M ${num(edgeX)} ${num(top)}`,
|
|
754
|
+
`L ${num(right - r)} ${num(top)}`,
|
|
755
|
+
`A ${r} ${r} 0 0 1 ${num(right)} ${num(top + r)}`,
|
|
756
|
+
`L ${num(right)} ${num(bottom - r)}`,
|
|
757
|
+
`A ${r} ${r} 0 0 1 ${num(right - r)} ${num(bottom)}`,
|
|
758
|
+
`L ${num(edgeX)} ${num(bottom)}`,
|
|
759
|
+
'Z',
|
|
760
|
+
].join(' ');
|
|
761
|
+
return tag('path', { d, fill: color });
|
|
762
|
+
}
|
|
763
|
+
// flag-left: squared RIGHT edge aligns with line's right outer
|
|
764
|
+
// stroke edge, rounded corners on the LEFT.
|
|
765
|
+
const left = edgeX - width;
|
|
766
|
+
const d = [
|
|
767
|
+
`M ${num(edgeX)} ${num(top)}`,
|
|
768
|
+
`L ${num(left + r)} ${num(top)}`,
|
|
769
|
+
`A ${r} ${r} 0 0 0 ${num(left)} ${num(top + r)}`,
|
|
770
|
+
`L ${num(left)} ${num(bottom - r)}`,
|
|
771
|
+
`A ${r} ${r} 0 0 0 ${num(left + r)} ${num(bottom)}`,
|
|
772
|
+
`L ${num(edgeX)} ${num(bottom)}`,
|
|
773
|
+
'Z',
|
|
774
|
+
].join(' ');
|
|
775
|
+
return tag('path', { d, fill: color });
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function renderNowPillLabel(n: PositionedNowline, labelTextColor: string): string {
|
|
779
|
+
const baselineY = n.pillTopY + NOW_PILL_LABEL_BASELINE_OFFSET_PX;
|
|
780
|
+
const edgeX = squaredEdgeX(n);
|
|
781
|
+
let labelX: number;
|
|
782
|
+
let textAnchor: 'start' | 'middle' | 'end';
|
|
783
|
+
if (n.pillMode === 'center') {
|
|
784
|
+
labelX = n.x;
|
|
785
|
+
textAnchor = 'middle';
|
|
786
|
+
} else if (n.pillMode === 'flag-right') {
|
|
787
|
+
labelX = edgeX + NOW_PILL_LABEL_INSET_X_PX;
|
|
788
|
+
textAnchor = 'start';
|
|
789
|
+
} else {
|
|
790
|
+
labelX = edgeX - NOW_PILL_LABEL_INSET_X_PX;
|
|
791
|
+
textAnchor = 'end';
|
|
792
|
+
}
|
|
793
|
+
return textTag(
|
|
794
|
+
{
|
|
795
|
+
x: num(labelX),
|
|
796
|
+
y: num(baselineY),
|
|
797
|
+
'font-family': FONT_STACK.sans,
|
|
798
|
+
'font-size': NOW_PILL_LABEL_FONT_SIZE_PX,
|
|
799
|
+
'font-weight': 700,
|
|
800
|
+
fill: labelTextColor,
|
|
801
|
+
'text-anchor': textAnchor,
|
|
802
|
+
},
|
|
803
|
+
n.label,
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function renderItem(
|
|
808
|
+
i: PositionedItem,
|
|
809
|
+
options: RenderOptions,
|
|
810
|
+
idPrefix: string,
|
|
811
|
+
palette: Theme,
|
|
812
|
+
): string {
|
|
813
|
+
const parts: string[] = [];
|
|
814
|
+
const shadow = shadowFilterUrl(idPrefix, i.style.shadow);
|
|
815
|
+
parts.push(
|
|
816
|
+
rectFrame(i.box.x, i.box.y, i.box.width, i.box.height, i.style, {
|
|
817
|
+
filter: shadow ?? null,
|
|
818
|
+
}),
|
|
819
|
+
);
|
|
820
|
+
// Status-dot color — the dot communicates status via hue, but
|
|
821
|
+
// the bar bg can range from pale status tints (`#eff6ff`) to
|
|
822
|
+
// saturated mid-tones (`#1e88e5` from `bg:blue` labels) to
|
|
823
|
+
// dark navies (`#172554` in dark theme), so a single palette
|
|
824
|
+
// can't keep contrast across all bars. Two palettes — `onLight`
|
|
825
|
+
// (deep tints, for pale bars) and `onDark` (pale tints, for
|
|
826
|
+
// saturated/dark bars) — are picked from based on the bar
|
|
827
|
+
// bg's relative luminance.
|
|
828
|
+
const dotPalette = pickStatusDotPalette(i.style.bg, palette);
|
|
829
|
+
const statusColors: Record<string, string> = {
|
|
830
|
+
done: dotPalette.done,
|
|
831
|
+
'in-progress': dotPalette.inProgress,
|
|
832
|
+
'at-risk': dotPalette.atRisk,
|
|
833
|
+
blocked: dotPalette.blocked,
|
|
834
|
+
planned: dotPalette.planned,
|
|
835
|
+
neutral: dotPalette.neutral,
|
|
836
|
+
};
|
|
837
|
+
const dotColor = statusColors[i.status] ?? statusColors.neutral;
|
|
838
|
+
// Bottom progress strip along the bottom edge. Height comes from
|
|
839
|
+
// `PROGRESS_STRIP_HEIGHT_PX` so layout's chip placement and the
|
|
840
|
+
// milestone slack-arrow attach Y stay in sync if it's ever bumped.
|
|
841
|
+
if (i.progressFraction > 0) {
|
|
842
|
+
const pw = Math.max(0, Math.min(i.box.width, i.box.width * i.progressFraction));
|
|
843
|
+
parts.push(
|
|
844
|
+
tag('rect', {
|
|
845
|
+
x: num(i.box.x),
|
|
846
|
+
y: num(i.box.y + i.box.height - PROGRESS_STRIP_HEIGHT_PX),
|
|
847
|
+
width: num(pw),
|
|
848
|
+
height: PROGRESS_STRIP_HEIGHT_PX,
|
|
849
|
+
fill: i.style.fg,
|
|
850
|
+
opacity: 0.55,
|
|
851
|
+
}),
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
// Status dot — upper-right inset inside the bar, OR pushed into
|
|
855
|
+
// the spill column when the bar is too narrow to host the dot's
|
|
856
|
+
// full inset (`dotSpills`). Layout pre-computes `dotSpillCx` for
|
|
857
|
+
// the spilled case so the renderer stays geometry-dumb.
|
|
858
|
+
const dotCx =
|
|
859
|
+
i.dotSpills && i.dotSpillCx !== null
|
|
860
|
+
? i.dotSpillCx
|
|
861
|
+
: i.box.x + i.box.width - ITEM_STATUS_DOT_INSET_RIGHT_PX;
|
|
862
|
+
parts.push(
|
|
863
|
+
tag('circle', {
|
|
864
|
+
cx: num(dotCx),
|
|
865
|
+
cy: num(i.box.y + ITEM_STATUS_DOT_INSET_TOP_PX),
|
|
866
|
+
r: ITEM_STATUS_DOT_RADIUS_PX,
|
|
867
|
+
fill: dotColor,
|
|
868
|
+
}),
|
|
869
|
+
);
|
|
870
|
+
// Title + meta are an atomic caption. When `textSpills` is set the
|
|
871
|
+
// layout has already bumped the next item to a fresh row, so we draw
|
|
872
|
+
// both lines BESIDE the bar (just past its right edge, stacked) at
|
|
873
|
+
// the same vertical positions they would occupy inside. When they
|
|
874
|
+
// fit, both go inside at the bar's left padding.
|
|
875
|
+
//
|
|
876
|
+
// The spilled-decoration cluster reads `[bar] [icon?] [title]
|
|
877
|
+
// [footnote?] [dot?]` — the only decoration to the LEFT of the
|
|
878
|
+
// title is the link icon (the icon→title affordance must stay
|
|
879
|
+
// adjacent). The dot trails the title to mirror its in-bar
|
|
880
|
+
// upper-right position; the footnote walks alongside the title
|
|
881
|
+
// (between title and dot) just like its in-bar `text-anchor: end`
|
|
882
|
+
// placement at the upper-right.
|
|
883
|
+
let captionX: number;
|
|
884
|
+
if (i.textSpills) {
|
|
885
|
+
captionX = i.box.x + i.box.width + ITEM_CAPTION_SPILL_GAP_PX;
|
|
886
|
+
if (i.iconSpills) {
|
|
887
|
+
captionX += ITEM_LINK_ICON_TILE_SIZE_PX + ITEM_DECORATION_SPILL_GAP_PX;
|
|
888
|
+
}
|
|
889
|
+
} else {
|
|
890
|
+
captionX = i.box.x + ITEM_CAPTION_INSET_X_PX;
|
|
891
|
+
}
|
|
892
|
+
// When the caption spills outside the bar it renders on the
|
|
893
|
+
// chart / group bg instead of the bar fill — `i.style.text` is
|
|
894
|
+
// resolved against the bar (e.g. `enterprise-style` propagates
|
|
895
|
+
// `text:white` from a label and audit-log's title becomes
|
|
896
|
+
// white-on-blue inside, but white-on-peach when spilled onto
|
|
897
|
+
// the orange-tinted audit-track group). Use the theme's
|
|
898
|
+
// default item text color (always tuned for chart bg) when
|
|
899
|
+
// text spills, and the per-bar color when it stays inside.
|
|
900
|
+
const captionInsideTextColor = i.style.text;
|
|
901
|
+
const captionOutsideTextColor = palette.entities.item.text;
|
|
902
|
+
const titleColor = i.textSpills ? captionOutsideTextColor : captionInsideTextColor;
|
|
903
|
+
const metaColor = i.textSpills ? captionOutsideTextColor : i.style.fg;
|
|
904
|
+
if (i.title) {
|
|
905
|
+
parts.push(
|
|
906
|
+
textTag(
|
|
907
|
+
{
|
|
908
|
+
x: num(captionX),
|
|
909
|
+
y: num(i.box.y + ITEM_CAPTION_TITLE_BASELINE_OFFSET_PX),
|
|
910
|
+
'font-family': FONT_STACK[i.style.font],
|
|
911
|
+
'font-size': ITEM_CAPTION_TITLE_FONT_SIZE_PX,
|
|
912
|
+
'font-weight': 600,
|
|
913
|
+
fill: titleColor,
|
|
914
|
+
},
|
|
915
|
+
i.title,
|
|
916
|
+
),
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
// Meta line and capacity suffix render as a unified SVG fragment.
|
|
920
|
+
// For pure-text suffixes (multiplier, literal glyph, no glyph) the
|
|
921
|
+
// unified path emits a single `<text>` element with `<tspan dx>`
|
|
922
|
+
// for the gap so the suffix hugs the metaText regardless of how
|
|
923
|
+
// wide it actually rendered. Built-in SVG icon glyphs still emit a
|
|
924
|
+
// sibling `<svg>` but at a tighter offset than the legacy two-text
|
|
925
|
+
// path. See `renderItemMetaLine` for the full case-by-case.
|
|
926
|
+
if (i.metaText || i.capacity) {
|
|
927
|
+
parts.push(
|
|
928
|
+
renderItemMetaLine({
|
|
929
|
+
metaText: i.metaText,
|
|
930
|
+
capacity: i.capacity,
|
|
931
|
+
x: captionX,
|
|
932
|
+
baselineY: i.box.y + ITEM_CAPTION_META_BASELINE_OFFSET_PX,
|
|
933
|
+
fontSize: ITEM_CAPTION_META_FONT_SIZE_PX,
|
|
934
|
+
fontFamily: FONT_STACK[i.style.font],
|
|
935
|
+
color: metaColor,
|
|
936
|
+
}),
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
// Footnote superscript indicators. Two render modes:
|
|
940
|
+
// - In-bar (default): glyphs walk LEFT from
|
|
941
|
+
// `bar.right - ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX`,
|
|
942
|
+
// anchored end. They sit on the bar fill, so use the bar's
|
|
943
|
+
// resolved text color for contrast (a hardcoded red was
|
|
944
|
+
// getting lost on saturated mid-tone bars from `bg:blue`
|
|
945
|
+
// labels). The "footnote = red" attention cue lives on the
|
|
946
|
+
// footnote PANEL's red number column at the bottom of the
|
|
947
|
+
// chart where red reads cleanly against white.
|
|
948
|
+
// - Spilled (narrow bars): the glyphs render in the spill
|
|
949
|
+
// column to the right of the bar, walking RIGHT from
|
|
950
|
+
// `footnoteSpillStartX` so they read in the same numerical
|
|
951
|
+
// order as the in-bar case. They sit on the chart bg, so
|
|
952
|
+
// use the chart-tuned default text color (same as spilled
|
|
953
|
+
// captions).
|
|
954
|
+
if (i.footnoteIndicators.length > 0) {
|
|
955
|
+
const footnoteY = i.box.y + ITEM_FOOTNOTE_INDICATOR_BASELINE_OFFSET_PX;
|
|
956
|
+
if (i.footnoteSpills && i.footnoteSpillStartX !== null) {
|
|
957
|
+
let fx = i.footnoteSpillStartX;
|
|
958
|
+
for (let k = 0; k < i.footnoteIndicators.length; k++) {
|
|
959
|
+
const n2 = i.footnoteIndicators[k];
|
|
960
|
+
parts.push(
|
|
961
|
+
textTag(
|
|
962
|
+
{
|
|
963
|
+
x: num(fx),
|
|
964
|
+
y: num(footnoteY),
|
|
965
|
+
'font-family': FONT_STACK.sans,
|
|
966
|
+
'font-size': 10,
|
|
967
|
+
'font-weight': 700,
|
|
968
|
+
fill: captionOutsideTextColor,
|
|
969
|
+
},
|
|
970
|
+
String(n2),
|
|
971
|
+
),
|
|
972
|
+
);
|
|
973
|
+
fx += ITEM_FOOTNOTE_INDICATOR_STEP_PX;
|
|
974
|
+
}
|
|
975
|
+
} else {
|
|
976
|
+
let fx = i.box.x + i.box.width - ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX;
|
|
977
|
+
for (let k = i.footnoteIndicators.length - 1; k >= 0; k--) {
|
|
978
|
+
const n2 = i.footnoteIndicators[k];
|
|
979
|
+
parts.push(
|
|
980
|
+
textTag(
|
|
981
|
+
{
|
|
982
|
+
x: num(fx),
|
|
983
|
+
y: num(footnoteY),
|
|
984
|
+
'font-family': FONT_STACK.sans,
|
|
985
|
+
'font-size': 10,
|
|
986
|
+
'font-weight': 700,
|
|
987
|
+
fill: i.style.text,
|
|
988
|
+
'text-anchor': 'end',
|
|
989
|
+
},
|
|
990
|
+
String(n2),
|
|
991
|
+
),
|
|
992
|
+
);
|
|
993
|
+
fx -= ITEM_FOOTNOTE_INDICATOR_STEP_PX;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
// Link icon — colored tile + white external-link glyph. Default
|
|
998
|
+
// position is the bar's UPPER-LEFT corner; on a bar too narrow
|
|
999
|
+
// to host both the icon and the status-dot column with a gap
|
|
1000
|
+
// between them, the icon spills out to the right of the bar
|
|
1001
|
+
// (in front of the spilled title) so the icon→title affordance
|
|
1002
|
+
// stays intact. The glyph is the same outbound-arrow ↗ for
|
|
1003
|
+
// every link kind (linear / github / jira / generic) — they
|
|
1004
|
+
// only differ in tile color. The include FILE-LEVEL region
|
|
1005
|
+
// (`include "./other.nowline"`) uses a separate stacked-sheets
|
|
1006
|
+
// glyph rendered by `renderIncludeRegion`, distinct from this
|
|
1007
|
+
// item-level link icon.
|
|
1008
|
+
if (!options.noLinks && i.linkIcon && i.linkIcon !== 'none') {
|
|
1009
|
+
const tileColor: Record<string, string> = {
|
|
1010
|
+
linear: '#5e6ad2',
|
|
1011
|
+
github: '#0f172a',
|
|
1012
|
+
jira: '#0052cc',
|
|
1013
|
+
generic: palette.item.linkIconFg,
|
|
1014
|
+
};
|
|
1015
|
+
const tile = tileColor[i.linkIcon] ?? tileColor.generic;
|
|
1016
|
+
const tileSize = ITEM_LINK_ICON_TILE_SIZE_PX;
|
|
1017
|
+
const tileX =
|
|
1018
|
+
i.iconSpills && i.iconSpillX !== null
|
|
1019
|
+
? i.iconSpillX
|
|
1020
|
+
: i.box.x + ITEM_LINK_ICON_INSET_PX;
|
|
1021
|
+
const tileY = i.box.y + ITEM_LINK_ICON_INSET_PX;
|
|
1022
|
+
const tileRect = tag('rect', {
|
|
1023
|
+
x: num(tileX),
|
|
1024
|
+
y: num(tileY),
|
|
1025
|
+
width: tileSize,
|
|
1026
|
+
height: tileSize,
|
|
1027
|
+
rx: 2,
|
|
1028
|
+
ry: 2,
|
|
1029
|
+
fill: tile,
|
|
1030
|
+
});
|
|
1031
|
+
const gx = tileX;
|
|
1032
|
+
const gy = tileY;
|
|
1033
|
+
const glyph = tag('path', {
|
|
1034
|
+
d: `M${num(gx + 4)} ${num(gy + 10)} L${num(gx + 10)} ${num(gy + 4)} M${num(gx + 6)} ${num(gy + 4)} H${num(gx + 10)} V${num(gy + 8)}`,
|
|
1035
|
+
stroke: '#ffffff',
|
|
1036
|
+
fill: 'none',
|
|
1037
|
+
'stroke-width': 1.1,
|
|
1038
|
+
'stroke-linecap': 'round',
|
|
1039
|
+
'stroke-linejoin': 'round',
|
|
1040
|
+
});
|
|
1041
|
+
const inner = tileRect + glyph;
|
|
1042
|
+
const link = i.linkHref
|
|
1043
|
+
? tag('a', { href: i.linkHref, target: '_blank', rel: 'noopener' }, inner)
|
|
1044
|
+
: inner;
|
|
1045
|
+
parts.push(link);
|
|
1046
|
+
}
|
|
1047
|
+
// Overflow tail — red fill + stroke + caption.
|
|
1048
|
+
if (i.hasOverflow && i.overflowBox) {
|
|
1049
|
+
const tailFill = palette.item.overflowTailFill;
|
|
1050
|
+
const tailStroke = palette.item.overflowTailStroke;
|
|
1051
|
+
const captionColor = palette.item.overflowCaption;
|
|
1052
|
+
parts.push(
|
|
1053
|
+
tag('rect', {
|
|
1054
|
+
x: num(i.overflowBox.x),
|
|
1055
|
+
y: num(i.overflowBox.y),
|
|
1056
|
+
width: num(i.overflowBox.width),
|
|
1057
|
+
height: num(i.overflowBox.height),
|
|
1058
|
+
fill: tailFill,
|
|
1059
|
+
stroke: tailStroke,
|
|
1060
|
+
'stroke-width': 1,
|
|
1061
|
+
}),
|
|
1062
|
+
);
|
|
1063
|
+
if (i.overflowAnchorId && i.overflowBox.width > 60) {
|
|
1064
|
+
parts.push(
|
|
1065
|
+
textTag(
|
|
1066
|
+
{
|
|
1067
|
+
x: num(i.overflowBox.x + i.overflowBox.width / 2),
|
|
1068
|
+
y: num(i.overflowBox.y + i.overflowBox.height / 2 + 3),
|
|
1069
|
+
'font-family': FONT_STACK.sans,
|
|
1070
|
+
'font-size': 9,
|
|
1071
|
+
'font-weight': 700,
|
|
1072
|
+
fill: captionColor,
|
|
1073
|
+
'text-anchor': 'middle',
|
|
1074
|
+
},
|
|
1075
|
+
`past ${i.overflowAnchorId}`,
|
|
1076
|
+
),
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
// Label chips
|
|
1081
|
+
for (const chip of i.labelChips) {
|
|
1082
|
+
const rx = Math.min(CORNER_RADIUS_PX[chip.style.cornerRadius] ?? 8, chip.box.height / 2);
|
|
1083
|
+
parts.push(
|
|
1084
|
+
tag('rect', {
|
|
1085
|
+
x: num(chip.box.x),
|
|
1086
|
+
y: num(chip.box.y),
|
|
1087
|
+
width: num(chip.box.width),
|
|
1088
|
+
height: num(chip.box.height),
|
|
1089
|
+
rx: num(rx),
|
|
1090
|
+
ry: num(rx),
|
|
1091
|
+
fill: chip.style.bg === 'none' ? 'transparent' : chip.style.bg,
|
|
1092
|
+
stroke: chip.style.fg,
|
|
1093
|
+
'stroke-width': 0.5,
|
|
1094
|
+
}),
|
|
1095
|
+
);
|
|
1096
|
+
parts.push(
|
|
1097
|
+
textTag(
|
|
1098
|
+
{
|
|
1099
|
+
x: num(chip.box.x + chip.box.width / 2),
|
|
1100
|
+
y: num(chip.box.y + chip.box.height / 2 + 3),
|
|
1101
|
+
...fontAttrs(chip.style, TEXT_SIZE_PX.xs),
|
|
1102
|
+
'text-anchor': 'middle',
|
|
1103
|
+
},
|
|
1104
|
+
chip.text,
|
|
1105
|
+
),
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
return tag('g', { 'data-layer': 'item', 'data-id': i.id ?? null }, parts.join(''));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function renderGroup(
|
|
1112
|
+
g: PositionedGroup,
|
|
1113
|
+
options: RenderOptions,
|
|
1114
|
+
idPrefix: string,
|
|
1115
|
+
palette: Theme,
|
|
1116
|
+
): string {
|
|
1117
|
+
const parts: string[] = [];
|
|
1118
|
+
const hasFill = g.style.bg !== 'none' && g.style.bg !== '#ffffff';
|
|
1119
|
+
if (hasFill) {
|
|
1120
|
+
// Filled-box style with a chiclet label flush in the upper-left
|
|
1121
|
+
// corner. The painted box matches the layout-reported `box` 1:1
|
|
1122
|
+
// (no overhang), so parents stack against the right rectangle.
|
|
1123
|
+
parts.push(
|
|
1124
|
+
tag('rect', {
|
|
1125
|
+
x: num(g.box.x),
|
|
1126
|
+
y: num(g.box.y),
|
|
1127
|
+
width: num(g.box.width),
|
|
1128
|
+
height: num(g.box.height),
|
|
1129
|
+
rx: 6,
|
|
1130
|
+
ry: 6,
|
|
1131
|
+
fill: g.style.bg,
|
|
1132
|
+
stroke: g.style.fg,
|
|
1133
|
+
'stroke-width': 1,
|
|
1134
|
+
'fill-opacity': 0.18,
|
|
1135
|
+
filter: `url(#${idPrefix}-shadow-subtle)`,
|
|
1136
|
+
}),
|
|
1137
|
+
);
|
|
1138
|
+
if (g.title) {
|
|
1139
|
+
const tabW =
|
|
1140
|
+
g.title.length * GROUP_TITLE_TAB_CHAR_WIDTH_PX + 2 * GROUP_TITLE_TAB_PAD_X_PX;
|
|
1141
|
+
const tabX = g.box.x;
|
|
1142
|
+
const tabY = g.box.y;
|
|
1143
|
+
const tabH = GROUP_TITLE_TAB_HEIGHT_PX;
|
|
1144
|
+
// Asymmetric corner shape: TOP-LEFT and BOTTOM-RIGHT are
|
|
1145
|
+
// rounded (radius 6, matching the parent group box), while
|
|
1146
|
+
// TOP-RIGHT and BOTTOM-LEFT are square. The TL roundness
|
|
1147
|
+
// continues the group box's outer corner; the squared
|
|
1148
|
+
// BL / TR sides "anchor" the tab into the box's left and
|
|
1149
|
+
// top edges so it reads as a corner-mounted label rather
|
|
1150
|
+
// than a floating pill.
|
|
1151
|
+
const r = 6;
|
|
1152
|
+
const tabPath =
|
|
1153
|
+
`M${num(tabX + r)} ${num(tabY)}` +
|
|
1154
|
+
`H${num(tabX + tabW)}` +
|
|
1155
|
+
`V${num(tabY + tabH - r)}` +
|
|
1156
|
+
`A${r} ${r} 0 0 1 ${num(tabX + tabW - r)} ${num(tabY + tabH)}` +
|
|
1157
|
+
`H${num(tabX)}` +
|
|
1158
|
+
`V${num(tabY + r)}` +
|
|
1159
|
+
`A${r} ${r} 0 0 1 ${num(tabX + r)} ${num(tabY)}` +
|
|
1160
|
+
`Z`;
|
|
1161
|
+
parts.push(
|
|
1162
|
+
tag('path', {
|
|
1163
|
+
d: tabPath,
|
|
1164
|
+
fill: g.style.fg,
|
|
1165
|
+
}),
|
|
1166
|
+
);
|
|
1167
|
+
parts.push(
|
|
1168
|
+
textTag(
|
|
1169
|
+
{
|
|
1170
|
+
x: num(tabX + GROUP_TITLE_TAB_PAD_X_PX),
|
|
1171
|
+
y: num(tabY + GROUP_TITLE_TAB_LABEL_BASELINE_OFFSET_PX),
|
|
1172
|
+
'font-family': FONT_STACK[g.style.font],
|
|
1173
|
+
'font-size': GROUP_TITLE_TAB_LABEL_FONT_SIZE_PX,
|
|
1174
|
+
'font-weight': 600,
|
|
1175
|
+
fill: '#ffffff',
|
|
1176
|
+
},
|
|
1177
|
+
g.title,
|
|
1178
|
+
),
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
} else {
|
|
1182
|
+
const bracketColor = g.style.fg;
|
|
1183
|
+
if (g.style.bracket !== 'none') {
|
|
1184
|
+
// Bracket-style groups paint a left-side `[` glyph along
|
|
1185
|
+
// `box.x`. When a title is present the layout has reserved
|
|
1186
|
+
// `GROUP_BRACKET_LABEL_OVERHANG_PX` of vertical space ABOVE
|
|
1187
|
+
// `box.y` (see GroupNode.place); the bracket extends up
|
|
1188
|
+
// through that overhang and adds a top foot mirroring the
|
|
1189
|
+
// bottom foot so the `[` visually wraps the title text that
|
|
1190
|
+
// sits in the reserved region. Title-less bracket groups
|
|
1191
|
+
// keep the historical asymmetric shape (vertical bar + a
|
|
1192
|
+
// single bottom foot) since there's nothing above to wrap.
|
|
1193
|
+
const stub = 4;
|
|
1194
|
+
const bottom = g.box.y + g.box.height;
|
|
1195
|
+
const dash = g.style.bracket === 'dashed' ? '3 2' : null;
|
|
1196
|
+
const bracketPath = g.title
|
|
1197
|
+
? `M${num(g.box.x + stub)} ${num(g.box.y - GROUP_BRACKET_LABEL_OVERHANG_PX)}` +
|
|
1198
|
+
` L${num(g.box.x)} ${num(g.box.y - GROUP_BRACKET_LABEL_OVERHANG_PX)}` +
|
|
1199
|
+
` L${num(g.box.x)} ${num(bottom)}` +
|
|
1200
|
+
` L${num(g.box.x + stub)} ${num(bottom)}`
|
|
1201
|
+
: `M${num(g.box.x)} ${num(g.box.y)}` +
|
|
1202
|
+
` L${num(g.box.x)} ${num(bottom)}` +
|
|
1203
|
+
` L${num(g.box.x + stub)} ${num(bottom)}`;
|
|
1204
|
+
parts.push(
|
|
1205
|
+
tag('path', {
|
|
1206
|
+
d: bracketPath,
|
|
1207
|
+
fill: 'none',
|
|
1208
|
+
stroke: bracketColor,
|
|
1209
|
+
'stroke-width': 1,
|
|
1210
|
+
'stroke-dasharray': dash,
|
|
1211
|
+
}),
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
if (g.title) {
|
|
1215
|
+
parts.push(
|
|
1216
|
+
textTag(
|
|
1217
|
+
{
|
|
1218
|
+
x: num(g.box.x + 6),
|
|
1219
|
+
y: num(g.box.y - 2),
|
|
1220
|
+
...fontAttrs(g.style, TEXT_SIZE_PX.xs),
|
|
1221
|
+
'fill-opacity': 0.7,
|
|
1222
|
+
},
|
|
1223
|
+
g.title,
|
|
1224
|
+
),
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
for (const c of g.children) {
|
|
1229
|
+
parts.push(renderTrackChild(c, options, idPrefix, palette));
|
|
1230
|
+
}
|
|
1231
|
+
void palette;
|
|
1232
|
+
return tag('g', { 'data-layer': 'group', 'data-id': g.id ?? null }, parts.join(''));
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function renderParallel(
|
|
1236
|
+
p: PositionedParallel,
|
|
1237
|
+
options: RenderOptions,
|
|
1238
|
+
idPrefix: string,
|
|
1239
|
+
palette: Theme,
|
|
1240
|
+
): string {
|
|
1241
|
+
const parts: string[] = [];
|
|
1242
|
+
// `bracket: solid|dashed` parallels render explicit [ ] brackets framing
|
|
1243
|
+
// the nested tracks with 12 px vertical padding above/below.
|
|
1244
|
+
if (p.style.bracket === 'solid' || p.style.bracket === 'dashed') {
|
|
1245
|
+
const padding = 12;
|
|
1246
|
+
const stub = 4;
|
|
1247
|
+
const top = p.box.y - padding;
|
|
1248
|
+
const bottom = p.box.y + p.box.height + padding;
|
|
1249
|
+
const lx = p.box.x;
|
|
1250
|
+
const rx = p.box.x + p.box.width;
|
|
1251
|
+
const stroke = palette.parallel.bracketStroke;
|
|
1252
|
+
parts.push(
|
|
1253
|
+
tag('path', {
|
|
1254
|
+
d: `M${num(lx + stub)} ${num(top)} H${num(lx)} V${num(bottom)} H${num(lx + stub)}`,
|
|
1255
|
+
fill: 'none',
|
|
1256
|
+
stroke,
|
|
1257
|
+
'stroke-width': 1.25,
|
|
1258
|
+
'stroke-dasharray': p.style.bracket === 'dashed' ? '3 3' : null,
|
|
1259
|
+
'stroke-linejoin': 'round',
|
|
1260
|
+
}),
|
|
1261
|
+
);
|
|
1262
|
+
parts.push(
|
|
1263
|
+
tag('path', {
|
|
1264
|
+
d: `M${num(rx - stub)} ${num(top)} H${num(rx)} V${num(bottom)} H${num(rx - stub)}`,
|
|
1265
|
+
fill: 'none',
|
|
1266
|
+
stroke,
|
|
1267
|
+
'stroke-width': 1.25,
|
|
1268
|
+
'stroke-dasharray': p.style.bracket === 'dashed' ? '3 3' : null,
|
|
1269
|
+
'stroke-linejoin': 'round',
|
|
1270
|
+
}),
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
if (p.title) {
|
|
1274
|
+
parts.push(
|
|
1275
|
+
textTag(
|
|
1276
|
+
{
|
|
1277
|
+
x: num(p.box.x + 4),
|
|
1278
|
+
y: num(p.box.y - 2),
|
|
1279
|
+
...fontAttrs(p.style, TEXT_SIZE_PX.xs),
|
|
1280
|
+
'fill-opacity': 0.7,
|
|
1281
|
+
},
|
|
1282
|
+
p.title,
|
|
1283
|
+
),
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
for (const c of p.children) {
|
|
1287
|
+
parts.push(renderTrackChild(c, options, idPrefix, palette));
|
|
1288
|
+
}
|
|
1289
|
+
return tag('g', { 'data-layer': 'parallel', 'data-id': p.id ?? null }, parts.join(''));
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function renderTrackChild(
|
|
1293
|
+
c: PositionedTrackChild,
|
|
1294
|
+
options: RenderOptions,
|
|
1295
|
+
idPrefix: string,
|
|
1296
|
+
palette: Theme,
|
|
1297
|
+
): string {
|
|
1298
|
+
if (c.kind === 'item') return renderItem(c, options, idPrefix, palette);
|
|
1299
|
+
if (c.kind === 'group') return renderGroup(c, options, idPrefix, palette);
|
|
1300
|
+
return renderParallel(c, options, idPrefix, palette);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// Renders only the swimlane's background tint rect. Emitted before the
|
|
1304
|
+
// chart-body grid lines so those lines visibly span the full chart width.
|
|
1305
|
+
// The frame tab (chiclet at top-left) and item content are emitted later,
|
|
1306
|
+
// in renderSwimlaneContent, so they appear on top of the grid.
|
|
1307
|
+
// Tri-state lane utilization underline. Painted along the bottom edge of
|
|
1308
|
+
// the band when the lane has `capacity:` AND at least one item contributing
|
|
1309
|
+
// load AND has not opted out of every color band via `utilization-*-at:none`.
|
|
1310
|
+
// Geometry per specs/rendering.md § Lane utilization underline:
|
|
1311
|
+
// - height: 2px (matches the milestone-line stroke weight)
|
|
1312
|
+
// - y: flush with the bottom edge of the band, fully inside it
|
|
1313
|
+
// - x: aligned to the segment boundaries the layout already pre-coalesced
|
|
1314
|
+
// One <rect> per coalesced segment; classification → palette token mapping
|
|
1315
|
+
// is the only renderer-side decision.
|
|
1316
|
+
const LANE_UTILIZATION_HEIGHT_PX = 2;
|
|
1317
|
+
|
|
1318
|
+
function utilizationColor(classification: 'green' | 'yellow' | 'red', palette: Theme): string {
|
|
1319
|
+
switch (classification) {
|
|
1320
|
+
case 'green':
|
|
1321
|
+
return palette.swimlane.utilizationOk;
|
|
1322
|
+
case 'yellow':
|
|
1323
|
+
return palette.swimlane.utilizationWarn;
|
|
1324
|
+
case 'red':
|
|
1325
|
+
return palette.swimlane.utilizationOver;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function renderLaneUtilization(s: PositionedSwimlane, palette: Theme): string {
|
|
1330
|
+
if (!s.utilization || s.utilization.segments.length === 0) return '';
|
|
1331
|
+
const y = s.box.y + s.box.height - LANE_UTILIZATION_HEIGHT_PX;
|
|
1332
|
+
const rects = s.utilization.segments.map((seg) => {
|
|
1333
|
+
const width = seg.endX - seg.startX;
|
|
1334
|
+
if (width <= 0) return '';
|
|
1335
|
+
return tag('rect', {
|
|
1336
|
+
x: num(seg.startX),
|
|
1337
|
+
y: num(y),
|
|
1338
|
+
width: num(width),
|
|
1339
|
+
height: LANE_UTILIZATION_HEIGHT_PX,
|
|
1340
|
+
fill: utilizationColor(seg.classification, palette),
|
|
1341
|
+
'data-utilization': seg.classification,
|
|
1342
|
+
'data-load': num(seg.load),
|
|
1343
|
+
});
|
|
1344
|
+
});
|
|
1345
|
+
return tag(
|
|
1346
|
+
'g',
|
|
1347
|
+
{
|
|
1348
|
+
'data-layer': 'lane-utilization',
|
|
1349
|
+
'data-id': s.id ?? null,
|
|
1350
|
+
},
|
|
1351
|
+
rects.join(''),
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function renderSwimlaneBg(s: PositionedSwimlane, palette: Theme): string {
|
|
1356
|
+
const tint = s.bandIndex % 2 === 0 ? palette.swimlane.rowTintEven : palette.swimlane.rowTintOdd;
|
|
1357
|
+
const borderColor = palette.swimlane.border;
|
|
1358
|
+
return tag(
|
|
1359
|
+
'g',
|
|
1360
|
+
{ 'data-layer': 'swimlane-bg', 'data-id': s.id ?? null },
|
|
1361
|
+
tag('rect', {
|
|
1362
|
+
x: num(s.box.x),
|
|
1363
|
+
y: num(s.box.y),
|
|
1364
|
+
width: num(s.box.width),
|
|
1365
|
+
height: num(s.box.height),
|
|
1366
|
+
fill: tint,
|
|
1367
|
+
stroke: borderColor,
|
|
1368
|
+
'stroke-width': 1,
|
|
1369
|
+
}),
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function renderSwimlane(
|
|
1374
|
+
s: PositionedSwimlane,
|
|
1375
|
+
options: RenderOptions,
|
|
1376
|
+
idPrefix: string,
|
|
1377
|
+
palette: Theme,
|
|
1378
|
+
): string {
|
|
1379
|
+
const tabFill = palette.swimlane.tabFill;
|
|
1380
|
+
const tabStroke = palette.swimlane.tabStroke;
|
|
1381
|
+
const tabText = palette.swimlane.tabText;
|
|
1382
|
+
const ownerText = palette.swimlane.ownerText;
|
|
1383
|
+
const footnoteColor = palette.swimlane.footnoteIndicator;
|
|
1384
|
+
const parts: string[] = [];
|
|
1385
|
+
// Frame-tab chiclet at the top-left of the band — auto-sized to fit
|
|
1386
|
+
// title + owner. Geometry comes from the shared `frameTabGeometry`
|
|
1387
|
+
// helper that the layout's row-packer also calls, so the chiclet's
|
|
1388
|
+
// visible footprint matches the collision box layout reserved for it.
|
|
1389
|
+
if (s.title) {
|
|
1390
|
+
// Lane capacity badge + footnote indicator sit inside the frame
|
|
1391
|
+
// tab after the owner (or after the title if no owner). Compute
|
|
1392
|
+
// both widths up-front so `frameTabGeometry` can size the chiclet
|
|
1393
|
+
// to fit them, then read the placement positions back out — no
|
|
1394
|
+
// second placement pass in the renderer.
|
|
1395
|
+
const LANE_BADGE_FONT_SIZE_PX = 10;
|
|
1396
|
+
const capacityBadgeBareWidthPx = s.capacity
|
|
1397
|
+
? estimateCapacitySuffixWidth(s.capacity.text, s.capacity.icon, LANE_BADGE_FONT_SIZE_PX)
|
|
1398
|
+
: 0;
|
|
1399
|
+
// Footnote indicator is a comma-joined number list painted at
|
|
1400
|
+
// 10 pt 700-weight; estimate via the shared caption helper.
|
|
1401
|
+
const footnoteIndicatorText =
|
|
1402
|
+
s.footnoteIndicators.length > 0 ? s.footnoteIndicators.join(',') : '';
|
|
1403
|
+
const footnoteIndicatorWidthPx = footnoteIndicatorText
|
|
1404
|
+
? estimateCaptionWidthPx(footnoteIndicatorText, LANE_BADGE_FONT_SIZE_PX)
|
|
1405
|
+
: 0;
|
|
1406
|
+
const tab = frameTabGeometry(
|
|
1407
|
+
s.box.x,
|
|
1408
|
+
s.title,
|
|
1409
|
+
s.owner,
|
|
1410
|
+
capacityBadgeBareWidthPx,
|
|
1411
|
+
footnoteIndicatorWidthPx,
|
|
1412
|
+
);
|
|
1413
|
+
const tabH = FRAME_TAB_HEIGHT_PX;
|
|
1414
|
+
const tabY = s.box.y + 10;
|
|
1415
|
+
const labelY = tabY + FRAME_TAB_LABEL_BASELINE_OFFSET_PX;
|
|
1416
|
+
parts.push(
|
|
1417
|
+
tag('rect', {
|
|
1418
|
+
x: num(tab.tabX),
|
|
1419
|
+
y: num(tabY),
|
|
1420
|
+
width: num(tab.tabW),
|
|
1421
|
+
height: num(tabH),
|
|
1422
|
+
rx: 4,
|
|
1423
|
+
ry: 4,
|
|
1424
|
+
fill: tabFill,
|
|
1425
|
+
stroke: tabStroke,
|
|
1426
|
+
'stroke-width': 1,
|
|
1427
|
+
}),
|
|
1428
|
+
);
|
|
1429
|
+
parts.push(
|
|
1430
|
+
textTag(
|
|
1431
|
+
{
|
|
1432
|
+
x: num(tab.titleX),
|
|
1433
|
+
y: num(labelY),
|
|
1434
|
+
'font-family': FONT_STACK[s.style.font],
|
|
1435
|
+
'font-size': 12,
|
|
1436
|
+
'font-weight': 600,
|
|
1437
|
+
fill: tabText,
|
|
1438
|
+
},
|
|
1439
|
+
s.title,
|
|
1440
|
+
),
|
|
1441
|
+
);
|
|
1442
|
+
if (s.owner) {
|
|
1443
|
+
parts.push(
|
|
1444
|
+
textTag(
|
|
1445
|
+
{
|
|
1446
|
+
x: num(tab.ownerX),
|
|
1447
|
+
y: num(labelY),
|
|
1448
|
+
'font-family': FONT_STACK[s.style.font],
|
|
1449
|
+
'font-size': 10,
|
|
1450
|
+
fill: ownerText,
|
|
1451
|
+
},
|
|
1452
|
+
`owner: ${s.owner}`,
|
|
1453
|
+
),
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
if (s.capacity) {
|
|
1457
|
+
// Re-uses the same `renderCapacitySuffix` helper that paints
|
|
1458
|
+
// item-level suffixes (m6) so multiplier / built-in SVG /
|
|
1459
|
+
// inline literal / dereferenced-custom-glyph paths stay
|
|
1460
|
+
// consistent across both contexts.
|
|
1461
|
+
parts.push(
|
|
1462
|
+
renderCapacitySuffix(
|
|
1463
|
+
s.capacity,
|
|
1464
|
+
undefined,
|
|
1465
|
+
tab.badgeX,
|
|
1466
|
+
labelY,
|
|
1467
|
+
LANE_BADGE_FONT_SIZE_PX,
|
|
1468
|
+
FONT_STACK[s.style.font],
|
|
1469
|
+
ownerText,
|
|
1470
|
+
),
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
if (footnoteIndicatorText) {
|
|
1474
|
+
parts.push(
|
|
1475
|
+
textTag(
|
|
1476
|
+
{
|
|
1477
|
+
x: num(tab.footnoteRightX),
|
|
1478
|
+
y: num(tabY + 14),
|
|
1479
|
+
'font-family': FONT_STACK.sans,
|
|
1480
|
+
'font-size': LANE_BADGE_FONT_SIZE_PX,
|
|
1481
|
+
'font-weight': 700,
|
|
1482
|
+
fill: footnoteColor,
|
|
1483
|
+
'text-anchor': 'end',
|
|
1484
|
+
},
|
|
1485
|
+
footnoteIndicatorText,
|
|
1486
|
+
),
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
for (const c of s.children) {
|
|
1491
|
+
parts.push(renderTrackChild(c, options, idPrefix, palette));
|
|
1492
|
+
}
|
|
1493
|
+
// m13: tri-state utilization underline along the band's bottom edge.
|
|
1494
|
+
// Painted after items so it overlays any item that happens to extend
|
|
1495
|
+
// to the band's bottom; under cut-lines / now-line which run as
|
|
1496
|
+
// separate top-level passes.
|
|
1497
|
+
parts.push(renderLaneUtilization(s, palette));
|
|
1498
|
+
return tag('g', { 'data-layer': 'swimlane', 'data-id': s.id ?? null }, parts.join(''));
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function renderAnchor(a: PositionedAnchor, palette: Theme): string {
|
|
1502
|
+
const size = a.radius;
|
|
1503
|
+
const cx = a.center.x;
|
|
1504
|
+
const cy = a.center.y;
|
|
1505
|
+
const fill = palette.anchorDiamond.fill;
|
|
1506
|
+
const stroke = palette.anchorDiamond.stroke;
|
|
1507
|
+
const diamond = tag('path', {
|
|
1508
|
+
d: `M${num(cx)} ${num(cy - size)} L${num(cx + size)} ${num(cy)} L${num(cx)} ${num(cy + size)} L${num(cx - size)} ${num(cy)} Z`,
|
|
1509
|
+
fill,
|
|
1510
|
+
stroke,
|
|
1511
|
+
'stroke-width': 1.25,
|
|
1512
|
+
});
|
|
1513
|
+
const labelColor = palette.anchorDiamond.label;
|
|
1514
|
+
// For left-flipped labels, anchor the text at its RIGHT edge using
|
|
1515
|
+
// `text-anchor: end`. The layout's `labelBox.width` is intentionally
|
|
1516
|
+
// pessimistic (0.58 em/char) so positioning by the box's left edge
|
|
1517
|
+
// would leave a visible gap between the actual text right edge and
|
|
1518
|
+
// the diamond. End-anchoring lets the browser size the glyph run
|
|
1519
|
+
// exactly and put the rightmost glyph 6 px from the diamond — same
|
|
1520
|
+
// rhythm the right-side labels already get from start-anchoring at
|
|
1521
|
+
// `diamondRight + 6`.
|
|
1522
|
+
const labelX = a.labelSide === 'left' ? a.labelBox.x + a.labelBox.width : a.labelBox.x;
|
|
1523
|
+
const labelAttrs: Record<string, string | number | null | undefined> = {
|
|
1524
|
+
x: num(labelX),
|
|
1525
|
+
y: num(cy + 4),
|
|
1526
|
+
'font-family': FONT_STACK.sans,
|
|
1527
|
+
'font-size': 10,
|
|
1528
|
+
fill: labelColor,
|
|
1529
|
+
};
|
|
1530
|
+
if (a.labelSide === 'left') labelAttrs['text-anchor'] = 'end';
|
|
1531
|
+
const label = a.title ? textTag(labelAttrs, a.title) : '';
|
|
1532
|
+
return tag('g', { 'data-layer': 'anchor', 'data-id': a.id ?? null }, diamond + label);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function renderAnchorCutLine(a: PositionedAnchor, palette: Theme): string {
|
|
1536
|
+
const stroke = palette.anchorDiamond.cutLine;
|
|
1537
|
+
return tag('line', {
|
|
1538
|
+
x1: num(a.center.x),
|
|
1539
|
+
y1: num(a.center.y + a.radius + 1),
|
|
1540
|
+
x2: num(a.center.x),
|
|
1541
|
+
y2: num(a.cutBottomY),
|
|
1542
|
+
stroke,
|
|
1543
|
+
'stroke-width': 1,
|
|
1544
|
+
'stroke-dasharray': '1 3',
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function renderMilestone(m: PositionedMilestone, palette: Theme): string {
|
|
1549
|
+
const cx = m.center.x;
|
|
1550
|
+
const cy = m.center.y;
|
|
1551
|
+
const r = m.radius;
|
|
1552
|
+
const fill = palette.milestoneDiamond.fill;
|
|
1553
|
+
const flag = tag('path', {
|
|
1554
|
+
d: `M${num(cx)} ${num(cy - r)} L${num(cx + r)} ${num(cy)} L${num(cx)} ${num(cy + r)} L${num(cx - r)} ${num(cy)} Z`,
|
|
1555
|
+
fill,
|
|
1556
|
+
stroke: fill,
|
|
1557
|
+
'stroke-width': 1,
|
|
1558
|
+
});
|
|
1559
|
+
const labelColor = palette.milestoneDiamond.label;
|
|
1560
|
+
// See renderAnchor — left-flipped labels use `text-anchor: end` so
|
|
1561
|
+
// the visual right edge sits at `diamondLeft - 6`, matching the
|
|
1562
|
+
// 6 px rhythm of right-side labels.
|
|
1563
|
+
const labelX = m.labelSide === 'left' ? m.labelBox.x + m.labelBox.width : m.labelBox.x;
|
|
1564
|
+
const labelAttrs: Record<string, string | number | null | undefined> = {
|
|
1565
|
+
x: num(labelX),
|
|
1566
|
+
y: num(cy + 4),
|
|
1567
|
+
'font-family': FONT_STACK.sans,
|
|
1568
|
+
'font-size': 10,
|
|
1569
|
+
'font-weight': 600,
|
|
1570
|
+
fill: labelColor,
|
|
1571
|
+
};
|
|
1572
|
+
if (m.labelSide === 'left') labelAttrs['text-anchor'] = 'end';
|
|
1573
|
+
const label = m.title ? textTag(labelAttrs, m.title) : '';
|
|
1574
|
+
return tag('g', { 'data-layer': 'milestone', 'data-id': m.id ?? null }, flag + label);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
function renderMilestoneCutLine(m: PositionedMilestone, palette: Theme): string {
|
|
1578
|
+
const stroke = m.isOverrun
|
|
1579
|
+
? palette.milestoneDiamond.cutLineOverrun
|
|
1580
|
+
: palette.milestoneDiamond.cutLineNormal;
|
|
1581
|
+
const parts: string[] = [];
|
|
1582
|
+
parts.push(
|
|
1583
|
+
tag('line', {
|
|
1584
|
+
x1: num(m.center.x),
|
|
1585
|
+
y1: num(m.center.y + m.radius + 1),
|
|
1586
|
+
x2: num(m.center.x),
|
|
1587
|
+
y2: num(m.cutBottomY),
|
|
1588
|
+
stroke,
|
|
1589
|
+
'stroke-width': 2,
|
|
1590
|
+
'stroke-dasharray': ACCENT_DASH_PATTERN,
|
|
1591
|
+
'stroke-linecap': 'round',
|
|
1592
|
+
}),
|
|
1593
|
+
);
|
|
1594
|
+
if (m.slackArrows && m.slackArrows.length > 0) {
|
|
1595
|
+
const slackColor = palette.milestoneDiamond.slack;
|
|
1596
|
+
for (const arrow of m.slackArrows) {
|
|
1597
|
+
parts.push(
|
|
1598
|
+
tag('path', {
|
|
1599
|
+
d: `M${num(arrow.x)} ${num(arrow.y)} H${num(m.center.x - 6)}`,
|
|
1600
|
+
fill: 'none',
|
|
1601
|
+
stroke: slackColor,
|
|
1602
|
+
'stroke-width': 1.1,
|
|
1603
|
+
'stroke-dasharray': '3 3',
|
|
1604
|
+
'stroke-linecap': 'round',
|
|
1605
|
+
'marker-end': 'url(#nl-arrow-dark)',
|
|
1606
|
+
}),
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
return parts.join('');
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function renderEdge(e: PositionedDependencyEdge, palette: Theme): string {
|
|
1614
|
+
const color =
|
|
1615
|
+
e.kind === 'overflow' ? palette.dependency.overflowStroke : palette.dependency.edgeStroke;
|
|
1616
|
+
const points = e.waypoints;
|
|
1617
|
+
if (points.length < 2) return '';
|
|
1618
|
+
// Under-bar edges paint BEFORE item fills (see `renderRoadmap`)
|
|
1619
|
+
// and use a thinner stroke so the bar stays foreground. Normal /
|
|
1620
|
+
// overflow edges sit on top of items and use the standard 1.1 px
|
|
1621
|
+
// stroke.
|
|
1622
|
+
const strokeWidth = e.kind === 'underBar' ? 0.8 : 1.1;
|
|
1623
|
+
return tag('path', {
|
|
1624
|
+
d: roundedOrthogonalPath(points, EDGE_CORNER_RADIUS),
|
|
1625
|
+
fill: 'none',
|
|
1626
|
+
stroke: color,
|
|
1627
|
+
'stroke-width': strokeWidth,
|
|
1628
|
+
'stroke-dasharray': e.kind === 'overflow' ? '4 2' : null,
|
|
1629
|
+
'stroke-linejoin': 'round',
|
|
1630
|
+
'marker-end': 'url(#nl-arrow)',
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// Build an SVG path for a sequence of orthogonal waypoints, inserting a
|
|
1635
|
+
// quarter-arc at every interior bend. Falls back to straight segments when
|
|
1636
|
+
// adjacent points aren't axis-aligned (defensive — the layout always emits
|
|
1637
|
+
// orthogonal segments).
|
|
1638
|
+
function roundedOrthogonalPath(points: Point[], radius: number): string {
|
|
1639
|
+
if (points.length < 2) return '';
|
|
1640
|
+
const parts: string[] = [`M${num(points[0].x)} ${num(points[0].y)}`];
|
|
1641
|
+
for (let i = 1; i < points.length; i++) {
|
|
1642
|
+
const prev = points[i - 1];
|
|
1643
|
+
const cur = points[i];
|
|
1644
|
+
if (i === points.length - 1) {
|
|
1645
|
+
parts.push(`L${num(cur.x)} ${num(cur.y)}`);
|
|
1646
|
+
continue;
|
|
1647
|
+
}
|
|
1648
|
+
const next = points[i + 1];
|
|
1649
|
+
const dxIn = Math.sign(cur.x - prev.x);
|
|
1650
|
+
const dyIn = Math.sign(cur.y - prev.y);
|
|
1651
|
+
const dxOut = Math.sign(next.x - cur.x);
|
|
1652
|
+
const dyOut = Math.sign(next.y - cur.y);
|
|
1653
|
+
const inLen = Math.hypot(cur.x - prev.x, cur.y - prev.y);
|
|
1654
|
+
const outLen = Math.hypot(next.x - cur.x, next.y - cur.y);
|
|
1655
|
+
const r = Math.min(radius, inLen / 2, outLen / 2);
|
|
1656
|
+
if (r <= 0 || (dxIn !== 0 && dxOut !== 0) || (dyIn !== 0 && dyOut !== 0)) {
|
|
1657
|
+
parts.push(`L${num(cur.x)} ${num(cur.y)}`);
|
|
1658
|
+
continue;
|
|
1659
|
+
}
|
|
1660
|
+
const beforeBend = { x: cur.x - dxIn * r, y: cur.y - dyIn * r };
|
|
1661
|
+
const afterBend = { x: cur.x + dxOut * r, y: cur.y + dyOut * r };
|
|
1662
|
+
parts.push(`L${num(beforeBend.x)} ${num(beforeBend.y)}`);
|
|
1663
|
+
parts.push(`Q${num(cur.x)} ${num(cur.y)} ${num(afterBend.x)} ${num(afterBend.y)}`);
|
|
1664
|
+
}
|
|
1665
|
+
return parts.join(' ');
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
function renderFootnotes(f: PositionedFootnoteArea, idPrefix: string, palette: Theme): string {
|
|
1669
|
+
if (f.entries.length === 0) return '';
|
|
1670
|
+
const panelFill = palette.footnotePanel.fill;
|
|
1671
|
+
const borderColor = palette.footnotePanel.border;
|
|
1672
|
+
const headerColor = palette.footnotePanel.header;
|
|
1673
|
+
const titleColor = palette.footnotePanel.title;
|
|
1674
|
+
const descColor = palette.footnotePanel.description;
|
|
1675
|
+
const numberColor = palette.footnotePanel.number;
|
|
1676
|
+
const parts: string[] = [];
|
|
1677
|
+
parts.push(
|
|
1678
|
+
tag('rect', {
|
|
1679
|
+
x: num(f.box.x),
|
|
1680
|
+
y: num(f.box.y),
|
|
1681
|
+
width: num(f.box.width),
|
|
1682
|
+
height: num(f.box.height),
|
|
1683
|
+
rx: 6,
|
|
1684
|
+
ry: 6,
|
|
1685
|
+
fill: panelFill,
|
|
1686
|
+
stroke: borderColor,
|
|
1687
|
+
'stroke-width': 1,
|
|
1688
|
+
filter: `url(#${idPrefix}-shadow-subtle)`,
|
|
1689
|
+
}),
|
|
1690
|
+
);
|
|
1691
|
+
parts.push(
|
|
1692
|
+
textTag(
|
|
1693
|
+
{
|
|
1694
|
+
x: num(f.box.x + FOOTNOTE_PANEL_PADDING_PX),
|
|
1695
|
+
y: num(f.box.y + FOOTNOTE_HEADER_BASELINE_OFFSET_PX),
|
|
1696
|
+
'font-family': FONT_STACK.sans,
|
|
1697
|
+
'font-size': 12,
|
|
1698
|
+
'font-weight': 700,
|
|
1699
|
+
fill: headerColor,
|
|
1700
|
+
},
|
|
1701
|
+
'Footnotes',
|
|
1702
|
+
),
|
|
1703
|
+
);
|
|
1704
|
+
// First entry baseline = panel-top + header band + one panel padding
|
|
1705
|
+
// (the gap between the header band and the first row).
|
|
1706
|
+
const firstEntryBaselineY = f.box.y + FOOTNOTE_HEADER_HEIGHT_PX + FOOTNOTE_PANEL_PADDING_PX;
|
|
1707
|
+
const numberX = f.box.x + FOOTNOTE_PANEL_PADDING_PX;
|
|
1708
|
+
const titleX = numberX + FOOTNOTE_PANEL_PADDING_PX;
|
|
1709
|
+
f.entries.forEach((e, i) => {
|
|
1710
|
+
const y = firstEntryBaselineY + i * FOOTNOTE_ROW_HEIGHT;
|
|
1711
|
+
parts.push(
|
|
1712
|
+
textTag(
|
|
1713
|
+
{
|
|
1714
|
+
x: num(numberX),
|
|
1715
|
+
y: num(y),
|
|
1716
|
+
'font-family': FONT_STACK.sans,
|
|
1717
|
+
'font-size': 10,
|
|
1718
|
+
'font-weight': 700,
|
|
1719
|
+
fill: numberColor,
|
|
1720
|
+
},
|
|
1721
|
+
String(e.number),
|
|
1722
|
+
),
|
|
1723
|
+
);
|
|
1724
|
+
parts.push(
|
|
1725
|
+
textTag(
|
|
1726
|
+
{
|
|
1727
|
+
x: num(titleX),
|
|
1728
|
+
y: num(y),
|
|
1729
|
+
'font-family': FONT_STACK.sans,
|
|
1730
|
+
'font-size': 11,
|
|
1731
|
+
'font-weight': 600,
|
|
1732
|
+
fill: titleColor,
|
|
1733
|
+
},
|
|
1734
|
+
e.title,
|
|
1735
|
+
),
|
|
1736
|
+
);
|
|
1737
|
+
if (e.description) {
|
|
1738
|
+
parts.push(
|
|
1739
|
+
textTag(
|
|
1740
|
+
{
|
|
1741
|
+
x: num(titleX + Math.max(120, e.title.length * 6)),
|
|
1742
|
+
y: num(y),
|
|
1743
|
+
'font-family': FONT_STACK.sans,
|
|
1744
|
+
'font-size': 11,
|
|
1745
|
+
fill: descColor,
|
|
1746
|
+
},
|
|
1747
|
+
`— ${e.description}`,
|
|
1748
|
+
),
|
|
1749
|
+
);
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
return tag('g', { 'data-layer': 'footnotes' }, parts.join(''));
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function renderIncludeRegion(
|
|
1756
|
+
r: PositionedIncludeRegion,
|
|
1757
|
+
options: RenderOptions,
|
|
1758
|
+
idPrefix: string,
|
|
1759
|
+
palette: Theme,
|
|
1760
|
+
): string {
|
|
1761
|
+
const border = palette.includeRegion.border;
|
|
1762
|
+
const fill = palette.includeRegion.fill;
|
|
1763
|
+
const tabFill = palette.includeRegion.tabFill;
|
|
1764
|
+
const tabStroke = palette.includeRegion.tabStroke;
|
|
1765
|
+
const tabText = palette.includeRegion.tabText;
|
|
1766
|
+
const badgeFill = palette.includeRegion.badgeFill;
|
|
1767
|
+
const badgeStroke = palette.includeRegion.badgeStroke;
|
|
1768
|
+
const badgeText = palette.includeRegion.badgeText;
|
|
1769
|
+
|
|
1770
|
+
const rx = r.box.x + 8;
|
|
1771
|
+
const ry = r.box.y;
|
|
1772
|
+
const rw = r.box.width - 16;
|
|
1773
|
+
const rh = r.box.height;
|
|
1774
|
+
|
|
1775
|
+
const region = tag('rect', {
|
|
1776
|
+
x: num(rx),
|
|
1777
|
+
y: num(ry),
|
|
1778
|
+
width: num(rw),
|
|
1779
|
+
height: num(rh),
|
|
1780
|
+
rx: 8,
|
|
1781
|
+
ry: 8,
|
|
1782
|
+
fill,
|
|
1783
|
+
stroke: border,
|
|
1784
|
+
'stroke-width': 1,
|
|
1785
|
+
'stroke-dasharray': ACCENT_DASH_PATTERN,
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
// Chrome geometry — single source of truth shared with the layout
|
|
1789
|
+
// (`buildIncludeRegions` calls the same helper to size the dashed
|
|
1790
|
+
// bracket so the chrome always fits inside it). All placement Xs
|
|
1791
|
+
// come from the helper directly so the renderer stays declarative.
|
|
1792
|
+
const tabHeight = FRAME_TAB_HEIGHT_PX;
|
|
1793
|
+
const chrome = includeChromeGeometry(r.box.x, r.label, r.sourcePath);
|
|
1794
|
+
const tabY = ry - tabHeight / 2;
|
|
1795
|
+
const tab = tag('rect', {
|
|
1796
|
+
x: num(chrome.tabX),
|
|
1797
|
+
y: num(tabY),
|
|
1798
|
+
width: num(chrome.tabWidth),
|
|
1799
|
+
height: tabHeight,
|
|
1800
|
+
rx: 4,
|
|
1801
|
+
ry: 4,
|
|
1802
|
+
fill: tabFill,
|
|
1803
|
+
stroke: tabStroke,
|
|
1804
|
+
'stroke-width': 1,
|
|
1805
|
+
});
|
|
1806
|
+
const tabLabel = textTag(
|
|
1807
|
+
{
|
|
1808
|
+
x: num(chrome.tabLabelX),
|
|
1809
|
+
y: num(tabY + FRAME_TAB_LABEL_BASELINE_OFFSET_PX),
|
|
1810
|
+
'font-family': FONT_STACK.sans,
|
|
1811
|
+
'font-size': 11,
|
|
1812
|
+
'font-weight': 600,
|
|
1813
|
+
fill: tabText,
|
|
1814
|
+
},
|
|
1815
|
+
r.label,
|
|
1816
|
+
);
|
|
1817
|
+
|
|
1818
|
+
// Content badge to the right of the tab. The glyph here is the
|
|
1819
|
+
// stacked-sheets icon, distinct from the item-level link-icon
|
|
1820
|
+
// outbound-arrow: an `include` is a content pull (one document
|
|
1821
|
+
// brings in another), conceptually different from a `link:` that
|
|
1822
|
+
// navigates somewhere.
|
|
1823
|
+
const badgeX = chrome.badgeX;
|
|
1824
|
+
const badgeSize = chrome.badgeSize;
|
|
1825
|
+
const badgeY = ry - badgeSize / 2;
|
|
1826
|
+
const badge = tag('rect', {
|
|
1827
|
+
x: num(badgeX),
|
|
1828
|
+
y: num(badgeY),
|
|
1829
|
+
width: badgeSize,
|
|
1830
|
+
height: badgeSize,
|
|
1831
|
+
rx: 4,
|
|
1832
|
+
ry: 4,
|
|
1833
|
+
fill: badgeFill,
|
|
1834
|
+
stroke: badgeStroke,
|
|
1835
|
+
'stroke-width': 1,
|
|
1836
|
+
});
|
|
1837
|
+
// Glyph: stacked sheets — back rectangle peeking behind front
|
|
1838
|
+
// rectangle. Sized for the 18×18 badge tile.
|
|
1839
|
+
const glyph = tag('path', {
|
|
1840
|
+
d:
|
|
1841
|
+
`M${num(badgeX + 7)} ${num(badgeY + 4)} H${num(badgeX + 14)} V${num(badgeY + 11)}` +
|
|
1842
|
+
` M${num(badgeX + 4)} ${num(badgeY + 7)} H${num(badgeX + 11)} V${num(badgeY + 14)} H${num(badgeX + 4)} Z`,
|
|
1843
|
+
stroke: badgeText,
|
|
1844
|
+
'stroke-width': 1.4,
|
|
1845
|
+
fill: 'none',
|
|
1846
|
+
'stroke-linecap': 'round',
|
|
1847
|
+
'stroke-linejoin': 'round',
|
|
1848
|
+
});
|
|
1849
|
+
// Halo behind the source-path text. The text's baseline at `ry + 4`
|
|
1850
|
+
// straddles the dashed region border (drawn at y = ry); without a
|
|
1851
|
+
// backing rect the dashed stroke cuts through the text body. Matches
|
|
1852
|
+
// how the tab and badge already mask the border where they cross it.
|
|
1853
|
+
// Fill is `includeRegion.fill` — same cream/tint as the region, near
|
|
1854
|
+
// the canvas surface above, so the halo disappears into both.
|
|
1855
|
+
const sourceFontSize = 9;
|
|
1856
|
+
const sourceTextY = ry + 4;
|
|
1857
|
+
const sourceHalo = tag('rect', {
|
|
1858
|
+
x: num(chrome.sourceHaloX),
|
|
1859
|
+
y: num(sourceTextY - sourceFontSize),
|
|
1860
|
+
width: num(chrome.sourceHaloWidth),
|
|
1861
|
+
height: sourceFontSize + 6,
|
|
1862
|
+
fill,
|
|
1863
|
+
});
|
|
1864
|
+
const sourceText = textTag(
|
|
1865
|
+
{
|
|
1866
|
+
x: num(chrome.sourceTextX),
|
|
1867
|
+
y: num(sourceTextY),
|
|
1868
|
+
'font-family': FONT_STACK.mono,
|
|
1869
|
+
'font-size': sourceFontSize,
|
|
1870
|
+
fill: badgeText,
|
|
1871
|
+
},
|
|
1872
|
+
r.sourcePath,
|
|
1873
|
+
);
|
|
1874
|
+
|
|
1875
|
+
// Nested swimlanes (laid out by buildIncludeRegions against the parent's timeline).
|
|
1876
|
+
const nested = r.nestedSwimlanes
|
|
1877
|
+
.map((s) => renderSwimlane(s, options, idPrefix, palette))
|
|
1878
|
+
.join('');
|
|
1879
|
+
|
|
1880
|
+
return tag(
|
|
1881
|
+
'g',
|
|
1882
|
+
{ 'data-layer': 'include' },
|
|
1883
|
+
region + nested + tab + tabLabel + badge + glyph + sourceHalo + sourceText,
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// Paint the "Powered by nowline" attribution mark inside the
|
|
1888
|
+
// layout-supplied `attributionBox`. The whole mark — prefix text,
|
|
1889
|
+
// "now", red "l" bar, and "ine" — sits inside one <a href> so the
|
|
1890
|
+
// entire string is clickable. Glyph anatomy (positions, widths, scale)
|
|
1891
|
+
// lives in `themes/shared.ts` (`ATTRIBUTION_*`); the layout reserves a
|
|
1892
|
+
// box of exactly that size at canvas-bottom-right.
|
|
1893
|
+
function renderAttributionMark(model: PositionedRoadmap): string {
|
|
1894
|
+
const muted = model.palette.attribution.mark;
|
|
1895
|
+
const accent = model.palette.attribution.link;
|
|
1896
|
+
if (model.swimlanes.length === 0) return '';
|
|
1897
|
+
const tx = model.header.attributionBox.x;
|
|
1898
|
+
const ty = model.header.attributionBox.y;
|
|
1899
|
+
// Both texts share the wordmark's baseline (y = wordmark font size)
|
|
1900
|
+
// so the smaller "Powered by" sits visually above the wordmark's
|
|
1901
|
+
// baseline without bumping the bar's bottom up.
|
|
1902
|
+
const baselineY = ATTRIBUTION_WORDMARK_FONT_SIZE;
|
|
1903
|
+
const inner =
|
|
1904
|
+
textTag(
|
|
1905
|
+
{
|
|
1906
|
+
x: '0',
|
|
1907
|
+
y: baselineY,
|
|
1908
|
+
'font-family': FONT_STACK.sans,
|
|
1909
|
+
'font-size': ATTRIBUTION_PREFIX_FONT_SIZE,
|
|
1910
|
+
'font-weight': 400,
|
|
1911
|
+
fill: muted,
|
|
1912
|
+
},
|
|
1913
|
+
ATTRIBUTION_TEXT,
|
|
1914
|
+
) +
|
|
1915
|
+
textTag(
|
|
1916
|
+
{
|
|
1917
|
+
x: ATTRIBUTION_NOW_LOGICAL_X,
|
|
1918
|
+
y: baselineY,
|
|
1919
|
+
'font-family': FONT_STACK.sans,
|
|
1920
|
+
'font-size': ATTRIBUTION_WORDMARK_FONT_SIZE,
|
|
1921
|
+
'font-weight': 700,
|
|
1922
|
+
fill: muted,
|
|
1923
|
+
},
|
|
1924
|
+
'now',
|
|
1925
|
+
) +
|
|
1926
|
+
tag('rect', {
|
|
1927
|
+
x: ATTRIBUTION_BAR_LOGICAL_X,
|
|
1928
|
+
y: 12,
|
|
1929
|
+
width: ATTRIBUTION_BAR_LOGICAL_WIDTH,
|
|
1930
|
+
height: ATTRIBUTION_WORDMARK_FONT_SIZE,
|
|
1931
|
+
fill: accent,
|
|
1932
|
+
}) +
|
|
1933
|
+
textTag(
|
|
1934
|
+
{
|
|
1935
|
+
x: ATTRIBUTION_INE_LOGICAL_X,
|
|
1936
|
+
y: baselineY,
|
|
1937
|
+
'font-family': FONT_STACK.sans,
|
|
1938
|
+
'font-size': ATTRIBUTION_WORDMARK_FONT_SIZE,
|
|
1939
|
+
'font-weight': 400,
|
|
1940
|
+
fill: muted,
|
|
1941
|
+
},
|
|
1942
|
+
'ine',
|
|
1943
|
+
);
|
|
1944
|
+
const group = tag(
|
|
1945
|
+
'g',
|
|
1946
|
+
{ transform: `translate(${num(tx)} ${num(ty)}) scale(${num(ATTRIBUTION_SCALE)})` },
|
|
1947
|
+
inner,
|
|
1948
|
+
);
|
|
1949
|
+
return tag(
|
|
1950
|
+
'a',
|
|
1951
|
+
{
|
|
1952
|
+
href: ATTRIBUTION_LINK,
|
|
1953
|
+
target: '_blank',
|
|
1954
|
+
rel: 'noopener',
|
|
1955
|
+
'aria-label': 'Powered by nowline',
|
|
1956
|
+
},
|
|
1957
|
+
tag('g', { 'data-layer': 'attribution' }, group),
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
async function embedLogo(
|
|
1962
|
+
logoRef: string,
|
|
1963
|
+
resolver: AssetResolver | undefined,
|
|
1964
|
+
idPrefix: string,
|
|
1965
|
+
options: RenderOptions,
|
|
1966
|
+
x: number,
|
|
1967
|
+
y: number,
|
|
1968
|
+
size: number,
|
|
1969
|
+
): Promise<string> {
|
|
1970
|
+
if (!resolver) return '';
|
|
1971
|
+
let asset: AssetBytes;
|
|
1972
|
+
try {
|
|
1973
|
+
asset = await resolver(logoRef);
|
|
1974
|
+
} catch (err) {
|
|
1975
|
+
const msg = `logo: failed to load ${logoRef}: ${err instanceof Error ? err.message : String(err)}`;
|
|
1976
|
+
if (options.strict) throw err;
|
|
1977
|
+
options.warn?.(msg);
|
|
1978
|
+
return '';
|
|
1979
|
+
}
|
|
1980
|
+
const mime = (asset.mime ?? '').toLowerCase();
|
|
1981
|
+
if (mime === 'image/svg+xml') {
|
|
1982
|
+
const raw = new TextDecoder().decode(asset.bytes);
|
|
1983
|
+
const cleaned = sanitizeSvg(raw, { idPrefix: `${idPrefix}-logo`, onWarn: options.warn });
|
|
1984
|
+
return tag('g', { transform: `translate(${num(x)} ${num(y)})` }, cleaned);
|
|
1985
|
+
}
|
|
1986
|
+
if (
|
|
1987
|
+
mime === 'image/png' ||
|
|
1988
|
+
mime === 'image/jpeg' ||
|
|
1989
|
+
mime === 'image/jpg' ||
|
|
1990
|
+
mime === 'image/webp'
|
|
1991
|
+
) {
|
|
1992
|
+
const b64 = bytesToBase64(asset.bytes);
|
|
1993
|
+
return tag('image', {
|
|
1994
|
+
href: `data:${mime};base64,${b64}`,
|
|
1995
|
+
x: num(x),
|
|
1996
|
+
y: num(y),
|
|
1997
|
+
width: num(size),
|
|
1998
|
+
height: num(size),
|
|
1999
|
+
preserveAspectRatio: 'xMidYMid meet',
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
const msg = `logo: unsupported mime ${mime} for ${logoRef}`;
|
|
2003
|
+
if (options.strict) throw new Error(msg);
|
|
2004
|
+
options.warn?.(msg);
|
|
2005
|
+
return '';
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// Base64 without depending on Node's Buffer (renderer stays browser-safe).
|
|
2009
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
2010
|
+
if (typeof btoa !== 'undefined') {
|
|
2011
|
+
let bin = '';
|
|
2012
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
2013
|
+
return btoa(bin);
|
|
2014
|
+
}
|
|
2015
|
+
// Node fallback without importing Buffer directly; use lazy dynamic require.
|
|
2016
|
+
const g = globalThis as {
|
|
2017
|
+
Buffer?: { from: (b: Uint8Array) => { toString: (enc: string) => string } };
|
|
2018
|
+
};
|
|
2019
|
+
if (g.Buffer) return g.Buffer.from(bytes).toString('base64');
|
|
2020
|
+
throw new Error('renderer: no base64 encoder available');
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
export async function renderSvg(
|
|
2024
|
+
model: PositionedRoadmap,
|
|
2025
|
+
options: RenderOptions = {},
|
|
2026
|
+
): Promise<string> {
|
|
2027
|
+
const ids = new IdGenerator(options.idPrefix ?? 'nl');
|
|
2028
|
+
const idPrefix = ids.next('root');
|
|
2029
|
+
|
|
2030
|
+
const palette = model.palette;
|
|
2031
|
+
const parts: string[] = [];
|
|
2032
|
+
|
|
2033
|
+
// <defs> — shadows + arrowhead markers (palette-driven fills baked in).
|
|
2034
|
+
const arrowFillNeutral = palette.arrowhead.neutral;
|
|
2035
|
+
const arrowFillLight = palette.arrowhead.light;
|
|
2036
|
+
const arrowFillDark = palette.arrowhead.dark;
|
|
2037
|
+
const arrowDef = (id: string, fill: string): string =>
|
|
2038
|
+
`<marker id="${id}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="${fill}"/></marker>`;
|
|
2039
|
+
const defs =
|
|
2040
|
+
`<defs>${allShadowDefs(idPrefix)}` +
|
|
2041
|
+
arrowDef('nl-arrow', arrowFillNeutral) +
|
|
2042
|
+
arrowDef('nl-arrow-light', arrowFillLight) +
|
|
2043
|
+
arrowDef('nl-arrow-dark', arrowFillDark) +
|
|
2044
|
+
`</defs>`;
|
|
2045
|
+
parts.push(defs);
|
|
2046
|
+
|
|
2047
|
+
// Background
|
|
2048
|
+
parts.push(
|
|
2049
|
+
tag('rect', {
|
|
2050
|
+
x: 0,
|
|
2051
|
+
y: 0,
|
|
2052
|
+
width: num(model.width),
|
|
2053
|
+
height: num(model.height),
|
|
2054
|
+
fill: model.backgroundColor,
|
|
2055
|
+
}),
|
|
2056
|
+
);
|
|
2057
|
+
|
|
2058
|
+
// Timeline header strip (panels + date labels). Chart-body grid
|
|
2059
|
+
// lines used to live here too, but they were occluded by the
|
|
2060
|
+
// swimlane background rects emitted later — so the major dotted
|
|
2061
|
+
// and minor grid lines never actually rendered in the chart body.
|
|
2062
|
+
// They now ship as their own layer below.
|
|
2063
|
+
parts.push(renderTimeline(model.timeline, palette));
|
|
2064
|
+
|
|
2065
|
+
// Swimlane backgrounds — emitted as their own pass so the grid
|
|
2066
|
+
// lines can be drawn on top of them, then the swimlane content
|
|
2067
|
+
// (frame tab + items) sits on top of the grid.
|
|
2068
|
+
for (const s of model.swimlanes) parts.push(renderSwimlaneBg(s, palette));
|
|
2069
|
+
|
|
2070
|
+
// Chart-body grid lines (major dotted at every labeled tick, plus
|
|
2071
|
+
// optional faint minor lines when minor-grid is set). Drawn after
|
|
2072
|
+
// swimlane backgrounds so they actually span the chart body, but
|
|
2073
|
+
// before items and overlays so item bars sit cleanly on top.
|
|
2074
|
+
parts.push(renderGridLines(model.timeline, model.chartBox.y, palette));
|
|
2075
|
+
|
|
2076
|
+
// m2g+: under-bar dependency edges go BEFORE swimlane / item
|
|
2077
|
+
// content. The channel router falls back to under-bar routing when
|
|
2078
|
+
// it can't find a clear vertical gutter between source and target;
|
|
2079
|
+
// these edges intentionally cross item bars and need the item fills
|
|
2080
|
+
// painted ON TOP so the bars stay the visual foreground. Renderer
|
|
2081
|
+
// applies a thinner stroke (see `renderEdge`) to further de-emphasise
|
|
2082
|
+
// the arrow body — only the head and stub at the target end stay
|
|
2083
|
+
// crisply visible.
|
|
2084
|
+
for (const e of model.edges) {
|
|
2085
|
+
if (e.kind === 'underBar') parts.push(renderEdge(e, palette));
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
// Swimlane content (frame tabs + items) on top of the grid lines.
|
|
2089
|
+
for (const s of model.swimlanes) parts.push(renderSwimlane(s, options, idPrefix, palette));
|
|
2090
|
+
|
|
2091
|
+
// Include regions (drawn after own swimlanes so the dashed border + tab
|
|
2092
|
+
// overlay the chart, with their own nested swimlanes inside).
|
|
2093
|
+
for (const r of model.includes) parts.push(renderIncludeRegion(r, options, idPrefix, palette));
|
|
2094
|
+
|
|
2095
|
+
// Normal / overflow dependency edges on top of items but below
|
|
2096
|
+
// cut-lines / nowline. Under-bar edges already painted above.
|
|
2097
|
+
for (const e of model.edges) {
|
|
2098
|
+
if (e.kind !== 'underBar') parts.push(renderEdge(e, palette));
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
// Anchor + milestone cut lines drawn AFTER items so they overlay the
|
|
2102
|
+
// swimlane fills.
|
|
2103
|
+
for (const a of model.anchors) parts.push(renderAnchorCutLine(a, palette));
|
|
2104
|
+
for (const m of model.milestones) parts.push(renderMilestoneCutLine(m, palette));
|
|
2105
|
+
|
|
2106
|
+
// Marker-row diamonds + labels.
|
|
2107
|
+
for (const a of model.anchors) parts.push(renderAnchor(a, palette));
|
|
2108
|
+
for (const m of model.milestones) parts.push(renderMilestone(m, palette));
|
|
2109
|
+
|
|
2110
|
+
// Now-line
|
|
2111
|
+
parts.push(renderNowline(model.nowline, palette));
|
|
2112
|
+
|
|
2113
|
+
// Footnotes + header last (always on top)
|
|
2114
|
+
parts.push(renderFootnotes(model.footnotes, idPrefix, palette));
|
|
2115
|
+
parts.push(renderHeader(model.header, idPrefix, palette));
|
|
2116
|
+
parts.push(renderAttributionMark(model));
|
|
2117
|
+
|
|
2118
|
+
// Logo (if header carries one)
|
|
2119
|
+
if (model.header.logo && options.assetResolver) {
|
|
2120
|
+
const logoSvg = await embedLogo(
|
|
2121
|
+
model.header.logo.assetRef ?? '',
|
|
2122
|
+
options.assetResolver,
|
|
2123
|
+
idPrefix,
|
|
2124
|
+
options,
|
|
2125
|
+
model.header.logo.box.x,
|
|
2126
|
+
model.header.logo.box.y,
|
|
2127
|
+
Math.max(model.header.logo.box.width, model.header.logo.box.height),
|
|
2128
|
+
);
|
|
2129
|
+
if (logoSvg) parts.push(logoSvg);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
const svgAttrs = attrs({
|
|
2133
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
2134
|
+
viewBox: `0 0 ${num(model.width)} ${num(model.height)}`,
|
|
2135
|
+
width: num(model.width),
|
|
2136
|
+
height: num(model.height),
|
|
2137
|
+
'data-theme': model.theme,
|
|
2138
|
+
'data-generator': 'nowline',
|
|
2139
|
+
});
|
|
2140
|
+
return `<svg${svgAttrs}>${parts.join('')}</svg>`;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// Exported for tests.
|
|
2144
|
+
export const __internal = {
|
|
2145
|
+
renderItem,
|
|
2146
|
+
renderSwimlane,
|
|
2147
|
+
renderTimeline,
|
|
2148
|
+
renderHeader,
|
|
2149
|
+
renderEdge,
|
|
2150
|
+
};
|
|
2151
|
+
|
|
2152
|
+
// These helpers are kept in the exports table so tsc doesn't prune them.
|
|
2153
|
+
void escAttr;
|
|
2154
|
+
void escText;
|