@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,396 +0,0 @@
1
- /**
2
- * TileMap SVG renderer: converts a TileMapLayout into SVG DOM elements.
3
- *
4
- * Creates an <svg> with tile rectangles, state code labels, value labels,
5
- * gradient 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
- ResolvedAnimation,
11
- TileMapLayout,
12
- TileMapTileMark,
13
- } from '@opendata-ai/openchart-core';
14
- import { stampAnimationVars } from './animation-vars';
15
- import { renderChromeElement } from './renderers/chrome';
16
- import { renderLegend } from './renderers/legend';
17
- import { nextSvgId } from './svg-ids';
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
- // ---------------------------------------------------------------------------
24
- // Helpers
25
- // ---------------------------------------------------------------------------
26
-
27
- function createSVGElement(tag: string): SVGElement {
28
- return document.createElementNS(SVG_NS, tag);
29
- }
30
-
31
- function setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {
32
- for (const [key, value] of Object.entries(attrs)) {
33
- el.setAttribute(key, String(value));
34
- }
35
- }
36
-
37
- // ---------------------------------------------------------------------------
38
- // Chrome rendering (delegates to the shared helper for text wrapping)
39
- // ---------------------------------------------------------------------------
40
-
41
- function renderChrome(parent: SVGElement, layout: TileMapLayout): void {
42
- const g = createSVGElement('g');
43
- g.setAttribute('class', 'oc-chrome');
44
-
45
- const { chrome, measureText } = layout;
46
- const bottomOffset = layout.area.y + layout.area.height;
47
-
48
- if (chrome.title) {
49
- renderChromeElement(g, chrome.title, 'oc-title', 'title', measureText);
50
- }
51
- if (chrome.subtitle) {
52
- renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);
53
- }
54
- if (chrome.source) {
55
- renderChromeElement(
56
- g,
57
- { ...chrome.source, y: bottomOffset + chrome.source.y },
58
- 'oc-source',
59
- 'source',
60
- measureText,
61
- );
62
- }
63
- if (chrome.byline) {
64
- renderChromeElement(
65
- g,
66
- { ...chrome.byline, y: bottomOffset + chrome.byline.y },
67
- 'oc-byline',
68
- 'byline',
69
- measureText,
70
- );
71
- }
72
- if (chrome.footer) {
73
- renderChromeElement(
74
- g,
75
- { ...chrome.footer, y: bottomOffset + chrome.footer.y },
76
- 'oc-footer',
77
- 'footer',
78
- measureText,
79
- );
80
- }
81
-
82
- parent.appendChild(g);
83
- }
84
-
85
- // ---------------------------------------------------------------------------
86
- // Watermark rendering
87
- // ---------------------------------------------------------------------------
88
-
89
- function renderWatermark(parent: SVGElement, layout: TileMapLayout): void {
90
- if (layout.width < 480) return; // Don't render if too narrow
91
-
92
- const { width, height } = layout;
93
- const { theme } = layout;
94
- const padding = theme.spacing.padding;
95
- const rightEdge = width - padding;
96
- const bottomEdge = height - padding;
97
- const fill = theme.colors.axis;
98
-
99
- const a = createSVGElement('a');
100
- a.setAttribute('href', BRAND_URL);
101
- a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
102
- a.setAttribute('target', '_blank');
103
- a.setAttribute('rel', 'noopener');
104
- a.setAttribute('class', 'oc-chrome-ref');
105
-
106
- const text = createSVGElement('text');
107
- setAttrs(text, {
108
- x: rightEdge,
109
- y: bottomEdge,
110
- 'dominant-baseline': 'alphabetic',
111
- 'text-anchor': 'end',
112
- 'font-family': theme.fonts.family,
113
- 'font-size': 12,
114
- 'fill-opacity': 0.55,
115
- });
116
- (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
117
-
118
- const trySpan = createSVGElement('tspan');
119
- setAttrs(trySpan, { 'font-weight': 500 });
120
- trySpan.textContent = 'try';
121
-
122
- const openDataSpan = createSVGElement('tspan');
123
- setAttrs(openDataSpan, { 'font-weight': 600, 'font-size': 16 });
124
- openDataSpan.textContent = 'OpenData';
125
-
126
- const aiSpan = createSVGElement('tspan');
127
- setAttrs(aiSpan, { 'font-weight': 500 });
128
- aiSpan.textContent = '.ai';
129
-
130
- text.appendChild(trySpan);
131
- text.appendChild(openDataSpan);
132
- text.appendChild(aiSpan);
133
- a.appendChild(text);
134
- parent.appendChild(a);
135
- }
136
-
137
- // ---------------------------------------------------------------------------
138
- // Tiles rendering
139
- // ---------------------------------------------------------------------------
140
-
141
- function renderTiles(
142
- parent: SVGElement,
143
- tiles: TileMapTileMark[],
144
- animation?: ResolvedAnimation,
145
- ): void {
146
- const g = createSVGElement('g');
147
- g.setAttribute('class', 'oc-tilemap-tiles');
148
- g.setAttribute('role', 'list');
149
-
150
- // Compute per-tile delays with jitter for organic feel.
151
- // Base delay spreads tiles across ~800ms window, jitter adds +-40% variation
152
- // so some tiles pop in clusters while others have longer gaps.
153
- const tileDelays: number[] = [];
154
- if (animation?.enter) {
155
- const baseStagger = 800 / Math.max(tiles.length, 1);
156
- let seed = 17;
157
- for (let i = 0; i < tiles.length; i++) {
158
- const idx = tiles[i].animationIndex ?? i;
159
- seed = (seed * 1103515245 + 12345) & 0x7fffffff;
160
- const jitter = ((seed % 1000) / 1000 - 0.5) * 0.8;
161
- tileDelays.push(Math.max(0, Math.round(idx * baseStagger * (1 + jitter))));
162
- }
163
- }
164
-
165
- for (let i = 0; i < tiles.length; i++) {
166
- const tile = tiles[i];
167
- const tileGroup = createSVGElement('g');
168
- tileGroup.setAttribute('class', 'oc-tilemap-tile');
169
- tileGroup.setAttribute('data-state', tile.stateCode);
170
- tileGroup.setAttribute('role', 'listitem');
171
- if (tile.aria?.label) {
172
- tileGroup.setAttribute('aria-label', tile.aria.label);
173
- }
174
-
175
- if (animation?.enter) {
176
- const idx = tile.animationIndex ?? i;
177
- tileGroup.setAttribute('data-animation-index', String(idx));
178
- (tileGroup as SVGElement & ElementCSSInlineStyle).style.setProperty(
179
- '--oc-mark-index',
180
- String(idx),
181
- );
182
- (tileGroup as SVGElement & ElementCSSInlineStyle).style.setProperty(
183
- '--oc-tile-delay',
184
- `${tileDelays[i]}ms`,
185
- );
186
- }
187
-
188
- // Tile background rect
189
- const rect = createSVGElement('rect');
190
- setAttrs(rect, {
191
- x: tile.x,
192
- y: tile.y,
193
- width: tile.size,
194
- height: tile.size,
195
- rx: tile.cornerRadius,
196
- fill: tile.fill,
197
- 'fill-opacity': tile.fillOpacity ?? 1,
198
- stroke: tile.stroke,
199
- 'stroke-width': tile.strokeWidth,
200
- });
201
- tileGroup.appendChild(rect);
202
-
203
- // State code label
204
- const codeLabel = createSVGElement('text');
205
- setAttrs(codeLabel, {
206
- x: tile.label.x,
207
- y: tile.label.y,
208
- 'text-anchor': 'middle',
209
- 'dominant-baseline': 'central',
210
- 'font-family': tile.label.style.fontFamily,
211
- 'font-size': tile.label.style.fontSize,
212
- 'font-weight': tile.label.style.fontWeight,
213
- });
214
- (codeLabel as SVGElement & ElementCSSInlineStyle).style.setProperty(
215
- 'fill',
216
- tile.label.style.fill,
217
- );
218
- codeLabel.textContent = tile.label.text;
219
- tileGroup.appendChild(codeLabel);
220
-
221
- // Value label (if visible)
222
- if (tile.valueLabel.visible && tile.valueLabel.text) {
223
- const valueLabel = createSVGElement('text');
224
- setAttrs(valueLabel, {
225
- x: tile.valueLabel.x,
226
- y: tile.valueLabel.y,
227
- 'text-anchor': 'middle',
228
- 'dominant-baseline': 'central',
229
- 'font-family': tile.valueLabel.style.fontFamily,
230
- 'font-size': tile.valueLabel.style.fontSize,
231
- 'font-weight': tile.valueLabel.style.fontWeight,
232
- });
233
- (valueLabel as SVGElement & ElementCSSInlineStyle).style.setProperty(
234
- 'fill',
235
- tile.valueLabel.style.fill,
236
- );
237
- valueLabel.textContent = tile.valueLabel.text;
238
- tileGroup.appendChild(valueLabel);
239
- }
240
-
241
- g.appendChild(tileGroup);
242
- }
243
-
244
- parent.appendChild(g);
245
- }
246
-
247
- // ---------------------------------------------------------------------------
248
- // Gradient legend rendering
249
- // ---------------------------------------------------------------------------
250
-
251
- function renderGradientLegend(parent: SVGElement, layout: TileMapLayout): void {
252
- if (!layout.gradientLegend) return;
253
-
254
- const { gradientLegend } = layout;
255
- const g = createSVGElement('g');
256
- g.setAttribute('class', 'oc-tilemap-legend');
257
-
258
- // Build linear gradient in defs
259
- const defs = parent.querySelector('defs') || createSVGElement('defs');
260
- const exists = parent.querySelector('defs');
261
- if (!exists) {
262
- parent.insertBefore(defs, parent.firstChild);
263
- }
264
-
265
- const gradientId = nextSvgId('oc-tilemap-legend-gradient');
266
- const grad = createSVGElement('linearGradient');
267
- grad.id = gradientId;
268
- grad.setAttribute('x1', '0%');
269
- grad.setAttribute('y1', '0%');
270
- grad.setAttribute('x2', '100%');
271
- grad.setAttribute('y2', '0%');
272
-
273
- for (const stop of gradientLegend.colorStops) {
274
- const s = createSVGElement('stop');
275
- const attrs: Record<string, string | number> = {
276
- offset: `${stop.offset * 100}%`,
277
- 'stop-color': stop.color,
278
- };
279
- if (stop.opacity !== undefined) {
280
- attrs['stop-opacity'] = stop.opacity;
281
- }
282
- setAttrs(s, attrs);
283
- grad.appendChild(s);
284
- }
285
-
286
- (defs as SVGElement).appendChild(grad);
287
-
288
- // Gradient bar (pill-shaped)
289
- const barHeight = gradientLegend.bounds.height;
290
- const bar = createSVGElement('rect');
291
- setAttrs(bar, {
292
- x: gradientLegend.bounds.x,
293
- y: gradientLegend.bounds.y,
294
- width: gradientLegend.bounds.width,
295
- height: barHeight,
296
- rx: barHeight / 2,
297
- fill: `url(#${gradientId})`,
298
- });
299
- g.appendChild(bar);
300
-
301
- // Min label
302
- const minText = createSVGElement('text');
303
- setAttrs(minText, {
304
- x: gradientLegend.bounds.x,
305
- y: gradientLegend.bounds.y + gradientLegend.bounds.height + 14,
306
- 'text-anchor': 'start',
307
- 'font-family': gradientLegend.labelStyle.fontFamily,
308
- 'font-size': gradientLegend.labelStyle.fontSize,
309
- 'font-weight': gradientLegend.labelStyle.fontWeight,
310
- });
311
- (minText as SVGElement & ElementCSSInlineStyle).style.setProperty(
312
- 'fill',
313
- gradientLegend.labelStyle.fill,
314
- );
315
- minText.textContent = gradientLegend.minLabel;
316
- g.appendChild(minText);
317
-
318
- // Max label
319
- const maxText = createSVGElement('text');
320
- setAttrs(maxText, {
321
- x: gradientLegend.bounds.x + gradientLegend.bounds.width,
322
- y: gradientLegend.bounds.y + gradientLegend.bounds.height + 14,
323
- 'text-anchor': 'end',
324
- 'font-family': gradientLegend.labelStyle.fontFamily,
325
- 'font-size': gradientLegend.labelStyle.fontSize,
326
- 'font-weight': gradientLegend.labelStyle.fontWeight,
327
- });
328
- (maxText as SVGElement & ElementCSSInlineStyle).style.setProperty(
329
- 'fill',
330
- gradientLegend.labelStyle.fill,
331
- );
332
- maxText.textContent = gradientLegend.maxLabel;
333
- g.appendChild(maxText);
334
-
335
- parent.appendChild(g);
336
- }
337
-
338
- // ---------------------------------------------------------------------------
339
- // Public API
340
- // ---------------------------------------------------------------------------
341
-
342
- /**
343
- * Render a TileMapLayout to an SVG element.
344
- */
345
- export function renderTileMapSVG(
346
- layout: TileMapLayout,
347
- opts?: { animate?: boolean },
348
- ): SVGSVGElement {
349
- const { width, height, tiles, a11y, watermark, animation } = layout;
350
- const animate = opts?.animate && !!animation?.enter;
351
-
352
- const svg = createSVGElement('svg') as SVGSVGElement;
353
- svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
354
- svg.setAttribute('role', 'img');
355
- if (a11y.altText) {
356
- svg.setAttribute('aria-label', a11y.altText);
357
- }
358
-
359
- const classes = animate ? 'oc-tilemap oc-animate' : 'oc-tilemap';
360
- svg.setAttribute('class', classes);
361
-
362
- if (animate && animation?.enter) {
363
- // Target ~1s total: stagger window ~800ms + per-tile pop ~200ms
364
- const stagger = Math.max(5, Math.round(800 / Math.max(tiles.length, 1)));
365
- stampAnimationVars(svg, {
366
- duration: animation.enter.duration,
367
- stagger,
368
- annotationDelay: animation.annotationDelay,
369
- ease: animation.enter.ease,
370
- });
371
- }
372
-
373
- // Empty defs element (will be filled by gradient legend)
374
- const defs = createSVGElement('defs');
375
- svg.appendChild(defs);
376
-
377
- // Render chrome
378
- renderChrome(svg, layout);
379
-
380
- // Render tiles
381
- renderTiles(svg, tiles, animate ? animation : undefined);
382
-
383
- // Render legend (gradient for quantitative, categorical for nominal)
384
- if (layout.categoricalLegend) {
385
- renderLegend(svg, layout.categoricalLegend);
386
- } else {
387
- renderGradientLegend(svg, layout);
388
- }
389
-
390
- // Render watermark
391
- if (watermark) {
392
- renderWatermark(svg, layout);
393
- }
394
-
395
- return svg;
396
- }
package/src/tooltip.ts DELETED
@@ -1,177 +0,0 @@
1
- /**
2
- * Tooltip manager: creates and positions a floating tooltip element.
3
- *
4
- * Shows tooltip content near the mouse/touch position with viewport
5
- * edge avoidance via @floating-ui/dom. Touch support via tap-to-show,
6
- * tap-outside-to-hide.
7
- */
8
-
9
- import type { Placement } from '@floating-ui/dom';
10
- import { computePosition, flip, offset, shift } from '@floating-ui/dom';
11
- import type { TooltipContent } from '@opendata-ai/openchart-core';
12
-
13
- export interface TooltipShowOptions {
14
- /**
15
- * Floating-ui placement. Defaults to 'bottom-start' for mark hover
16
- * (the legacy behavior). Line/area snap-tooltips pass 'right-start'
17
- * so the card sits beside the snapped point and flips to 'left-start'
18
- * via the flip middleware when crowding the right edge.
19
- */
20
- placement?: Placement;
21
- }
22
-
23
- export interface TooltipManager {
24
- /** Show the tooltip with content at a given position. */
25
- show(content: TooltipContent, x: number, y: number, opts?: TooltipShowOptions): void;
26
- /** Hide the tooltip. */
27
- hide(): void;
28
- /** Remove the tooltip element and clean up event listeners. */
29
- destroy(): void;
30
- }
31
-
32
- const TOOLTIP_OFFSET = 12;
33
-
34
- /**
35
- * Create a tooltip manager attached to a container element.
36
- *
37
- * The manager creates a floating div positioned relative to the container.
38
- * Content is rendered as a title line with optional color indicator,
39
- * followed by a compact list of field-value pairs.
40
- *
41
- * @param container - The parent element for the tooltip.
42
- * @returns TooltipManager with show/hide/destroy methods.
43
- */
44
- export function createTooltipManager(container: HTMLElement): TooltipManager {
45
- const tooltip = document.createElement('div');
46
- tooltip.className = 'oc-tooltip';
47
- tooltip.setAttribute('role', 'tooltip');
48
-
49
- container.style.position = container.style.position || 'relative';
50
- container.appendChild(tooltip);
51
-
52
- // Track last content to skip innerHTML when only position changes
53
- let lastContentKey = '';
54
- // Generation counter to discard stale async position callbacks
55
- let currentPositionId = 0;
56
-
57
- // Hide on tap-outside for touch devices
58
- const handleDocumentTouch = (e: Event): void => {
59
- if (!container.contains(e.target as Node)) {
60
- hide();
61
- }
62
- };
63
- document.addEventListener('touchstart', handleDocumentTouch);
64
-
65
- // WCAG 1.4.13: hover-triggered content must be dismissible without moving
66
- // the pointer. Document-level so it works while hovering, when the chart
67
- // container does not have keyboard focus.
68
- const handleDocumentKeydown = (e: KeyboardEvent): void => {
69
- if (e.key === 'Escape' && tooltip.style.display === 'block') {
70
- hide();
71
- }
72
- };
73
- document.addEventListener('keydown', handleDocumentKeydown);
74
-
75
- function show(content: TooltipContent, x: number, y: number, opts?: TooltipShowOptions): void {
76
- // Fast content identity check: title + field count + first/last field values
77
- const contentKey = `${content.title}|${content.fields.length}|${content.fields[0]?.value}|${content.fields[content.fields.length - 1]?.value}`;
78
-
79
- if (contentKey !== lastContentKey) {
80
- lastContentKey = contentKey;
81
-
82
- let html = '';
83
-
84
- // Multi-series tooltips put a swatch on each row instead of in the
85
- // header, so detect that case once.
86
- const colorRowCount = content.fields.filter((f) => f.color).length;
87
- const perRowSwatches = colorRowCount > 1;
88
-
89
- // Title row: header dot only when there's a single color (single-series)
90
- if (content.title) {
91
- const titleColor = perRowSwatches ? undefined : content.fields.find((f) => f.color)?.color;
92
- html += '<div class="oc-tooltip-header">';
93
- if (titleColor) {
94
- html += `<span class="oc-tooltip-dot" style="background:${esc(titleColor)}"></span>`;
95
- }
96
- html += `<span class="oc-tooltip-title">${esc(content.title)}</span>`;
97
- html += '</div>';
98
- }
99
-
100
- // Field rows
101
- if (content.fields.length > 0) {
102
- html += '<div class="oc-tooltip-body">';
103
- for (const field of content.fields) {
104
- html += '<div class="oc-tooltip-row">';
105
- html += '<span class="oc-tooltip-label">';
106
- if (perRowSwatches && field.color) {
107
- html += `<span class="oc-tooltip-row-swatch" style="background:${esc(field.color)}"></span>`;
108
- }
109
- html += `${esc(field.label)}</span>`;
110
- html += `<span class="oc-tooltip-value">${esc(field.value)}</span>`;
111
- html += '</div>';
112
- }
113
- html += '</div>';
114
- }
115
-
116
- tooltip.innerHTML = html;
117
- }
118
-
119
- tooltip.style.display = 'block';
120
-
121
- // Position with viewport-aware edge avoidance via @floating-ui/dom.
122
- // Uses a virtual element since the reference point is a coordinate,
123
- // not a DOM element.
124
- const positionId = ++currentPositionId;
125
- const virtualRef = {
126
- getBoundingClientRect() {
127
- const rect = container.getBoundingClientRect();
128
- return {
129
- x: rect.left + x,
130
- y: rect.top + y,
131
- width: 0,
132
- height: 0,
133
- top: rect.top + y,
134
- left: rect.left + x,
135
- right: rect.left + x,
136
- bottom: rect.top + y,
137
- };
138
- },
139
- };
140
-
141
- computePosition(virtualRef, tooltip, {
142
- placement: opts?.placement ?? 'bottom-start',
143
- middleware: [offset(TOOLTIP_OFFSET), flip(), shift({ padding: 8 })],
144
- }).then(({ x: fx, y: fy }) => {
145
- // Discard stale callbacks from earlier show() calls
146
- if (positionId !== currentPositionId) return;
147
- // computePosition returns coordinates relative to the tooltip's offset
148
- // parent (the container with position: relative), so apply directly.
149
- tooltip.style.left = `${fx}px`;
150
- tooltip.style.top = `${fy}px`;
151
- });
152
- }
153
-
154
- function hide(): void {
155
- tooltip.style.display = 'none';
156
- lastContentKey = '';
157
- }
158
-
159
- function destroy(): void {
160
- document.removeEventListener('touchstart', handleDocumentTouch);
161
- document.removeEventListener('keydown', handleDocumentKeydown);
162
- if (tooltip.parentNode) {
163
- tooltip.parentNode.removeChild(tooltip);
164
- }
165
- }
166
-
167
- return { show, hide, destroy };
168
- }
169
-
170
- function esc(str: string): string {
171
- return str
172
- .replace(/&/g, '&amp;')
173
- .replace(/</g, '&lt;')
174
- .replace(/>/g, '&gt;')
175
- .replace(/"/g, '&quot;')
176
- .replace(/'/g, '&#x27;');
177
- }