@opendata-ai/openchart-vanilla 8.0.0-rc.8 → 8.0.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 (147) hide show
  1. package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/{chunk-UZ7J3NOF.js → chunk-6BUCYCLD.js} +7314 -5605
  3. package/dist/chunk-6BUCYCLD.js.map +1 -0
  4. package/dist/chunk-DCODCLWI.js +234 -0
  5. package/dist/chunk-DCODCLWI.js.map +1 -0
  6. package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
  7. package/dist/chunk-EKAUQAAX.js.map +1 -0
  8. package/dist/chunk-RM5XS6NR.js +113 -0
  9. package/dist/chunk-RM5XS6NR.js.map +1 -0
  10. package/dist/chunk-T32UHMZA.js +282 -0
  11. package/dist/chunk-T32UHMZA.js.map +1 -0
  12. package/dist/export-gif.d.ts +113 -0
  13. package/dist/export-gif.js +21 -0
  14. package/dist/export-gif.js.map +1 -0
  15. package/dist/index.d.ts +309 -27
  16. package/dist/index.js +2020 -836
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-CL-nA0f-.d.ts} +85 -2
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.d.ts +2 -0
  21. package/dist/static.js +8 -69
  22. package/dist/static.js.map +1 -1
  23. package/dist/story/index.d.ts +25 -9
  24. package/dist/story/index.js +9 -7
  25. package/dist/story/index.js.map +1 -1
  26. package/dist/styles.css +1 -1
  27. package/dist/styles.css.map +1 -1
  28. package/package.json +14 -6
  29. package/dist/chunk-5EOV5JQZ.js.map +0 -1
  30. package/dist/chunk-UZ7J3NOF.js.map +0 -1
  31. package/src/__test-fixtures__/dom.ts +0 -70
  32. package/src/__test-fixtures__/specs.ts +0 -214
  33. package/src/__tests__/animation.test.ts +0 -358
  34. package/src/__tests__/annotations-render.test.ts +0 -201
  35. package/src/__tests__/compound-labels.test.ts +0 -122
  36. package/src/__tests__/continuous-legend.test.ts +0 -91
  37. package/src/__tests__/crosshair.test.ts +0 -130
  38. package/src/__tests__/edit-events.test.ts +0 -950
  39. package/src/__tests__/events.test.ts +0 -387
  40. package/src/__tests__/export.test.ts +0 -241
  41. package/src/__tests__/font-race.test.ts +0 -199
  42. package/src/__tests__/gradient-ids.test.ts +0 -159
  43. package/src/__tests__/gradient-utils.test.ts +0 -125
  44. package/src/__tests__/known-bugs-render.test.ts +0 -183
  45. package/src/__tests__/map.test.ts +0 -636
  46. package/src/__tests__/mount.test.ts +0 -357
  47. package/src/__tests__/pattern-fills.test.ts +0 -105
  48. package/src/__tests__/resize-timing.test.ts +0 -218
  49. package/src/__tests__/sankey.test.ts +0 -133
  50. package/src/__tests__/selection-events.test.ts +0 -869
  51. package/src/__tests__/series-search.test.ts +0 -311
  52. package/src/__tests__/static.test.ts +0 -141
  53. package/src/__tests__/svg-renderer.test.ts +0 -895
  54. package/src/__tests__/table-keyboard.test.ts +0 -361
  55. package/src/__tests__/table-mount.test.ts +0 -639
  56. package/src/__tests__/tilemap.test.ts +0 -158
  57. package/src/__tests__/tooltip.test.ts +0 -337
  58. package/src/__tests__/transition.test.ts +0 -1792
  59. package/src/__tests__/update-scrolly-matrix.test.ts +0 -563
  60. package/src/__tests__/you-draw-it.test.ts +0 -273
  61. package/src/animation-vars.ts +0 -42
  62. package/src/animation.ts +0 -76
  63. package/src/barlist-mount.ts +0 -383
  64. package/src/barlist-renderer.ts +0 -267
  65. package/src/dom-helpers.ts +0 -17
  66. package/src/export.ts +0 -482
  67. package/src/gradient-utils.ts +0 -138
  68. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  69. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  70. package/src/graph/__tests__/interaction.test.ts +0 -205
  71. package/src/graph/__tests__/keyboard.test.ts +0 -653
  72. package/src/graph/__tests__/search.test.ts +0 -88
  73. package/src/graph/__tests__/simulation.test.ts +0 -233
  74. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  75. package/src/graph/__tests__/zoom.test.ts +0 -203
  76. package/src/graph/canvas-renderer.ts +0 -747
  77. package/src/graph/interaction.ts +0 -367
  78. package/src/graph/keyboard.ts +0 -208
  79. package/src/graph/search.ts +0 -50
  80. package/src/graph/simulation-worker-url.ts +0 -12
  81. package/src/graph/simulation-worker.ts +0 -278
  82. package/src/graph/simulation.ts +0 -422
  83. package/src/graph/spatial-index.ts +0 -121
  84. package/src/graph/types.ts +0 -47
  85. package/src/graph/worker-protocol.ts +0 -70
  86. package/src/graph/zoom.ts +0 -134
  87. package/src/graph-mount.ts +0 -888
  88. package/src/index.ts +0 -76
  89. package/src/interactions/chart-events.ts +0 -139
  90. package/src/interactions/crosshair.ts +0 -233
  91. package/src/interactions/drag-handler.ts +0 -175
  92. package/src/interactions/editing-drags.ts +0 -515
  93. package/src/interactions/index.ts +0 -25
  94. package/src/interactions/keyboard-nav.ts +0 -111
  95. package/src/interactions/legend-interaction.ts +0 -46
  96. package/src/interactions/selection.ts +0 -280
  97. package/src/interactions/tooltip-events.ts +0 -72
  98. package/src/map-camera.ts +0 -111
  99. package/src/map-mount.ts +0 -792
  100. package/src/map-renderer.ts +0 -460
  101. package/src/map-transition.ts +0 -99
  102. package/src/measure-text.ts +0 -134
  103. package/src/mount.ts +0 -1382
  104. package/src/pattern-utils.ts +0 -110
  105. package/src/renderers/annotations.ts +0 -388
  106. package/src/renderers/axes.ts +0 -351
  107. package/src/renderers/brand.ts +0 -87
  108. package/src/renderers/chrome.ts +0 -196
  109. package/src/renderers/endpoint-labels.ts +0 -150
  110. package/src/renderers/legend.ts +0 -311
  111. package/src/renderers/marks.ts +0 -561
  112. package/src/renderers/metrics.ts +0 -63
  113. package/src/renderers/svg-dom.ts +0 -40
  114. package/src/renderers/table-cells.ts +0 -446
  115. package/src/resize-observer.ts +0 -46
  116. package/src/sankey-mount.ts +0 -622
  117. package/src/sankey-renderer.ts +0 -614
  118. package/src/series-search.ts +0 -392
  119. package/src/static.ts +0 -251
  120. package/src/story/__tests__/camera-math.test.ts +0 -145
  121. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  122. package/src/story/__tests__/progress-math.test.ts +0 -95
  123. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  124. package/src/story/__tests__/tween.test.ts +0 -159
  125. package/src/story/camera-math.ts +0 -125
  126. package/src/story/create-chart-story.ts +0 -353
  127. package/src/story/crossfade.ts +0 -70
  128. package/src/story/index.ts +0 -39
  129. package/src/story/progress-math.ts +0 -85
  130. package/src/story/resolve-camera-target.ts +0 -119
  131. package/src/story/scroll-driver.ts +0 -206
  132. package/src/story/story-camera.ts +0 -49
  133. package/src/story/tween.ts +0 -112
  134. package/src/story/types.ts +0 -76
  135. package/src/svg-ids.ts +0 -23
  136. package/src/svg-renderer.ts +0 -383
  137. package/src/table-keyboard.ts +0 -266
  138. package/src/table-mount.ts +0 -567
  139. package/src/table-renderer.ts +0 -414
  140. package/src/text-edit-overlay.ts +0 -255
  141. package/src/theme-tokens.ts +0 -51
  142. package/src/tilemap-mount.ts +0 -511
  143. package/src/tilemap-renderer.ts +0 -396
  144. package/src/tooltip.ts +0 -177
  145. package/src/transition.ts +0 -2816
  146. package/src/you-draw-it.ts +0 -480
  147. /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
