@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
package/src/svg-ids.ts DELETED
@@ -1,23 +0,0 @@
1
- /**
2
- * Monotonic SVG ID generator shared by gradient and clipPath defs.
3
- *
4
- * Why a single counter: SVG `url(#id)` references resolve against the full
5
- * document, so any ID collision (across charts on the same page) silently
6
- * cross-wires one chart's fill/clip into another. Random-hex suffixes have a
7
- * small-but-real collision probability that surfaced in production; a global
8
- * monotonic counter makes uniqueness unconditional.
9
- *
10
- * Gradient and clip-path IDs share one counter so we can't regress one half of
11
- * the system by accident. Consumers just pick a prefix.
12
- */
13
-
14
- let counter = 0;
15
-
16
- export function nextSvgId(prefix: string): string {
17
- return `${prefix}-${counter++}`;
18
- }
19
-
20
- /** @internal Used by static rendering only. Never call from browser-facing code. */
21
- export function resetSvgIdCounter(): void {
22
- counter = 0;
23
- }
@@ -1,383 +0,0 @@
1
- /**
2
- * SVG renderer: converts a ChartLayout into SVG DOM elements.
3
- *
4
- * Creates an <svg> element with viewBox matching layout dimensions,
5
- * renders chrome (title/subtitle/source), axes, marks, annotations,
6
- * and legend. All styling via inline SVG attributes from layout data.
7
- *
8
- * This file is the orchestrator only. Each rendering concern lives in its
9
- * own module under `./renderers/`.
10
- */
11
-
12
- import type {
13
- ChartLayout,
14
- FacetPanelLayout,
15
- RectMark,
16
- ResolvedFillPattern,
17
- } from '@opendata-ai/openchart-core';
18
- import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
19
- import { stampAnimationVars } from './animation-vars';
20
- import { buildGradientDefs } from './gradient-utils';
21
- import { buildPatternDefs } from './pattern-utils';
22
- import { renderAnnotations } from './renderers/annotations';
23
- import { renderAxes } from './renderers/axes';
24
- import { renderBrand } from './renderers/brand';
25
- import { renderChrome } from './renderers/chrome';
26
- import { renderEndpointLabels } from './renderers/endpoint-labels';
27
- import { renderLegend } from './renderers/legend';
28
- import { renderMarks, resetMarkRenderState, setMarkRenderState } from './renderers/marks';
29
- import { renderMetrics } from './renderers/metrics';
30
- import { createSVGElement, SVG_NS, setAttrs } from './renderers/svg-dom';
31
- import { nextSvgId } from './svg-ids';
32
-
33
- // Re-export registerMarkRenderer so external consumers can still register
34
- // custom mark renderers via the vanilla package entry point.
35
- export { registerMarkRenderer } from './renderers/marks';
36
-
37
- /**
38
- * Render a compiled ChartLayout into an SVG element and append it to a container.
39
- *
40
- * @param layout - Compiled ChartLayout from compileChart().
41
- * @param container - DOM element to mount the SVG into.
42
- * @returns The created SVG element.
43
- */
44
- export function renderChartSVG(
45
- layout: ChartLayout,
46
- container: HTMLElement,
47
- opts?: { animate?: boolean; crosshair?: boolean },
48
- ): SVGElement {
49
- const { width, height } = layout.dimensions;
50
- const animation = layout.animation;
51
-
52
- const svg = createSVGElement('svg') as SVGSVGElement;
53
- setAttrs(svg, {
54
- viewBox: `0 0 ${width} ${height}`,
55
- xmlns: SVG_NS,
56
- // The SVG spec default is overflow:"hidden", which clips anything a hair
57
- // outside the viewBox. We now position all text on the alphabetic/central
58
- // baseline (dominant-baseline:hanging was dropped because WebKit computed
59
- // it from different metrics), but WebKit/iOS still reports getBBox extents
60
- // with a few pixels of slack around tspans, so text touching an edge can
61
- // still get clipped. overflow:visible avoids that. Chart marks are already
62
- // constrained by a clipPath, so nothing bleeds out.
63
- overflow: 'visible',
64
- // Hint browsers to enable sub-pixel font hinting and kerning for chart text.
65
- 'text-rendering': 'optimizeLegibility',
66
- });
67
- // Set explicit pixel height via inline style. iOS Safari misresolves CSS
68
- // height:100% when the ancestor chain uses minHeight instead of height,
69
- // causing the top of the chart (title) to clip on real mobile devices.
70
- svg.style.height = `${height}px`;
71
- // Author opt-out (a11y.hidden) hides the chart from assistive technology;
72
- // mount.ts also skips the screen-reader table and keyboard nav wiring.
73
- if (layout.a11y.hidden) {
74
- svg.setAttribute('aria-hidden', 'true');
75
- } else {
76
- svg.setAttribute('role', layout.a11y.role);
77
- svg.setAttribute('aria-label', layout.a11y.altText);
78
- }
79
-
80
- // Sparkline display mode: stamp a data attribute so consumers can target
81
- // sparkline-specific styles. Only set the attribute in sparkline mode so
82
- // regular charts keep an unchanged DOM signature.
83
- if (layout.display === 'sparkline') {
84
- svg.setAttribute('data-display', 'sparkline');
85
- }
86
-
87
- // oc-animate must be set before the SVG enters the DOM to prevent a flash
88
- // of the final state. mount.ts passes animate: true only on genuine first render.
89
- const classes = opts?.animate ? 'oc-chart oc-animate' : 'oc-chart';
90
- svg.setAttribute('class', classes);
91
-
92
- // Set animation CSS custom properties when the enter phase is enabled
93
- const enterPhase = animation?.enter;
94
- if (enterPhase) {
95
- const markCount = layout.marks.length;
96
- const stagger = clampStaggerDelay(enterPhase.staggerDelay, markCount);
97
-
98
- // Compute per-segment duration for stacked bars so the total bar animation
99
- // time stays consistent regardless of segment count.
100
- // stackPos is set by the engine (0-indexed position within each stack group).
101
- let maxSegments = 0;
102
- for (const m of layout.marks) {
103
- if (m.type === 'rect') {
104
- const pos = (m as RectMark).stackPos;
105
- if (pos !== undefined && pos + 1 > maxSegments) {
106
- maxSegments = pos + 1;
107
- }
108
- }
109
- }
110
- const stackSegmentDuration =
111
- maxSegments > 0 ? Math.round(enterPhase.duration / maxSegments) : undefined;
112
-
113
- stampAnimationVars(svg, {
114
- duration: enterPhase.duration,
115
- stagger,
116
- annotationDelay: animation!.annotationDelay,
117
- ease: enterPhase.ease,
118
- stackSegmentDuration,
119
- });
120
- }
121
-
122
- // Background. Sparkline mode skips the background rect entirely so the
123
- // host page's surface color shows through — sparklines are drop-ins for
124
- // KPI cards, table cells, and inline contexts where the consumer owns
125
- // the background. Other display modes paint a fill so the chart is a
126
- // self-contained visual on any host surface.
127
- if (layout.display !== 'sparkline') {
128
- const bg = createSVGElement('rect');
129
- setAttrs(bg, {
130
- x: 0,
131
- y: 0,
132
- width,
133
- height,
134
- fill: layout.theme.colors.background,
135
- });
136
- svg.appendChild(bg);
137
- }
138
-
139
- // Clip path to prevent marks (especially area fills) from overflowing
140
- // into the chrome region (title/subtitle). Extends full width so
141
- // end-of-line labels aren't clipped, but constrains vertically.
142
- const clipId = nextSvgId('oc-clip');
143
- const defs = createSVGElement('defs');
144
- const clipPath = createSVGElement('clipPath');
145
- clipPath.setAttribute('id', clipId);
146
- const maxPointR = layout.marks.reduce(
147
- (max, m) =>
148
- m.type === 'point' && (m as { r?: number }).r ? Math.max(max, (m as { r?: number }).r!) : max,
149
- 0,
150
- );
151
- const clipPad = Math.max(maxPointR, 2);
152
- const clipRect = createSVGElement('rect');
153
- setAttrs(clipRect, {
154
- x: 0,
155
- y: layout.area.y - clipPad,
156
- width,
157
- height: layout.area.height + clipPad * 2,
158
- });
159
- clipPath.appendChild(clipRect);
160
- defs.appendChild(clipPath);
161
-
162
- // Build gradient defs for marks with gradient fills
163
- const gradientMap = buildGradientDefs(layout.marks as Array<{ fill?: unknown }>, defs);
164
-
165
- // Build pattern defs for marks with engine-assigned fill patterns
166
- const patternMap = buildPatternDefs(
167
- layout.marks as Array<{ pattern?: ResolvedFillPattern }>,
168
- defs,
169
- );
170
-
171
- svg.appendChild(defs);
172
-
173
- // Prime mark-renderer module-level state so mark sub-renderers can resolve
174
- // animation + gradient/pattern fills without signature changes. try/finally
175
- // guarantees the reset fires even if any downstream renderer throws, so the
176
- // next render starts with a clean slate.
177
- setMarkRenderState({ animation, gradientMap, patternMap });
178
- try {
179
- if (layout.facet) {
180
- // Faceted rendering: per-panel axes, marks, annotations
181
- renderFacetedPanels(svg, layout, layout.facet.panels, defs);
182
- } else {
183
- // Standard (non-faceted) rendering
184
- // Axes render outside clip (labels extend beyond chart area)
185
- renderAxes(svg, layout);
186
-
187
- // Marks are clipped to chart area so area fills don't cover chrome.
188
- // The clip and the camera transform must live on SEPARATE, nested groups:
189
- // `clip-path` with the default userSpaceOnUse resolves in the coordinate
190
- // system of the element's own `transform`, so putting both on one group
191
- // would drag the clip rect along with a story-camera zoom/pan and stop it
192
- // masking at the chart-area boundary. The outer group holds the clip fixed
193
- // to the area; the inner `data-oc-marks-group` is the stable hook external
194
- // features (e.g. the scrollytelling story camera) transform, so the camera
195
- // moves inside the clip. SVG transform (not CSS) so it composes with each
196
- // mark's own transform rather than replacing it.
197
- const clipGroup = createSVGElement('g');
198
- clipGroup.setAttribute('clip-path', `url(#${clipId})`);
199
- const clippedGroup = createSVGElement('g');
200
- clippedGroup.setAttribute('data-oc-marks-group', 'true');
201
- clipGroup.appendChild(clippedGroup);
202
- const markLabelsOverlay = renderMarks(clippedGroup, layout);
203
-
204
- // Add transparent overlay rect for line/area charts to enable voronoi tooltip lookup.
205
- const hasLineOrAreaWithDataPoints = layout.marks.some(
206
- (m) => (m.type === 'line' || m.type === 'area') && m.dataPoints && m.dataPoints.length > 0,
207
- );
208
- if (hasLineOrAreaWithDataPoints) {
209
- const pointEls = clippedGroup.querySelectorAll('circle.oc-mark-point');
210
- for (const el of pointEls) {
211
- el.setAttribute('pointer-events', 'none');
212
- }
213
-
214
- const overlay = createSVGElement('rect');
215
- setAttrs(overlay, {
216
- x: layout.area.x,
217
- y: layout.area.y,
218
- width: layout.area.width,
219
- height: layout.area.height,
220
- fill: 'transparent',
221
- });
222
- overlay.setAttribute('class', 'oc-voronoi-overlay');
223
- overlay.setAttribute('data-voronoi-overlay', 'true');
224
- clippedGroup.appendChild(overlay);
225
-
226
- if (opts?.crosshair) {
227
- const crosshairLine = createSVGElement('line');
228
- crosshairLine.setAttribute('data-crosshair', 'true');
229
- crosshairLine.setAttribute('class', 'oc-crosshair');
230
- setAttrs(crosshairLine, {
231
- x1: 0,
232
- y1: layout.area.y,
233
- x2: 0,
234
- y2: layout.area.y + layout.area.height,
235
- stroke: layout.theme.colors.axis,
236
- 'stroke-opacity': '0.4',
237
- 'stroke-dasharray': '3,3',
238
- 'stroke-width': '1',
239
- 'pointer-events': 'none',
240
- });
241
- crosshairLine.style.display = 'none';
242
- clippedGroup.appendChild(crosshairLine);
243
- }
244
-
245
- const dotsGroup = createSVGElement('g');
246
- dotsGroup.setAttribute('data-snap-dots', 'true');
247
- dotsGroup.setAttribute('class', 'oc-snap-dots');
248
- dotsGroup.setAttribute('pointer-events', 'none');
249
- clippedGroup.appendChild(dotsGroup);
250
- }
251
-
252
- svg.appendChild(clipGroup);
253
-
254
- if (markLabelsOverlay) {
255
- svg.appendChild(markLabelsOverlay);
256
- }
257
-
258
- renderAnnotations(svg, layout);
259
- renderEndpointLabels(svg, layout);
260
-
261
- // Suppress decorative point marks under endpoint markers
262
- const epEntries = layout.endpointLabels?.entries ?? [];
263
- if (epEntries.length > 0) {
264
- const pointEls = clippedGroup.querySelectorAll<SVGCircleElement>('circle.oc-mark-point');
265
- for (const entry of epEntries) {
266
- if (!entry.marker) continue;
267
- const mx = entry.marker.dataX;
268
- const my = entry.marker.y;
269
- for (const el of pointEls) {
270
- const cx = Number(el.getAttribute('cx'));
271
- const cy = Number(el.getAttribute('cy'));
272
- if (Math.abs(cx - mx) < 0.5 && Math.abs(cy - my) < 0.5) {
273
- el.setAttribute('opacity', '0');
274
- }
275
- }
276
- }
277
- }
278
- }
279
-
280
- // Every legend, not just the primary: a bubble chart carries a color legend
281
- // AND a size legend. Falls back to the singular slot for layouts built by
282
- // paths that don't populate `legends` (graph, sankey).
283
- for (const legend of layout.legends ?? [layout.legend]) {
284
- renderLegend(svg, legend);
285
- }
286
-
287
- // Chrome renders on top so titles are never obscured by chart elements
288
- renderChrome(svg, layout);
289
- renderMetrics(svg, layout);
290
-
291
- // Brand renders as a footer item, right-aligned on the source/footer row.
292
- // Suppressed when the spec supplies a custom chrome.brand so the two
293
- // brand blocks don't stack.
294
- if (layout.watermark && !layout.chrome.brand) {
295
- renderBrand(svg, layout);
296
- }
297
- } finally {
298
- resetMarkRenderState();
299
- }
300
-
301
- container.appendChild(svg);
302
- return svg;
303
- }
304
-
305
- function renderFacetedPanels(
306
- svg: SVGElement,
307
- layout: ChartLayout,
308
- panels: FacetPanelLayout[],
309
- defs: SVGElement,
310
- ): void {
311
- for (const panel of panels) {
312
- const g = createSVGElement('g');
313
- g.setAttribute('class', 'oc-facet-panel');
314
- g.setAttribute('data-facet', panel.key);
315
-
316
- // Panel background for visual grouping (starts below the header)
317
- const bg = createSVGElement('rect');
318
- setAttrs(bg, {
319
- x: panel.area.x,
320
- y: panel.area.y,
321
- width: panel.area.width,
322
- height: panel.area.height,
323
- rx: 3,
324
- fill: layout.theme.isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)',
325
- });
326
- g.appendChild(bg);
327
-
328
- // Panel header label
329
- const headerText = createSVGElement('text');
330
- setAttrs(headerText, {
331
- x: panel.header.x,
332
- y: panel.header.y,
333
- 'text-anchor': 'middle',
334
- 'font-family': layout.theme.fonts.family,
335
- 'font-size': panel.header.fontSize,
336
- 'font-weight': panel.header.fontWeight,
337
- fill: layout.theme.colors.text,
338
- });
339
- headerText.textContent = panel.header.text;
340
- g.appendChild(headerText);
341
-
342
- // Build a per-panel synthetic layout for the existing renderers
343
- const panelLayout: ChartLayout = {
344
- ...layout,
345
- area: panel.area,
346
- axes: panel.axes,
347
- marks: panel.marks,
348
- annotations: panel.annotations,
349
- };
350
-
351
- // Panel axes (ticks already suppressed for inner panels by the engine)
352
- renderAxes(g, panelLayout);
353
-
354
- // Panel clip path
355
- const panelClipId = nextSvgId('oc-facet-clip');
356
- const clipPath = createSVGElement('clipPath');
357
- clipPath.setAttribute('id', panelClipId);
358
- const clipRect = createSVGElement('rect');
359
- setAttrs(clipRect, {
360
- x: panel.area.x,
361
- y: panel.area.y,
362
- width: panel.area.width,
363
- height: panel.area.height,
364
- });
365
- clipPath.appendChild(clipRect);
366
- defs.appendChild(clipPath);
367
-
368
- // Panel marks (clipped)
369
- const clippedGroup = createSVGElement('g');
370
- clippedGroup.setAttribute('clip-path', `url(#${panelClipId})`);
371
- const panelLabelsOverlay = renderMarks(clippedGroup, panelLayout);
372
- g.appendChild(clippedGroup);
373
-
374
- if (panelLabelsOverlay) {
375
- g.appendChild(panelLabelsOverlay);
376
- }
377
-
378
- // Panel annotations
379
- renderAnnotations(g, panelLayout);
380
-
381
- svg.appendChild(g);
382
- }
383
- }
@@ -1,266 +0,0 @@
1
- /**
2
- * Table keyboard navigation: arrow-key cell navigation, Enter to sort,
3
- * Escape to clear search, and aria-activedescendant management.
4
- *
5
- * Designed to be wired up by table-mount.ts after render. Returns a
6
- * cleanup function to remove listeners on re-render or destroy.
7
- */
8
-
9
- // ---------------------------------------------------------------------------
10
- // Types
11
- // ---------------------------------------------------------------------------
12
-
13
- export interface KeyboardNavOptions {
14
- /** The wrapper element containing the whole table UI. */
15
- wrapper: HTMLElement;
16
- /** Callback to trigger sort on a column. */
17
- onSort: (columnKey: string) => void;
18
- /** Callback to clear search and return focus to the table body. */
19
- onClearSearch: () => void;
20
- /** Callback to announce text to screen readers via the live region. */
21
- onAnnounce: (message: string) => void;
22
- }
23
-
24
- interface CellPosition {
25
- row: number;
26
- col: number;
27
- }
28
-
29
- // ---------------------------------------------------------------------------
30
- // Main
31
- // ---------------------------------------------------------------------------
32
-
33
- /**
34
- * Attach keyboard navigation to a rendered table.
35
- *
36
- * @returns A cleanup function that removes all event listeners.
37
- */
38
- export function attachKeyboardNav(options: KeyboardNavOptions): () => void {
39
- const { wrapper, onSort, onClearSearch, onAnnounce } = options;
40
-
41
- let focusedCell: CellPosition = { row: -1, col: 0 };
42
-
43
- const table = wrapper.querySelector('table');
44
- if (!table) return () => {};
45
-
46
- const tbody = table.querySelector('tbody');
47
- const thead = table.querySelector('thead');
48
- if (!tbody || !thead) return () => {};
49
-
50
- // Make tbody focusable
51
- tbody.setAttribute('tabindex', '0');
52
-
53
- function getRows(): HTMLTableRowElement[] {
54
- if (!tbody) return [];
55
- return Array.from(tbody.querySelectorAll('tr'));
56
- }
57
-
58
- function getHeaderCells(): HTMLTableCellElement[] {
59
- if (!thead) return [];
60
- const headerRow = thead.querySelector('tr');
61
- if (!headerRow) return [];
62
- return Array.from(headerRow.querySelectorAll('th'));
63
- }
64
-
65
- function getCellsInRow(tr: HTMLTableRowElement): HTMLTableCellElement[] {
66
- return Array.from(tr.querySelectorAll('td'));
67
- }
68
-
69
- function getColCount(): number {
70
- const rows = getRows();
71
- if (rows.length === 0) return getHeaderCells().length;
72
- return getCellsInRow(rows[0]).length;
73
- }
74
-
75
- function clearFocusHighlight(): void {
76
- const prev = wrapper.querySelector('.oc-table-cell-focus');
77
- if (prev) {
78
- prev.classList.remove('oc-table-cell-focus');
79
- prev.removeAttribute('id');
80
- }
81
- }
82
-
83
- function setFocusedCell(row: number, col: number): void {
84
- clearFocusHighlight();
85
- const rows = getRows();
86
- const colCount = getColCount();
87
-
88
- // Clamp values
89
- if (rows.length === 0) return;
90
- row = Math.max(0, Math.min(row, rows.length - 1));
91
- col = Math.max(0, Math.min(col, colCount - 1));
92
-
93
- focusedCell = { row, col };
94
-
95
- // Highlight the cell
96
- const tr = rows[row];
97
- if (!tr) return;
98
- const cells = getCellsInRow(tr);
99
- const cell = cells[col];
100
- if (!cell) return;
101
-
102
- const cellId = `oc-cell-${row}-${col}`;
103
- cell.id = cellId;
104
- cell.classList.add('oc-table-cell-focus');
105
- cell.setAttribute('data-row', String(row));
106
- cell.setAttribute('data-col', String(col));
107
-
108
- // Set aria-activedescendant on tbody
109
- if (tbody) {
110
- tbody.setAttribute('aria-activedescendant', cellId);
111
- }
112
-
113
- // Scroll cell into view if needed
114
- cell.scrollIntoView({ block: 'nearest', inline: 'nearest' });
115
- }
116
-
117
- function handleTbodyFocus(): void {
118
- // When tbody receives focus, highlight the first cell (or restore last)
119
- if (focusedCell.row < 0) {
120
- setFocusedCell(0, 0);
121
- } else {
122
- setFocusedCell(focusedCell.row, focusedCell.col);
123
- }
124
- }
125
-
126
- function handleTbodyKeydown(e: KeyboardEvent): void {
127
- const rows = getRows();
128
- if (rows.length === 0) return;
129
-
130
- const colCount = getColCount();
131
- const { row, col } = focusedCell;
132
-
133
- switch (e.key) {
134
- case 'ArrowDown':
135
- e.preventDefault();
136
- if (row < rows.length - 1) {
137
- setFocusedCell(row + 1, col);
138
- }
139
- break;
140
- case 'ArrowUp':
141
- e.preventDefault();
142
- if (row > 0) {
143
- setFocusedCell(row - 1, col);
144
- } else {
145
- // Move focus to the header
146
- focusHeaderCell(col);
147
- }
148
- break;
149
- case 'ArrowRight':
150
- e.preventDefault();
151
- if (col < colCount - 1) {
152
- setFocusedCell(row, col + 1);
153
- }
154
- break;
155
- case 'ArrowLeft':
156
- e.preventDefault();
157
- if (col > 0) {
158
- setFocusedCell(row, col - 1);
159
- }
160
- break;
161
- case 'Home':
162
- e.preventDefault();
163
- setFocusedCell(row, 0);
164
- break;
165
- case 'End':
166
- e.preventDefault();
167
- setFocusedCell(row, colCount - 1);
168
- break;
169
- }
170
- }
171
-
172
- // Header cell keyboard handling
173
- function focusHeaderCell(col: number): void {
174
- const headers = getHeaderCells();
175
- if (col >= 0 && col < headers.length) {
176
- clearFocusHighlight();
177
- headers[col].focus();
178
- }
179
- }
180
-
181
- function handleHeaderKeydown(e: KeyboardEvent): void {
182
- const th = e.currentTarget as HTMLTableCellElement;
183
- const headers = getHeaderCells();
184
- const colIndex = headers.indexOf(th);
185
- if (colIndex < 0) return;
186
-
187
- switch (e.key) {
188
- case 'ArrowRight':
189
- e.preventDefault();
190
- if (colIndex < headers.length - 1) {
191
- headers[colIndex + 1].focus();
192
- }
193
- break;
194
- case 'ArrowLeft':
195
- e.preventDefault();
196
- if (colIndex > 0) {
197
- headers[colIndex - 1].focus();
198
- }
199
- break;
200
- case 'ArrowDown':
201
- e.preventDefault();
202
- // Move focus to first body row at this column
203
- if (tbody) {
204
- tbody.focus();
205
- setFocusedCell(0, colIndex);
206
- }
207
- break;
208
- case 'Enter':
209
- case ' ': {
210
- e.preventDefault();
211
- const sortColumn = th.getAttribute('data-column');
212
- const sortBtn = th.querySelector('[data-sort-column]');
213
- if (sortColumn && sortBtn) {
214
- onSort(sortColumn);
215
- }
216
- break;
217
- }
218
- }
219
- }
220
-
221
- // Search escape handling
222
- const searchInput = wrapper.querySelector('.oc-table-search input') as HTMLInputElement | null;
223
-
224
- function handleSearchKeydown(e: KeyboardEvent): void {
225
- if (e.key === 'Escape') {
226
- e.preventDefault();
227
- onClearSearch();
228
- // Return focus to tbody
229
- if (tbody) {
230
- tbody.focus();
231
- onAnnounce('Search cleared');
232
- }
233
- }
234
- }
235
-
236
- // Wire up event listeners
237
- tbody.addEventListener('focus', handleTbodyFocus);
238
- tbody.addEventListener('keydown', handleTbodyKeydown as EventListener);
239
-
240
- // Make header cells focusable and wire keyboard
241
- const headerCells = getHeaderCells();
242
- for (const th of headerCells) {
243
- th.setAttribute('tabindex', '0');
244
- th.addEventListener('keydown', handleHeaderKeydown as EventListener);
245
- }
246
-
247
- if (searchInput) {
248
- searchInput.addEventListener('keydown', handleSearchKeydown as EventListener);
249
- }
250
-
251
- // Cleanup
252
- return () => {
253
- tbody.removeEventListener('focus', handleTbodyFocus);
254
- tbody.removeEventListener('keydown', handleTbodyKeydown as EventListener);
255
-
256
- for (const th of headerCells) {
257
- th.removeEventListener('keydown', handleHeaderKeydown as EventListener);
258
- }
259
-
260
- if (searchInput) {
261
- searchInput.removeEventListener('keydown', handleSearchKeydown as EventListener);
262
- }
263
-
264
- clearFocusHighlight();
265
- };
266
- }