@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.
- package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
- package/dist/chunk-DCODCLWI.js +234 -0
- package/dist/chunk-DCODCLWI.js.map +1 -0
- package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
- package/dist/chunk-EKAUQAAX.js.map +1 -0
- package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
- package/dist/chunk-KNT5AZ2O.js.map +1 -0
- package/dist/chunk-RM5XS6NR.js +113 -0
- package/dist/chunk-RM5XS6NR.js.map +1 -0
- package/dist/chunk-T32UHMZA.js +282 -0
- package/dist/chunk-T32UHMZA.js.map +1 -0
- package/dist/export-gif.d.ts +113 -0
- package/dist/export-gif.js +21 -0
- package/dist/export-gif.js.map +1 -0
- package/dist/index.d.ts +283 -24
- package/dist/index.js +2012 -763
- package/dist/index.js.map +1 -1
- package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
- package/dist/simulation-worker.js +83 -2
- package/dist/static.js +7 -69
- package/dist/static.js.map +1 -1
- package/dist/story/index.d.ts +3 -2
- package/dist/story/index.js +5 -3
- package/dist/story/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +14 -6
- package/dist/chunk-5EOV5JQZ.js.map +0 -1
- package/dist/chunk-ENXSUXC5.js.map +0 -1
- package/src/__test-fixtures__/dom.ts +0 -70
- package/src/__test-fixtures__/specs.ts +0 -214
- package/src/__tests__/animation.test.ts +0 -358
- package/src/__tests__/annotations-render.test.ts +0 -201
- package/src/__tests__/compound-labels.test.ts +0 -122
- package/src/__tests__/continuous-legend.test.ts +0 -91
- package/src/__tests__/crosshair.test.ts +0 -130
- package/src/__tests__/edit-events.test.ts +0 -902
- package/src/__tests__/events.test.ts +0 -387
- package/src/__tests__/export.test.ts +0 -241
- package/src/__tests__/font-race.test.ts +0 -199
- package/src/__tests__/gradient-ids.test.ts +0 -159
- package/src/__tests__/gradient-utils.test.ts +0 -125
- package/src/__tests__/known-bugs-render.test.ts +0 -183
- package/src/__tests__/map.test.ts +0 -293
- package/src/__tests__/mount.test.ts +0 -357
- package/src/__tests__/pattern-fills.test.ts +0 -105
- package/src/__tests__/resize-timing.test.ts +0 -218
- package/src/__tests__/sankey.test.ts +0 -133
- package/src/__tests__/selection-events.test.ts +0 -869
- package/src/__tests__/series-search.test.ts +0 -311
- package/src/__tests__/static.test.ts +0 -141
- package/src/__tests__/svg-renderer.test.ts +0 -895
- package/src/__tests__/table-keyboard.test.ts +0 -361
- package/src/__tests__/table-mount.test.ts +0 -639
- package/src/__tests__/tilemap.test.ts +0 -158
- package/src/__tests__/tooltip.test.ts +0 -337
- package/src/__tests__/transition.test.ts +0 -1777
- package/src/__tests__/you-draw-it.test.ts +0 -273
- package/src/animation-vars.ts +0 -42
- package/src/animation.ts +0 -76
- package/src/barlist-mount.ts +0 -383
- package/src/barlist-renderer.ts +0 -267
- package/src/dom-helpers.ts +0 -17
- package/src/export.ts +0 -482
- package/src/gradient-utils.ts +0 -138
- package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
- package/src/graph/__tests__/graph-mount.test.ts +0 -213
- package/src/graph/__tests__/interaction.test.ts +0 -205
- package/src/graph/__tests__/keyboard.test.ts +0 -653
- package/src/graph/__tests__/search.test.ts +0 -88
- package/src/graph/__tests__/simulation.test.ts +0 -233
- package/src/graph/__tests__/spatial-index.test.ts +0 -142
- package/src/graph/__tests__/zoom.test.ts +0 -203
- package/src/graph/canvas-renderer.ts +0 -747
- package/src/graph/interaction.ts +0 -367
- package/src/graph/keyboard.ts +0 -208
- package/src/graph/search.ts +0 -50
- package/src/graph/simulation-worker-url.ts +0 -12
- package/src/graph/simulation-worker.ts +0 -278
- package/src/graph/simulation.ts +0 -422
- package/src/graph/spatial-index.ts +0 -121
- package/src/graph/types.ts +0 -47
- package/src/graph/worker-protocol.ts +0 -70
- package/src/graph/zoom.ts +0 -134
- package/src/graph-mount.ts +0 -888
- package/src/index.ts +0 -76
- package/src/interactions/chart-events.ts +0 -139
- package/src/interactions/crosshair.ts +0 -233
- package/src/interactions/drag-handler.ts +0 -175
- package/src/interactions/editing-drags.ts +0 -515
- package/src/interactions/index.ts +0 -25
- package/src/interactions/keyboard-nav.ts +0 -111
- package/src/interactions/legend-interaction.ts +0 -46
- package/src/interactions/selection.ts +0 -280
- package/src/interactions/tooltip-events.ts +0 -72
- package/src/map-camera.ts +0 -88
- package/src/map-mount.ts +0 -701
- package/src/map-renderer.ts +0 -338
- package/src/map-transition.ts +0 -83
- package/src/measure-text.ts +0 -134
- package/src/mount.ts +0 -1382
- package/src/pattern-utils.ts +0 -110
- package/src/renderers/annotations.ts +0 -388
- package/src/renderers/axes.ts +0 -351
- package/src/renderers/brand.ts +0 -87
- package/src/renderers/chrome.ts +0 -196
- package/src/renderers/endpoint-labels.ts +0 -150
- package/src/renderers/legend.ts +0 -311
- package/src/renderers/marks.ts +0 -561
- package/src/renderers/metrics.ts +0 -63
- package/src/renderers/svg-dom.ts +0 -40
- package/src/renderers/table-cells.ts +0 -446
- package/src/resize-observer.ts +0 -46
- package/src/sankey-mount.ts +0 -622
- package/src/sankey-renderer.ts +0 -614
- package/src/series-search.ts +0 -392
- package/src/static.ts +0 -251
- package/src/story/__tests__/camera-math.test.ts +0 -145
- package/src/story/__tests__/create-chart-story.test.ts +0 -317
- package/src/story/__tests__/progress-math.test.ts +0 -95
- package/src/story/__tests__/scroll-driver.test.ts +0 -130
- package/src/story/__tests__/tween.test.ts +0 -159
- package/src/story/camera-math.ts +0 -125
- package/src/story/create-chart-story.ts +0 -353
- package/src/story/crossfade.ts +0 -70
- package/src/story/index.ts +0 -39
- package/src/story/progress-math.ts +0 -85
- package/src/story/resolve-camera-target.ts +0 -119
- package/src/story/scroll-driver.ts +0 -206
- package/src/story/story-camera.ts +0 -49
- package/src/story/tween.ts +0 -112
- package/src/story/types.ts +0 -76
- package/src/svg-ids.ts +0 -23
- package/src/svg-renderer.ts +0 -383
- package/src/table-keyboard.ts +0 -266
- package/src/table-mount.ts +0 -567
- package/src/table-renderer.ts +0 -414
- package/src/text-edit-overlay.ts +0 -255
- package/src/theme-tokens.ts +0 -51
- package/src/tilemap-mount.ts +0 -511
- package/src/tilemap-renderer.ts +0 -396
- package/src/tooltip.ts +0 -177
- package/src/transition.ts +0 -2816
- package/src/you-draw-it.ts +0 -480
- /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
|
-
}
|
package/src/svg-renderer.ts
DELETED
|
@@ -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
|
-
}
|
package/src/table-keyboard.ts
DELETED
|
@@ -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
|
-
}
|