@@ -1,561 +0,0 @@
1
- /**
2
- * Mark rendering: dispatches to per-mark-type sub-renderers (line, area, rect,
3
- * arc, point, text, rule, tick).
4
- *
5
- * Mark renderers read module-level animation and gradient state so their
6
- * signatures stay `(mark, index) => SVGElement`. Callers must invoke
7
- * `setMarkRenderState()` before `renderMarks()` and `resetMarkRenderState()`
8
- * after.
9
- */
10
-
11
- import type {
12
- ArcMark,
13
- AreaMark,
14
- ChartLayout,
15
- LineMark,
16
- Mark,
17
- PointMark,
18
- RectMark,
19
- ResolvedAnimation,
20
- ResolvedFillPattern,
21
- RuleMarkLayout,
22
- TextMarkLayout,
23
- TickMarkLayout,
24
- } from '@opendata-ai/openchart-core';
25
- import { resolveMarkFill } from '../gradient-utils';
26
- import { resolvePatternFill } from '../pattern-utils';
27
- import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
28
-
29
- /**
30
- * Module-level animation state. Set by the orchestrator before rendering marks
31
- * so mark renderers can read it without changing their function signatures.
32
- */
33
- let currentAnimation: ResolvedAnimation | undefined;
34
-
35
- /**
36
- * Module-level gradient map. Set by the orchestrator after building gradient defs
37
- * so mark renderers can resolve gradient fills without signature changes.
38
- */
39
- let currentGradientMap: Map<string, string> = new Map();
40
-
41
- /**
42
- * Module-level pattern map. Same lifecycle as the gradient map, built from
43
- * pattern defs (see pattern-utils.ts).
44
- */
45
- let currentPatternMap: Map<string, string> = new Map();
46
-
47
- /** Set animation + gradient + pattern state before rendering marks. */
48
- export function setMarkRenderState(state: {
49
- animation: ResolvedAnimation | undefined;
50
- gradientMap: Map<string, string>;
51
- patternMap?: Map<string, string>;
52
- }): void {
53
- currentAnimation = state.animation;
54
- currentGradientMap = state.gradientMap;
55
- currentPatternMap = state.patternMap ?? new Map();
56
- }
57
-
58
- /** Reset animation + gradient + pattern state after rendering. */
59
- export function resetMarkRenderState(): void {
60
- currentAnimation = undefined;
61
- currentGradientMap = new Map();
62
- currentPatternMap = new Map();
63
- }
64
-
65
- /**
66
- * Resolve a filled mark's paint: the pattern url when the engine assigned a
67
- * fill pattern, the plain/gradient fill otherwise.
68
- */
69
- function resolveFillOrPattern(
70
- fill: Parameters<typeof resolveMarkFill>[0],
71
- pattern: ResolvedFillPattern | undefined,
72
- ): string {
73
- if (pattern) return resolvePatternFill(pattern, currentPatternMap);
74
- return resolveMarkFill(fill, currentGradientMap);
75
- }
76
-
77
- /**
78
- * Stamp animation index attributes on a mark element when animation is enabled.
79
- * Sets `data-animation-index` (for querySelector) and `--oc-mark-index`
80
- * (for CSS calc-based stagger delay).
81
- */
82
- function stampAnimationAttrs(
83
- el: SVGElement,
84
- mark: { animationIndex?: number; key?: string },
85
- fallbackIndex: number,
86
- ): void {
87
- // Stamp stable identity key for data-update transitions (always, not just enter)
88
- if (mark.key) {
89
- el.setAttribute('data-key', mark.key);
90
- }
91
- if (!currentAnimation?.enter) return;
92
- const idx = mark.animationIndex ?? fallbackIndex;
93
- el.setAttribute('data-animation-index', String(idx));
94
- (el as SVGElement & ElementCSSInlineStyle).style.setProperty('--oc-mark-index', String(idx));
95
- }
96
-
97
- type MarkRenderer<T extends Mark> = (mark: T, index: number) => SVGElement;
98
-
99
- const markRenderers: Record<string, MarkRenderer<Mark>> = {};
100
-
101
- /**
102
- * Register a mark renderer for a specific mark type.
103
- * Built-in renderers are registered below for all chart types.
104
- */
105
- export function registerMarkRenderer<T extends Mark>(
106
- type: T['type'],
107
- renderer: MarkRenderer<T>,
108
- ): void {
109
- markRenderers[type] = renderer as MarkRenderer<Mark>;
110
- }
111
-
112
- function renderLineMark(mark: LineMark, index: number): SVGElement {
113
- const g = createSVGElement('g');
114
- g.setAttribute('data-mark-id', `line-${mark.seriesKey ?? index}`);
115
- g.setAttribute('class', 'oc-mark oc-mark-line');
116
- stampAnimationAttrs(g, mark, index);
117
-
118
- if (mark.points.length > 1) {
119
- const path = createSVGElement('path');
120
- // Use the pre-computed D3 curve path when available (smooth monotone),
121
- // otherwise fall back to straight M/L segments.
122
- const d =
123
- mark.path ?? mark.points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
124
- setAttrs(path, {
125
- d,
126
- fill: 'none',
127
- stroke: mark.stroke,
128
- 'stroke-width': mark.strokeWidth,
129
- });
130
- if (mark.strokeDasharray) {
131
- path.setAttribute('stroke-dasharray', mark.strokeDasharray);
132
- }
133
- if (mark.opacity != null) {
134
- path.setAttribute('opacity', String(mark.opacity));
135
- }
136
- // Note: line drawing animation is handled via CSS clip-path on the group,
137
- // no inline dasharray/dashoffset needed.
138
- g.appendChild(path);
139
- }
140
-
141
- // Render end-of-line label if present and visible
142
- if (mark.label?.visible) {
143
- const label = createSVGElement('text');
144
- label.setAttribute('class', 'oc-mark-label');
145
- if (mark.seriesKey) {
146
- label.setAttribute('data-series', mark.seriesKey);
147
- }
148
- setAttrs(label, { x: mark.label.x, y: mark.label.y });
149
- applyTextStyle(label, mark.label.style);
150
- label.textContent = mark.label.text;
151
- g.appendChild(label);
152
-
153
- // Render connector line if label was offset from anchor
154
- if (mark.label.connector) {
155
- const connector = createSVGElement('line');
156
- connector.setAttribute('class', 'oc-mark-connector');
157
- setAttrs(connector, {
158
- x1: mark.label.connector.from.x,
159
- y1: mark.label.connector.from.y,
160
- x2: mark.label.connector.to.x,
161
- y2: mark.label.connector.to.y,
162
- stroke: mark.label.connector.stroke,
163
- 'stroke-width': 1,
164
- 'stroke-opacity': 0.5,
165
- });
166
- g.appendChild(connector);
167
- }
168
- }
169
-
170
- return g;
171
- }
172
-
173
- function renderAreaMark(mark: AreaMark, index: number): SVGElement {
174
- const g = createSVGElement('g');
175
- g.setAttribute('data-mark-id', `area-${mark.seriesKey ?? index}`);
176
- g.setAttribute('class', 'oc-mark oc-mark-area');
177
- stampAnimationAttrs(g, mark, index);
178
-
179
- if (mark.path) {
180
- // Area fill: the full closed shape (top line + baseline), no stroke
181
- const fill = createSVGElement('path');
182
- setAttrs(fill, {
183
- d: mark.path,
184
- fill: resolveFillOrPattern(mark.fill, mark.pattern),
185
- 'fill-opacity': mark.fillOpacity,
186
- stroke: 'none',
187
- });
188
- g.appendChild(fill);
189
-
190
- // Top-line stroke: only along the data points, not the baseline
191
- if (mark.stroke && mark.topPath) {
192
- const strokePath = createSVGElement('path');
193
- strokePath.setAttribute('class', 'oc-area-top');
194
- setAttrs(strokePath, {
195
- d: mark.topPath,
196
- fill: 'none',
197
- stroke: mark.stroke,
198
- 'stroke-width': mark.strokeWidth ?? 1,
199
- });
200
- // Note: area drawing animation is handled via CSS clip-path on the group,
201
- // no inline dasharray/dashoffset needed.
202
- g.appendChild(strokePath);
203
- }
204
- }
205
-
206
- return g;
207
- }
208
-
209
- /**
210
- * Build an SVG path describing a rectangle with selectively rounded corners.
211
- * Used by stacked segments where only the leading edge (top of a vertical
212
- * stack, right of a horizontal stack) should round so the seams between
213
- * adjacent segments stay flush.
214
- */
215
- export function rectPathWithCorners(
216
- mark: RectMark,
217
- sides: NonNullable<RectMark['cornerRadiusSides']>,
218
- ): string {
219
- const { x, y, width: w, height: h } = mark;
220
- // Clamp the radius so it never exceeds half of the shorter side, otherwise
221
- // the arcs would overlap and the path would render as a degenerate shape.
222
- const r = Math.max(0, Math.min(mark.cornerRadius ?? 0, w / 2, h / 2));
223
- const tl = sides.tl ? r : 0;
224
- const tr = sides.tr ? r : 0;
225
- const br = sides.br ? r : 0;
226
- const bl = sides.bl ? r : 0;
227
- return [
228
- `M${x + tl},${y}`,
229
- `H${x + w - tr}`,
230
- tr ? `A${tr},${tr} 0 0 1 ${x + w},${y + tr}` : '',
231
- `V${y + h - br}`,
232
- br ? `A${br},${br} 0 0 1 ${x + w - br},${y + h}` : '',
233
- `H${x + bl}`,
234
- bl ? `A${bl},${bl} 0 0 1 ${x},${y + h - bl}` : '',
235
- `V${y + tl}`,
236
- tl ? `A${tl},${tl} 0 0 1 ${x + tl},${y}` : '',
237
- 'Z',
238
- ]
239
- .filter(Boolean)
240
- .join(' ');
241
- }
242
-
243
- function renderRectMark(mark: RectMark, index: number): SVGElement {
244
- const g = createSVGElement('g');
245
- g.setAttribute('data-mark-id', `rect-${index}`);
246
- g.setAttribute('class', 'oc-mark oc-mark-rect');
247
- stampAnimationAttrs(g, mark, index);
248
- // Use engine-provided orientation for animation direction
249
- if (currentAnimation?.enter && mark.orient === 'horizontal') {
250
- g.setAttribute('data-orient', 'horizontal');
251
- }
252
-
253
- // When `cornerRadiusSides` selects a subset of corners (e.g. top-only for
254
- // the topmost segment of a stacked column), SVG's `rx`/`ry` won't do —
255
- // it rounds all four corners or none. Emit a `<path>` with per-corner
256
- // arcs in that case so the stacked segments stay flush at the seam.
257
- const sides = mark.cornerRadiusSides;
258
- const partialCorners =
259
- !!sides && (!sides.tl || !sides.tr || !sides.br || !sides.bl) && !!mark.cornerRadius;
260
- const shapeEl = partialCorners ? createSVGElement('path') : createSVGElement('rect');
261
- if (partialCorners) {
262
- shapeEl.setAttribute('d', rectPathWithCorners(mark, sides));
263
- } else {
264
- setAttrs(shapeEl, {
265
- x: mark.x,
266
- y: mark.y,
267
- width: mark.width,
268
- height: mark.height,
269
- });
270
- if (mark.cornerRadius) {
271
- setAttrs(shapeEl, { rx: mark.cornerRadius, ry: mark.cornerRadius });
272
- }
273
- }
274
- shapeEl.setAttribute('fill', resolveFillOrPattern(mark.fill, mark.pattern));
275
- if (mark.stroke) {
276
- shapeEl.setAttribute('stroke', mark.stroke);
277
- }
278
- if (mark.strokeWidth) {
279
- shapeEl.setAttribute('stroke-width', String(mark.strokeWidth));
280
- }
281
- g.appendChild(shapeEl);
282
-
283
- // Labels for rect marks are rendered in a dedicated overlay group by
284
- // renderMarks() so they always paint above all bars in SVG z-order.
285
- // See the two-pass logic in renderMarks() below.
286
-
287
- return g;
288
- }
289
-
290
- function renderArcMark(mark: ArcMark, index: number): SVGElement {
291
- const g = createSVGElement('g');
292
- g.setAttribute('data-mark-id', `arc-${index}`);
293
- g.setAttribute('class', 'oc-mark oc-mark-arc');
294
- g.setAttribute('transform', `translate(${mark.center.x},${mark.center.y})`);
295
- stampAnimationAttrs(g, mark, index);
296
-
297
- const path = createSVGElement('path');
298
- setAttrs(path, {
299
- d: mark.path,
300
- fill: resolveFillOrPattern(mark.fill, mark.pattern),
301
- stroke: mark.stroke,
302
- 'stroke-width': mark.strokeWidth,
303
- });
304
- g.appendChild(path);
305
-
306
- // Render label if present and visible
307
- if (mark.label?.visible) {
308
- const label = createSVGElement('text');
309
- label.setAttribute('class', 'oc-mark-label');
310
- // Label position is in absolute coords, but we're in a translated group,
311
- // so subtract the center offset
312
- setAttrs(label, {
313
- x: mark.label.x - mark.center.x,
314
- y: mark.label.y - mark.center.y,
315
- });
316
- applyTextStyle(label, mark.label.style);
317
- label.textContent = mark.label.text;
318
- g.appendChild(label);
319
- }
320
-
321
- return g;
322
- }
323
-
324
- function renderPointMark(mark: PointMark, index: number): SVGElement {
325
- const circle = createSVGElement('circle');
326
- circle.setAttribute('data-mark-id', `point-${index}`);
327
- circle.setAttribute('class', 'oc-mark oc-mark-point');
328
- stampAnimationAttrs(circle, mark, index);
329
-
330
- setAttrs(circle, {
331
- cx: mark.cx,
332
- cy: mark.cy,
333
- r: mark.r,
334
- fill: resolveMarkFill(mark.fill, currentGradientMap),
335
- stroke: mark.stroke,
336
- 'stroke-width': mark.strokeWidth,
337
- });
338
- if (mark.fillOpacity !== undefined) {
339
- circle.setAttribute('fill-opacity', String(mark.fillOpacity));
340
- }
341
- return circle;
342
- }
343
-
344
- function renderTextMark(mark: TextMarkLayout, index: number): SVGElement {
345
- const text = createSVGElement('text');
346
- text.setAttribute('data-mark-id', `textMark-${index}`);
347
- text.setAttribute('class', 'oc-mark oc-mark-text');
348
- stampAnimationAttrs(text, mark, index);
349
-
350
- setAttrs(text, {
351
- x: mark.x,
352
- y: mark.y,
353
- 'font-size': mark.fontSize,
354
- 'text-anchor': mark.textAnchor,
355
- });
356
- // Only when set: calendar and parliament text marks hand-compute their y
357
- // against the default alphabetic baseline, so stamping one would shift them.
358
- if (mark.dominantBaseline) {
359
- text.setAttribute('dominant-baseline', mark.dominantBaseline);
360
- }
361
- (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', mark.fill);
362
- if (mark.fontWeight) {
363
- text.setAttribute('font-weight', String(mark.fontWeight));
364
- }
365
- if (mark.fontFamily) {
366
- text.setAttribute('font-family', mark.fontFamily);
367
- }
368
- if (mark.angle) {
369
- text.setAttribute('transform', `rotate(${mark.angle}, ${mark.x}, ${mark.y})`);
370
- }
371
- text.textContent = mark.text;
372
- return text;
373
- }
374
-
375
- function renderRuleMark(mark: RuleMarkLayout, index: number): SVGElement {
376
- const line = createSVGElement('line');
377
- line.setAttribute('data-mark-id', `rule-${index}`);
378
- line.setAttribute('class', 'oc-mark oc-mark-rule');
379
- stampAnimationAttrs(line, mark, index);
380
-
381
- setAttrs(line, {
382
- x1: mark.x1,
383
- y1: mark.y1,
384
- x2: mark.x2,
385
- y2: mark.y2,
386
- stroke: mark.stroke,
387
- 'stroke-width': mark.strokeWidth,
388
- });
389
- if (mark.strokeDasharray) {
390
- line.setAttribute('stroke-dasharray', mark.strokeDasharray);
391
- }
392
- if (mark.opacity != null) {
393
- line.setAttribute('opacity', String(mark.opacity));
394
- }
395
- return line;
396
- }
397
-
398
- function renderTickMark(mark: TickMarkLayout, index: number): SVGElement {
399
- const line = createSVGElement('line');
400
- line.setAttribute('data-mark-id', `tick-${index}`);
401
- line.setAttribute('class', 'oc-mark oc-mark-tick');
402
- stampAnimationAttrs(line, mark, index);
403
-
404
- // Tick is a short line segment centered at (x, y)
405
- const half = mark.length / 2;
406
- if (mark.orient === 'vertical') {
407
- setAttrs(line, {
408
- x1: mark.x,
409
- y1: mark.y - half,
410
- x2: mark.x,
411
- y2: mark.y + half,
412
- stroke: mark.stroke,
413
- 'stroke-width': mark.strokeWidth,
414
- });
415
- } else {
416
- setAttrs(line, {
417
- x1: mark.x - half,
418
- y1: mark.y,
419
- x2: mark.x + half,
420
- y2: mark.y,
421
- stroke: mark.stroke,
422
- 'stroke-width': mark.strokeWidth,
423
- });
424
- }
425
-
426
- if (mark.opacity != null) {
427
- line.setAttribute('opacity', String(mark.opacity));
428
- }
429
- return line;
430
- }
431
-
432
- // Register built-in renderers
433
- registerMarkRenderer('line', renderLineMark as MarkRenderer<Mark>);
434
- registerMarkRenderer('area', renderAreaMark as MarkRenderer<Mark>);
435
- registerMarkRenderer('rect', renderRectMark as MarkRenderer<Mark>);
436
- registerMarkRenderer('arc', renderArcMark as MarkRenderer<Mark>);
437
- registerMarkRenderer('point', renderPointMark as MarkRenderer<Mark>);
438
- registerMarkRenderer('textMark', renderTextMark as MarkRenderer<Mark>);
439
- registerMarkRenderer('rule', renderRuleMark as MarkRenderer<Mark>);
440
- registerMarkRenderer('tick', renderTickMark as MarkRenderer<Mark>);
441
-
442
- /**
443
- * Render a single mark to an SVG element without appending it anywhere.
444
- * Used by the transition module to create ghost elements for exiting marks.
445
- */
446
- export function renderSingleMark(mark: Mark, index: number): SVGElement | undefined {
447
- const renderer = markRenderers[mark.type];
448
- if (!renderer) return undefined;
449
- return renderer(mark, index);
450
- }
451
-
452
- /** Extract series name from a mark for legend toggle matching. */
453
- function getMarkSeries(mark: Mark): string | undefined {
454
- // Line and area marks have an explicit seriesKey
455
- if (mark.type === 'line' || mark.type === 'area') {
456
- return mark.seriesKey;
457
- }
458
- // For arc marks, the category name is the first part of the aria label (before ':')
459
- if (mark.type === 'arc') {
460
- return mark.aria.label?.split(':')[0]?.trim();
461
- }
462
- // For rect/point, the aria label may be "category: value" or "category, group: value".
463
- // The series name is the category part (before the colon).
464
- if (mark.aria?.label) {
465
- const beforeColon = mark.aria.label.split(':')[0]?.trim();
466
- if (beforeColon) return beforeColon;
467
- }
468
- return undefined;
469
- }
470
-
471
- /**
472
- * Render chart marks into `parent`.
473
- *
474
- * Returns an optional overlay group containing rect mark value labels.
475
- * The caller should append this group to the outer SVG (outside any clip path)
476
- * so tall labels above near-full-height bars are not clipped by the chart-area
477
- * clip path that constrains the marks themselves.
478
- */
479
- export function renderMarks(parent: SVGElement, layout: ChartLayout): SVGElement | undefined {
480
- const g = createSVGElement('g');
481
- g.setAttribute('class', 'oc-marks');
482
-
483
- for (let i = 0; i < layout.marks.length; i++) {
484
- const mark = layout.marks[i];
485
- const renderer = markRenderers[mark.type];
486
- if (!renderer) continue;
487
-
488
- const el = renderer(mark, i);
489
- // Decorative marks (e.g. sparkline endpoint dots) are hidden from
490
- // assistive tech because they duplicate an existing data point.
491
- if (mark.aria?.decorative) {
492
- el.setAttribute('aria-hidden', 'true');
493
- } else if (mark.aria?.label) {
494
- el.setAttribute('aria-label', mark.aria.label);
495
- }
496
- // Add data-series attribute for legend toggle matching
497
- const series = getMarkSeries(mark);
498
- if (series) {
499
- el.setAttribute('data-series', series);
500
- }
501
-
502
- // For stacked segments, set stack position for sequential animation chaining.
503
- // stackPos is computed by the engine on RectMark during compilation.
504
- if (currentAnimation?.enter && mark.type === 'rect') {
505
- const rect = mark as RectMark;
506
- if (rect.stackGroup && rect.stackPos !== undefined) {
507
- el.setAttribute('data-stack-pos', String(rect.stackPos));
508
- (el as SVGElement & ElementCSSInlineStyle).style.setProperty(
509
- '--oc-stack-pos',
510
- String(rect.stackPos),
511
- );
512
- }
513
- }
514
-
515
- g.appendChild(el);
516
- }
517
-
518
- parent.appendChild(g);
519
-
520
- // Second pass: render rect mark labels in a dedicated overlay group so they
521
- // always paint above all bars in SVG z-order. On grouped column charts, a
522
- // later-rendered bar can otherwise paint over a label from an adjacent column
523
- // when the label extends into a neighbor's airspace.
524
- //
525
- // Each overlay label gets --oc-mark-index stamped directly on it so the CSS
526
- // animation delay calc (which reads that property) works without inheriting
527
- // from a parent group.
528
- let labelsGroup: SVGElement | undefined;
529
- for (let i = 0; i < layout.marks.length; i++) {
530
- const mark = layout.marks[i];
531
- if (mark.type !== 'rect') continue;
532
- const rect = mark as RectMark;
533
- if (!rect.label?.visible) continue;
534
-
535
- if (!labelsGroup) {
536
- labelsGroup = createSVGElement('g');
537
- labelsGroup.setAttribute('class', 'oc-mark-labels');
538
- }
539
-
540
- const label = createSVGElement('text');
541
- label.setAttribute('class', 'oc-mark-label');
542
- setAttrs(label, { x: rect.label.x, y: rect.label.y });
543
- applyTextStyle(label, rect.label.style);
544
- label.textContent = rect.label.text;
545
-
546
- // Stamp animation index so the CSS stagger delay works for overlay labels
547
- // (they're not children of a .oc-mark-rect group that carries --oc-mark-index).
548
- if (currentAnimation?.enter) {
549
- const idx = rect.animationIndex ?? i;
550
- label.setAttribute('data-animation-index', String(idx));
551
- (label as SVGElement & ElementCSSInlineStyle).style.setProperty(
552
- '--oc-mark-index',
553
- String(idx),
554
- );
555
- }
556
-
557
- labelsGroup.appendChild(label);
558
- }
559
-
560
- return labelsGroup;
561
- }
@@ -1,63 +0,0 @@
1
- /**
2
- * KPI metric bar rendering. Emits a `<g class="oc-metrics">` group containing
3
- * a label/value pair per cell. Visual styling lives in `chrome.css`
4
- * (`.oc-metric-*` classes); this renderer only sets positions and structure.
5
- */
6
-
7
- import type { ChartLayout } from '@opendata-ai/openchart-core';
8
- import { createSVGElement, setAttrs } from './svg-dom';
9
-
10
- // Delta and secondary text run at a fraction of the primary value size. This
11
- // preserves the editorial mock's proportion (12px delta against a 22px value)
12
- // at any theme-driven value size, so scaling metricValue scales the whole cell.
13
- const DELTA_SIZE_RATIO = 12 / 22;
14
-
15
- export function renderMetrics(parent: SVGElement, layout: ChartLayout): void {
16
- const bar = layout.metrics;
17
- if (!bar || bar.cells.length === 0) return;
18
-
19
- // Font sizes flow from the theme. Set them inline so they override the CSS
20
- // defaults (.oc-metric-* in chrome.css) when a chart customizes the sizes.
21
- const labelSize = layout.theme.fonts.sizes.metricLabel;
22
- const valueSize = layout.theme.fonts.sizes.metricValue;
23
- const deltaSize = Math.round(valueSize * DELTA_SIZE_RATIO);
24
-
25
- const g = createSVGElement('g');
26
- g.setAttribute('class', 'oc-metrics');
27
-
28
- for (const cell of bar.cells) {
29
- const label = createSVGElement('text');
30
- label.setAttribute('class', 'oc-metric-label');
31
- setAttrs(label, { x: cell.x, y: cell.labelY, 'font-size': labelSize });
32
- label.textContent = cell.metric.label.toUpperCase();
33
- g.appendChild(label);
34
-
35
- const value = createSVGElement('text');
36
- value.setAttribute('class', 'oc-metric-value');
37
- setAttrs(value, { x: cell.x, y: cell.valueY, 'font-size': valueSize });
38
- value.textContent = cell.metric.value;
39
-
40
- if (cell.metric.delta) {
41
- const delta = createSVGElement('tspan');
42
- const tone = cell.metric.deltaTone ?? 'up';
43
- delta.setAttribute('class', tone === 'down' ? 'oc-metric-delta-down' : 'oc-metric-delta-up');
44
- delta.setAttribute('dx', '8');
45
- delta.setAttribute('font-size', String(deltaSize));
46
- delta.textContent = cell.metric.delta;
47
- value.appendChild(delta);
48
- }
49
-
50
- if (cell.metric.secondary) {
51
- const secondary = createSVGElement('tspan');
52
- secondary.setAttribute('class', 'oc-metric-secondary');
53
- secondary.setAttribute('dx', '6');
54
- secondary.setAttribute('font-size', String(deltaSize));
55
- secondary.textContent = cell.metric.secondary;
56
- value.appendChild(secondary);
57
- }
58
-
59
- g.appendChild(value);
60
- }
61
-
62
- parent.appendChild(g);
63
- }
@@ -1,40 +0,0 @@
1
- /**
2
- * Shared SVG DOM helpers used across the per-concern renderers.
3
- *
4
- * Pure, stateless utilities. No layout/theme knowledge.
5
- */
6
-
7
- import type { TextStyle } from '@opendata-ai/openchart-core';
8
-
9
- export const SVG_NS = 'http://www.w3.org/2000/svg';
10
- export const XLINK_NS = 'http://www.w3.org/1999/xlink';
11
-
12
- export function createSVGElement(tag: string): SVGElement {
13
- return document.createElementNS(SVG_NS, tag);
14
- }
15
-
16
- export function setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {
17
- for (const [key, value] of Object.entries(attrs)) {
18
- el.setAttribute(key, String(value));
19
- }
20
- }
21
-
22
- export function applyTextStyle(el: SVGElement, style: TextStyle): void {
23
- // Use inline styles so engine-computed values take priority over CSS class
24
- // defaults (e.g. .oc-title { font-size: var(--oc-title-size) } would otherwise
25
- // override the responsive scaling applied by the chrome layout).
26
- const inline = (el as SVGElement & ElementCSSInlineStyle).style;
27
- inline.setProperty('fill', style.fill);
28
- inline.setProperty('font-size', `${style.fontSize}px`);
29
- inline.setProperty('font-weight', String(style.fontWeight));
30
- inline.setProperty('font-family', style.fontFamily);
31
- if (style.textAnchor) {
32
- el.setAttribute('text-anchor', style.textAnchor);
33
- }
34
- if (style.dominantBaseline) {
35
- el.setAttribute('dominant-baseline', style.dominantBaseline);
36
- }
37
- if (style.fontVariant) {
38
- el.setAttribute('font-variant', style.fontVariant);
39
- }
40
- }