@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21

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 (145) hide show
  1. package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/chunk-DCODCLWI.js +234 -0
  3. package/dist/chunk-DCODCLWI.js.map +1 -0
  4. package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
  5. package/dist/chunk-EKAUQAAX.js.map +1 -0
  6. package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
  7. package/dist/chunk-KNT5AZ2O.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 +283 -24
  16. package/dist/index.js +2012 -763
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.js +7 -69
  21. package/dist/static.js.map +1 -1
  22. package/dist/story/index.d.ts +3 -2
  23. package/dist/story/index.js +5 -3
  24. package/dist/story/index.js.map +1 -1
  25. package/dist/styles.css +1 -1
  26. package/dist/styles.css.map +1 -1
  27. package/package.json +14 -6
  28. package/dist/chunk-5EOV5JQZ.js.map +0 -1
  29. package/dist/chunk-ENXSUXC5.js.map +0 -1
  30. package/src/__test-fixtures__/dom.ts +0 -70
  31. package/src/__test-fixtures__/specs.ts +0 -214
  32. package/src/__tests__/animation.test.ts +0 -358
  33. package/src/__tests__/annotations-render.test.ts +0 -201
  34. package/src/__tests__/compound-labels.test.ts +0 -122
  35. package/src/__tests__/continuous-legend.test.ts +0 -91
  36. package/src/__tests__/crosshair.test.ts +0 -130
  37. package/src/__tests__/edit-events.test.ts +0 -902
  38. package/src/__tests__/events.test.ts +0 -387
  39. package/src/__tests__/export.test.ts +0 -241
  40. package/src/__tests__/font-race.test.ts +0 -199
  41. package/src/__tests__/gradient-ids.test.ts +0 -159
  42. package/src/__tests__/gradient-utils.test.ts +0 -125
  43. package/src/__tests__/known-bugs-render.test.ts +0 -183
  44. package/src/__tests__/map.test.ts +0 -293
  45. package/src/__tests__/mount.test.ts +0 -357
  46. package/src/__tests__/pattern-fills.test.ts +0 -105
  47. package/src/__tests__/resize-timing.test.ts +0 -218
  48. package/src/__tests__/sankey.test.ts +0 -133
  49. package/src/__tests__/selection-events.test.ts +0 -869
  50. package/src/__tests__/series-search.test.ts +0 -311
  51. package/src/__tests__/static.test.ts +0 -141
  52. package/src/__tests__/svg-renderer.test.ts +0 -895
  53. package/src/__tests__/table-keyboard.test.ts +0 -361
  54. package/src/__tests__/table-mount.test.ts +0 -639
  55. package/src/__tests__/tilemap.test.ts +0 -158
  56. package/src/__tests__/tooltip.test.ts +0 -337
  57. package/src/__tests__/transition.test.ts +0 -1777
  58. package/src/__tests__/you-draw-it.test.ts +0 -273
  59. package/src/animation-vars.ts +0 -42
  60. package/src/animation.ts +0 -76
  61. package/src/barlist-mount.ts +0 -383
  62. package/src/barlist-renderer.ts +0 -267
  63. package/src/dom-helpers.ts +0 -17
  64. package/src/export.ts +0 -482
  65. package/src/gradient-utils.ts +0 -138
  66. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  67. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  68. package/src/graph/__tests__/interaction.test.ts +0 -205
  69. package/src/graph/__tests__/keyboard.test.ts +0 -653
  70. package/src/graph/__tests__/search.test.ts +0 -88
  71. package/src/graph/__tests__/simulation.test.ts +0 -233
  72. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  73. package/src/graph/__tests__/zoom.test.ts +0 -203
  74. package/src/graph/canvas-renderer.ts +0 -747
  75. package/src/graph/interaction.ts +0 -367
  76. package/src/graph/keyboard.ts +0 -208
  77. package/src/graph/search.ts +0 -50
  78. package/src/graph/simulation-worker-url.ts +0 -12
  79. package/src/graph/simulation-worker.ts +0 -278
  80. package/src/graph/simulation.ts +0 -422
  81. package/src/graph/spatial-index.ts +0 -121
  82. package/src/graph/types.ts +0 -47
  83. package/src/graph/worker-protocol.ts +0 -70
  84. package/src/graph/zoom.ts +0 -134
  85. package/src/graph-mount.ts +0 -888
  86. package/src/index.ts +0 -76
  87. package/src/interactions/chart-events.ts +0 -139
  88. package/src/interactions/crosshair.ts +0 -233
  89. package/src/interactions/drag-handler.ts +0 -175
  90. package/src/interactions/editing-drags.ts +0 -515
  91. package/src/interactions/index.ts +0 -25
  92. package/src/interactions/keyboard-nav.ts +0 -111
  93. package/src/interactions/legend-interaction.ts +0 -46
  94. package/src/interactions/selection.ts +0 -280
  95. package/src/interactions/tooltip-events.ts +0 -72
  96. package/src/map-camera.ts +0 -88
  97. package/src/map-mount.ts +0 -701
  98. package/src/map-renderer.ts +0 -338
  99. package/src/map-transition.ts +0 -83
  100. package/src/measure-text.ts +0 -134
  101. package/src/mount.ts +0 -1382
  102. package/src/pattern-utils.ts +0 -110
  103. package/src/renderers/annotations.ts +0 -388
  104. package/src/renderers/axes.ts +0 -351
  105. package/src/renderers/brand.ts +0 -87
  106. package/src/renderers/chrome.ts +0 -196
  107. package/src/renderers/endpoint-labels.ts +0 -150
  108. package/src/renderers/legend.ts +0 -311
  109. package/src/renderers/marks.ts +0 -561
  110. package/src/renderers/metrics.ts +0 -63
  111. package/src/renderers/svg-dom.ts +0 -40
  112. package/src/renderers/table-cells.ts +0 -446
  113. package/src/resize-observer.ts +0 -46
  114. package/src/sankey-mount.ts +0 -622
  115. package/src/sankey-renderer.ts +0 -614
  116. package/src/series-search.ts +0 -392
  117. package/src/static.ts +0 -251
  118. package/src/story/__tests__/camera-math.test.ts +0 -145
  119. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  120. package/src/story/__tests__/progress-math.test.ts +0 -95
  121. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  122. package/src/story/__tests__/tween.test.ts +0 -159
  123. package/src/story/camera-math.ts +0 -125
  124. package/src/story/create-chart-story.ts +0 -353
  125. package/src/story/crossfade.ts +0 -70
  126. package/src/story/index.ts +0 -39
  127. package/src/story/progress-math.ts +0 -85
  128. package/src/story/resolve-camera-target.ts +0 -119
  129. package/src/story/scroll-driver.ts +0 -206
  130. package/src/story/story-camera.ts +0 -49
  131. package/src/story/tween.ts +0 -112
  132. package/src/story/types.ts +0 -76
  133. package/src/svg-ids.ts +0 -23
  134. package/src/svg-renderer.ts +0 -383
  135. package/src/table-keyboard.ts +0 -266
  136. package/src/table-mount.ts +0 -567
  137. package/src/table-renderer.ts +0 -414
  138. package/src/text-edit-overlay.ts +0 -255
  139. package/src/theme-tokens.ts +0 -51
  140. package/src/tilemap-mount.ts +0 -511
  141. package/src/tilemap-renderer.ts +0 -396
  142. package/src/tooltip.ts +0 -177
  143. package/src/transition.ts +0 -2816
  144. package/src/you-draw-it.ts +0 -480
  145. /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
