@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,54 +0,0 @@
1
- import type { ScaleInvert } from '@opendata-ai/openchart-core';
2
-
3
- /**
4
- * Convert a pixel position back to a data-space value using the serialized
5
- * scale inversion anchors from ChartLayout. For band/point scales, snaps to
6
- * the nearest domain value. For continuous scales, applies the inverse of
7
- * the scale transform (log, pow, sqrt, symlog, or linear).
8
- */
9
- export function invertScale(invert: ScaleInvert, pixel: number): string | number {
10
- // Band/point: snap to nearest domain value
11
- if (invert.domain && invert.positions && invert.domain.length > 0) {
12
- let closest = 0;
13
- let minDist = Infinity;
14
- for (let i = 0; i < invert.positions.length; i++) {
15
- const dist = Math.abs(invert.positions[i] - pixel);
16
- if (dist < minDist) {
17
- minDist = dist;
18
- closest = i;
19
- }
20
- }
21
- return invert.domain[closest];
22
- }
23
- // Continuous: interpolate in the scale's native space
24
- const span = invert.bottomPixel - invert.topPixel;
25
- if (span === 0) return invert.topData;
26
- const t = (pixel - invert.topPixel) / span;
27
-
28
- const { topData, bottomData, scaleType } = invert;
29
-
30
- if (scaleType === 'log') {
31
- if (topData <= 0 || bottomData <= 0) return topData + t * (bottomData - topData);
32
- return Math.exp(Math.log(topData) + t * (Math.log(bottomData) - Math.log(topData)));
33
- }
34
- if (scaleType === 'sqrt') {
35
- const sqrtTop = Math.sqrt(Math.abs(topData)) * Math.sign(topData);
36
- const sqrtBottom = Math.sqrt(Math.abs(bottomData)) * Math.sign(bottomData);
37
- const sqrtVal = sqrtTop + t * (sqrtBottom - sqrtTop);
38
- return sqrtVal * Math.abs(sqrtVal);
39
- }
40
- if (scaleType === 'pow') {
41
- const powTop = Math.sign(topData) * Math.abs(topData) ** 0.5;
42
- const powBottom = Math.sign(bottomData) * Math.abs(bottomData) ** 0.5;
43
- const powVal = powTop + t * (powBottom - powTop);
44
- return Math.sign(powVal) * Math.abs(powVal) ** 2;
45
- }
46
- if (scaleType === 'symlog') {
47
- const symlog = (x: number) => Math.sign(x) * Math.log1p(Math.abs(x));
48
- const symexp = (x: number) => Math.sign(x) * Math.expm1(Math.abs(x));
49
- return symexp(symlog(topData) + t * (symlog(bottomData) - symlog(topData)));
50
- }
51
-
52
- // linear, time, or unspecified
53
- return topData + t * (bottomData - topData);
54
- }
@@ -1,111 +0,0 @@
1
- import type { ChartLayout, TooltipContent } from '@opendata-ai/openchart-core';
2
- import type { TooltipManager } from '../tooltip';
3
-
4
- /**
5
- * Wire keyboard navigation on the SVG element.
6
- * Arrow keys move focus between mark elements. Enter/Space shows tooltip.
7
- * Escape hides tooltip. Returns a cleanup function.
8
- */
9
- export function wireKeyboardNav(
10
- svg: SVGElement,
11
- container: HTMLElement,
12
- tooltipDescriptors: Map<string, TooltipContent>,
13
- tooltipManager: TooltipManager,
14
- layout: ChartLayout,
15
- ): () => void {
16
- container.setAttribute('tabindex', '0');
17
- container.setAttribute('aria-roledescription', 'chart');
18
- container.setAttribute('aria-label', layout.a11y.altText);
19
-
20
- const markElements: SVGElement[] = [];
21
- const allMarkEls = svg.querySelectorAll('[data-mark-id]');
22
- for (const el of allMarkEls) {
23
- const markId = el.getAttribute('data-mark-id');
24
- if (markId && tooltipDescriptors.has(markId)) {
25
- markElements.push(el as SVGElement);
26
- }
27
- }
28
-
29
- let focusIndex = -1;
30
-
31
- function highlightMark(index: number): void {
32
- if (focusIndex >= 0 && focusIndex < markElements.length) {
33
- markElements[focusIndex].classList.remove('oc-mark-focused');
34
- markElements[focusIndex].removeAttribute('aria-selected');
35
- }
36
-
37
- focusIndex = index;
38
-
39
- if (focusIndex >= 0 && focusIndex < markElements.length) {
40
- const el = markElements[focusIndex];
41
- el.classList.add('oc-mark-focused');
42
- el.setAttribute('aria-selected', 'true');
43
- }
44
- }
45
-
46
- function showTooltipForFocused(): void {
47
- if (focusIndex < 0 || focusIndex >= markElements.length) return;
48
-
49
- const el = markElements[focusIndex];
50
- const markId = el.getAttribute('data-mark-id');
51
- if (!markId) return;
52
-
53
- const content = tooltipDescriptors.get(markId);
54
- if (!content) return;
55
-
56
- const bbox = el.getBoundingClientRect();
57
- const containerRect = container.getBoundingClientRect();
58
- const x = bbox.left + bbox.width / 2 - containerRect.left;
59
- const y = bbox.top - containerRect.top;
60
- tooltipManager.show(content, x, y);
61
- }
62
-
63
- const handleKeyDown = (e: KeyboardEvent) => {
64
- if (markElements.length === 0) return;
65
-
66
- switch (e.key) {
67
- case 'ArrowRight':
68
- case 'ArrowDown': {
69
- e.preventDefault();
70
- const next = focusIndex < markElements.length - 1 ? focusIndex + 1 : 0;
71
- highlightMark(next);
72
- showTooltipForFocused();
73
- break;
74
- }
75
- case 'ArrowLeft':
76
- case 'ArrowUp': {
77
- e.preventDefault();
78
- const prev = focusIndex > 0 ? focusIndex - 1 : markElements.length - 1;
79
- highlightMark(prev);
80
- showTooltipForFocused();
81
- break;
82
- }
83
- case 'Enter':
84
- case ' ': {
85
- e.preventDefault();
86
- if (focusIndex >= 0) {
87
- showTooltipForFocused();
88
- } else if (markElements.length > 0) {
89
- highlightMark(0);
90
- showTooltipForFocused();
91
- }
92
- break;
93
- }
94
- case 'Escape': {
95
- e.preventDefault();
96
- tooltipManager.hide();
97
- highlightMark(-1);
98
- break;
99
- }
100
- }
101
- };
102
-
103
- container.addEventListener('keydown', handleKeyDown);
104
-
105
- return () => {
106
- container.removeEventListener('keydown', handleKeyDown);
107
- container.removeAttribute('tabindex');
108
- container.removeAttribute('aria-roledescription');
109
- container.removeAttribute('aria-label');
110
- };
111
- }
@@ -1,46 +0,0 @@
1
- import type { ChartLayout, ElementEdit } from '@opendata-ai/openchart-core';
2
-
3
- /**
4
- * Wire click handlers on legend entries to toggle series visibility.
5
- * Returns a cleanup function.
6
- */
7
- export function wireLegendInteraction(
8
- svg: SVGElement,
9
- _layout: ChartLayout,
10
- toggleSeries: (series: string) => boolean,
11
- onLegendToggle?: (series: string, visible: boolean) => void,
12
- onEdit?: (edit: ElementEdit) => void,
13
- ): () => void {
14
- // Scoped OUT of the size legend explicitly. Clicking a legend entry toggles a
15
- // *series*, and a size legend's circles are values, not series -- clicking
16
- // "500M" must not try to hide a series by that name. The size legend already
17
- // emits no `data-legend-index`, but a bare `[data-legend-index]` sweep would
18
- // silently pick one up the moment that changed, and the failure would be a
19
- // click that quietly hides the wrong thing rather than an error.
20
- const legendEntries = svg.querySelectorAll(
21
- '.oc-legend:not(.oc-legend--size) [data-legend-index]',
22
- );
23
- const cleanups: Array<() => void> = [];
24
-
25
- for (const entry of legendEntries) {
26
- if (entry.getAttribute('data-legend-overflow') === 'true') continue;
27
-
28
- const handleClick = () => {
29
- const label = entry.getAttribute('data-legend-label');
30
- if (!label) return;
31
-
32
- const nowHidden = toggleSeries(label);
33
- onLegendToggle?.(label, !nowHidden);
34
- onEdit?.({ type: 'legend-toggle', series: label, hidden: nowHidden });
35
- };
36
-
37
- entry.addEventListener('click', handleClick);
38
- cleanups.push(() => entry.removeEventListener('click', handleClick));
39
- }
40
-
41
- return () => {
42
- for (const cleanup of cleanups) {
43
- cleanup();
44
- }
45
- };
46
- }
@@ -1,286 +0,0 @@
1
- import type {
2
- Annotation,
3
- ChartLayout,
4
- ChartSpec,
5
- ChromeKey,
6
- ElementRef,
7
- GraphSpec,
8
- LayerSpec,
9
- TextAnnotation,
10
- } from '@opendata-ai/openchart-core';
11
- import { elementRef } from '@opendata-ai/openchart-core';
12
-
13
- /**
14
- * Find a DOM element inside the SVG that matches the given ElementRef.
15
- */
16
- export function findElementByRef(svg: SVGElement, ref: ElementRef): SVGElement | null {
17
- switch (ref.type) {
18
- case 'annotation': {
19
- if (ref.id) {
20
- const byId = svg.querySelector(`[data-annotation-id="${ref.id}"]`);
21
- if (byId) return byId as SVGElement;
22
- }
23
- return svg.querySelector(`[data-annotation-index="${ref.index}"]`) as SVGElement | null;
24
- }
25
- case 'chrome':
26
- return svg.querySelector(`[data-chrome-key="${ref.key}"]`) as SVGElement | null;
27
- case 'series-label':
28
- return svg.querySelector(`.oc-mark-label[data-series="${ref.series}"]`) as SVGElement | null;
29
- // The editable legend is the color legend. Excluding the size legend
30
- // explicitly rather than relying on it rendering second: `querySelector`
31
- // takes the first match, so a bare `.oc-legend` would silently retarget
32
- // (and drag) the wrong legend if render order ever changed.
33
- case 'legend':
34
- return svg.querySelector('.oc-legend:not(.oc-legend--size)') as SVGElement | null;
35
- case 'legend-entry':
36
- return svg.querySelector(
37
- `.oc-legend:not(.oc-legend--size) [data-legend-index="${ref.index}"]`,
38
- ) as SVGElement | null;
39
- }
40
- }
41
-
42
- /**
43
- * Build an ElementRef from a DOM element's data attributes.
44
- */
45
- export function encodePointerToken(token: string): string {
46
- return token.replace(/~/g, '~0').replace(/\//g, '~1');
47
- }
48
-
49
- export function buildElementRef(
50
- element: Element,
51
- _specAnnotations: Annotation[],
52
- ): ElementRef | null {
53
- const annotationEl = element.closest('[data-annotation-index]');
54
- if (annotationEl) {
55
- const index = Number(annotationEl.getAttribute('data-annotation-index'));
56
- const id = annotationEl.getAttribute('data-annotation-id') ?? undefined;
57
- return elementRef.annotation(index, id, `/annotations/${index}`);
58
- }
59
-
60
- const chromeEl = element.closest('[data-chrome-key]');
61
- if (chromeEl) {
62
- const key = chromeEl.getAttribute('data-chrome-key') as ChromeKey;
63
- if (key) return elementRef.chrome(key, `/chrome/${key}`);
64
- }
65
-
66
- const seriesLabelEl = element.closest('.oc-mark-label[data-series]');
67
- if (seriesLabelEl) {
68
- const series = seriesLabelEl.getAttribute('data-series');
69
- if (series)
70
- return elementRef.seriesLabel(series, `/labels/offsets/${encodePointerToken(series)}`);
71
- }
72
-
73
- const legendEntryEl = element.closest('[data-legend-index]');
74
- if (legendEntryEl) {
75
- const index = Number(legendEntryEl.getAttribute('data-legend-index'));
76
- const series = legendEntryEl.getAttribute('data-legend-label') ?? '';
77
- return elementRef.legendEntry(series, index);
78
- }
79
-
80
- // Only the color legend is selectable/editable. A click inside the size legend
81
- // resolves to no ref rather than reporting the *color* legend's ref, which is
82
- // what a bare `.oc-legend` match would do.
83
- const legendEl = element.closest('.oc-legend');
84
- if (legendEl && !legendEl.classList.contains('oc-legend--size'))
85
- return elementRef.legend('/legend');
86
-
87
- return null;
88
- }
89
-
90
- /**
91
- * Get an ordered list of all editable ElementRefs from the current spec and layout.
92
- */
93
- export function getEditableElements(
94
- spec: ChartSpec | LayerSpec | GraphSpec,
95
- layout: ChartLayout,
96
- ): ElementRef[] {
97
- const refs: ElementRef[] = [];
98
-
99
- const chromeKeys: ChromeKey[] = ['title', 'subtitle', 'source', 'byline', 'footer'];
100
- for (const key of chromeKeys) {
101
- if (layout.chrome[key]) {
102
- refs.push(elementRef.chrome(key));
103
- }
104
- }
105
-
106
- const annotations: Annotation[] =
107
- 'annotations' in spec && Array.isArray(spec.annotations) ? spec.annotations : [];
108
- for (let i = 0; i < annotations.length; i++) {
109
- refs.push(elementRef.annotation(i, annotations[i].id));
110
- }
111
-
112
- const seriesLabels: string[] = [];
113
- for (const mark of layout.marks) {
114
- if (mark.type === 'line' && mark.label?.visible && mark.seriesKey) {
115
- seriesLabels.push(mark.seriesKey);
116
- }
117
- }
118
- seriesLabels.sort();
119
- for (const series of seriesLabels) {
120
- refs.push(elementRef.seriesLabel(series));
121
- }
122
-
123
- if ('entries' in layout.legend && layout.legend.entries.length > 0) {
124
- refs.push(elementRef.legend());
125
- }
126
-
127
- return refs;
128
- }
129
-
130
- /**
131
- * Check if an ElementRef points to a text-editable element.
132
- */
133
- export function isTextEditable(ref: ElementRef, specAnnotations: Annotation[]): boolean {
134
- if (ref.type === 'chrome') return true;
135
- if (ref.type === 'annotation') {
136
- const annotation = specAnnotations[ref.index];
137
- return annotation?.type === 'text';
138
- }
139
- return false;
140
- }
141
-
142
- /**
143
- * Get the current text content for an element ref.
144
- */
145
- export function getElementText(
146
- ref: ElementRef,
147
- spec: ChartSpec | LayerSpec | GraphSpec,
148
- ): string | null {
149
- if (ref.type === 'chrome') {
150
- const chromeConfig = 'chrome' in spec ? spec.chrome : undefined;
151
- if (!chromeConfig) return null;
152
- const entry = chromeConfig[ref.key];
153
- if (typeof entry === 'string') return entry;
154
- if (typeof entry === 'object' && entry !== null && 'text' in entry) {
155
- return (entry as { text: string }).text;
156
- }
157
- return null;
158
- }
159
- if (ref.type === 'annotation') {
160
- const annotations: Annotation[] =
161
- 'annotations' in spec && Array.isArray(spec.annotations) ? spec.annotations : [];
162
- const annotation = annotations[ref.index];
163
- if (annotation?.type === 'text') return (annotation as TextAnnotation).text ?? null;
164
- if (annotation?.label) return annotation.label;
165
- return null;
166
- }
167
- return null;
168
- }
169
-
170
- /**
171
- * Compare two ElementRefs for equality.
172
- */
173
- export function refsEqual(a: ElementRef | null, b: ElementRef | null): boolean {
174
- if (a === null || b === null) return a === b;
175
- if (a.type !== b.type) return false;
176
- switch (a.type) {
177
- case 'annotation': {
178
- const bAnno = b as typeof a;
179
- if (a.id && bAnno.id) return a.id === bAnno.id;
180
- return a.index === bAnno.index;
181
- }
182
- case 'chrome':
183
- return a.key === (b as typeof a).key;
184
- case 'series-label':
185
- return a.series === (b as typeof a).series;
186
- case 'legend':
187
- return true;
188
- case 'legend-entry': {
189
- const bEntry = b as typeof a;
190
- return a.index === bEntry.index && a.series === bEntry.series;
191
- }
192
- }
193
- }
194
-
195
- /**
196
- * Render a selection overlay rectangle around a target element.
197
- */
198
- export function renderSelectionOverlay(
199
- svg: SVGElement,
200
- ref: ElementRef,
201
- layout: ChartLayout,
202
- ): SVGGElement | null {
203
- const target = findElementByRef(svg, ref);
204
- if (!target) return null;
205
-
206
- const bbox = (target as SVGGraphicsElement).getBBox();
207
- const padding = 4;
208
-
209
- const accentColor = layout.theme.colors.categorical?.[0] ?? '#4f46e5';
210
-
211
- const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
212
- g.setAttribute('class', 'oc-selection-overlay');
213
-
214
- const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
215
- rect.setAttribute('x', String(bbox.x - padding));
216
- rect.setAttribute('y', String(bbox.y - padding));
217
- rect.setAttribute('width', String(bbox.width + padding * 2));
218
- rect.setAttribute('height', String(bbox.height + padding * 2));
219
- rect.setAttribute('rx', '3');
220
- rect.setAttribute('fill', 'transparent');
221
- rect.setAttribute('stroke', accentColor);
222
- rect.setAttribute('stroke-width', '1.5');
223
- rect.setAttribute('pointer-events', 'none');
224
-
225
- g.appendChild(rect);
226
- svg.appendChild(g);
227
-
228
- return g;
229
- }
230
-
231
- /**
232
- * Create a visually-hidden data table from the chart's a11y fallback data.
233
- */
234
- export function createScreenReaderTable(
235
- layout: ChartLayout,
236
- container: HTMLElement,
237
- ): HTMLTableElement | null {
238
- const data = layout.a11y.dataTableFallback;
239
- if (!data || data.length === 0) return null;
240
-
241
- const table = document.createElement('table');
242
- table.className = 'oc-sr-only';
243
- table.style.position = 'absolute';
244
- table.style.width = '1px';
245
- table.style.height = '1px';
246
- table.style.padding = '0';
247
- table.style.margin = '-1px';
248
- table.style.overflow = 'hidden';
249
- table.style.clipPath = 'inset(50%)';
250
- table.style.whiteSpace = 'nowrap';
251
- table.style.borderWidth = '0';
252
- table.setAttribute('role', 'table');
253
- table.setAttribute('aria-label', `Data table: ${layout.a11y.altText}`);
254
-
255
- if (data.length > 0) {
256
- const thead = document.createElement('thead');
257
- const headerRow = document.createElement('tr');
258
- const headers = data[0] as unknown[];
259
- for (const header of headers) {
260
- const th = document.createElement('th');
261
- th.textContent = String(header ?? '');
262
- th.setAttribute('scope', 'col');
263
- headerRow.appendChild(th);
264
- }
265
- thead.appendChild(headerRow);
266
- table.appendChild(thead);
267
- }
268
-
269
- if (data.length > 1) {
270
- const tbody = document.createElement('tbody');
271
- for (let i = 1; i < data.length; i++) {
272
- const tr = document.createElement('tr');
273
- const cells = data[i] as unknown[];
274
- for (const cell of cells) {
275
- const td = document.createElement('td');
276
- td.textContent = String(cell ?? '');
277
- tr.appendChild(td);
278
- }
279
- tbody.appendChild(tr);
280
- }
281
- table.appendChild(tbody);
282
- }
283
-
284
- container.appendChild(table);
285
- return table;
286
- }
@@ -1,72 +0,0 @@
1
- import type { TooltipContent } from '@opendata-ai/openchart-core';
2
- import type { TooltipManager } from '../tooltip';
3
-
4
- /**
5
- * Wire tooltip events on mark elements inside an SVG.
6
- * Returns a cleanup function to remove all listeners.
7
- */
8
- export function wireTooltipEvents(
9
- svg: SVGElement,
10
- tooltipDescriptors: Map<string, TooltipContent>,
11
- tooltipManager: TooltipManager,
12
- ): () => void {
13
- const markElements = svg.querySelectorAll('[data-mark-id]');
14
- const cleanups: Array<() => void> = [];
15
-
16
- for (const el of markElements) {
17
- const markId = el.getAttribute('data-mark-id');
18
- if (!markId) continue;
19
-
20
- const content = tooltipDescriptors.get(markId);
21
- if (!content) continue;
22
-
23
- const handleMouseEnter = (e: Event) => {
24
- const mouseEvent = e as MouseEvent;
25
- const svgRect = svg.getBoundingClientRect();
26
- const x = mouseEvent.clientX - svgRect.left;
27
- const y = mouseEvent.clientY - svgRect.top;
28
- tooltipManager.show(content, x, y);
29
- };
30
-
31
- const handleMouseMove = (e: Event) => {
32
- const mouseEvent = e as MouseEvent;
33
- const svgRect = svg.getBoundingClientRect();
34
- const x = mouseEvent.clientX - svgRect.left;
35
- const y = mouseEvent.clientY - svgRect.top;
36
- tooltipManager.show(content, x, y);
37
- };
38
-
39
- const handleMouseLeave = () => {
40
- tooltipManager.hide();
41
- };
42
-
43
- const handleTouchStart = (e: Event) => {
44
- const touchEvent = e as TouchEvent;
45
- if (touchEvent.touches.length > 0) {
46
- const touch = touchEvent.touches[0];
47
- const svgRect = svg.getBoundingClientRect();
48
- const x = touch.clientX - svgRect.left;
49
- const y = touch.clientY - svgRect.top;
50
- tooltipManager.show(content, x, y);
51
- }
52
- };
53
-
54
- el.addEventListener('mouseenter', handleMouseEnter);
55
- el.addEventListener('mousemove', handleMouseMove);
56
- el.addEventListener('mouseleave', handleMouseLeave);
57
- el.addEventListener('touchstart', handleTouchStart);
58
-
59
- cleanups.push(() => {
60
- el.removeEventListener('mouseenter', handleMouseEnter);
61
- el.removeEventListener('mousemove', handleMouseMove);
62
- el.removeEventListener('mouseleave', handleMouseLeave);
63
- el.removeEventListener('touchstart', handleTouchStart);
64
- });
65
- }
66
-
67
- return () => {
68
- for (const cleanup of cleanups) {
69
- cleanup();
70
- }
71
- };
72
- }
package/src/map-camera.ts DELETED
@@ -1,111 +0,0 @@
1
- /**
2
- * Map camera utilities: zoom, pan, and focus for map visualizations.
3
- *
4
- * Reuses the existing camera-math infrastructure from the scrollytelling
5
- * system. The camera transform is applied as an SVG `transform` attribute
6
- * on the `[data-oc-map-camera]` group, never as a CSS transform.
7
- */
8
-
9
- import type { MapLayout } from '@opendata-ai/openchart-core';
10
- import type { Camera, CameraTarget } from './story/camera-math';
11
- import { cameraTransform, FULL_VIEW, fitTarget } from './story/camera-math';
12
-
13
- export type { Camera } from './story/camera-math';
14
-
15
- /** Resting opacity for features outside the current focus set. */
16
- export const FOCUS_DIM_OPACITY = 0.25;
17
-
18
- export interface MapCameraOptions {
19
- /** Transition duration in ms. 0 snaps instantly. Default 600. */
20
- duration?: number;
21
- /** Padding in map-local units around the target. Default 16. */
22
- padding?: number;
23
- }
24
-
25
- /**
26
- * Compute a CameraTarget from the union of feature bounds.
27
- * Returns null if no matching features found.
28
- */
29
- export function focusTargetForFeatures(
30
- layout: MapLayout,
31
- ids: Array<string | number>,
32
- padding = 16,
33
- ): CameraTarget | null {
34
- const idSet = new Set(ids.map(String));
35
- let minX = Infinity;
36
- let minY = Infinity;
37
- let maxX = -Infinity;
38
- let maxY = -Infinity;
39
- let found = false;
40
-
41
- for (const f of layout.features) {
42
- if (idSet.has(String(f.id))) {
43
- found = true;
44
- minX = Math.min(minX, f.bounds.x);
45
- minY = Math.min(minY, f.bounds.y);
46
- maxX = Math.max(maxX, f.bounds.x + f.bounds.width);
47
- maxY = Math.max(maxY, f.bounds.y + f.bounds.height);
48
- }
49
- }
50
-
51
- if (!found) return null;
52
- return { x: minX, y: minY, width: maxX - minX, height: maxY - minY, padding };
53
- }
54
-
55
- /**
56
- * Compute the Camera for a target, fitting against mapSize.
57
- * Returns the full-view camera if no target provided.
58
- */
59
- export function cameraForTarget(layout: MapLayout, target?: CameraTarget | null): Camera {
60
- const vb = layout.mapSize;
61
- if (!target) return fitTarget(FULL_VIEW(vb), vb);
62
- const cam = fitTarget(target, vb);
63
- return { ...cam, k: Math.max(1, Math.min(40, cam.k)) };
64
- }
65
-
66
- /**
67
- * Apply a camera transform to the map's camera group.
68
- * Toggles vector-effect="non-scaling-stroke" on features/borders while zoomed.
69
- */
70
- export function applyMapCamera(svg: SVGElement, camera: Camera, layout: MapLayout): void {
71
- const cameraGroup = svg.querySelector('[data-oc-map-camera]');
72
- if (!cameraGroup) return;
73
-
74
- const vb = layout.mapSize;
75
- const isZoomed = Math.abs(camera.k - 1) > 1e-3;
76
-
77
- if (isZoomed) {
78
- cameraGroup.setAttribute('transform', cameraTransform(camera, vb));
79
- // Toggle non-scaling-stroke so strokes don't fatten when zoomed
80
- const paths = svg.querySelectorAll('.oc-map-feature, .oc-map-borders path');
81
- for (const p of paths) {
82
- p.setAttribute('vector-effect', 'non-scaling-stroke');
83
- }
84
- // Counter-scale point radii so dots keep constant screen size through zoom.
85
- // Only use attribute counter-scaling (r/k, stroke-width/k) without
86
- // vector-effect: the group transform scales both back to constant screen
87
- // size. Adding vector-effect on top would double-compensate stroke.
88
- const points = svg.querySelectorAll('.oc-map-point');
89
- for (const p of points) {
90
- const baseR = Number(p.getAttribute('data-base-r') ?? 5);
91
- const baseSW = Number(p.getAttribute('data-base-stroke-width') ?? 1);
92
- p.setAttribute('r', String(baseR / camera.k));
93
- p.setAttribute('stroke-width', String(baseSW / camera.k));
94
- }
95
- } else {
96
- cameraGroup.removeAttribute('transform');
97
- const paths = svg.querySelectorAll('.oc-map-feature, .oc-map-borders path');
98
- for (const p of paths) {
99
- p.removeAttribute('vector-effect');
100
- }
101
- // Reset point radii when not zoomed
102
- const points = svg.querySelectorAll('.oc-map-point');
103
- for (const p of points) {
104
- const baseR = Number(p.getAttribute('data-base-r') ?? 5);
105
- const baseSW = Number(p.getAttribute('data-base-stroke-width') ?? 1);
106
- p.setAttribute('r', String(baseR));
107
- p.setAttribute('stroke-width', String(baseSW));
108
- p.removeAttribute('vector-effect');
109
- }
110
- }
111
- }