@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,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
- }