@@ -1,392 +0,0 @@
1
- /**
2
- * Series search combobox: the DOM overlay for the `seriesSearch` spec option.
3
- *
4
- * Renders an ARIA 1.2 editable combobox (input + popup listbox) with
5
- * multi-select chips, absolutely positioned inside `.oc-root` over the band
6
- * the chrome layout reserves (`layout.seriesSearch`). Because the overlay is
7
- * position: absolute, mounting or updating it never changes the observed
8
- * container's size, so it cannot re-trigger the container ResizeObserver.
9
- *
10
- * Matching is a diacritic-insensitive normalized prefix/substring match
11
- * (prefix matches rank first). No fuzzy-ranking dependency.
12
- */
13
-
14
- import type { ResolvedSeriesSearch } from '@opendata-ai/openchart-core';
15
-
16
- export interface SeriesSearchOptions {
17
- /** The chart container (`.oc-root`). Must be position: relative. */
18
- container: HTMLElement;
19
- /** Fired with the full selection (insertion order) after every add/remove. */
20
- onChange: (selected: string[]) => void;
21
- }
22
-
23
- export interface SeriesSearchController {
24
- /** Reposition and refresh the control against a freshly rendered layout. */
25
- update(band: ResolvedSeriesSearch, svg: SVGElement): void;
26
- /** Hide the control (layout stripped the band but the spec still wants it). */
27
- hide(): void;
28
- /** Currently selected values, in insertion order. */
29
- getSelected(): string[];
30
- /** Replace the selection without firing onChange (e.g. reset on spec update). */
31
- setSelected(values: string[]): void;
32
- /** Remove the control from the DOM. */
33
- destroy(): void;
34
- }
35
-
36
- let seriesSearchIdCounter = 0;
37
-
38
- const SVG_NS = 'http://www.w3.org/2000/svg';
39
-
40
- /**
41
- * Fold a string for matching: lowercase with combining diacritical marks
42
- * stripped. `map[i]` is the index in the original string that produced
43
- * folded character `i`, so match ranges can be mapped back for emphasis.
44
- */
45
- function foldWithMap(s: string): { folded: string; map: number[] } {
46
- let folded = '';
47
- const map: number[] = [];
48
- for (let i = 0; i < s.length; i++) {
49
- const f = s[i]
50
- .normalize('NFD')
51
- .replace(/[\u0300-\u036f]/g, '')
52
- .toLowerCase();
53
- for (const ch of f) {
54
- folded += ch;
55
- map.push(i);
56
- }
57
- }
58
- return { folded, map };
59
- }
60
-
61
- function fold(s: string): string {
62
- return foldWithMap(s).folded;
63
- }
64
-
65
- /** Small stroke icon (magnifier or cross) built from SVG path data. */
66
- function createIcon(size: number, paths: string[]): SVGSVGElement {
67
- const svg = document.createElementNS(SVG_NS, 'svg');
68
- svg.setAttribute('width', String(size));
69
- svg.setAttribute('height', String(size));
70
- svg.setAttribute('viewBox', '0 0 16 16');
71
- svg.setAttribute('fill', 'none');
72
- svg.setAttribute('aria-hidden', 'true');
73
- for (const d of paths) {
74
- const path = document.createElementNS(SVG_NS, 'path');
75
- path.setAttribute('d', d);
76
- path.setAttribute('stroke', 'currentColor');
77
- path.setAttribute('stroke-width', '1.5');
78
- path.setAttribute('stroke-linecap', 'round');
79
- svg.appendChild(path);
80
- }
81
- return svg;
82
- }
83
-
84
- /**
85
- * Create the series search control. Mounted once per chart instance and kept
86
- * across re-renders (so typing focus and chips survive highlight re-renders);
87
- * `update()` re-syncs position, values, and placeholder after each render.
88
- */
89
- export function createSeriesSearch(options: SeriesSearchOptions): SeriesSearchController {
90
- const { container, onChange } = options;
91
-
92
- const id = `oc-series-search-${++seriesSearchIdCounter}`;
93
- const listboxId = `${id}-listbox`;
94
-
95
- let values: string[] = [];
96
- let selected: string[] = [];
97
- let matches: string[] = [];
98
- let activeIndex = -1;
99
- let open = false;
100
- let destroyed = false;
101
-
102
- // ---------------------------------------------------------------------------
103
- // DOM
104
- // ---------------------------------------------------------------------------
105
-
106
- const root = document.createElement('div');
107
- root.className = 'oc-series-search';
108
- root.style.display = 'none';
109
-
110
- const chips = document.createElement('div');
111
- chips.className = 'oc-series-search-chips';
112
-
113
- const box = document.createElement('div');
114
- box.className = 'oc-series-search-box';
115
-
116
- const icon = createIcon(14, [
117
- 'M7 11.5a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9Z',
118
- 'M10.5 10.5 13.5 13.5',
119
- ]);
120
- icon.classList.add('oc-series-search-icon');
121
-
122
- const input = document.createElement('input');
123
- input.className = 'oc-series-search-input';
124
- input.type = 'text';
125
- input.setAttribute('role', 'combobox');
126
- input.setAttribute('aria-expanded', 'false');
127
- input.setAttribute('aria-controls', listboxId);
128
- input.setAttribute('aria-autocomplete', 'list');
129
- input.setAttribute('aria-haspopup', 'listbox');
130
- input.autocomplete = 'off';
131
- input.spellcheck = false;
132
-
133
- const listbox = document.createElement('ul');
134
- listbox.className = 'oc-series-search-listbox';
135
- listbox.id = listboxId;
136
- listbox.setAttribute('role', 'listbox');
137
- listbox.hidden = true;
138
-
139
- box.append(icon, input, listbox);
140
- root.append(chips, box);
141
- // Ensure absolute children position against the chart container, matching
142
- // the tooltip manager's approach.
143
- container.style.position = container.style.position || 'relative';
144
- // First child so the tab order matches the visual order (search above chart);
145
- // render() re-appends the SVG after this node on every re-render.
146
- container.insertBefore(root, container.firstChild);
147
-
148
- // ---------------------------------------------------------------------------
149
- // Matching
150
- // ---------------------------------------------------------------------------
151
-
152
- function computeMatches(): string[] {
153
- const q = fold(input.value.trim());
154
- const available = values.filter((v) => !selected.includes(v));
155
- if (!q) return available;
156
- const prefix: string[] = [];
157
- const substring: string[] = [];
158
- for (const v of available) {
159
- const idx = fold(v).indexOf(q);
160
- if (idx === 0) prefix.push(v);
161
- else if (idx > 0) substring.push(v);
162
- }
163
- return [...prefix, ...substring];
164
- }
165
-
166
- /** Append the option label with the matched substring emphasized. */
167
- function appendOptionText(li: HTMLLIElement, value: string): void {
168
- const q = fold(input.value.trim());
169
- if (!q) {
170
- li.textContent = value;
171
- return;
172
- }
173
- const { folded, map } = foldWithMap(value);
174
- const idx = folded.indexOf(q);
175
- if (idx < 0) {
176
- li.textContent = value;
177
- return;
178
- }
179
- const start = map[idx];
180
- const end = idx + q.length < folded.length ? map[idx + q.length] : value.length;
181
- li.append(document.createTextNode(value.slice(0, start)));
182
- const match = document.createElement('span');
183
- match.className = 'oc-series-search-match';
184
- match.textContent = value.slice(start, end);
185
- li.append(match, document.createTextNode(value.slice(end)));
186
- }
187
-
188
- // ---------------------------------------------------------------------------
189
- // Rendering
190
- // ---------------------------------------------------------------------------
191
-
192
- function renderChips(): void {
193
- chips.textContent = '';
194
- for (const value of selected) {
195
- const chip = document.createElement('span');
196
- chip.className = 'oc-series-search-chip';
197
- chip.append(document.createTextNode(value));
198
-
199
- const remove = document.createElement('button');
200
- remove.type = 'button';
201
- remove.className = 'oc-series-search-chip-remove';
202
- remove.setAttribute('aria-label', `Remove ${value}`);
203
- remove.appendChild(createIcon(8, ['M3 3 13 13', 'M13 3 3 13']));
204
- remove.addEventListener('click', () => {
205
- removeValue(value);
206
- input.focus();
207
- });
208
-
209
- chip.appendChild(remove);
210
- chips.appendChild(chip);
211
- }
212
- }
213
-
214
- function renderList(): void {
215
- matches = computeMatches();
216
- if (activeIndex >= matches.length) activeIndex = matches.length - 1;
217
- listbox.textContent = '';
218
-
219
- if (matches.length === 0) {
220
- const empty = document.createElement('li');
221
- empty.className = 'oc-series-search-empty';
222
- empty.setAttribute('role', 'presentation');
223
- empty.textContent = 'No matches';
224
- listbox.appendChild(empty);
225
- input.removeAttribute('aria-activedescendant');
226
- return;
227
- }
228
-
229
- matches.forEach((value, i) => {
230
- const li = document.createElement('li');
231
- li.className = 'oc-series-search-option';
232
- li.id = `${id}-opt-${i}`;
233
- li.setAttribute('role', 'option');
234
- li.setAttribute('aria-selected', String(i === activeIndex));
235
- appendOptionText(li, value);
236
- li.addEventListener('click', () => selectValue(value));
237
- listbox.appendChild(li);
238
- });
239
-
240
- if (activeIndex >= 0) {
241
- input.setAttribute('aria-activedescendant', `${id}-opt-${activeIndex}`);
242
- const activeEl = listbox.children[activeIndex] as HTMLElement | undefined;
243
- activeEl?.scrollIntoView?.({ block: 'nearest' });
244
- } else {
245
- input.removeAttribute('aria-activedescendant');
246
- }
247
- }
248
-
249
- function openList(): void {
250
- if (open) return;
251
- open = true;
252
- listbox.hidden = false;
253
- input.setAttribute('aria-expanded', 'true');
254
- }
255
-
256
- function closeList(): void {
257
- if (!open) return;
258
- open = false;
259
- activeIndex = -1;
260
- listbox.hidden = true;
261
- input.setAttribute('aria-expanded', 'false');
262
- input.removeAttribute('aria-activedescendant');
263
- }
264
-
265
- // ---------------------------------------------------------------------------
266
- // Selection
267
- // ---------------------------------------------------------------------------
268
-
269
- function selectValue(value: string): void {
270
- if (!selected.includes(value)) selected.push(value);
271
- input.value = '';
272
- closeList();
273
- renderChips();
274
- onChange([...selected]);
275
- }
276
-
277
- function removeValue(value: string): void {
278
- const idx = selected.indexOf(value);
279
- if (idx < 0) return;
280
- selected.splice(idx, 1);
281
- renderChips();
282
- onChange([...selected]);
283
- }
284
-
285
- // ---------------------------------------------------------------------------
286
- // Events
287
- // ---------------------------------------------------------------------------
288
-
289
- input.addEventListener('input', () => {
290
- openList();
291
- activeIndex = computeMatches().length > 0 ? 0 : -1;
292
- renderList();
293
- });
294
-
295
- input.addEventListener('keydown', (e: KeyboardEvent) => {
296
- switch (e.key) {
297
- case 'ArrowDown':
298
- case 'ArrowUp': {
299
- e.preventDefault();
300
- const wasOpen = open;
301
- openList();
302
- if (!wasOpen) {
303
- matches = computeMatches();
304
- activeIndex = matches.length > 0 ? 0 : -1;
305
- } else if (matches.length > 0) {
306
- const delta = e.key === 'ArrowDown' ? 1 : -1;
307
- activeIndex = (activeIndex + delta + matches.length) % matches.length;
308
- }
309
- renderList();
310
- break;
311
- }
312
- case 'Enter': {
313
- if (open && activeIndex >= 0 && activeIndex < matches.length) {
314
- e.preventDefault();
315
- selectValue(matches[activeIndex]);
316
- }
317
- break;
318
- }
319
- case 'Escape': {
320
- if (open) {
321
- e.preventDefault();
322
- e.stopPropagation();
323
- closeList();
324
- }
325
- break;
326
- }
327
- case 'Backspace': {
328
- if (input.value === '' && selected.length > 0) {
329
- removeValue(selected[selected.length - 1]);
330
- }
331
- break;
332
- }
333
- case 'Tab': {
334
- closeList();
335
- break;
336
- }
337
- }
338
- });
339
-
340
- // Keep focus in the input while clicking options/chips inside the popup.
341
- listbox.addEventListener('mousedown', (e) => e.preventDefault());
342
-
343
- // Close when focus leaves the whole control.
344
- root.addEventListener('focusout', (e: FocusEvent) => {
345
- const next = e.relatedTarget as Node | null;
346
- if (!next || !root.contains(next)) closeList();
347
- });
348
-
349
- // ---------------------------------------------------------------------------
350
- // Controller
351
- // ---------------------------------------------------------------------------
352
-
353
- return {
354
- update(band: ResolvedSeriesSearch, svg: SVGElement): void {
355
- if (destroyed) return;
356
- values = band.values;
357
- input.placeholder = band.placeholder;
358
- input.setAttribute('aria-label', band.placeholder);
359
-
360
- // Layout coords -> pixel coords relative to the container, same
361
- // viewBox-scale approach as the text edit overlay.
362
- const viewBox = (svg as SVGSVGElement).viewBox?.baseVal;
363
- const svgRect = svg.getBoundingClientRect();
364
- const containerRect = container.getBoundingClientRect();
365
- const scaleX = viewBox?.width && svgRect.width ? svgRect.width / viewBox.width : 1;
366
- const scaleY = viewBox?.height && svgRect.height ? svgRect.height / viewBox.height : 1;
367
- root.style.left = `${band.x * scaleX + (svgRect.left - containerRect.left)}px`;
368
- root.style.top = `${band.y * scaleY + (svgRect.top - containerRect.top)}px`;
369
- root.style.width = `${band.width * scaleX}px`;
370
- root.style.height = `${band.height * scaleY}px`;
371
- root.style.display = 'flex';
372
-
373
- if (open) renderList();
374
- },
375
- hide(): void {
376
- root.style.display = 'none';
377
- closeList();
378
- },
379
- getSelected(): string[] {
380
- return [...selected];
381
- },
382
- setSelected(next: string[]): void {
383
- selected = [...next];
384
- renderChips();
385
- },
386
- destroy(): void {
387
- if (destroyed) return;
388
- destroyed = true;
389
- root.remove();
390
- },
391
- };
392
- }
package/src/static.ts DELETED
@@ -1,251 +0,0 @@
1
- // Node.js only — do not import from browser code.
2
- import { createRequire } from 'node:module';
3
- import type {
4
- ChartLayout,
5
- ChartSpec,
6
- CompileOptions,
7
- DarkMode,
8
- LayerSpec,
9
- ResolvedTheme,
10
- ThemeConfig,
11
- TileMapSpec,
12
- } from '@opendata-ai/openchart-core';
13
- import {
14
- adaptForLightLineStroke,
15
- cssTokenDefault,
16
- isLayerSpec,
17
- isTileMapSpec,
18
- } from '@opendata-ai/openchart-core';
19
- import { compileChart, compileLayer, compileTileMap } from '@opendata-ai/openchart-engine';
20
- import { SVG_NS } from './renderers/svg-dom';
21
- import { resetSvgIdCounter } from './svg-ids';
22
- import { renderChartSVG } from './svg-renderer';
23
- import { renderTileMapSVG } from './tilemap-renderer';
24
-
25
- const esmRequire = createRequire(import.meta.url);
26
-
27
- let cachedWindow: typeof import('happy-dom').Window | undefined;
28
- function getHappyDomWindow(): typeof import('happy-dom').Window {
29
- if (cachedWindow) return cachedWindow;
30
- try {
31
- ({ Window: cachedWindow } = esmRequire('happy-dom') as typeof import('happy-dom'));
32
- } catch {
33
- throw new Error(
34
- "renderStaticSVG requires 'happy-dom' as a peer dependency. Install it with: npm add happy-dom",
35
- );
36
- }
37
- return cachedWindow;
38
- }
39
-
40
- let rendering = false;
41
-
42
- export interface StaticRenderOptions {
43
- width?: number;
44
- height?: number;
45
- theme?: ThemeConfig;
46
- /**
47
- * Dark mode setting. In static rendering `'auto'` resolves to light mode
48
- * since there is no `matchMedia` to query. Use `'force'` for dark output.
49
- */
50
- darkMode?: DarkMode;
51
- watermark?: boolean;
52
- }
53
-
54
- function resolveStaticDarkMode(mode?: DarkMode): boolean {
55
- if (mode === 'force') return true;
56
- return false;
57
- }
58
-
59
- function buildThemeStyleBlock(theme: ResolvedTheme): string {
60
- const accent = theme.colors.categorical[0] ?? cssTokenDefault('--oc-accent', 'light');
61
- const bg =
62
- theme.colors.background === 'transparent'
63
- ? theme.isDark
64
- ? cssTokenDefault('--oc-bg', 'dark')
65
- : cssTokenDefault('--oc-bg', 'light')
66
- : theme.colors.background;
67
-
68
- const props = [
69
- `--oc-font-family: ${theme.fonts.family}`,
70
- `--oc-font-mono: ${theme.fonts.mono}`,
71
- `--oc-title-size: ${theme.chrome.title.fontSize}px`,
72
- `--oc-title-weight: ${theme.chrome.title.fontWeight}`,
73
- `--oc-title-tracking: ${cssTokenDefault('--oc-title-tracking', 'light')}`,
74
- `--oc-subtitle-size: ${theme.chrome.subtitle.fontSize}px`,
75
- `--oc-subtitle-weight: ${theme.chrome.subtitle.fontWeight}`,
76
- `--oc-source-size: ${theme.chrome.source.fontSize}px`,
77
- `--oc-source-weight: ${theme.chrome.source.fontWeight}`,
78
- `--oc-body-size: ${theme.fonts.sizes.body}px`,
79
- `--oc-eyebrow-size: ${theme.chrome.eyebrow.fontSize}px`,
80
- `--oc-eyebrow-weight: ${theme.chrome.eyebrow.fontWeight}`,
81
- `--oc-eyebrow-tracking: ${cssTokenDefault('--oc-eyebrow-tracking', 'light')}`,
82
- `--oc-bg: ${bg}`,
83
- `--oc-text: ${theme.colors.text}`,
84
- `--oc-text-muted: ${theme.colors.axis}`,
85
- `--oc-text-faint: ${theme.isDark ? cssTokenDefault('--oc-text-faint', 'dark') : cssTokenDefault('--oc-text-faint', 'light')}`,
86
- `--oc-gridline: ${theme.colors.gridline}`,
87
- `--oc-axis: ${theme.colors.axis}`,
88
- `--oc-border-radius: ${theme.borderRadius}px`,
89
- `--oc-accent: ${accent}`,
90
- `--oc-accent-strong: ${adaptForLightLineStroke(accent)}`,
91
- `--oc-positive: ${theme.colors.positive}`,
92
- `--oc-negative: ${theme.colors.negative}`,
93
- `--oc-legend-text: ${theme.isDark ? cssTokenDefault('--oc-legend-text', 'dark') : cssTokenDefault('--oc-legend-text', 'light')}`,
94
- `--oc-space-2: ${theme.spacing.chromeGap * 2}px`,
95
- `--oc-space-4: ${theme.spacing.padding}px`,
96
- ];
97
-
98
- const rules = [
99
- `svg.oc-chart { ${props.join('; ')}; }`,
100
- `.oc-chrome { font-family: var(--oc-font-family); }`,
101
- `.oc-eyebrow { font-size: var(--oc-eyebrow-size); font-weight: var(--oc-eyebrow-weight); letter-spacing: var(--oc-eyebrow-tracking); text-transform: uppercase; fill: var(--oc-accent); }`,
102
- `.oc-title { font-size: var(--oc-title-size); font-weight: var(--oc-title-weight); letter-spacing: var(--oc-title-tracking); fill: var(--oc-text); }`,
103
- `.oc-subtitle { font-size: var(--oc-subtitle-size); font-weight: var(--oc-subtitle-weight); fill: var(--oc-text-muted); }`,
104
- `.oc-source, .oc-byline, .oc-footer { font-size: var(--oc-source-size); font-weight: var(--oc-source-weight); fill: var(--oc-text-muted); }`,
105
- `.oc-brand { font-size: 11px; font-weight: 510; letter-spacing: 0.02em; fill: var(--oc-text-faint); }`,
106
- `.oc-brand-dot { fill: var(--oc-accent); }`,
107
- `.oc-eyebrow-dot { fill: var(--oc-accent); }`,
108
- `.oc-metrics { font-family: var(--oc-font-family); }`,
109
- `.oc-metric-label { font-size: 10px; font-weight: 510; letter-spacing: 0.08em; text-transform: uppercase; fill: var(--oc-text-muted); }`,
110
- `.oc-metric-value { font-size: 22px; font-weight: 510; letter-spacing: -0.01em; fill: var(--oc-text); font-variant-numeric: tabular-nums; }`,
111
- `.oc-metric-delta-up { fill: var(--oc-positive); font-size: 12px; font-weight: 510; }`,
112
- `.oc-metric-delta-down { fill: var(--oc-negative); font-size: 12px; font-weight: 510; }`,
113
- `.oc-axis-tick-inline { font-size: 11px; font-weight: 400; fill: var(--oc-text-muted); }`,
114
- `.oc-endpoint-labels { font-family: var(--oc-font-family); }`,
115
- `.oc-endpoint-label { fill: var(--oc-endpoint-label-color, var(--oc-text)); }`,
116
- `.oc-endpoint-value { fill: var(--oc-endpoint-value-color, var(--oc-text-muted)); }`,
117
- `.oc-endpoint-leader { stroke: var(--oc-endpoint-leader-color, currentColor); }`,
118
- `.oc-annotation-subtitle { fill: var(--oc-annotation-subtitle-color, var(--oc-text-muted)); }`,
119
- `.oc-metric-secondary { fill: var(--oc-positive); font-size: 12px; font-weight: 400; }`,
120
- `.oc-legend { font-family: var(--oc-font-family); font-size: var(--oc-body-size); }`,
121
- `.oc-legend-entry { cursor: default; }`,
122
- `.oc-legend text { fill: var(--oc-legend-text); }`,
123
- ];
124
-
125
- return rules.join('\n');
126
- }
127
-
128
- function stripInteractiveElements(svg: Element): void {
129
- const selectors = ['[data-voronoi-overlay]', '[data-crosshair]', '[data-snap-dots]'];
130
- for (const selector of selectors) {
131
- const els = svg.querySelectorAll(selector);
132
- for (const el of els) {
133
- el.parentNode?.removeChild(el);
134
- }
135
- }
136
- }
137
-
138
- /**
139
- * Render a chart spec to a standalone SVG string without a browser DOM.
140
- *
141
- * Requires `happy-dom` as a peer dependency (`bun add happy-dom`).
142
- *
143
- * Not safe for concurrent invocation: the render pipeline relies on a global
144
- * SVG ID counter and a temporary global `document` swap, both of which are
145
- * single-threaded. In a server handling parallel requests, serialize calls
146
- * through a queue or mutex.
147
- *
148
- * The entire render pipeline is synchronous; the global swap is safe as long
149
- * as no code schedules microtasks that outlive the call.
150
- */
151
- export function renderStaticSVG(
152
- spec: ChartSpec | LayerSpec | TileMapSpec,
153
- options?: StaticRenderOptions,
154
- ): string {
155
- if (rendering) {
156
- throw new Error('renderStaticSVG is not reentrant — serialize calls through a queue or mutex');
157
- }
158
- rendering = true;
159
-
160
- const Window = getHappyDomWindow();
161
- const win = new Window({ url: 'about:blank' });
162
-
163
- const prevDocument = globalThis.document;
164
- const prevWindow = (globalThis as Record<string, unknown>).window;
165
-
166
- try {
167
- (globalThis as Record<string, unknown>).document = win.document;
168
- (globalThis as Record<string, unknown>).window = win;
169
-
170
- resetSvgIdCounter();
171
-
172
- const width = options?.width ?? 640;
173
- const height = options?.height ?? 420;
174
- const darkMode = resolveStaticDarkMode(options?.darkMode);
175
-
176
- const compileOpts: CompileOptions = {
177
- width,
178
- height,
179
- theme: options?.theme,
180
- darkMode,
181
- watermark: options?.watermark,
182
- };
183
-
184
- let svg: SVGElement;
185
- let themeForStyle: ResolvedTheme;
186
-
187
- if (isTileMapSpec(spec)) {
188
- const tileMapLayout = compileTileMap(spec, compileOpts);
189
- svg = renderTileMapSVG(tileMapLayout, { animate: false });
190
- themeForStyle = tileMapLayout.theme;
191
- } else {
192
- let layout: ChartLayout;
193
- if (isLayerSpec(spec)) {
194
- layout = compileLayer(spec, compileOpts);
195
- } else {
196
- layout = compileChart(spec, compileOpts);
197
- }
198
-
199
- const container = win.document.createElement('div');
200
- Object.defineProperty(container, 'getBoundingClientRect', {
201
- value: () => ({
202
- width,
203
- height,
204
- top: 0,
205
- left: 0,
206
- right: width,
207
- bottom: height,
208
- x: 0,
209
- y: 0,
210
- toJSON: () => ({}),
211
- }),
212
- });
213
-
214
- svg = renderChartSVG(layout, container as unknown as HTMLElement, {
215
- animate: false,
216
- crosshair: false,
217
- });
218
-
219
- stripInteractiveElements(svg);
220
- themeForStyle = layout.theme;
221
- }
222
-
223
- const doc = win.document as unknown as Document;
224
- let defs = svg.querySelector('defs');
225
- if (!defs) {
226
- defs = doc.createElementNS(SVG_NS, 'defs');
227
- svg.insertBefore(defs as unknown as Node, svg.firstChild);
228
- }
229
- const styleEl = doc.createElementNS(SVG_NS, 'style');
230
- styleEl.textContent = buildThemeStyleBlock(themeForStyle);
231
- defs.insertBefore(styleEl as unknown as Node, defs.firstChild);
232
-
233
- const serializer = new (
234
- win as unknown as { XMLSerializer: typeof XMLSerializer }
235
- ).XMLSerializer();
236
- return serializer.serializeToString(svg as unknown as Node);
237
- } finally {
238
- rendering = false;
239
- if (prevDocument !== undefined) {
240
- (globalThis as Record<string, unknown>).document = prevDocument;
241
- } else {
242
- delete (globalThis as Record<string, unknown>).document;
243
- }
244
- if (prevWindow !== undefined) {
245
- (globalThis as Record<string, unknown>).window = prevWindow;
246
- } else {
247
- delete (globalThis as Record<string, unknown>).window;
248
- }
249
- win.close();
250
- }
251
- }