@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.
Files changed (86) hide show
  1. package/dist/image-size.d.ts +9 -0
  2. package/dist/image-size.d.ts.map +1 -0
  3. package/dist/image-size.js +104 -0
  4. package/dist/image-size.js.map +1 -0
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/logo.d.ts +24 -0
  10. package/dist/logo.d.ts.map +1 -0
  11. package/dist/logo.js +60 -0
  12. package/dist/logo.js.map +1 -0
  13. package/dist/stage.d.ts +78 -0
  14. package/dist/stage.d.ts.map +1 -0
  15. package/dist/stage.js +88 -0
  16. package/dist/stage.js.map +1 -0
  17. package/dist/svg/edges.d.ts +3 -0
  18. package/dist/svg/edges.d.ts.map +1 -0
  19. package/dist/svg/edges.js +144 -0
  20. package/dist/svg/edges.js.map +1 -0
  21. package/dist/svg/footnotes.d.ts +4 -0
  22. package/dist/svg/footnotes.d.ts.map +1 -0
  23. package/dist/svg/footnotes.js +81 -0
  24. package/dist/svg/footnotes.js.map +1 -0
  25. package/dist/svg/header.d.ts +6 -0
  26. package/dist/svg/header.d.ts.map +1 -0
  27. package/dist/svg/header.js +215 -0
  28. package/dist/svg/header.js.map +1 -0
  29. package/dist/svg/include-region.d.ts +4 -0
  30. package/dist/svg/include-region.d.ts.map +1 -0
  31. package/dist/svg/include-region.js +157 -0
  32. package/dist/svg/include-region.js.map +1 -0
  33. package/dist/svg/item.d.ts +36 -0
  34. package/dist/svg/item.d.ts.map +1 -0
  35. package/dist/svg/item.js +549 -0
  36. package/dist/svg/item.js.map +1 -0
  37. package/dist/svg/markers.d.ts +7 -0
  38. package/dist/svg/markers.d.ts.map +1 -0
  39. package/dist/svg/markers.js +151 -0
  40. package/dist/svg/markers.js.map +1 -0
  41. package/dist/svg/nowline.d.ts +4 -0
  42. package/dist/svg/nowline.d.ts.map +1 -0
  43. package/dist/svg/nowline.js +133 -0
  44. package/dist/svg/nowline.js.map +1 -0
  45. package/dist/svg/primitives.d.ts +7 -0
  46. package/dist/svg/primitives.d.ts.map +1 -0
  47. package/dist/svg/primitives.js +70 -0
  48. package/dist/svg/primitives.js.map +1 -0
  49. package/dist/svg/render.d.ts +13 -7
  50. package/dist/svg/render.d.ts.map +1 -1
  51. package/dist/svg/render.js +64 -1701
  52. package/dist/svg/render.js.map +1 -1
  53. package/dist/svg/sanitize.d.ts +10 -0
  54. package/dist/svg/sanitize.d.ts.map +1 -1
  55. package/dist/svg/sanitize.js +264 -16
  56. package/dist/svg/sanitize.js.map +1 -1
  57. package/dist/svg/swimlanes.d.ts +5 -0
  58. package/dist/svg/swimlanes.d.ts.map +1 -0
  59. package/dist/svg/swimlanes.js +228 -0
  60. package/dist/svg/swimlanes.js.map +1 -0
  61. package/dist/svg/timeline.d.ts +24 -0
  62. package/dist/svg/timeline.d.ts.map +1 -0
  63. package/dist/svg/timeline.js +284 -0
  64. package/dist/svg/timeline.js.map +1 -0
  65. package/dist/svg/waves.d.ts +62 -0
  66. package/dist/svg/waves.d.ts.map +1 -0
  67. package/dist/svg/waves.js +397 -0
  68. package/dist/svg/waves.js.map +1 -0
  69. package/package.json +6 -6
  70. package/src/image-size.ts +113 -0
  71. package/src/index.ts +10 -1
  72. package/src/logo.ts +82 -0
  73. package/src/stage.ts +191 -0
  74. package/src/svg/edges.ts +154 -0
  75. package/src/svg/footnotes.ts +115 -0
  76. package/src/svg/header.ts +285 -0
  77. package/src/svg/include-region.ts +201 -0
  78. package/src/svg/item.ts +672 -0
  79. package/src/svg/markers.ts +178 -0
  80. package/src/svg/nowline.ts +152 -0
  81. package/src/svg/primitives.ts +86 -0
  82. package/src/svg/render.ts +94 -2110
  83. package/src/svg/sanitize.ts +286 -16
  84. package/src/svg/swimlanes.ts +317 -0
  85. package/src/svg/timeline.ts +343 -0
  86. 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
+ }