@opendata-ai/openchart-vanilla 8.0.0-rc.9 → 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 (150) hide show
  1. package/dist/{chunk-SPILUE5S.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/{chunk-IZQBT6QM.js → chunk-6BUCYCLD.js} +7283 -5881
  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-QW7ZB6NE.js → chunk-EKAUQAAX.js} +49 -45
  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-IZQBT6QM.js.map +0 -1
  30. package/dist/chunk-QW7ZB6NE.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 -1477
  39. package/src/__tests__/editable-prop.test.ts +0 -174
  40. package/src/__tests__/events.test.ts +0 -387
  41. package/src/__tests__/export.test.ts +0 -241
  42. package/src/__tests__/font-race.test.ts +0 -199
  43. package/src/__tests__/gradient-ids.test.ts +0 -159
  44. package/src/__tests__/gradient-utils.test.ts +0 -125
  45. package/src/__tests__/invert.test.ts +0 -187
  46. package/src/__tests__/known-bugs-render.test.ts +0 -183
  47. package/src/__tests__/map.test.ts +0 -636
  48. package/src/__tests__/mount.test.ts +0 -357
  49. package/src/__tests__/pattern-fills.test.ts +0 -105
  50. package/src/__tests__/resize-timing.test.ts +0 -218
  51. package/src/__tests__/sankey.test.ts +0 -133
  52. package/src/__tests__/selection-events.test.ts +0 -1062
  53. package/src/__tests__/series-search.test.ts +0 -311
  54. package/src/__tests__/static.test.ts +0 -141
  55. package/src/__tests__/svg-renderer.test.ts +0 -895
  56. package/src/__tests__/table-keyboard.test.ts +0 -361
  57. package/src/__tests__/table-mount.test.ts +0 -639
  58. package/src/__tests__/tilemap.test.ts +0 -158
  59. package/src/__tests__/tooltip.test.ts +0 -337
  60. package/src/__tests__/transition.test.ts +0 -1792
  61. package/src/__tests__/update-scrolly-matrix.test.ts +0 -563
  62. package/src/__tests__/you-draw-it.test.ts +0 -273
  63. package/src/animation-vars.ts +0 -42
  64. package/src/animation.ts +0 -76
  65. package/src/barlist-mount.ts +0 -383
  66. package/src/barlist-renderer.ts +0 -267
  67. package/src/dom-helpers.ts +0 -17
  68. package/src/export.ts +0 -482
  69. package/src/gradient-utils.ts +0 -138
  70. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  71. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  72. package/src/graph/__tests__/interaction.test.ts +0 -205
  73. package/src/graph/__tests__/keyboard.test.ts +0 -653
  74. package/src/graph/__tests__/search.test.ts +0 -88
  75. package/src/graph/__tests__/simulation.test.ts +0 -233
  76. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  77. package/src/graph/__tests__/zoom.test.ts +0 -203
  78. package/src/graph/canvas-renderer.ts +0 -747
  79. package/src/graph/interaction.ts +0 -367
  80. package/src/graph/keyboard.ts +0 -208
  81. package/src/graph/search.ts +0 -50
  82. package/src/graph/simulation-worker-url.ts +0 -12
  83. package/src/graph/simulation-worker.ts +0 -278
  84. package/src/graph/simulation.ts +0 -422
  85. package/src/graph/spatial-index.ts +0 -121
  86. package/src/graph/types.ts +0 -47
  87. package/src/graph/worker-protocol.ts +0 -70
  88. package/src/graph/zoom.ts +0 -134
  89. package/src/graph-mount.ts +0 -888
  90. package/src/index.ts +0 -76
  91. package/src/interactions/chart-events.ts +0 -139
  92. package/src/interactions/crosshair.ts +0 -233
  93. package/src/interactions/drag-handler.ts +0 -175
  94. package/src/interactions/editing-drags.ts +0 -649
  95. package/src/interactions/index.ts +0 -27
  96. package/src/interactions/invert.ts +0 -54
  97. package/src/interactions/keyboard-nav.ts +0 -111
  98. package/src/interactions/legend-interaction.ts +0 -46
  99. package/src/interactions/selection.ts +0 -286
  100. package/src/interactions/tooltip-events.ts +0 -72
  101. package/src/map-camera.ts +0 -111
  102. package/src/map-mount.ts +0 -797
  103. package/src/map-renderer.ts +0 -476
  104. package/src/map-transition.ts +0 -99
  105. package/src/measure-text.ts +0 -134
  106. package/src/mount.ts +0 -1434
  107. package/src/pattern-utils.ts +0 -110
  108. package/src/renderers/annotations.ts +0 -388
  109. package/src/renderers/axes.ts +0 -351
  110. package/src/renderers/brand.ts +0 -87
  111. package/src/renderers/chrome.ts +0 -196
  112. package/src/renderers/endpoint-labels.ts +0 -150
  113. package/src/renderers/legend.ts +0 -312
  114. package/src/renderers/marks.ts +0 -561
  115. package/src/renderers/metrics.ts +0 -63
  116. package/src/renderers/svg-dom.ts +0 -40
  117. package/src/renderers/table-cells.ts +0 -446
  118. package/src/resize-observer.ts +0 -46
  119. package/src/sankey-mount.ts +0 -622
  120. package/src/sankey-renderer.ts +0 -614
  121. package/src/series-search.ts +0 -392
  122. package/src/static.ts +0 -251
  123. package/src/story/__tests__/camera-math.test.ts +0 -145
  124. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  125. package/src/story/__tests__/progress-math.test.ts +0 -95
  126. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  127. package/src/story/__tests__/tween.test.ts +0 -159
  128. package/src/story/camera-math.ts +0 -125
  129. package/src/story/create-chart-story.ts +0 -353
  130. package/src/story/crossfade.ts +0 -70
  131. package/src/story/index.ts +0 -39
  132. package/src/story/progress-math.ts +0 -85
  133. package/src/story/resolve-camera-target.ts +0 -119
  134. package/src/story/scroll-driver.ts +0 -206
  135. package/src/story/story-camera.ts +0 -49
  136. package/src/story/tween.ts +0 -112
  137. package/src/story/types.ts +0 -76
  138. package/src/svg-ids.ts +0 -23
  139. package/src/svg-renderer.ts +0 -383
  140. package/src/table-keyboard.ts +0 -266
  141. package/src/table-mount.ts +0 -567
  142. package/src/table-renderer.ts +0 -414
  143. package/src/text-edit-overlay.ts +0 -389
  144. package/src/theme-tokens.ts +0 -51
  145. package/src/tilemap-mount.ts +0 -511
  146. package/src/tilemap-renderer.ts +0 -396
  147. package/src/tooltip.ts +0 -177
  148. package/src/transition.ts +0 -2816
  149. package/src/you-draw-it.ts +0 -480
  150. /package/dist/{chunk-SPILUE5S.js.map → chunk-2NAQRP66.js.map} +0 -0
