@nowline/renderer 0.8.6 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/image-size.d.ts +9 -0
- package/dist/image-size.d.ts.map +1 -0
- package/dist/image-size.js +104 -0
- package/dist/image-size.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/logo.d.ts +24 -0
- package/dist/logo.d.ts.map +1 -0
- package/dist/logo.js +60 -0
- package/dist/logo.js.map +1 -0
- package/dist/stage.d.ts +78 -0
- package/dist/stage.d.ts.map +1 -0
- package/dist/stage.js +88 -0
- package/dist/stage.js.map +1 -0
- package/dist/svg/edges.d.ts +3 -0
- package/dist/svg/edges.d.ts.map +1 -0
- package/dist/svg/edges.js +144 -0
- package/dist/svg/edges.js.map +1 -0
- package/dist/svg/footnotes.d.ts +4 -0
- package/dist/svg/footnotes.d.ts.map +1 -0
- package/dist/svg/footnotes.js +81 -0
- package/dist/svg/footnotes.js.map +1 -0
- package/dist/svg/header.d.ts +6 -0
- package/dist/svg/header.d.ts.map +1 -0
- package/dist/svg/header.js +215 -0
- package/dist/svg/header.js.map +1 -0
- package/dist/svg/include-region.d.ts +4 -0
- package/dist/svg/include-region.d.ts.map +1 -0
- package/dist/svg/include-region.js +157 -0
- package/dist/svg/include-region.js.map +1 -0
- package/dist/svg/item.d.ts +36 -0
- package/dist/svg/item.d.ts.map +1 -0
- package/dist/svg/item.js +549 -0
- package/dist/svg/item.js.map +1 -0
- package/dist/svg/markers.d.ts +7 -0
- package/dist/svg/markers.d.ts.map +1 -0
- package/dist/svg/markers.js +151 -0
- package/dist/svg/markers.js.map +1 -0
- package/dist/svg/nowline.d.ts +4 -0
- package/dist/svg/nowline.d.ts.map +1 -0
- package/dist/svg/nowline.js +133 -0
- package/dist/svg/nowline.js.map +1 -0
- package/dist/svg/primitives.d.ts +7 -0
- package/dist/svg/primitives.d.ts.map +1 -0
- package/dist/svg/primitives.js +70 -0
- package/dist/svg/primitives.js.map +1 -0
- package/dist/svg/render.d.ts +13 -7
- package/dist/svg/render.d.ts.map +1 -1
- package/dist/svg/render.js +64 -1701
- package/dist/svg/render.js.map +1 -1
- package/dist/svg/sanitize.d.ts +10 -0
- package/dist/svg/sanitize.d.ts.map +1 -1
- package/dist/svg/sanitize.js +264 -16
- package/dist/svg/sanitize.js.map +1 -1
- package/dist/svg/swimlanes.d.ts +5 -0
- package/dist/svg/swimlanes.d.ts.map +1 -0
- package/dist/svg/swimlanes.js +228 -0
- package/dist/svg/swimlanes.js.map +1 -0
- package/dist/svg/timeline.d.ts +24 -0
- package/dist/svg/timeline.d.ts.map +1 -0
- package/dist/svg/timeline.js +284 -0
- package/dist/svg/timeline.js.map +1 -0
- package/dist/svg/waves.d.ts +62 -0
- package/dist/svg/waves.d.ts.map +1 -0
- package/dist/svg/waves.js +397 -0
- package/dist/svg/waves.js.map +1 -0
- package/package.json +6 -6
- package/src/image-size.ts +113 -0
- package/src/index.ts +10 -1
- package/src/logo.ts +82 -0
- package/src/stage.ts +191 -0
- package/src/svg/edges.ts +154 -0
- package/src/svg/footnotes.ts +115 -0
- package/src/svg/header.ts +285 -0
- package/src/svg/include-region.ts +201 -0
- package/src/svg/item.ts +672 -0
- package/src/svg/markers.ts +178 -0
- package/src/svg/nowline.ts +152 -0
- package/src/svg/primitives.ts +86 -0
- package/src/svg/render.ts +94 -2110
- package/src/svg/sanitize.ts +286 -16
- package/src/svg/swimlanes.ts +317 -0
- package/src/svg/timeline.ts +343 -0
- package/src/svg/waves.ts +502 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// The timeline layer: the tick scale across the top, the vertical grid lines
|
|
2
|
+
// behind the lanes, and the shaded non-working bands with their labels.
|
|
3
|
+
|
|
4
|
+
import type { BoundingBox, PositionedTimelineScale, Theme } from '@nowline/layout';
|
|
5
|
+
import {
|
|
6
|
+
NON_WORKING_FILL_OPACITY,
|
|
7
|
+
TIMELINE_TICK_LABEL_FONT_SIZE_PX,
|
|
8
|
+
timelineTickLabelBaselineOffsetPx,
|
|
9
|
+
} from '@nowline/layout';
|
|
10
|
+
import { intersectBox } from './primitives.js';
|
|
11
|
+
import type { FontFamilies } from './render.js';
|
|
12
|
+
import { num, tag, textTag } from './xml.js';
|
|
13
|
+
|
|
14
|
+
// Renders the chart-body vertical grid lines (major dotted at every
|
|
15
|
+
// labeled tick, plus optional faint minor lines at every tick when
|
|
16
|
+
// `minorGrid` is set). Emitted as its own layer drawn AFTER the
|
|
17
|
+
// swimlane backgrounds so the lines actually span the chart body
|
|
18
|
+
// instead of being occluded — without this layer the lines would only
|
|
19
|
+
// be visible inside the timeline header strip. Grid lines stay BEHIND
|
|
20
|
+
// items, edges, anchor/milestone cuts, and the now-line so item bars
|
|
21
|
+
// sit cleanly on top of the ruled-paper backdrop.
|
|
22
|
+
export function renderGridLines(
|
|
23
|
+
t: PositionedTimelineScale,
|
|
24
|
+
swimlaneTopY: number,
|
|
25
|
+
palette: Theme,
|
|
26
|
+
): string {
|
|
27
|
+
const gridColor = palette.timeline.gridLine;
|
|
28
|
+
const minorGridColor = palette.timeline.minorGridLine;
|
|
29
|
+
// Major lines thread through the FULL timeline strip — they start
|
|
30
|
+
// at the top of the top date-label panel (when present) and run
|
|
31
|
+
// all the way through the bottom date-label panel (when one is
|
|
32
|
+
// mirrored at the chart bottom via `timeline-position:both` or
|
|
33
|
+
// `timeline-position:bottom`). This ties date labels at both ends
|
|
34
|
+
// to their column boundaries.
|
|
35
|
+
//
|
|
36
|
+
// Minor lines stay quieter: they start at the TOP OF THE TOPMOST
|
|
37
|
+
// SWIMLANE (i.e. below the top date panel AND below the marker
|
|
38
|
+
// row, so they don't streak through anchor/milestone diamonds in
|
|
39
|
+
// the header) and stop ABOVE the bottom date panel.
|
|
40
|
+
//
|
|
41
|
+
// Anchor diamonds, milestone markers, and date label text are
|
|
42
|
+
// rendered later in the orchestrator so they sit on top of any
|
|
43
|
+
// crossing line.
|
|
44
|
+
const bottomTickPanelHeight = t.bottomTickPanelHeight ?? 0;
|
|
45
|
+
const hasBottomTickPanel = t.bottomTickPanelY !== undefined && bottomTickPanelHeight > 0;
|
|
46
|
+
const majorTopY = t.tickPanelY;
|
|
47
|
+
const majorBottomY = hasBottomTickPanel
|
|
48
|
+
? t.bottomTickPanelY! + bottomTickPanelHeight
|
|
49
|
+
: t.box.y + t.box.height;
|
|
50
|
+
// Use the topmost swimlane's top edge directly — `markerRow.height`
|
|
51
|
+
// alone misses the small gap (`timelineHeightBudget` slack) between
|
|
52
|
+
// the marker row and the swimlane area, which would leave the minor
|
|
53
|
+
// lines short and floating in dead space above the swimlane.
|
|
54
|
+
const minorTopY = swimlaneTopY;
|
|
55
|
+
const minorBottomY = t.box.y + t.box.height;
|
|
56
|
+
const parts: string[] = [];
|
|
57
|
+
for (const tick of t.ticks) {
|
|
58
|
+
if (tick.major) {
|
|
59
|
+
// Solid major line at every labeled tick — the dominant
|
|
60
|
+
// column boundary, drawn in the stronger gridLine color.
|
|
61
|
+
parts.push(
|
|
62
|
+
tag('line', {
|
|
63
|
+
x1: num(tick.x),
|
|
64
|
+
y1: num(majorTopY),
|
|
65
|
+
x2: num(tick.x),
|
|
66
|
+
y2: num(majorBottomY),
|
|
67
|
+
stroke: gridColor,
|
|
68
|
+
'stroke-width': 1,
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
} else if (t.minorGrid) {
|
|
72
|
+
// Solid faint minor line at every non-major tick boundary.
|
|
73
|
+
// Hierarchy is established by color (lighter than the major)
|
|
74
|
+
// rather than by texture. Skip the very last tick since it
|
|
75
|
+
// has no following column — a line at the chart's right edge
|
|
76
|
+
// just doubles up the chart border.
|
|
77
|
+
if (tick.labelX === undefined) continue;
|
|
78
|
+
parts.push(
|
|
79
|
+
tag('line', {
|
|
80
|
+
x1: num(tick.x),
|
|
81
|
+
y1: num(minorTopY),
|
|
82
|
+
x2: num(tick.x),
|
|
83
|
+
y2: num(minorBottomY),
|
|
84
|
+
stroke: minorGridColor,
|
|
85
|
+
'stroke-width': 1,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Seams where hidden non-working days collapse and no grid line
|
|
91
|
+
// already marks the join (specs/working-calendar.md §7.2): faint and
|
|
92
|
+
// dotted, on the minor line's span. Layout marks which runs get one.
|
|
93
|
+
for (const run of t.nonWorking ?? []) {
|
|
94
|
+
if (!run.seam) continue;
|
|
95
|
+
parts.push(
|
|
96
|
+
tag('line', {
|
|
97
|
+
x1: num(run.x),
|
|
98
|
+
y1: num(minorTopY),
|
|
99
|
+
x2: num(run.x),
|
|
100
|
+
y2: num(minorBottomY),
|
|
101
|
+
stroke: palette.timeline.nonWorkingSeam,
|
|
102
|
+
'stroke-width': 1,
|
|
103
|
+
'stroke-dasharray': '1 3',
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return tag('g', { 'data-layer': 'grid' }, parts.join(''));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Non-working days under `show` (specs/working-calendar.md §7.3): a faint
|
|
112
|
+
* band over each run layout flagged `band`, on the seam / minor-grid span
|
|
113
|
+
* (from the top of the chart body to the timeline bottom), clipped to
|
|
114
|
+
* `clip` when given (an include region's painted rect). Empty under `hide`,
|
|
115
|
+
* where no run has `band`, so that view emits no bytes.
|
|
116
|
+
*/
|
|
117
|
+
export function renderNonWorkingBands(
|
|
118
|
+
t: PositionedTimelineScale,
|
|
119
|
+
chartTopY: number,
|
|
120
|
+
palette: Theme,
|
|
121
|
+
clip?: BoundingBox,
|
|
122
|
+
): string {
|
|
123
|
+
const bottomY = t.box.y + t.box.height;
|
|
124
|
+
const parts: string[] = [];
|
|
125
|
+
for (const run of t.nonWorking ?? []) {
|
|
126
|
+
if (!run.band) continue;
|
|
127
|
+
const span: BoundingBox = {
|
|
128
|
+
x: run.x,
|
|
129
|
+
y: chartTopY,
|
|
130
|
+
width: run.width,
|
|
131
|
+
height: bottomY - chartTopY,
|
|
132
|
+
};
|
|
133
|
+
const box = clip ? intersectBox(span, clip) : span;
|
|
134
|
+
if (!box || box.width <= 0 || box.height <= 0) continue;
|
|
135
|
+
parts.push(
|
|
136
|
+
tag('rect', {
|
|
137
|
+
x: num(box.x),
|
|
138
|
+
y: num(box.y),
|
|
139
|
+
width: num(box.width),
|
|
140
|
+
height: num(box.height),
|
|
141
|
+
fill: palette.timeline.nonWorkingFill,
|
|
142
|
+
'fill-opacity': NON_WORKING_FILL_OPACITY,
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (parts.length === 0) return '';
|
|
147
|
+
return tag('g', { 'data-layer': 'non-working' }, parts.join(''));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Labels of the titled non-working runs and the working exceptions
|
|
152
|
+
* (specs/working-calendar.md §7.4): each one's text in the marker row,
|
|
153
|
+
* placed by the layout like an anchor label. Under `hide` a titled run
|
|
154
|
+
* also draws a dotted cut line at the seam from the label row's centre to
|
|
155
|
+
* the timeline bottom, since the zero-width seam has no band to name. A
|
|
156
|
+
* working exception is text only in either view: a working day removes no
|
|
157
|
+
* time, so it has no cut. Empty when nothing has a label, so a roadmap
|
|
158
|
+
* without one emits no bytes.
|
|
159
|
+
*/
|
|
160
|
+
export function renderNonWorkingLabels(
|
|
161
|
+
t: PositionedTimelineScale,
|
|
162
|
+
palette: Theme,
|
|
163
|
+
fonts: FontFamilies,
|
|
164
|
+
): string {
|
|
165
|
+
const bottomY = t.box.y + t.box.height;
|
|
166
|
+
const parts: string[] = [];
|
|
167
|
+
for (const run of t.nonWorking ?? []) {
|
|
168
|
+
if (run.label === undefined || !run.labelBox) continue;
|
|
169
|
+
if (run.cut) {
|
|
170
|
+
parts.push(
|
|
171
|
+
tag('line', {
|
|
172
|
+
x1: num(run.x),
|
|
173
|
+
y1: num(run.labelBox.y + 4),
|
|
174
|
+
x2: num(run.x),
|
|
175
|
+
y2: num(bottomY),
|
|
176
|
+
stroke: palette.timeline.nonWorkingSeam,
|
|
177
|
+
'stroke-width': 1,
|
|
178
|
+
'stroke-dasharray': '1 3',
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
parts.push(calendarLabelText(run.label, run.labelBox, run.labelSide, palette, fonts));
|
|
183
|
+
}
|
|
184
|
+
for (const exception of t.workingExceptions ?? []) {
|
|
185
|
+
if (!exception.labelBox) continue;
|
|
186
|
+
parts.push(
|
|
187
|
+
calendarLabelText(
|
|
188
|
+
exception.label,
|
|
189
|
+
exception.labelBox,
|
|
190
|
+
exception.labelSide,
|
|
191
|
+
palette,
|
|
192
|
+
fonts,
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (parts.length === 0) return '';
|
|
197
|
+
return tag('g', { 'data-layer': 'non-working-label' }, parts.join(''));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** One calendar label's text in the marker row. */
|
|
201
|
+
function calendarLabelText(
|
|
202
|
+
label: string,
|
|
203
|
+
box: BoundingBox,
|
|
204
|
+
side: 'left' | 'right' | undefined,
|
|
205
|
+
palette: Theme,
|
|
206
|
+
fonts: FontFamilies,
|
|
207
|
+
): string {
|
|
208
|
+
// Left-side labels end-anchor at the box's right edge, as anchor
|
|
209
|
+
// labels do: the box width is a pessimistic estimate.
|
|
210
|
+
const isLeft = side === 'left';
|
|
211
|
+
const centerY = box.y + 4;
|
|
212
|
+
const labelAttrs: Record<string, string | number | null | undefined> = {
|
|
213
|
+
x: num(isLeft ? box.x + box.width : box.x),
|
|
214
|
+
y: num(centerY + 4),
|
|
215
|
+
'font-family': fonts.sans,
|
|
216
|
+
'font-size': 10,
|
|
217
|
+
fill: palette.timeline.labelText,
|
|
218
|
+
};
|
|
219
|
+
if (isLeft) labelAttrs['text-anchor'] = 'end';
|
|
220
|
+
return textTag(labelAttrs, label);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function renderTimeline(
|
|
224
|
+
t: PositionedTimelineScale,
|
|
225
|
+
palette: Theme,
|
|
226
|
+
fonts: FontFamilies,
|
|
227
|
+
): string {
|
|
228
|
+
const panelFill = palette.timeline.panelFill;
|
|
229
|
+
const borderColor = palette.timeline.border;
|
|
230
|
+
const labelColor = palette.timeline.labelText;
|
|
231
|
+
const parts: string[] = [];
|
|
232
|
+
// Header layout from top: now-pill row → tick-label panel → (wave
|
|
233
|
+
// strip) → marker row.
|
|
234
|
+
// The pill row owns its space (no panel rect); the now-line crosses it
|
|
235
|
+
// visually. Marker row is omitted entirely when empty. The top tick
|
|
236
|
+
// panel is also omitted when the roadmap requested
|
|
237
|
+
// `timeline-position:bottom` (height 0). The optional bottom tick
|
|
238
|
+
// panel mirrors the top panel directly above the footnote area.
|
|
239
|
+
const tickPanelY = t.tickPanelY;
|
|
240
|
+
const tickPanelHeight = t.tickPanelHeight;
|
|
241
|
+
const hasTopTickPanel = tickPanelHeight > 0;
|
|
242
|
+
const hasMarkerRow = t.markerRow.height > 0;
|
|
243
|
+
// The wave strip, when there is one, sits between the tick panel and
|
|
244
|
+
// the marker rows (specs/waves.md §8.8); its panel is the wave-strip
|
|
245
|
+
// layer's.
|
|
246
|
+
const markerRowY = t.waveStrip
|
|
247
|
+
? t.waveStrip.y + t.waveStrip.height
|
|
248
|
+
: tickPanelY + tickPanelHeight;
|
|
249
|
+
const markerRowHeight = t.markerRow.height;
|
|
250
|
+
const bottomTickPanelY = t.bottomTickPanelY;
|
|
251
|
+
const bottomTickPanelHeight = t.bottomTickPanelHeight ?? 0;
|
|
252
|
+
const hasBottomTickPanel = bottomTickPanelY !== undefined && bottomTickPanelHeight > 0;
|
|
253
|
+
|
|
254
|
+
if (hasTopTickPanel) {
|
|
255
|
+
parts.push(
|
|
256
|
+
tag('rect', {
|
|
257
|
+
x: num(t.box.x),
|
|
258
|
+
y: num(tickPanelY),
|
|
259
|
+
width: num(t.box.width),
|
|
260
|
+
height: num(tickPanelHeight),
|
|
261
|
+
rx: 4,
|
|
262
|
+
ry: 4,
|
|
263
|
+
fill: panelFill,
|
|
264
|
+
stroke: borderColor,
|
|
265
|
+
'stroke-width': 1,
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (hasMarkerRow) {
|
|
270
|
+
parts.push(
|
|
271
|
+
tag('rect', {
|
|
272
|
+
x: num(t.box.x),
|
|
273
|
+
y: num(markerRowY),
|
|
274
|
+
width: num(t.box.width),
|
|
275
|
+
height: num(markerRowHeight),
|
|
276
|
+
rx: 4,
|
|
277
|
+
ry: 4,
|
|
278
|
+
fill: panelFill,
|
|
279
|
+
stroke: borderColor,
|
|
280
|
+
'stroke-width': 1,
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
if (hasBottomTickPanel) {
|
|
285
|
+
parts.push(
|
|
286
|
+
tag('rect', {
|
|
287
|
+
x: num(t.box.x),
|
|
288
|
+
y: num(bottomTickPanelY!),
|
|
289
|
+
width: num(t.box.width),
|
|
290
|
+
height: num(bottomTickPanelHeight),
|
|
291
|
+
rx: 4,
|
|
292
|
+
ry: 4,
|
|
293
|
+
fill: panelFill,
|
|
294
|
+
stroke: borderColor,
|
|
295
|
+
'stroke-width': 1,
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
// Header-only labels — the chart-body grid lines themselves are
|
|
300
|
+
// emitted by `renderGridLines` after the swimlane backgrounds so
|
|
301
|
+
// they actually span the chart body rather than being occluded.
|
|
302
|
+
for (const tick of t.ticks) {
|
|
303
|
+
if (!tick.major) continue;
|
|
304
|
+
if (!tick.label || tick.labelX === undefined) continue;
|
|
305
|
+
// Label sits at the COLUMN CENTER (tick.labelX), not at the
|
|
306
|
+
// tick boundary. The last tick has no following column → no
|
|
307
|
+
// label.
|
|
308
|
+
if (hasTopTickPanel) {
|
|
309
|
+
parts.push(
|
|
310
|
+
textTag(
|
|
311
|
+
{
|
|
312
|
+
x: num(tick.labelX),
|
|
313
|
+
y: num(tickPanelY + timelineTickLabelBaselineOffsetPx(tickPanelHeight)),
|
|
314
|
+
'font-family': fonts.sans,
|
|
315
|
+
'font-size': TIMELINE_TICK_LABEL_FONT_SIZE_PX,
|
|
316
|
+
fill: labelColor,
|
|
317
|
+
'text-anchor': 'middle',
|
|
318
|
+
},
|
|
319
|
+
tick.label,
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
if (hasBottomTickPanel) {
|
|
324
|
+
parts.push(
|
|
325
|
+
textTag(
|
|
326
|
+
{
|
|
327
|
+
x: num(tick.labelX),
|
|
328
|
+
y: num(
|
|
329
|
+
bottomTickPanelY! +
|
|
330
|
+
timelineTickLabelBaselineOffsetPx(bottomTickPanelHeight),
|
|
331
|
+
),
|
|
332
|
+
'font-family': fonts.sans,
|
|
333
|
+
'font-size': TIMELINE_TICK_LABEL_FONT_SIZE_PX,
|
|
334
|
+
fill: labelColor,
|
|
335
|
+
'text-anchor': 'middle',
|
|
336
|
+
},
|
|
337
|
+
tick.label,
|
|
338
|
+
),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return tag('g', { 'data-layer': 'timeline' }, parts.join(''));
|
|
343
|
+
}
|