@nowline/renderer 0.8.6 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/image-size.d.ts +9 -0
- package/dist/image-size.d.ts.map +1 -0
- package/dist/image-size.js +104 -0
- package/dist/image-size.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/logo.d.ts +24 -0
- package/dist/logo.d.ts.map +1 -0
- package/dist/logo.js +60 -0
- package/dist/logo.js.map +1 -0
- package/dist/stage.d.ts +78 -0
- package/dist/stage.d.ts.map +1 -0
- package/dist/stage.js +88 -0
- package/dist/stage.js.map +1 -0
- package/dist/svg/edges.d.ts +3 -0
- package/dist/svg/edges.d.ts.map +1 -0
- package/dist/svg/edges.js +144 -0
- package/dist/svg/edges.js.map +1 -0
- package/dist/svg/footnotes.d.ts +4 -0
- package/dist/svg/footnotes.d.ts.map +1 -0
- package/dist/svg/footnotes.js +81 -0
- package/dist/svg/footnotes.js.map +1 -0
- package/dist/svg/header.d.ts +6 -0
- package/dist/svg/header.d.ts.map +1 -0
- package/dist/svg/header.js +215 -0
- package/dist/svg/header.js.map +1 -0
- package/dist/svg/include-region.d.ts +4 -0
- package/dist/svg/include-region.d.ts.map +1 -0
- package/dist/svg/include-region.js +157 -0
- package/dist/svg/include-region.js.map +1 -0
- package/dist/svg/item.d.ts +36 -0
- package/dist/svg/item.d.ts.map +1 -0
- package/dist/svg/item.js +549 -0
- package/dist/svg/item.js.map +1 -0
- package/dist/svg/markers.d.ts +7 -0
- package/dist/svg/markers.d.ts.map +1 -0
- package/dist/svg/markers.js +151 -0
- package/dist/svg/markers.js.map +1 -0
- package/dist/svg/nowline.d.ts +4 -0
- package/dist/svg/nowline.d.ts.map +1 -0
- package/dist/svg/nowline.js +133 -0
- package/dist/svg/nowline.js.map +1 -0
- package/dist/svg/primitives.d.ts +7 -0
- package/dist/svg/primitives.d.ts.map +1 -0
- package/dist/svg/primitives.js +70 -0
- package/dist/svg/primitives.js.map +1 -0
- package/dist/svg/render.d.ts +13 -7
- package/dist/svg/render.d.ts.map +1 -1
- package/dist/svg/render.js +64 -1701
- package/dist/svg/render.js.map +1 -1
- package/dist/svg/sanitize.d.ts +10 -0
- package/dist/svg/sanitize.d.ts.map +1 -1
- package/dist/svg/sanitize.js +264 -16
- package/dist/svg/sanitize.js.map +1 -1
- package/dist/svg/swimlanes.d.ts +5 -0
- package/dist/svg/swimlanes.d.ts.map +1 -0
- package/dist/svg/swimlanes.js +228 -0
- package/dist/svg/swimlanes.js.map +1 -0
- package/dist/svg/timeline.d.ts +24 -0
- package/dist/svg/timeline.d.ts.map +1 -0
- package/dist/svg/timeline.js +284 -0
- package/dist/svg/timeline.js.map +1 -0
- package/dist/svg/waves.d.ts +62 -0
- package/dist/svg/waves.d.ts.map +1 -0
- package/dist/svg/waves.js +397 -0
- package/dist/svg/waves.js.map +1 -0
- package/package.json +6 -6
- package/src/image-size.ts +113 -0
- package/src/index.ts +10 -1
- package/src/logo.ts +82 -0
- package/src/stage.ts +191 -0
- package/src/svg/edges.ts +154 -0
- package/src/svg/footnotes.ts +115 -0
- package/src/svg/header.ts +285 -0
- package/src/svg/include-region.ts +201 -0
- package/src/svg/item.ts +672 -0
- package/src/svg/markers.ts +178 -0
- package/src/svg/nowline.ts +152 -0
- package/src/svg/primitives.ts +86 -0
- package/src/svg/render.ts +94 -2110
- package/src/svg/sanitize.ts +286 -16
- package/src/svg/swimlanes.ts +317 -0
- package/src/svg/timeline.ts +343 -0
- package/src/svg/waves.ts +502 -0
package/src/svg/item.ts
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
// The item layer: a work item's bar, hatch overlay, progress strip, status dot,
|
|
2
|
+
// inline date pins, caption (title and meta line, inside or spilled), footnote
|
|
3
|
+
// indicators, link icon, overflow tail and label chips, plus the capacity suffix
|
|
4
|
+
// and inline date pins that lanes and containers reuse.
|
|
5
|
+
|
|
6
|
+
import type { InlineDatePin, PositionedCapacity, PositionedItem, Theme } from '@nowline/layout';
|
|
7
|
+
import {
|
|
8
|
+
CORNER_RADIUS_PX,
|
|
9
|
+
ITEM_CAPTION_META_FONT_SIZE_PX,
|
|
10
|
+
ITEM_CAPTION_SPILL_GAP_PX,
|
|
11
|
+
ITEM_CAPTION_TITLE_BASELINE_OFFSET_PX,
|
|
12
|
+
ITEM_CAPTION_TITLE_FONT_SIZE_PX,
|
|
13
|
+
ITEM_CAPTION_TITLE_LINE_HEIGHT_PX,
|
|
14
|
+
ITEM_DECORATION_SPILL_GAP_PX,
|
|
15
|
+
ITEM_FOOTNOTE_INDICATOR_BASELINE_OFFSET_PX,
|
|
16
|
+
ITEM_FOOTNOTE_INDICATOR_FONT_SIZE_PX,
|
|
17
|
+
ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX,
|
|
18
|
+
ITEM_FOOTNOTE_INDICATOR_STEP_PX,
|
|
19
|
+
ITEM_LINK_ICON_INSET_PX,
|
|
20
|
+
ITEM_LINK_ICON_TILE_SIZE_PX,
|
|
21
|
+
ITEM_STATUS_DOT_INSET_RIGHT_PX,
|
|
22
|
+
ITEM_STATUS_DOT_INSET_TOP_PX,
|
|
23
|
+
ITEM_STATUS_DOT_RADIUS_PX,
|
|
24
|
+
itemCaptionInsetX,
|
|
25
|
+
itemCaptionMetaBaselineOffset,
|
|
26
|
+
META_SUFFIX_DX_EM,
|
|
27
|
+
PROGRESS_STRIP_HEIGHT_PX,
|
|
28
|
+
TEXT_SIZE_PX,
|
|
29
|
+
} from '@nowline/layout';
|
|
30
|
+
import { BUILTIN_ICON_SVG, CAPACITY_ICON_SVG } from './icons.js';
|
|
31
|
+
import { fontAttrs, rectFrame } from './primitives.js';
|
|
32
|
+
import type { FontFamilies, RenderOptions } from './render.js';
|
|
33
|
+
import { shadowFilterUrl } from './shadow.js';
|
|
34
|
+
import { hatchOverlay } from './waves.js';
|
|
35
|
+
import { escAttr, escText, num, tag, textTag } from './xml.js';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A built-in capacity icon's x: the run start plus layout's
|
|
39
|
+
* `iconDxTerms`, added one at a time in order so the double is the same
|
|
40
|
+
* as summing the widths onto x in turn.
|
|
41
|
+
*/
|
|
42
|
+
function iconXFrom(x0: number, capacity: PositionedCapacity): number {
|
|
43
|
+
let x = x0;
|
|
44
|
+
for (const term of capacity.iconDxTerms ?? []) x += term;
|
|
45
|
+
return x;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Paint an item / lane capacity suffix starting at `(x0, baselineY)`. The
|
|
50
|
+
* capacity model arrives pre-resolved from layout (`PositionedCapacity`):
|
|
51
|
+
* `text` is the formatted number, `icon` is either `null` (no glyph), a
|
|
52
|
+
* built-in name, or an inline literal string, and `iconDxTerms` places a
|
|
53
|
+
* built-in SVG icon.
|
|
54
|
+
*
|
|
55
|
+
* Three rendering paths:
|
|
56
|
+
*
|
|
57
|
+
* 1. `icon === null` (the resolved `capacity-icon` was `'none'`): paint
|
|
58
|
+
* the bare number as a single `<text>` node.
|
|
59
|
+
* 2. `icon.kind === 'builtin'` and `name === 'multiplier'`: paint
|
|
60
|
+
* `5×` as a single `<text>` node — the multiplication sign is a
|
|
61
|
+
* typographic operator with consistent rendering across system
|
|
62
|
+
* fonts and built-in side bearing, so no `<tspan dx>` separator.
|
|
63
|
+
* 3. `icon.kind === 'builtin'` and `name` ∈ {person, people, points,
|
|
64
|
+
* time}: paint the number as `<text>`, then drop the curated SVG
|
|
65
|
+
* icon at `x0` plus `iconDxTerms` (layout's `capacityIconDxTerms`:
|
|
66
|
+
* the number's estimated width and a `0.25em` gap, sized to one em). The
|
|
67
|
+
* icon's `style="color:..."` propagates through the
|
|
68
|
+
* `currentColor`-bound paths in the icon library.
|
|
69
|
+
* 4. `icon.kind === 'literal'` (inline Unicode literal or
|
|
70
|
+
* dereferenced custom `symbol`): paint number + glyph in a single
|
|
71
|
+
* `<text>`, with a `<tspan dx="0.1em">` separator before the
|
|
72
|
+
* glyph payload.
|
|
73
|
+
*
|
|
74
|
+
* The number always starts the run at `x0`: an item with meta text paints
|
|
75
|
+
* its suffix through `renderItemMetaLine` instead.
|
|
76
|
+
*/
|
|
77
|
+
export function renderCapacitySuffix(
|
|
78
|
+
capacity: PositionedCapacity,
|
|
79
|
+
x0: number,
|
|
80
|
+
baselineY: number,
|
|
81
|
+
fontSize: number,
|
|
82
|
+
fontFamily: string,
|
|
83
|
+
color: string,
|
|
84
|
+
): string {
|
|
85
|
+
const numberX = x0;
|
|
86
|
+
const { text: numberStr, icon } = capacity;
|
|
87
|
+
const baseAttrs = {
|
|
88
|
+
'font-family': fontFamily,
|
|
89
|
+
'font-size': fontSize,
|
|
90
|
+
fill: color,
|
|
91
|
+
} as const;
|
|
92
|
+
|
|
93
|
+
if (!icon) {
|
|
94
|
+
return textTag({ x: num(numberX), y: num(baselineY), ...baseAttrs }, numberStr);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (icon.kind === 'builtin' && icon.name === 'multiplier') {
|
|
98
|
+
return textTag({ x: num(numberX), y: num(baselineY), ...baseAttrs }, `${numberStr}\u00D7`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (icon.kind === 'builtin') {
|
|
102
|
+
const def = CAPACITY_ICON_SVG[icon.name];
|
|
103
|
+
// Render `<text>5</text>` followed by the curated SVG icon. The
|
|
104
|
+
// icon sits at one font-em wide and tall, where layout placed it
|
|
105
|
+
// past the number. Vertical positioning lifts the icon so its
|
|
106
|
+
// visual center sits on the text x-height
|
|
107
|
+
// (`baselineY - fontSize * 0.85`); this matches how Lucide-style
|
|
108
|
+
// outline icons read in inline text.
|
|
109
|
+
const numberSvg = textTag({ x: num(numberX), y: num(baselineY), ...baseAttrs }, numberStr);
|
|
110
|
+
const iconSize = fontSize;
|
|
111
|
+
const iconX = iconXFrom(x0, capacity);
|
|
112
|
+
const iconY = baselineY - fontSize * 0.85;
|
|
113
|
+
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>`;
|
|
114
|
+
return numberSvg + iconSvg;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Literal glyph (inline Unicode literal or dereferenced custom glyph).
|
|
118
|
+
// Single <text> node with the number + a tspan-separated glyph.
|
|
119
|
+
const dx = num(fontSize * 0.1);
|
|
120
|
+
return (
|
|
121
|
+
`<text x="${num(numberX)}" y="${num(baselineY)}" font-family="${escAttr(fontFamily)}"` +
|
|
122
|
+
` font-size="${fontSize}" fill="${escAttr(color)}">` +
|
|
123
|
+
`${escText(numberStr)}<tspan dx="${dx}">${escText(icon.text)}</tspan>` +
|
|
124
|
+
'</text>'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Paint the item meta line (metaText plus optional capacity suffix) as
|
|
130
|
+
* a single SVG fragment. Call sites used to emit metaText and the
|
|
131
|
+
* suffix as two unrelated `<text>` nodes whose horizontal offsets came
|
|
132
|
+
* from `estimateTextWidth` — fine for short metas like `2w`, but
|
|
133
|
+
* the per-character estimate over-reserves for longer captions like
|
|
134
|
+
* `L 1w — 1w remaining`, leaving a visible gap before `2×`.
|
|
135
|
+
*
|
|
136
|
+
* The fix flows the suffix INSIDE the same `<text>` element via
|
|
137
|
+
* `<tspan dx>` whenever the suffix is pure inline text (multiplier,
|
|
138
|
+
* literal glyph, or no glyph). Browsers compute the dx anchor relative
|
|
139
|
+
* to the previously rendered glyph so the gap matches the spec
|
|
140
|
+
* regardless of how wide metaText actually rendered.
|
|
141
|
+
*
|
|
142
|
+
* Built-in SVG icons (person/people/points/time) still need an `<svg>`
|
|
143
|
+
* sibling outside `<text>`, so their number leaves the flow too: layout's
|
|
144
|
+
* `capacity.iconDxTerms` (from `capacityIconDxTerms`) place both the number
|
|
145
|
+
* and the icon, inside the suffix's spill reservation.
|
|
146
|
+
*/
|
|
147
|
+
function renderItemMetaLine(opts: {
|
|
148
|
+
metaText: string | undefined;
|
|
149
|
+
capacity: PositionedCapacity | null;
|
|
150
|
+
x: number;
|
|
151
|
+
baselineY: number;
|
|
152
|
+
fontSize: number;
|
|
153
|
+
fontFamily: string;
|
|
154
|
+
color: string;
|
|
155
|
+
}): string {
|
|
156
|
+
const { metaText, capacity, x, baselineY, fontSize, fontFamily, color } = opts;
|
|
157
|
+
if (!metaText && !capacity) return '';
|
|
158
|
+
const baseAttrs = {
|
|
159
|
+
'font-family': fontFamily,
|
|
160
|
+
'font-size': fontSize,
|
|
161
|
+
fill: color,
|
|
162
|
+
} as const;
|
|
163
|
+
if (!capacity) {
|
|
164
|
+
return textTag({ x: num(x), y: num(baselineY), ...baseAttrs }, metaText!);
|
|
165
|
+
}
|
|
166
|
+
if (!metaText) {
|
|
167
|
+
return renderCapacitySuffix(capacity, x, baselineY, fontSize, fontFamily, color);
|
|
168
|
+
}
|
|
169
|
+
const sepDx = num(fontSize * META_SUFFIX_DX_EM);
|
|
170
|
+
const openText =
|
|
171
|
+
`<text x="${num(x)}" y="${num(baselineY)}" font-family="${escAttr(fontFamily)}"` +
|
|
172
|
+
` font-size="${fontSize}" fill="${escAttr(color)}">`;
|
|
173
|
+
const { text: numberStr, icon } = capacity;
|
|
174
|
+
if (!icon) {
|
|
175
|
+
return (
|
|
176
|
+
openText +
|
|
177
|
+
escText(metaText) +
|
|
178
|
+
`<tspan dx="${sepDx}">${escText(numberStr)}</tspan>` +
|
|
179
|
+
'</text>'
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (icon.kind === 'builtin' && icon.name === 'multiplier') {
|
|
183
|
+
return (
|
|
184
|
+
openText +
|
|
185
|
+
escText(metaText) +
|
|
186
|
+
`<tspan dx="${sepDx}">${escText(numberStr)}\u00D7</tspan>` +
|
|
187
|
+
'</text>'
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (icon.kind === 'literal') {
|
|
191
|
+
const glyphDx = num(fontSize * 0.1);
|
|
192
|
+
return (
|
|
193
|
+
openText +
|
|
194
|
+
escText(metaText) +
|
|
195
|
+
`<tspan dx="${sepDx}">${escText(numberStr)}</tspan>` +
|
|
196
|
+
`<tspan dx="${glyphDx}">${escText(icon.text)}</tspan>` +
|
|
197
|
+
'</text>'
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
// Built-in SVG icon: the number is NOT flowed after metaText, because
|
|
201
|
+
// the icon's x can only come from layout's estimates; a number drawn
|
|
202
|
+
// after the real metaText sits wherever the font put it, and the icon
|
|
203
|
+
// landed on it. Paint metaText, then the number and the icon both where
|
|
204
|
+
// layout's `iconDxTerms` place them, so they agree as on a lane badge.
|
|
205
|
+
const terms = capacity.iconDxTerms ?? [];
|
|
206
|
+
let numberX = x;
|
|
207
|
+
for (const term of terms.slice(0, 2)) numberX += term;
|
|
208
|
+
const def = CAPACITY_ICON_SVG[icon.name];
|
|
209
|
+
const iconSize = fontSize;
|
|
210
|
+
const iconX = iconXFrom(x, capacity);
|
|
211
|
+
const iconY = baselineY - fontSize * 0.85;
|
|
212
|
+
const textPart =
|
|
213
|
+
openText +
|
|
214
|
+
escText(metaText) +
|
|
215
|
+
'</text>' +
|
|
216
|
+
textTag({ x: num(numberX), y: num(baselineY), ...baseAttrs }, numberStr);
|
|
217
|
+
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>`;
|
|
218
|
+
return textPart + iconPart;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Paint one inline-date pin (`after:DATE` / `before:DATE`) as a small
|
|
223
|
+
* `calendar` glyph from the curated icon library. Wrapped in a `<g>`
|
|
224
|
+
* carrying `<title>YYYY-MM-DD</title>` so browsers surface the date as
|
|
225
|
+
* a native hover tooltip; non-interactive exports (PDF / PNG) still
|
|
226
|
+
* carry the date in the title text. Z-order: above the bar fill,
|
|
227
|
+
* alongside status dot and footnote indicators, below dependency
|
|
228
|
+
* arrowheads.
|
|
229
|
+
*
|
|
230
|
+
* Shared by item, group, and parallel render paths so the visual is
|
|
231
|
+
* identical across every entity type.
|
|
232
|
+
*/
|
|
233
|
+
function renderInlineDatePin(pin: InlineDatePin, color: string): string {
|
|
234
|
+
const def = BUILTIN_ICON_SVG.calendar;
|
|
235
|
+
const inner =
|
|
236
|
+
`<svg x="${num(pin.glyphTopLeft.x)}" y="${num(pin.glyphTopLeft.y)}"` +
|
|
237
|
+
` width="${num(pin.glyphSize)}" height="${num(pin.glyphSize)}"` +
|
|
238
|
+
` viewBox="${def.viewBox}" style="color:${escAttr(color)}" aria-hidden="true">${def.body}</svg>`;
|
|
239
|
+
const titleEl = `<title>${escText(pin.isoDate)}</title>`;
|
|
240
|
+
return tag(
|
|
241
|
+
'g',
|
|
242
|
+
{
|
|
243
|
+
'data-layer': 'inline-date-pin',
|
|
244
|
+
'data-side': pin.side,
|
|
245
|
+
'data-date': pin.isoDate,
|
|
246
|
+
'data-spilled': pin.spilled ? 'true' : null,
|
|
247
|
+
},
|
|
248
|
+
titleEl + inner,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function renderInlineDatePins(pins: InlineDatePin[] | undefined, color: string): string {
|
|
253
|
+
if (!pins || pins.length === 0) return '';
|
|
254
|
+
return pins.map((pin) => renderInlineDatePin(pin, color)).join('');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function hasAfterPin(pins: InlineDatePin[] | undefined): boolean {
|
|
258
|
+
return pins?.some((pin) => pin.side === 'after') ?? false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The bar's hover tooltip: the full title when the caption cap at the
|
|
263
|
+
* chart's end truncated it (specs/rendering.md § "Caption cap at the
|
|
264
|
+
* chart's end"), then background work's line (specs/waves.md §9.3).
|
|
265
|
+
*/
|
|
266
|
+
function renderItemTooltip(i: PositionedItem): string {
|
|
267
|
+
const tooltipLines: string[] = [];
|
|
268
|
+
if (i.titleTruncated) tooltipLines.push(i.title);
|
|
269
|
+
if (i.waveTooltip !== undefined) tooltipLines.push(i.waveTooltip);
|
|
270
|
+
if (tooltipLines.length > 0) return `<title>${escText(tooltipLines.join('\n'))}</title>`;
|
|
271
|
+
return '';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The bar rect, plus background work's hatch overlay when the item is one. */
|
|
275
|
+
function renderItemBar(i: PositionedItem, idPrefix: string): string {
|
|
276
|
+
const parts: string[] = [];
|
|
277
|
+
const shadow = shadowFilterUrl(idPrefix, i.style.shadow);
|
|
278
|
+
parts.push(
|
|
279
|
+
rectFrame(i.box.x, i.box.y, i.box.width, i.box.height, i.style, {
|
|
280
|
+
filter: shadow ?? null,
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
// Background work's hatch overlay: a second rect right after the bar
|
|
284
|
+
// rect (which stays the item group's first <rect>), under the progress
|
|
285
|
+
// strip, the status dot and the text.
|
|
286
|
+
if (i.waveRole === 'background' && i.waveHatch) {
|
|
287
|
+
parts.push(hatchOverlay(i.box, i.style, i.waveHatch, idPrefix));
|
|
288
|
+
}
|
|
289
|
+
return parts.join('');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Bottom progress strip along the bottom edge. Height comes from
|
|
294
|
+
* `PROGRESS_STRIP_HEIGHT_PX` so layout's chip placement and the
|
|
295
|
+
* milestone slack-arrow attach Y stay in sync if it's ever bumped.
|
|
296
|
+
*/
|
|
297
|
+
function renderItemProgress(i: PositionedItem): string {
|
|
298
|
+
if (i.progressFraction > 0) {
|
|
299
|
+
const pw = Math.max(0, Math.min(i.box.width, i.box.width * i.progressFraction));
|
|
300
|
+
return tag('rect', {
|
|
301
|
+
x: num(i.box.x),
|
|
302
|
+
y: num(i.box.y + i.box.height - PROGRESS_STRIP_HEIGHT_PX),
|
|
303
|
+
width: num(pw),
|
|
304
|
+
height: PROGRESS_STRIP_HEIGHT_PX,
|
|
305
|
+
fill: i.style.fg,
|
|
306
|
+
opacity: 0.55,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return '';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function renderItemStatusDot(i: PositionedItem): string {
|
|
313
|
+
// Status-dot color — layout resolves it from the bar bg's relative
|
|
314
|
+
// luminance and the item's status (`statusDotColor`, layout's
|
|
315
|
+
// `status-dot.ts`), so the dot reads on pale and dark bars alike.
|
|
316
|
+
//
|
|
317
|
+
// Status dot — upper-right inset inside the bar, OR pushed into
|
|
318
|
+
// the spill column when the bar is too narrow to host the dot's
|
|
319
|
+
// full inset (`dotSpills`). Layout pre-computes `dotSpillCx` for
|
|
320
|
+
// the spilled case so the renderer stays geometry-dumb.
|
|
321
|
+
const dotCx =
|
|
322
|
+
i.dotSpills && i.dotSpillCx !== null
|
|
323
|
+
? i.dotSpillCx
|
|
324
|
+
: i.box.x + i.box.width - ITEM_STATUS_DOT_INSET_RIGHT_PX;
|
|
325
|
+
return tag('circle', {
|
|
326
|
+
cx: num(dotCx),
|
|
327
|
+
cy: num(i.box.y + ITEM_STATUS_DOT_INSET_TOP_PX),
|
|
328
|
+
r: ITEM_STATUS_DOT_RADIUS_PX,
|
|
329
|
+
fill: i.statusDotColor,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Where the title and meta lines start. Title + meta are an atomic caption.
|
|
335
|
+
* When `textSpills` is set the layout has already bumped the next item to a
|
|
336
|
+
* fresh row, so we draw both lines BESIDE the bar (just past its right edge,
|
|
337
|
+
* stacked) at the same vertical positions they would occupy inside. When
|
|
338
|
+
* they fit, both go inside at the bar's left padding.
|
|
339
|
+
*
|
|
340
|
+
* The spilled-decoration cluster reads `[bar] [icon?] [title]
|
|
341
|
+
* [footnote?] [dot?]` — the only decoration to the LEFT of the
|
|
342
|
+
* title is the link icon (the icon→title affordance must stay
|
|
343
|
+
* adjacent). The dot trails the title to mirror its in-bar
|
|
344
|
+
* upper-right position; the footnote walks alongside the title
|
|
345
|
+
* (between title and dot) just like its in-bar `text-anchor: end`
|
|
346
|
+
* placement at the upper-right.
|
|
347
|
+
*/
|
|
348
|
+
function itemCaptionX(i: PositionedItem, options: RenderOptions): number {
|
|
349
|
+
let captionX: number;
|
|
350
|
+
|
|
351
|
+
if (i.textSpills) {
|
|
352
|
+
captionX = i.box.x + i.box.width + ITEM_CAPTION_SPILL_GAP_PX;
|
|
353
|
+
if (i.iconSpills) {
|
|
354
|
+
captionX += ITEM_LINK_ICON_TILE_SIZE_PX + ITEM_DECORATION_SPILL_GAP_PX;
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
// Same inset layout used to size the wrap: 12px, 24px past a
|
|
358
|
+
// link-icon tile (spanning `box.x + 6 .. box.x + 20`), and past an
|
|
359
|
+
// in-bar `after:` glyph too (22px, or 40px beside the link tile).
|
|
360
|
+
// `linkIcon` is the string 'none' (truthy) for an item without a
|
|
361
|
+
// `link:`, and `noLinks` omits the tile entirely, so only indent
|
|
362
|
+
// for the tile when it is really drawn. An icon that spilled out
|
|
363
|
+
// of the bar forces the caption to spill too, so that case never
|
|
364
|
+
// reaches here.
|
|
365
|
+
const hasLink = !!i.linkIcon && i.linkIcon !== 'none';
|
|
366
|
+
const afterPin = i.inlineDatePins?.find((p) => p.side === 'after' && !p.spilled);
|
|
367
|
+
// Layout placed an `after:` glyph beside the link tile whether or
|
|
368
|
+
// not `noLinks` hides it, so the glyph's clearance keys off the
|
|
369
|
+
// `link:` itself rather than off the tile being drawn.
|
|
370
|
+
const insetX = afterPin
|
|
371
|
+
? itemCaptionInsetX(hasLink, { barWidth: i.box.width })
|
|
372
|
+
: itemCaptionInsetX(!options.noLinks && hasLink && !i.iconSpills);
|
|
373
|
+
captionX = i.box.x + insetX;
|
|
374
|
+
}
|
|
375
|
+
return captionX;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The title lines and the meta line (with its capacity suffix). */
|
|
379
|
+
function renderItemCaption(
|
|
380
|
+
i: PositionedItem,
|
|
381
|
+
captionX: number,
|
|
382
|
+
palette: Theme,
|
|
383
|
+
fonts: FontFamilies,
|
|
384
|
+
): string {
|
|
385
|
+
const parts: string[] = [];
|
|
386
|
+
|
|
387
|
+
// When the caption spills outside the bar it renders on the
|
|
388
|
+
// chart / group bg instead of the bar fill — `i.style.text` is
|
|
389
|
+
// resolved against the bar (e.g. `enterprise-style` propagates
|
|
390
|
+
// `text:white` from a label and audit-log's title becomes
|
|
391
|
+
// white-on-blue inside, but white-on-peach when spilled onto
|
|
392
|
+
// the orange-tinted audit-track group). Use the theme's
|
|
393
|
+
// default item text color (always tuned for chart bg) when
|
|
394
|
+
// text spills, and the per-bar color when it stays inside.
|
|
395
|
+
const captionInsideTextColor = i.style.text;
|
|
396
|
+
const captionOutsideTextColor = palette.entities.item.text;
|
|
397
|
+
const titleColor = i.textSpills ? captionOutsideTextColor : captionInsideTextColor;
|
|
398
|
+
const metaColor = i.textSpills ? captionOutsideTextColor : i.style.fg;
|
|
399
|
+
// A title that auto-wrapped inside the bar, or that carries explicit
|
|
400
|
+
// `\n` breaks (in-bar or spilled), arrives pre-split in `titleLines`;
|
|
401
|
+
// paint one `<text>` per line at the same baselines either way, the
|
|
402
|
+
// same pattern as the header card, with the meta line below the last
|
|
403
|
+
// one. Everything else (a single-line title) is just `[title]`. A blank
|
|
404
|
+
// line between two explicit breaks keeps its baseline slot but paints
|
|
405
|
+
// nothing.
|
|
406
|
+
const titleLines = i.titleLines ?? (i.title ? [i.title] : []);
|
|
407
|
+
titleLines.forEach((line, n) => {
|
|
408
|
+
if (line === '') return;
|
|
409
|
+
parts.push(
|
|
410
|
+
textTag(
|
|
411
|
+
{
|
|
412
|
+
x: num(captionX),
|
|
413
|
+
y: num(
|
|
414
|
+
i.box.y +
|
|
415
|
+
ITEM_CAPTION_TITLE_BASELINE_OFFSET_PX +
|
|
416
|
+
n * ITEM_CAPTION_TITLE_LINE_HEIGHT_PX,
|
|
417
|
+
),
|
|
418
|
+
'font-family': fonts[i.style.font],
|
|
419
|
+
'font-size': ITEM_CAPTION_TITLE_FONT_SIZE_PX,
|
|
420
|
+
'font-weight': 600,
|
|
421
|
+
fill: titleColor,
|
|
422
|
+
},
|
|
423
|
+
line,
|
|
424
|
+
),
|
|
425
|
+
);
|
|
426
|
+
});
|
|
427
|
+
// Meta line and capacity suffix render as a unified SVG fragment.
|
|
428
|
+
// For pure-text suffixes (multiplier, literal glyph, no glyph) the
|
|
429
|
+
// unified path emits a single `<text>` element with `<tspan dx>`
|
|
430
|
+
// for the gap so the suffix hugs the metaText regardless of how
|
|
431
|
+
// wide it actually rendered. Built-in SVG icon glyphs emit their
|
|
432
|
+
// number and a sibling `<svg>` where layout placed them. See
|
|
433
|
+
// `renderItemMetaLine` for the full case-by-case.
|
|
434
|
+
if (i.metaText || i.capacity) {
|
|
435
|
+
parts.push(
|
|
436
|
+
renderItemMetaLine({
|
|
437
|
+
metaText: i.metaText,
|
|
438
|
+
capacity: i.capacity,
|
|
439
|
+
x: captionX,
|
|
440
|
+
baselineY: i.box.y + itemCaptionMetaBaselineOffset(titleLines.length),
|
|
441
|
+
fontSize: ITEM_CAPTION_META_FONT_SIZE_PX,
|
|
442
|
+
fontFamily: fonts[i.style.font],
|
|
443
|
+
color: metaColor,
|
|
444
|
+
}),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
return parts.join('');
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Footnote superscript indicators, in the bar or spilled beside it. */
|
|
451
|
+
function renderItemFootnoteIndicators(
|
|
452
|
+
i: PositionedItem,
|
|
453
|
+
palette: Theme,
|
|
454
|
+
fonts: FontFamilies,
|
|
455
|
+
): string {
|
|
456
|
+
const parts: string[] = [];
|
|
457
|
+
const captionOutsideTextColor = palette.entities.item.text;
|
|
458
|
+
|
|
459
|
+
// Footnote superscript indicators. Two render modes:
|
|
460
|
+
// - In-bar (default): glyphs walk LEFT from
|
|
461
|
+
// `bar.right - ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX`,
|
|
462
|
+
// anchored end. They sit on the bar fill, so use the bar's
|
|
463
|
+
// resolved text color for contrast (a hardcoded red was
|
|
464
|
+
// getting lost on saturated mid-tone bars from `bg:blue`
|
|
465
|
+
// labels). The "footnote = red" attention cue lives on the
|
|
466
|
+
// footnote PANEL's red number column at the bottom of the
|
|
467
|
+
// chart where red reads cleanly against white.
|
|
468
|
+
// - Spilled (narrow bars): the glyphs render in the spill
|
|
469
|
+
// column to the right of the bar, walking RIGHT from
|
|
470
|
+
// `footnoteSpillStartX` so they read in the same numerical
|
|
471
|
+
// order as the in-bar case. They sit on the chart bg, so
|
|
472
|
+
// use the chart-tuned default text color (same as spilled
|
|
473
|
+
// captions).
|
|
474
|
+
if (i.footnoteIndicators.length > 0) {
|
|
475
|
+
const footnoteY = i.box.y + ITEM_FOOTNOTE_INDICATOR_BASELINE_OFFSET_PX;
|
|
476
|
+
if (i.footnoteSpills && i.footnoteSpillStartX !== null) {
|
|
477
|
+
let fx = i.footnoteSpillStartX;
|
|
478
|
+
for (let k = 0; k < i.footnoteIndicators.length; k++) {
|
|
479
|
+
const n2 = i.footnoteIndicators[k];
|
|
480
|
+
parts.push(
|
|
481
|
+
textTag(
|
|
482
|
+
{
|
|
483
|
+
x: num(fx),
|
|
484
|
+
y: num(footnoteY),
|
|
485
|
+
'font-family': fonts.sans,
|
|
486
|
+
'font-size': ITEM_FOOTNOTE_INDICATOR_FONT_SIZE_PX,
|
|
487
|
+
'font-weight': 700,
|
|
488
|
+
fill: captionOutsideTextColor,
|
|
489
|
+
},
|
|
490
|
+
String(n2),
|
|
491
|
+
),
|
|
492
|
+
);
|
|
493
|
+
fx += ITEM_FOOTNOTE_INDICATOR_STEP_PX;
|
|
494
|
+
}
|
|
495
|
+
} else {
|
|
496
|
+
let fx = i.box.x + i.box.width - ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX;
|
|
497
|
+
for (let k = i.footnoteIndicators.length - 1; k >= 0; k--) {
|
|
498
|
+
const n2 = i.footnoteIndicators[k];
|
|
499
|
+
parts.push(
|
|
500
|
+
textTag(
|
|
501
|
+
{
|
|
502
|
+
x: num(fx),
|
|
503
|
+
y: num(footnoteY),
|
|
504
|
+
'font-family': fonts.sans,
|
|
505
|
+
'font-size': ITEM_FOOTNOTE_INDICATOR_FONT_SIZE_PX,
|
|
506
|
+
'font-weight': 700,
|
|
507
|
+
fill: i.style.text,
|
|
508
|
+
'text-anchor': 'end',
|
|
509
|
+
},
|
|
510
|
+
String(n2),
|
|
511
|
+
),
|
|
512
|
+
);
|
|
513
|
+
fx -= ITEM_FOOTNOTE_INDICATOR_STEP_PX;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return parts.join('');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function renderItemLinkIcon(i: PositionedItem, options: RenderOptions, palette: Theme): string {
|
|
521
|
+
const parts: string[] = [];
|
|
522
|
+
|
|
523
|
+
// Link icon — colored tile + white external-link glyph. Default
|
|
524
|
+
// position is the bar's UPPER-LEFT corner; on a bar too narrow
|
|
525
|
+
// to host both the icon and the status-dot column with a gap
|
|
526
|
+
// between them, the icon spills out to the right of the bar
|
|
527
|
+
// (in front of the spilled title) so the icon→title affordance
|
|
528
|
+
// stays intact. The glyph is the same outbound-arrow ↗ for
|
|
529
|
+
// every link kind (linear / github / jira / generic) — they
|
|
530
|
+
// only differ in tile color. The include FILE-LEVEL region
|
|
531
|
+
// (`include "./other.nowline"`) uses a separate stacked-sheets
|
|
532
|
+
// glyph rendered by `renderIncludeRegion`, distinct from this
|
|
533
|
+
// item-level link icon.
|
|
534
|
+
if (!options.noLinks && i.linkIcon && i.linkIcon !== 'none') {
|
|
535
|
+
const tileColor: Record<string, string> = {
|
|
536
|
+
linear: '#5e6ad2',
|
|
537
|
+
github: '#0f172a',
|
|
538
|
+
jira: '#0052cc',
|
|
539
|
+
generic: palette.item.linkIconFg,
|
|
540
|
+
};
|
|
541
|
+
const tile = tileColor[i.linkIcon] ?? tileColor.generic;
|
|
542
|
+
const tileSize = ITEM_LINK_ICON_TILE_SIZE_PX;
|
|
543
|
+
const tileX =
|
|
544
|
+
i.iconSpills && i.iconSpillX !== null
|
|
545
|
+
? i.iconSpillX
|
|
546
|
+
: i.box.x + ITEM_LINK_ICON_INSET_PX;
|
|
547
|
+
const tileY = i.box.y + ITEM_LINK_ICON_INSET_PX;
|
|
548
|
+
const tileRect = tag('rect', {
|
|
549
|
+
x: num(tileX),
|
|
550
|
+
y: num(tileY),
|
|
551
|
+
width: tileSize,
|
|
552
|
+
height: tileSize,
|
|
553
|
+
rx: 2,
|
|
554
|
+
ry: 2,
|
|
555
|
+
fill: tile,
|
|
556
|
+
});
|
|
557
|
+
const gx = tileX;
|
|
558
|
+
const gy = tileY;
|
|
559
|
+
const glyph = tag('path', {
|
|
560
|
+
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)}`,
|
|
561
|
+
stroke: '#ffffff',
|
|
562
|
+
fill: 'none',
|
|
563
|
+
'stroke-width': 1.1,
|
|
564
|
+
'stroke-linecap': 'round',
|
|
565
|
+
'stroke-linejoin': 'round',
|
|
566
|
+
});
|
|
567
|
+
const inner = tileRect + glyph;
|
|
568
|
+
const link = i.linkHref
|
|
569
|
+
? tag('a', { href: i.linkHref, target: '_blank', rel: 'noopener' }, inner)
|
|
570
|
+
: inner;
|
|
571
|
+
parts.push(link);
|
|
572
|
+
}
|
|
573
|
+
return parts.join('');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function renderItemOverflowTail(i: PositionedItem, palette: Theme, fonts: FontFamilies): string {
|
|
577
|
+
const parts: string[] = [];
|
|
578
|
+
|
|
579
|
+
// Overflow tail — red fill + stroke + caption.
|
|
580
|
+
if (i.hasOverflow && i.overflowBox) {
|
|
581
|
+
const tailFill = palette.item.overflowTailFill;
|
|
582
|
+
const tailStroke = palette.item.overflowTailStroke;
|
|
583
|
+
const captionColor = palette.item.overflowCaption;
|
|
584
|
+
parts.push(
|
|
585
|
+
tag('rect', {
|
|
586
|
+
x: num(i.overflowBox.x),
|
|
587
|
+
y: num(i.overflowBox.y),
|
|
588
|
+
width: num(i.overflowBox.width),
|
|
589
|
+
height: num(i.overflowBox.height),
|
|
590
|
+
fill: tailFill,
|
|
591
|
+
stroke: tailStroke,
|
|
592
|
+
'stroke-width': 1,
|
|
593
|
+
}),
|
|
594
|
+
);
|
|
595
|
+
if (i.overflowAnchorId && i.overflowBox.width > 60) {
|
|
596
|
+
parts.push(
|
|
597
|
+
textTag(
|
|
598
|
+
{
|
|
599
|
+
x: num(i.overflowBox.x + i.overflowBox.width / 2),
|
|
600
|
+
y: num(i.overflowBox.y + i.overflowBox.height / 2 + 3),
|
|
601
|
+
'font-family': fonts.sans,
|
|
602
|
+
'font-size': 9,
|
|
603
|
+
'font-weight': 700,
|
|
604
|
+
fill: captionColor,
|
|
605
|
+
'text-anchor': 'middle',
|
|
606
|
+
},
|
|
607
|
+
`past ${i.overflowAnchorId}`,
|
|
608
|
+
),
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return parts.join('');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function renderItemLabelChips(i: PositionedItem, fonts: FontFamilies): string {
|
|
616
|
+
const parts: string[] = [];
|
|
617
|
+
|
|
618
|
+
// Label chips
|
|
619
|
+
for (const chip of i.labelChips) {
|
|
620
|
+
const rx = Math.min(CORNER_RADIUS_PX[chip.style.cornerRadius] ?? 8, chip.box.height / 2);
|
|
621
|
+
parts.push(
|
|
622
|
+
tag('rect', {
|
|
623
|
+
x: num(chip.box.x),
|
|
624
|
+
y: num(chip.box.y),
|
|
625
|
+
width: num(chip.box.width),
|
|
626
|
+
height: num(chip.box.height),
|
|
627
|
+
rx: num(rx),
|
|
628
|
+
ry: num(rx),
|
|
629
|
+
fill: chip.style.bg === 'none' ? 'transparent' : chip.style.bg,
|
|
630
|
+
stroke: chip.style.fg,
|
|
631
|
+
'stroke-width': 0.5,
|
|
632
|
+
}),
|
|
633
|
+
);
|
|
634
|
+
parts.push(
|
|
635
|
+
textTag(
|
|
636
|
+
{
|
|
637
|
+
x: num(chip.box.x + chip.box.width / 2),
|
|
638
|
+
y: num(chip.box.y + chip.box.height / 2 + 3),
|
|
639
|
+
...fontAttrs(chip.style, fonts, TEXT_SIZE_PX.xs),
|
|
640
|
+
'text-anchor': 'middle',
|
|
641
|
+
},
|
|
642
|
+
chip.text,
|
|
643
|
+
),
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
return parts.join('');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export function renderItem(
|
|
650
|
+
i: PositionedItem,
|
|
651
|
+
options: RenderOptions,
|
|
652
|
+
idPrefix: string,
|
|
653
|
+
palette: Theme,
|
|
654
|
+
fonts: FontFamilies,
|
|
655
|
+
): string {
|
|
656
|
+
const parts: string[] = [];
|
|
657
|
+
parts.push(renderItemTooltip(i));
|
|
658
|
+
parts.push(renderItemBar(i, idPrefix));
|
|
659
|
+
parts.push(renderItemProgress(i));
|
|
660
|
+
parts.push(renderItemStatusDot(i));
|
|
661
|
+
// Inline-date pins (after:DATE / before:DATE). Same z-order family as
|
|
662
|
+
// the status dot and footnote indicators — small badge in the top
|
|
663
|
+
// decoration row. Color matches the bar's resolved meta (`fg`) so the
|
|
664
|
+
// glyph reads against the bar fill the same way the meta text does.
|
|
665
|
+
parts.push(renderInlineDatePins(i.inlineDatePins, i.style.fg));
|
|
666
|
+
parts.push(renderItemCaption(i, itemCaptionX(i, options), palette, fonts));
|
|
667
|
+
parts.push(renderItemFootnoteIndicators(i, palette, fonts));
|
|
668
|
+
parts.push(renderItemLinkIcon(i, options, palette));
|
|
669
|
+
parts.push(renderItemOverflowTail(i, palette, fonts));
|
|
670
|
+
parts.push(renderItemLabelChips(i, fonts));
|
|
671
|
+
return tag('g', { 'data-layer': 'item', 'data-id': i.id ?? null }, parts.join(''));
|
|
672
|
+
}
|