@@ -1,476 +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
- MapPointMark,
14
- ResolvedAnimation,
15
- } from '@opendata-ai/openchart-core';
16
- import { stampAnimationVars } from './animation-vars';
17
- import { FOCUS_DIM_OPACITY } from './map-camera';
18
- import { renderChromeElement } from './renderers/chrome';
19
- import { renderLegend } from './renderers/legend';
20
-
21
- const SVG_NS = 'http://www.w3.org/2000/svg';
22
- const XLINK_NS = 'http://www.w3.org/1999/xlink';
23
- const BRAND_URL = 'https://tryopendata.ai';
24
-
25
- // Above this count, skip per-feature CSS fill animations (not GPU-compositable)
26
- // and fade the entire features group with a single opacity animation instead.
27
- const BULK_ANIMATION_THRESHOLD = 200;
28
-
29
- // ---------------------------------------------------------------------------
30
- // Helpers
31
- // ---------------------------------------------------------------------------
32
-
33
- function createSVGElement(tag: string): SVGElement {
34
- return document.createElementNS(SVG_NS, tag);
35
- }
36
-
37
- function setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {
38
- for (const [key, value] of Object.entries(attrs)) {
39
- el.setAttribute(key, String(value));
40
- }
41
- }
42
-
43
- // ---------------------------------------------------------------------------
44
- // Chrome rendering (delegates to the shared helper for text wrapping)
45
- // ---------------------------------------------------------------------------
46
-
47
- function renderChrome(parent: SVGElement, layout: MapLayout): void {
48
- const g = createSVGElement('g');
49
- g.setAttribute('class', 'oc-chrome');
50
-
51
- const { chrome, measureText } = layout;
52
- const bottomOffset = chrome.bottomAnchorY ?? layout.area.y + layout.area.height;
53
-
54
- if (chrome.title) {
55
- renderChromeElement(g, chrome.title, 'oc-title', 'title', measureText);
56
- }
57
- if (chrome.subtitle) {
58
- renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);
59
- }
60
- if (chrome.source) {
61
- renderChromeElement(
62
- g,
63
- { ...chrome.source, y: bottomOffset + chrome.source.y },
64
- 'oc-source',
65
- 'source',
66
- measureText,
67
- );
68
- }
69
- if (chrome.byline) {
70
- renderChromeElement(
71
- g,
72
- { ...chrome.byline, y: bottomOffset + chrome.byline.y },
73
- 'oc-byline',
74
- 'byline',
75
- measureText,
76
- );
77
- }
78
- if (chrome.footer) {
79
- renderChromeElement(
80
- g,
81
- { ...chrome.footer, y: bottomOffset + chrome.footer.y },
82
- 'oc-footer',
83
- 'footer',
84
- measureText,
85
- );
86
- }
87
-
88
- parent.appendChild(g);
89
- }
90
-
91
- // ---------------------------------------------------------------------------
92
- // Watermark rendering
93
- // ---------------------------------------------------------------------------
94
-
95
- function renderWatermark(parent: SVGElement, layout: MapLayout): void {
96
- if (layout.width < 480) return;
97
-
98
- const { width, height, theme } = layout;
99
- const padding = theme.spacing.padding;
100
- const rightEdge = width - padding;
101
- const bottomEdge = height - padding;
102
- const fill = theme.colors.axis;
103
-
104
- const a = createSVGElement('a');
105
- a.setAttribute('href', BRAND_URL);
106
- a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
107
- a.setAttribute('target', '_blank');
108
- a.setAttribute('rel', 'noopener');
109
- a.setAttribute('class', 'oc-chrome-ref');
110
-
111
- const text = createSVGElement('text');
112
- setAttrs(text, {
113
- x: rightEdge,
114
- y: bottomEdge,
115
- 'dominant-baseline': 'alphabetic',
116
- 'text-anchor': 'end',
117
- 'font-family': theme.fonts.family,
118
- 'font-size': 12,
119
- 'fill-opacity': 0.55,
120
- });
121
- (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
122
-
123
- const trySpan = createSVGElement('tspan');
124
- setAttrs(trySpan, { 'font-weight': 500 });
125
- trySpan.textContent = 'try';
126
-
127
- const openDataSpan = createSVGElement('tspan');
128
- setAttrs(openDataSpan, { 'font-weight': 600, 'font-size': 16 });
129
- openDataSpan.textContent = 'OpenData';
130
-
131
- const aiSpan = createSVGElement('tspan');
132
- setAttrs(aiSpan, { 'font-weight': 500 });
133
- aiSpan.textContent = '.ai';
134
-
135
- text.appendChild(trySpan);
136
- text.appendChild(openDataSpan);
137
- text.appendChild(aiSpan);
138
- a.appendChild(text);
139
- parent.appendChild(a);
140
- }
141
-
142
- // ---------------------------------------------------------------------------
143
- // Border rendering
144
- // ---------------------------------------------------------------------------
145
-
146
- function renderBorders(parent: SVGElement, borders: MapBorders): void {
147
- const g = createSVGElement('g');
148
- g.setAttribute('class', 'oc-map-borders');
149
-
150
- // Interior borders (between features)
151
- if (borders.interiorPath) {
152
- const interior = createSVGElement('path');
153
- setAttrs(interior, {
154
- d: borders.interiorPath,
155
- fill: 'none',
156
- stroke: borders.interiorStroke,
157
- 'stroke-width': 0.5,
158
- 'stroke-linejoin': 'round',
159
- });
160
- interior.setAttribute('class', 'oc-map-border-interior');
161
- interior.setAttribute('pointer-events', 'none');
162
- g.appendChild(interior);
163
- }
164
-
165
- // Outline border (coastline / outer boundary)
166
- if (borders.outlinePath) {
167
- const outline = createSVGElement('path');
168
- setAttrs(outline, {
169
- d: borders.outlinePath,
170
- fill: 'none',
171
- stroke: borders.outlineStroke,
172
- 'stroke-width': 1,
173
- 'stroke-linejoin': 'round',
174
- });
175
- outline.setAttribute('class', 'oc-map-border-outline');
176
- outline.setAttribute('pointer-events', 'none');
177
- g.appendChild(outline);
178
- }
179
-
180
- parent.appendChild(g);
181
- }
182
-
183
- // ---------------------------------------------------------------------------
184
- // Feature rendering
185
- // ---------------------------------------------------------------------------
186
-
187
- function renderFeatures(
188
- parent: SVGElement,
189
- features: MapFeatureMark[],
190
- animation?: ResolvedAnimation,
191
- staggerBudget = 0,
192
- focusIds?: Set<string>,
193
- ): void {
194
- const g = createSVGElement('g');
195
- g.setAttribute('class', 'oc-map-features');
196
- g.setAttribute('role', 'list');
197
-
198
- const bulk = features.length > BULK_ANIMATION_THRESHOLD;
199
-
200
- if (bulk && animation?.enter) {
201
- g.setAttribute('data-bulk-animate', '');
202
- }
203
-
204
- const maxIdx = features.length - 1;
205
-
206
- // Build evenly spaced delays then shuffle for organic pop-in
207
- // (skipped in bulk mode where the whole group fades as one)
208
- const shuffledDelays: number[] = [];
209
- if (!bulk && staggerBudget > 0 && maxIdx > 0) {
210
- for (let i = 0; i <= maxIdx; i++) {
211
- shuffledDelays.push((i / maxIdx) * staggerBudget);
212
- }
213
- // Seeded Fisher-Yates shuffle (deterministic per-map via first feature id)
214
- let seed = 0x9e3779b9 ^ (features.length * 2654435761);
215
- for (let i = maxIdx; i > 0; i--) {
216
- seed = (seed + 0x6d2b79f5) | 0;
217
- let t = seed ^ (seed >>> 15);
218
- t = Math.imul(t, t | 1);
219
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
220
- const r = ((t ^ (t >>> 14)) >>> 0) % (i + 1);
221
- [shuffledDelays[i], shuffledDelays[r]] = [shuffledDelays[r], shuffledDelays[i]];
222
- }
223
- }
224
-
225
- for (let i = 0; i < features.length; i++) {
226
- const feature = features[i];
227
- const path = createSVGElement('path');
228
- path.setAttribute('class', 'oc-map-feature');
229
- path.setAttribute('role', 'listitem');
230
- setAttrs(path, {
231
- d: feature.path,
232
- fill: feature.fill,
233
- stroke: feature.stroke,
234
- 'stroke-width': feature.strokeWidth,
235
- });
236
- path.setAttribute('data-feature-id', String(feature.id));
237
- path.setAttribute('data-key', String(feature.id));
238
- if (feature.name) {
239
- path.setAttribute('data-feature-name', feature.name);
240
- }
241
- if (feature.aria?.label) {
242
- path.setAttribute('aria-label', feature.aria.label);
243
- }
244
-
245
- // Under a focus, features outside the set rest at the dim opacity. Set it
246
- // as the resting inline opacity (correct in every render path) and as the
247
- // entrance animation's target so they fade straight to dim instead of
248
- // hitting full opacity and snapping down when the dim lands post-animation.
249
- const dimmed = focusIds ? !focusIds.has(String(feature.id)) : false;
250
- const s = (path as SVGElement & ElementCSSInlineStyle).style;
251
- if (dimmed) {
252
- s.setProperty('opacity', String(FOCUS_DIM_OPACITY));
253
- }
254
-
255
- // Per-feature animation props only in non-bulk mode
256
- if (!bulk && animation?.enter) {
257
- const idx = feature.animationIndex ?? i;
258
- path.setAttribute('data-animation-index', String(idx));
259
- s.setProperty('--oc-mark-index', String(idx));
260
- s.setProperty('--oc-feature-fill', feature.fill);
261
- if (dimmed) {
262
- s.setProperty('--oc-feature-target-opacity', String(FOCUS_DIM_OPACITY));
263
- }
264
- if (staggerBudget > 0 && maxIdx > 0) {
265
- s.setProperty('--oc-map-delay', `${shuffledDelays[i]}ms`);
266
- }
267
- }
268
-
269
- g.appendChild(path);
270
- }
271
-
272
- parent.appendChild(g);
273
- }
274
-
275
- // ---------------------------------------------------------------------------
276
- // Point mark rendering
277
- // ---------------------------------------------------------------------------
278
-
279
- function renderPointMarks(
280
- parent: SVGElement,
281
- points: MapPointMark[],
282
- animation?: ResolvedAnimation,
283
- ): void {
284
- if (points.length === 0) return;
285
-
286
- const g = createSVGElement('g');
287
- g.setAttribute('class', 'oc-map-points');
288
-
289
- for (const pt of points) {
290
- const circle = createSVGElement('circle');
291
- circle.setAttribute('class', 'oc-map-point');
292
- setAttrs(circle, {
293
- cx: pt.cx,
294
- cy: pt.cy,
295
- r: pt.r,
296
- fill: pt.fill,
297
- 'fill-opacity': pt.fillOpacity,
298
- stroke: pt.stroke,
299
- 'stroke-width': pt.strokeWidth,
300
- });
301
- circle.setAttribute('data-point-key', pt.key);
302
- circle.setAttribute('data-base-r', String(pt.r));
303
- circle.setAttribute('data-base-stroke-width', String(pt.strokeWidth));
304
- if (pt.aria?.label) {
305
- circle.setAttribute('aria-label', pt.aria.label);
306
- }
307
- if (animation?.enter) {
308
- const s = (circle as unknown as ElementCSSInlineStyle).style;
309
- s.setProperty('--oc-mark-index', String(pt.animationIndex));
310
- s.setProperty('transform-origin', `${pt.cx}px ${pt.cy}px`);
311
- }
312
- g.appendChild(circle);
313
- }
314
-
315
- parent.appendChild(g);
316
- }
317
-
318
- // ---------------------------------------------------------------------------
319
- // Public API
320
- // ---------------------------------------------------------------------------
321
-
322
- /**
323
- * Render a MapLayout to an SVG element.
324
- */
325
- export function renderMapSVG(layout: MapLayout, opts?: { animate?: boolean }): SVGSVGElement {
326
- const { width, height, features, borders, a11y, watermark, animation } = layout;
327
- const animate = opts?.animate && !!animation?.enter;
328
-
329
- const svg = createSVGElement('svg') as SVGSVGElement;
330
- svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
331
- svg.setAttribute('role', 'img');
332
- if (a11y.altText) {
333
- svg.setAttribute('aria-label', a11y.altText);
334
- }
335
-
336
- const classes = animate ? 'oc-map oc-animate' : 'oc-map';
337
- svg.setAttribute('class', classes);
338
-
339
- const bulk = features.length > BULK_ANIMATION_THRESHOLD;
340
- let mapStaggerBudget = 0;
341
- if (animate && animation?.enter) {
342
- const dur = animation.enter.duration;
343
- const perFeature = Math.min(dur, 500);
344
- if (!bulk) {
345
- mapStaggerBudget = Math.round(dur * 1.2);
346
- }
347
- const n = Math.max(features.length, 1);
348
- const stagger = !bulk && n > 1 ? mapStaggerBudget / (n - 1) : 0;
349
- stampAnimationVars(svg, {
350
- duration: perFeature,
351
- stagger,
352
- annotationDelay: animation.annotationDelay,
353
- ease: animation.enter.ease,
354
- });
355
- }
356
-
357
- // Empty defs element (will be filled by gradient legend)
358
- const defs = createSVGElement('defs');
359
- svg.appendChild(defs);
360
-
361
- // Create map group offset to the drawing area
362
- const mapGroup = createSVGElement('g');
363
- mapGroup.setAttribute('class', 'oc-map-group');
364
- mapGroup.setAttribute('transform', `translate(${layout.area.x},${layout.area.y})`);
365
-
366
- // Camera group wraps features + borders only (chrome, legend, watermark stay outside)
367
- const cameraGroup = createSVGElement('g');
368
- cameraGroup.setAttribute('class', 'oc-map-camera');
369
- cameraGroup.setAttribute('data-oc-map-camera', '');
370
-
371
- // Features outside the focus set rest dimmed; the renderer stamps that so it's
372
- // correct in every render path and the entrance fades straight to it. This
373
- // handles *declarative* focus (layout.focus from the spec) only. Imperative
374
- // focus set via zoomTo()/panTo() never re-renders, so it dims through
375
- // applyMapCamera + applyFocusDim on the live SVG instead.
376
- const focusIdSet =
377
- layout.focus && layout.focus.ids.length > 0 ? new Set(layout.focus.ids.map(String)) : undefined;
378
-
379
- // Render features first (so borders overlay them)
380
- renderFeatures(
381
- cameraGroup,
382
- features,
383
- animate ? animation : undefined,
384
- mapStaggerBudget,
385
- focusIdSet,
386
- );
387
-
388
- // Render borders on top of features
389
- renderBorders(cameraGroup, borders);
390
-
391
- // Render point marks inside camera group (above borders).
392
- // Stamp a per-point stagger so the total spread stays bounded regardless of
393
- // point count (the CSS hardcoded 60ms per point which explodes past ~15 pts).
394
- if (animate && animation?.enter && layout.pointMarks.length > 1) {
395
- const pointStaggerBudget = animation.enter.duration * 0.5;
396
- const perPoint = pointStaggerBudget / (layout.pointMarks.length - 1);
397
- svg.style.setProperty('--oc-point-stagger', `${perPoint}ms`);
398
- }
399
- renderPointMarks(cameraGroup, layout.pointMarks, animate ? animation : undefined);
400
-
401
- mapGroup.appendChild(cameraGroup);
402
- svg.appendChild(mapGroup);
403
-
404
- // Render chrome AFTER the map group so the title/subtitle paint on top of a
405
- // focus-zoomed map that overflows up into the chrome band. Earlier SVG
406
- // children paint underneath later ones, so chrome must come last of the two.
407
- renderChrome(svg, layout);
408
-
409
- // Render legend (continuous for quantitative, categorical for nominal)
410
- if (layout.continuousLegend) {
411
- renderLegend(svg, layout.continuousLegend);
412
- } else if (layout.categoricalLegend) {
413
- renderLegend(svg, layout.categoricalLegend);
414
- }
415
-
416
- // Render point legends with a semi-transparent background so they read
417
- // clearly over map geography (e.g. AK/HI insets in Albers USA).
418
- const ptLegend = layout.pointCategoricalLegend ?? layout.pointContinuousLegend;
419
- if (ptLegend) {
420
- const isDark = layout.theme.isDark;
421
- const padX = 10;
422
- const padY = 8;
423
-
424
- // Compute content width from entries so the background wraps tightly
425
- const swatchSize =
426
- 'swatchSize' in ptLegend ? (ptLegend as { swatchSize: number }).swatchSize : 10;
427
- const swatchGap = 'swatchGap' in ptLegend ? (ptLegend as { swatchGap: number }).swatchGap : 6;
428
- const entryGap = 'entryGap' in ptLegend ? (ptLegend as { entryGap: number }).entryGap : 16;
429
- let contentWidth = ptLegend.bounds.width;
430
- if ('entries' in ptLegend && ptLegend.entries) {
431
- const cat = ptLegend as typeof layout.pointCategoricalLegend & {
432
- entries: Array<{ label: string }>;
433
- };
434
- if (cat && cat.entries.length > 0) {
435
- const fontSize = ptLegend.labelStyle.fontSize;
436
- let w = 0;
437
- for (const entry of cat.entries) {
438
- w += swatchSize + swatchGap + entry.label.length * fontSize * 0.6 + entryGap;
439
- }
440
- w -= entryGap;
441
- contentWidth = Math.min(w, ptLegend.bounds.width);
442
- }
443
- }
444
-
445
- // Center the background rect vertically around the swatch content.
446
- // Overlay continuous legends ('top-left') float over map geography, so
447
- // their backdrop must cover the full bounds (tick labels hang below the
448
- // bar) instead of just the swatch row. Categorical rows keep the
449
- // swatch-centered backdrop in both positions.
450
- const isOverlayContinuous =
451
- ptLegend.position === 'top-left' && !('entries' in ptLegend && ptLegend.entries);
452
- const contentCenterY = ptLegend.bounds.y + swatchSize / 2;
453
- const bgHeight = isOverlayContinuous
454
- ? ptLegend.bounds.height + padY * 2
455
- : swatchSize + padY * 2;
456
- const bgY = isOverlayContinuous ? ptLegend.bounds.y - padY : contentCenterY - bgHeight / 2;
457
- const bg = createSVGElement('rect');
458
- setAttrs(bg, {
459
- x: ptLegend.bounds.x - padX,
460
- y: bgY,
461
- width: contentWidth + padX * 2,
462
- height: bgHeight,
463
- rx: 4,
464
- fill: isDark ? 'rgba(24,24,27,0.9)' : 'rgba(255,255,255,0.9)',
465
- });
466
- svg.appendChild(bg);
467
- renderLegend(svg, ptLegend);
468
- }
469
-
470
- // Render watermark
471
- if (watermark) {
472
- renderWatermark(svg, layout);
473
- }
474
-
475
- return svg;
476
- }
@@ -1,99 +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
- const points = svg.querySelectorAll('.oc-map-point[data-point-key]');
23
- for (const p of points) {
24
- const key = `pt:${p.getAttribute('data-point-key')}`;
25
- const fill = p.getAttribute('fill');
26
- if (fill) fills.set(key, fill);
27
- }
28
- return fills;
29
- }
30
-
31
- export function runMapFillTransition(
32
- svg: SVGElement,
33
- prevFills: Map<string, string>,
34
- opts: { duration: number },
35
- ): { cancel: () => void } {
36
- const tweens: Array<{ el: Element; from: string; to: string; interp: (t: number) => string }> =
37
- [];
38
-
39
- const features = svg.querySelectorAll('.oc-map-feature[data-key]');
40
- for (const p of features) {
41
- const key = p.getAttribute('data-key');
42
- const to = p.getAttribute('fill');
43
- const from = key ? prevFills.get(key) : null;
44
- if (from && to && from !== to) {
45
- tweens.push({ el: p, from, to, interp: interpolateRgb(from, to) });
46
- p.setAttribute('fill', from);
47
- }
48
- }
49
-
50
- const points = svg.querySelectorAll('.oc-map-point[data-point-key]');
51
- for (const p of points) {
52
- const key = `pt:${p.getAttribute('data-point-key')}`;
53
- const to = p.getAttribute('fill');
54
- const from = prevFills.get(key);
55
- if (from && to && from !== to) {
56
- tweens.push({ el: p, from, to, interp: interpolateRgb(from, to) });
57
- p.setAttribute('fill', from);
58
- }
59
- }
60
-
61
- if (tweens.length === 0 || opts.duration <= 0) {
62
- for (const tw of tweens) tw.el.setAttribute('fill', tw.to);
63
- return { cancel: () => {} };
64
- }
65
-
66
- const duration = opts.duration;
67
- let startTime: number | null = null;
68
- let rafId = 0;
69
- let cancelled = false;
70
-
71
- function tick(now: number) {
72
- if (cancelled) return;
73
- if (startTime === null) startTime = now;
74
- const elapsed = now - startTime;
75
- const t = Math.min(1, elapsed / duration);
76
- const eased = easingFns.easeInOutCubic(t);
77
-
78
- for (const tw of tweens) {
79
- tw.el.setAttribute('fill', tw.interp(eased));
80
- }
81
-
82
- if (t < 1) {
83
- rafId = requestAnimationFrame(tick);
84
- }
85
- }
86
-
87
- rafId = requestAnimationFrame(tick);
88
-
89
- return {
90
- cancel() {
91
- cancelled = true;
92
- cancelAnimationFrame(rafId);
93
- // Snap to final state
94
- for (const tw of tweens) {
95
- tw.el.setAttribute('fill', tw.to);
96
- }
97
- },
98
- };
99
- }