@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,338 +0,0 @@
1
- /**
2
- * Map SVG renderer: converts a MapLayout into SVG DOM elements.
3
- *
4
- * Creates an <svg> with geo feature paths, border overlays, gradient or
5
- * categorical legend, and chrome. All styling via inline SVG attributes from
6
- * layout data. Animation is pure CSS, driven by data attributes.
7
- */
8
-
9
- import type {
10
- MapBorders,
11
- MapFeatureMark,
12
- MapLayout,
13
- ResolvedAnimation,
14
- } from '@opendata-ai/openchart-core';
15
- import { stampAnimationVars } from './animation-vars';
16
- import { renderChromeElement } from './renderers/chrome';
17
- import { renderLegend } from './renderers/legend';
18
-
19
- const SVG_NS = 'http://www.w3.org/2000/svg';
20
- const XLINK_NS = 'http://www.w3.org/1999/xlink';
21
- const BRAND_URL = 'https://tryopendata.ai';
22
-
23
- // Above this count, skip per-feature CSS fill animations (not GPU-compositable)
24
- // and fade the entire features group with a single opacity animation instead.
25
- const BULK_ANIMATION_THRESHOLD = 200;
26
-
27
- // ---------------------------------------------------------------------------
28
- // Helpers
29
- // ---------------------------------------------------------------------------
30
-
31
- function createSVGElement(tag: string): SVGElement {
32
- return document.createElementNS(SVG_NS, tag);
33
- }
34
-
35
- function setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {
36
- for (const [key, value] of Object.entries(attrs)) {
37
- el.setAttribute(key, String(value));
38
- }
39
- }
40
-
41
- // ---------------------------------------------------------------------------
42
- // Chrome rendering (delegates to the shared helper for text wrapping)
43
- // ---------------------------------------------------------------------------
44
-
45
- function renderChrome(parent: SVGElement, layout: MapLayout): void {
46
- const g = createSVGElement('g');
47
- g.setAttribute('class', 'oc-chrome');
48
-
49
- const { chrome, measureText } = layout;
50
- const bottomOffset = chrome.bottomAnchorY ?? layout.area.y + layout.area.height;
51
-
52
- if (chrome.title) {
53
- renderChromeElement(g, chrome.title, 'oc-title', 'title', measureText);
54
- }
55
- if (chrome.subtitle) {
56
- renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);
57
- }
58
- if (chrome.source) {
59
- renderChromeElement(
60
- g,
61
- { ...chrome.source, y: bottomOffset + chrome.source.y },
62
- 'oc-source',
63
- 'source',
64
- measureText,
65
- );
66
- }
67
- if (chrome.byline) {
68
- renderChromeElement(
69
- g,
70
- { ...chrome.byline, y: bottomOffset + chrome.byline.y },
71
- 'oc-byline',
72
- 'byline',
73
- measureText,
74
- );
75
- }
76
- if (chrome.footer) {
77
- renderChromeElement(
78
- g,
79
- { ...chrome.footer, y: bottomOffset + chrome.footer.y },
80
- 'oc-footer',
81
- 'footer',
82
- measureText,
83
- );
84
- }
85
-
86
- parent.appendChild(g);
87
- }
88
-
89
- // ---------------------------------------------------------------------------
90
- // Watermark rendering
91
- // ---------------------------------------------------------------------------
92
-
93
- function renderWatermark(parent: SVGElement, layout: MapLayout): void {
94
- if (layout.width < 480) return;
95
-
96
- const { width, height, theme } = layout;
97
- const padding = theme.spacing.padding;
98
- const rightEdge = width - padding;
99
- const bottomEdge = height - padding;
100
- const fill = theme.colors.axis;
101
-
102
- const a = createSVGElement('a');
103
- a.setAttribute('href', BRAND_URL);
104
- a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
105
- a.setAttribute('target', '_blank');
106
- a.setAttribute('rel', 'noopener');
107
- a.setAttribute('class', 'oc-chrome-ref');
108
-
109
- const text = createSVGElement('text');
110
- setAttrs(text, {
111
- x: rightEdge,
112
- y: bottomEdge,
113
- 'dominant-baseline': 'alphabetic',
114
- 'text-anchor': 'end',
115
- 'font-family': theme.fonts.family,
116
- 'font-size': 12,
117
- 'fill-opacity': 0.55,
118
- });
119
- (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
120
-
121
- const trySpan = createSVGElement('tspan');
122
- setAttrs(trySpan, { 'font-weight': 500 });
123
- trySpan.textContent = 'try';
124
-
125
- const openDataSpan = createSVGElement('tspan');
126
- setAttrs(openDataSpan, { 'font-weight': 600, 'font-size': 16 });
127
- openDataSpan.textContent = 'OpenData';
128
-
129
- const aiSpan = createSVGElement('tspan');
130
- setAttrs(aiSpan, { 'font-weight': 500 });
131
- aiSpan.textContent = '.ai';
132
-
133
- text.appendChild(trySpan);
134
- text.appendChild(openDataSpan);
135
- text.appendChild(aiSpan);
136
- a.appendChild(text);
137
- parent.appendChild(a);
138
- }
139
-
140
- // ---------------------------------------------------------------------------
141
- // Border rendering
142
- // ---------------------------------------------------------------------------
143
-
144
- function renderBorders(parent: SVGElement, borders: MapBorders): void {
145
- const g = createSVGElement('g');
146
- g.setAttribute('class', 'oc-map-borders');
147
-
148
- // Interior borders (between features)
149
- if (borders.interiorPath) {
150
- const interior = createSVGElement('path');
151
- setAttrs(interior, {
152
- d: borders.interiorPath,
153
- fill: 'none',
154
- stroke: borders.interiorStroke,
155
- 'stroke-width': 0.5,
156
- 'stroke-linejoin': 'round',
157
- });
158
- interior.setAttribute('class', 'oc-map-border-interior');
159
- interior.setAttribute('pointer-events', 'none');
160
- g.appendChild(interior);
161
- }
162
-
163
- // Outline border (coastline / outer boundary)
164
- if (borders.outlinePath) {
165
- const outline = createSVGElement('path');
166
- setAttrs(outline, {
167
- d: borders.outlinePath,
168
- fill: 'none',
169
- stroke: borders.outlineStroke,
170
- 'stroke-width': 1,
171
- 'stroke-linejoin': 'round',
172
- });
173
- outline.setAttribute('class', 'oc-map-border-outline');
174
- outline.setAttribute('pointer-events', 'none');
175
- g.appendChild(outline);
176
- }
177
-
178
- parent.appendChild(g);
179
- }
180
-
181
- // ---------------------------------------------------------------------------
182
- // Feature rendering
183
- // ---------------------------------------------------------------------------
184
-
185
- function renderFeatures(
186
- parent: SVGElement,
187
- features: MapFeatureMark[],
188
- animation?: ResolvedAnimation,
189
- staggerBudget = 0,
190
- ): void {
191
- const g = createSVGElement('g');
192
- g.setAttribute('class', 'oc-map-features');
193
- g.setAttribute('role', 'list');
194
-
195
- const bulk = features.length > BULK_ANIMATION_THRESHOLD;
196
-
197
- if (bulk && animation?.enter) {
198
- g.setAttribute('data-bulk-animate', '');
199
- }
200
-
201
- const maxIdx = features.length - 1;
202
-
203
- // Build evenly spaced delays then shuffle for organic pop-in
204
- // (skipped in bulk mode where the whole group fades as one)
205
- const shuffledDelays: number[] = [];
206
- if (!bulk && staggerBudget > 0 && maxIdx > 0) {
207
- for (let i = 0; i <= maxIdx; i++) {
208
- shuffledDelays.push((i / maxIdx) * staggerBudget);
209
- }
210
- // Seeded Fisher-Yates shuffle (deterministic per-map via first feature id)
211
- let seed = 0x9e3779b9 ^ (features.length * 2654435761);
212
- for (let i = maxIdx; i > 0; i--) {
213
- seed = (seed + 0x6d2b79f5) | 0;
214
- let t = seed ^ (seed >>> 15);
215
- t = Math.imul(t, t | 1);
216
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
217
- const r = ((t ^ (t >>> 14)) >>> 0) % (i + 1);
218
- [shuffledDelays[i], shuffledDelays[r]] = [shuffledDelays[r], shuffledDelays[i]];
219
- }
220
- }
221
-
222
- for (let i = 0; i < features.length; i++) {
223
- const feature = features[i];
224
- const path = createSVGElement('path');
225
- path.setAttribute('class', 'oc-map-feature');
226
- path.setAttribute('role', 'listitem');
227
- setAttrs(path, {
228
- d: feature.path,
229
- fill: feature.fill,
230
- stroke: feature.stroke,
231
- 'stroke-width': feature.strokeWidth,
232
- });
233
- path.setAttribute('data-feature-id', String(feature.id));
234
- path.setAttribute('data-key', String(feature.id));
235
- if (feature.name) {
236
- path.setAttribute('data-feature-name', feature.name);
237
- }
238
- if (feature.aria?.label) {
239
- path.setAttribute('aria-label', feature.aria.label);
240
- }
241
-
242
- // Per-feature animation props only in non-bulk mode
243
- if (!bulk && animation?.enter) {
244
- const idx = feature.animationIndex ?? i;
245
- path.setAttribute('data-animation-index', String(idx));
246
- const s = (path as SVGElement & ElementCSSInlineStyle).style;
247
- s.setProperty('--oc-mark-index', String(idx));
248
- s.setProperty('--oc-feature-fill', feature.fill);
249
- if (staggerBudget > 0 && maxIdx > 0) {
250
- s.setProperty('--oc-map-delay', `${shuffledDelays[i]}ms`);
251
- }
252
- }
253
-
254
- g.appendChild(path);
255
- }
256
-
257
- parent.appendChild(g);
258
- }
259
-
260
- // ---------------------------------------------------------------------------
261
- // Public API
262
- // ---------------------------------------------------------------------------
263
-
264
- /**
265
- * Render a MapLayout to an SVG element.
266
- */
267
- export function renderMapSVG(layout: MapLayout, opts?: { animate?: boolean }): SVGSVGElement {
268
- const { width, height, features, borders, a11y, watermark, animation } = layout;
269
- const animate = opts?.animate && !!animation?.enter;
270
-
271
- const svg = createSVGElement('svg') as SVGSVGElement;
272
- svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
273
- svg.setAttribute('role', 'img');
274
- if (a11y.altText) {
275
- svg.setAttribute('aria-label', a11y.altText);
276
- }
277
-
278
- const classes = animate ? 'oc-map oc-animate' : 'oc-map';
279
- svg.setAttribute('class', classes);
280
-
281
- const bulk = features.length > BULK_ANIMATION_THRESHOLD;
282
- let mapStaggerBudget = 0;
283
- if (animate && animation?.enter) {
284
- const dur = animation.enter.duration;
285
- const perFeature = Math.min(dur, 500);
286
- if (!bulk) {
287
- mapStaggerBudget = Math.round(dur * 1.2);
288
- }
289
- const n = Math.max(features.length, 1);
290
- const stagger = !bulk && n > 1 ? mapStaggerBudget / (n - 1) : 0;
291
- stampAnimationVars(svg, {
292
- duration: perFeature,
293
- stagger,
294
- annotationDelay: animation.annotationDelay,
295
- ease: animation.enter.ease,
296
- });
297
- }
298
-
299
- // Empty defs element (will be filled by gradient legend)
300
- const defs = createSVGElement('defs');
301
- svg.appendChild(defs);
302
-
303
- // Render chrome
304
- renderChrome(svg, layout);
305
-
306
- // Create map group offset to the drawing area
307
- const mapGroup = createSVGElement('g');
308
- mapGroup.setAttribute('class', 'oc-map-group');
309
- mapGroup.setAttribute('transform', `translate(${layout.area.x},${layout.area.y})`);
310
-
311
- // Camera group wraps features + borders only (chrome, legend, watermark stay outside)
312
- const cameraGroup = createSVGElement('g');
313
- cameraGroup.setAttribute('class', 'oc-map-camera');
314
- cameraGroup.setAttribute('data-oc-map-camera', '');
315
-
316
- // Render features first (so borders overlay them)
317
- renderFeatures(cameraGroup, features, animate ? animation : undefined, mapStaggerBudget);
318
-
319
- // Render borders on top of features
320
- renderBorders(cameraGroup, borders);
321
-
322
- mapGroup.appendChild(cameraGroup);
323
- svg.appendChild(mapGroup);
324
-
325
- // Render legend (continuous for quantitative, categorical for nominal)
326
- if (layout.continuousLegend) {
327
- renderLegend(svg, layout.continuousLegend);
328
- } else if (layout.categoricalLegend) {
329
- renderLegend(svg, layout.categoricalLegend);
330
- }
331
-
332
- // Render watermark
333
- if (watermark) {
334
- renderWatermark(svg, layout);
335
- }
336
-
337
- return svg;
338
- }
@@ -1,83 +0,0 @@
1
- /**
2
- * Map fill transition: tweens feature fills between layout snapshots.
3
- *
4
- * Captures current fill colors before a re-render, then interpolates
5
- * from previous to new fills using a rAF loop. Same approach as the
6
- * main chart transition driver, but scoped to map-specific concerns
7
- * (no geometry tweening, just color).
8
- */
9
-
10
- import { interpolateRgb } from 'd3-interpolate';
11
- import { easingFns } from './story/tween';
12
-
13
- export function captureFeatureFills(svg: SVGElement | null): Map<string, string> {
14
- const fills = new Map<string, string>();
15
- if (!svg) return fills;
16
- const paths = svg.querySelectorAll('.oc-map-feature[data-key]');
17
- for (const p of paths) {
18
- const key = p.getAttribute('data-key');
19
- const fill = p.getAttribute('fill');
20
- if (key && fill) fills.set(key, fill);
21
- }
22
- return fills;
23
- }
24
-
25
- export function runMapFillTransition(
26
- svg: SVGElement,
27
- prevFills: Map<string, string>,
28
- opts: { duration: number },
29
- ): { cancel: () => void } {
30
- const paths = svg.querySelectorAll('.oc-map-feature[data-key]');
31
- const tweens: Array<{ el: Element; from: string; to: string; interp: (t: number) => string }> =
32
- [];
33
-
34
- for (const p of paths) {
35
- const key = p.getAttribute('data-key');
36
- const to = p.getAttribute('fill');
37
- const from = key ? prevFills.get(key) : null;
38
- if (from && to && from !== to) {
39
- tweens.push({ el: p, from, to, interp: interpolateRgb(from, to) });
40
- // Synchronous from-state: set previous fill before first rAF
41
- p.setAttribute('fill', from);
42
- }
43
- }
44
-
45
- if (tweens.length === 0 || opts.duration <= 0) {
46
- for (const tw of tweens) tw.el.setAttribute('fill', tw.to);
47
- return { cancel: () => {} };
48
- }
49
-
50
- const duration = opts.duration;
51
- let startTime: number | null = null;
52
- let rafId = 0;
53
- let cancelled = false;
54
-
55
- function tick(now: number) {
56
- if (cancelled) return;
57
- if (startTime === null) startTime = now;
58
- const elapsed = now - startTime;
59
- const t = Math.min(1, elapsed / duration);
60
- const eased = easingFns.easeInOutCubic(t);
61
-
62
- for (const tw of tweens) {
63
- tw.el.setAttribute('fill', tw.interp(eased));
64
- }
65
-
66
- if (t < 1) {
67
- rafId = requestAnimationFrame(tick);
68
- }
69
- }
70
-
71
- rafId = requestAnimationFrame(tick);
72
-
73
- return {
74
- cancel() {
75
- cancelled = true;
76
- cancelAnimationFrame(rafId);
77
- // Snap to final state
78
- for (const tw of tweens) {
79
- tw.el.setAttribute('fill', tw.to);
80
- }
81
- },
82
- };
83
- }
@@ -1,134 +0,0 @@
1
- /**
2
- * Canvas-backed text measurement factory.
3
- *
4
- * Shared by mount.ts (charts) and sankey-mount.ts (sankey diagrams) so both
5
- * pipelines get accurate browser-measured text widths instead of the heuristic
6
- * fallback. Falls back to the heuristic when canvas isn't available (e.g. SSR).
7
- *
8
- * The font family is passed in (resolved from the container's --oc-font-family
9
- * or the spec theme) rather than hardcoded, so measurement matches what the SVG
10
- * text actually renders with. This matters on real devices where the primary
11
- * webfont (e.g. Inter) loads late: measuring against the wrong font produces
12
- * wrong widths and mangles layout.
13
- */
14
-
15
- import type { MeasureTextFn } from '@opendata-ai/openchart-core';
16
- import { estimateTextWidth } from '@opendata-ai/openchart-core';
17
-
18
- const DEFAULT_FONT_FAMILY = 'Inter, sans-serif';
19
-
20
- export function createMeasureText(fontFamily: string = DEFAULT_FONT_FAMILY): MeasureTextFn {
21
- let canvas: HTMLCanvasElement | null = null;
22
- let ctx: CanvasRenderingContext2D | null = null;
23
-
24
- return (
25
- text: string,
26
- fontSize: number,
27
- fontWeight?: number,
28
- ): { width: number; height: number } => {
29
- if (!canvas) {
30
- canvas = document.createElement('canvas');
31
- ctx = canvas.getContext('2d');
32
- }
33
- if (!ctx) {
34
- // Fallback: heuristic estimation
35
- return {
36
- width: estimateTextWidth(text, fontSize, fontWeight ?? 400),
37
- height: fontSize * 1.2,
38
- };
39
- }
40
-
41
- const weight = fontWeight ?? 400;
42
- ctx.font = `${weight} ${fontSize}px ${fontFamily}`;
43
- const metrics = ctx.measureText(text);
44
- return {
45
- width: metrics.width,
46
- height: fontSize * 1.2,
47
- };
48
- };
49
- }
50
-
51
- /**
52
- * Resolve the font family a container's SVG text will actually render with.
53
- *
54
- * Reads the --oc-font-family custom property (set by tokens.css once .oc-root
55
- * is applied, or overridden per-spec by the theme resolver), falling back to
56
- * the container's computed fontFamily, then to the default Inter stack. Returns
57
- * the previous hardcoded stack when there is no DOM (SSR) or no computed style.
58
- */
59
- export function resolveFontFamily(container: HTMLElement): string {
60
- if (typeof window === 'undefined' || typeof getComputedStyle !== 'function') {
61
- return DEFAULT_FONT_FAMILY;
62
- }
63
- try {
64
- const style = getComputedStyle(container);
65
- const custom = style.getPropertyValue('--oc-font-family').trim();
66
- if (custom) return custom;
67
- const computed = style.fontFamily?.trim();
68
- if (computed) return computed;
69
- } catch {
70
- // getComputedStyle can throw on detached nodes in some engines; fall through.
71
- }
72
- return DEFAULT_FONT_FAMILY;
73
- }
74
-
75
- /**
76
- * The first family in a CSS font stack, with surrounding quotes stripped, e.g.
77
- * `'"Inter Variable", Inter, sans-serif'` -> `Inter Variable`. Used to build a
78
- * representative check string for document.fonts.check().
79
- */
80
- export function primaryFontName(fontFamily: string): string {
81
- const first = fontFamily.split(',')[0]?.trim() ?? '';
82
- return first.replace(/^["']|["']$/g, '').trim();
83
- }
84
-
85
- /**
86
- * True when the primary font of the resolved stack is not yet loaded and a
87
- * later document.fonts.ready may change text metrics. False when fonts are
88
- * ready, unavailable (SSR / no FontFaceSet), or the primary is a generic family.
89
- */
90
- export function fontsPending(fontFamily: string): boolean {
91
- if (typeof document === 'undefined') return false;
92
- const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;
93
- if (!fonts || typeof fonts.check !== 'function') return false;
94
- const name = primaryFontName(fontFamily);
95
- if (!name) return false;
96
- const generics = new Set([
97
- 'sans-serif',
98
- 'serif',
99
- 'monospace',
100
- 'system-ui',
101
- 'cursive',
102
- 'fantasy',
103
- 'ui-sans-serif',
104
- 'ui-serif',
105
- 'ui-monospace',
106
- ]);
107
- if (generics.has(name.toLowerCase())) return false;
108
- try {
109
- return !fonts.check(`12px "${name}"`);
110
- } catch {
111
- return false;
112
- }
113
- }
114
-
115
- /**
116
- * Run `onReady` exactly once after webfonts finish loading, if they were
117
- * pending at call time. `isAlive` is checked before invoking so a destroyed
118
- * chart is never re-rendered. Returns the initial pending state so callers can
119
- * set their `ocFontsState` dataset flag. No-op (returns false) under SSR or
120
- * when the primary font is already loaded.
121
- */
122
- export function scheduleFontReload(
123
- fontFamily: string,
124
- isAlive: () => boolean,
125
- onReady: () => void,
126
- ): boolean {
127
- if (!fontsPending(fontFamily)) return false;
128
- const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;
129
- if (!fonts?.ready) return false;
130
- fonts.ready.then(() => {
131
- if (isAlive()) onReady();
132
- });
133
- return true;
134
- }