@opendata-ai/openchart-vanilla 6.28.5 → 7.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.
- package/dist/index.d.ts +13 -8
- package/dist/index.js +2797 -2356
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +3 -3
- package/src/__tests__/crosshair.test.ts +11 -2
- package/src/__tests__/events.test.ts +55 -10
- package/src/graph/__tests__/canvas-renderer.test.ts +1 -0
- package/src/interactions/chart-events.ts +139 -0
- package/src/interactions/crosshair.ts +228 -0
- package/src/interactions/drag-handler.ts +175 -0
- package/src/interactions/editing-drags.ts +512 -0
- package/src/interactions/index.ts +25 -0
- package/src/interactions/keyboard-nav.ts +111 -0
- package/src/interactions/legend-interaction.ts +38 -0
- package/src/interactions/selection.ts +271 -0
- package/src/interactions/tooltip-events.ts +72 -0
- package/src/mount.ts +182 -1761
- package/src/renderers/annotations.ts +82 -2
- package/src/renderers/axes.ts +18 -1
- package/src/renderers/brand.ts +7 -1
- package/src/renderers/chrome.ts +50 -3
- package/src/renderers/endpoint-labels.ts +164 -0
- package/src/renderers/legend.ts +32 -27
- package/src/renderers/marks.ts +65 -17
- package/src/renderers/metrics.ts +50 -0
- package/src/svg-renderer.ts +80 -20
- package/src/tilemap-mount.ts +6 -6
- package/src/tilemap-renderer.ts +0 -2
- package/src/tooltip.ts +27 -7
package/src/svg-renderer.ts
CHANGED
|
@@ -16,8 +16,10 @@ import { renderAnnotations } from './renderers/annotations';
|
|
|
16
16
|
import { renderAxes } from './renderers/axes';
|
|
17
17
|
import { renderBrand } from './renderers/brand';
|
|
18
18
|
import { renderChrome } from './renderers/chrome';
|
|
19
|
+
import { renderEndpointLabels } from './renderers/endpoint-labels';
|
|
19
20
|
import { renderLegend } from './renderers/legend';
|
|
20
21
|
import { renderMarks, resetMarkRenderState, setMarkRenderState } from './renderers/marks';
|
|
22
|
+
import { renderMetrics } from './renderers/metrics';
|
|
21
23
|
import { createSVGElement, SVG_NS, setAttrs } from './renderers/svg-dom';
|
|
22
24
|
import { nextSvgId } from './svg-ids';
|
|
23
25
|
|
|
@@ -104,16 +106,22 @@ export function renderChartSVG(
|
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
// Background
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
109
|
+
// Background. Sparkline mode skips the background rect entirely so the
|
|
110
|
+
// host page's surface color shows through — sparklines are drop-ins for
|
|
111
|
+
// KPI cards, table cells, and inline contexts where the consumer owns
|
|
112
|
+
// the background. Other display modes paint a fill so the chart is a
|
|
113
|
+
// self-contained visual on any host surface.
|
|
114
|
+
if (layout.display !== 'sparkline') {
|
|
115
|
+
const bg = createSVGElement('rect');
|
|
116
|
+
setAttrs(bg, {
|
|
117
|
+
x: 0,
|
|
118
|
+
y: 0,
|
|
119
|
+
width,
|
|
120
|
+
height,
|
|
121
|
+
fill: layout.theme.colors.background,
|
|
122
|
+
});
|
|
123
|
+
svg.appendChild(bg);
|
|
124
|
+
}
|
|
117
125
|
|
|
118
126
|
// Clip path to prevent marks (especially area fills) from overflowing
|
|
119
127
|
// into the chrome region (title/subtitle). Extends full width so
|
|
@@ -159,13 +167,21 @@ export function renderChartSVG(
|
|
|
159
167
|
renderMarks(clippedGroup, layout);
|
|
160
168
|
|
|
161
169
|
// Add transparent overlay rect for line/area charts to enable voronoi tooltip lookup.
|
|
162
|
-
//
|
|
163
|
-
//
|
|
170
|
+
// Always emitted for line/area with dataPoints — the overlay-driven snap tooltip
|
|
171
|
+
// with crosshair is the canonical interaction for these chart types. When point
|
|
172
|
+
// marks coexist (e.g. mark.point: true), they still render decoratively but
|
|
173
|
+
// pointer events route to the overlay so the snap behavior wins.
|
|
164
174
|
const hasLineOrAreaWithDataPoints = layout.marks.some(
|
|
165
175
|
(m) => (m.type === 'line' || m.type === 'area') && m.dataPoints && m.dataPoints.length > 0,
|
|
166
176
|
);
|
|
167
|
-
|
|
168
|
-
|
|
177
|
+
if (hasLineOrAreaWithDataPoints) {
|
|
178
|
+
// Decorative point marks on line/area: route pointer events to the
|
|
179
|
+
// overlay so the snap-tooltip wins instead of competing per-point hover.
|
|
180
|
+
const pointEls = clippedGroup.querySelectorAll('circle.oc-mark-point');
|
|
181
|
+
for (const el of pointEls) {
|
|
182
|
+
el.setAttribute('pointer-events', 'none');
|
|
183
|
+
}
|
|
184
|
+
|
|
169
185
|
const overlay = createSVGElement('rect');
|
|
170
186
|
setAttrs(overlay, {
|
|
171
187
|
x: layout.area.x,
|
|
@@ -178,7 +194,10 @@ export function renderChartSVG(
|
|
|
178
194
|
overlay.setAttribute('data-voronoi-overlay', 'true');
|
|
179
195
|
clippedGroup.appendChild(overlay);
|
|
180
196
|
|
|
181
|
-
// Crosshair line:
|
|
197
|
+
// Crosshair line: vertical line that tracks the snapped data point x.
|
|
198
|
+
// Gated on `opts.crosshair` because the dashed line is the optional bit;
|
|
199
|
+
// the snap-dots layer below ships regardless so the multi-series
|
|
200
|
+
// hover-tooltip stays useful even when the user opts out of the line.
|
|
182
201
|
if (opts?.crosshair) {
|
|
183
202
|
const crosshairLine = createSVGElement('line');
|
|
184
203
|
crosshairLine.setAttribute('data-crosshair', 'true');
|
|
@@ -188,27 +207,68 @@ export function renderChartSVG(
|
|
|
188
207
|
y1: layout.area.y,
|
|
189
208
|
x2: 0,
|
|
190
209
|
y2: layout.area.y + layout.area.height,
|
|
191
|
-
stroke: layout.theme.colors.
|
|
192
|
-
'stroke-opacity': '0.
|
|
193
|
-
'stroke-dasharray': '
|
|
210
|
+
stroke: layout.theme.colors.axis,
|
|
211
|
+
'stroke-opacity': '0.4',
|
|
212
|
+
'stroke-dasharray': '3,3',
|
|
194
213
|
'stroke-width': '1',
|
|
195
214
|
'pointer-events': 'none',
|
|
196
215
|
});
|
|
197
216
|
crosshairLine.style.display = 'none';
|
|
198
217
|
clippedGroup.appendChild(crosshairLine);
|
|
199
218
|
}
|
|
219
|
+
|
|
220
|
+
// Snap-dot layer: mount.ts populates one circle per series at the
|
|
221
|
+
// snapped x on hover. Always emitted so the merged tooltip has its
|
|
222
|
+
// anchors regardless of `opts.crosshair`.
|
|
223
|
+
const dotsGroup = createSVGElement('g');
|
|
224
|
+
dotsGroup.setAttribute('data-snap-dots', 'true');
|
|
225
|
+
dotsGroup.setAttribute('class', 'oc-snap-dots');
|
|
226
|
+
dotsGroup.setAttribute('pointer-events', 'none');
|
|
227
|
+
clippedGroup.appendChild(dotsGroup);
|
|
200
228
|
}
|
|
201
229
|
|
|
202
230
|
svg.appendChild(clippedGroup);
|
|
203
231
|
|
|
204
232
|
renderAnnotations(svg, layout);
|
|
233
|
+
|
|
234
|
+
// Endpoint labels render after marks/annotations so they sit on top of any
|
|
235
|
+
// chart-edge content, but before the traditional legend so chrome wins on
|
|
236
|
+
// collision. The engine handles all suppression — when entries is empty,
|
|
237
|
+
// renderEndpointLabels is a no-op.
|
|
238
|
+
renderEndpointLabels(svg, layout);
|
|
239
|
+
|
|
240
|
+
// Suppress decorative point marks that sit underneath an endpoint marker
|
|
241
|
+
// (mark.point: true + endpoint marker on produces a double-circle at the
|
|
242
|
+
// line's right terminus). The endpoint marker is the canonical terminator
|
|
243
|
+
// when present, so the point mark hides via opacity (not removal) so the
|
|
244
|
+
// SVG DOM stays diff-friendly for animated re-renders.
|
|
245
|
+
const epEntries = layout.endpointLabels?.entries ?? [];
|
|
246
|
+
if (epEntries.length > 0) {
|
|
247
|
+
const pointEls = clippedGroup.querySelectorAll<SVGCircleElement>('circle.oc-mark-point');
|
|
248
|
+
for (const entry of epEntries) {
|
|
249
|
+
if (!entry.marker) continue;
|
|
250
|
+
const mx = entry.marker.x;
|
|
251
|
+
const my = entry.marker.y;
|
|
252
|
+
for (const el of pointEls) {
|
|
253
|
+
const cx = Number(el.getAttribute('cx'));
|
|
254
|
+
const cy = Number(el.getAttribute('cy'));
|
|
255
|
+
if (Math.abs(cx - mx) < 0.5 && Math.abs(cy - my) < 0.5) {
|
|
256
|
+
el.setAttribute('opacity', '0');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
205
262
|
renderLegend(svg, layout.legend);
|
|
206
263
|
|
|
207
264
|
// Chrome renders on top so titles are never obscured by chart elements
|
|
208
265
|
renderChrome(svg, layout);
|
|
266
|
+
renderMetrics(svg, layout);
|
|
209
267
|
|
|
210
|
-
// Brand renders as a footer item, right-aligned on the source/footer row
|
|
211
|
-
|
|
268
|
+
// Brand renders as a footer item, right-aligned on the source/footer row.
|
|
269
|
+
// Suppressed when the spec supplies a custom chrome.brand so the two
|
|
270
|
+
// brand blocks don't stack.
|
|
271
|
+
if (layout.watermark && !layout.chrome.brand) {
|
|
212
272
|
renderBrand(svg, layout);
|
|
213
273
|
}
|
|
214
274
|
} finally {
|
package/src/tilemap-mount.ts
CHANGED
|
@@ -126,10 +126,10 @@ export function createTileMap(
|
|
|
126
126
|
|
|
127
127
|
function getContainerDimensions(): { width: number; height: number } {
|
|
128
128
|
const rect = container.getBoundingClientRect();
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
};
|
|
129
|
+
const width = Math.max(rect.width || 600, 100);
|
|
130
|
+
// Height is derived from content by the compiler (tight viewBox),
|
|
131
|
+
// so pass a large value to ensure width is the binding constraint.
|
|
132
|
+
return { width, height: width };
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
function compile(): TileMapLayout {
|
|
@@ -263,8 +263,8 @@ export function createTileMap(
|
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
// Setup animation cleanup
|
|
267
|
-
if (currentLayout.animation?.enabled) {
|
|
266
|
+
// Setup animation cleanup only when actually animating
|
|
267
|
+
if (animate && currentLayout.animation?.enabled) {
|
|
268
268
|
animationCleanup = setupAnimationCleanup(newSvg, () => {
|
|
269
269
|
// On animation complete, check if resize was pending
|
|
270
270
|
if (pendingResize && !destroyed) {
|
package/src/tilemap-renderer.ts
CHANGED
|
@@ -392,8 +392,6 @@ export function renderTileMapSVG(
|
|
|
392
392
|
|
|
393
393
|
const svg = createSVGElement('svg') as SVGSVGElement;
|
|
394
394
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
395
|
-
svg.setAttribute('width', String(width));
|
|
396
|
-
svg.setAttribute('height', String(height));
|
|
397
395
|
svg.setAttribute('role', 'img');
|
|
398
396
|
if (a11y.altText) {
|
|
399
397
|
svg.setAttribute('aria-label', a11y.altText);
|
package/src/tooltip.ts
CHANGED
|
@@ -6,12 +6,23 @@
|
|
|
6
6
|
* tap-outside-to-hide.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { Placement } from '@floating-ui/dom';
|
|
9
10
|
import { computePosition, flip, offset, shift } from '@floating-ui/dom';
|
|
10
11
|
import type { TooltipContent } from '@opendata-ai/openchart-core';
|
|
11
12
|
|
|
13
|
+
export interface TooltipShowOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Floating-ui placement. Defaults to 'bottom-start' for mark hover
|
|
16
|
+
* (the legacy behavior). Line/area snap-tooltips pass 'right-start'
|
|
17
|
+
* so the card sits beside the snapped point and flips to 'left-start'
|
|
18
|
+
* via the flip middleware when crowding the right edge.
|
|
19
|
+
*/
|
|
20
|
+
placement?: Placement;
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
export interface TooltipManager {
|
|
13
24
|
/** Show the tooltip with content at a given position. */
|
|
14
|
-
show(content: TooltipContent, x: number, y: number): void;
|
|
25
|
+
show(content: TooltipContent, x: number, y: number, opts?: TooltipShowOptions): void;
|
|
15
26
|
/** Hide the tooltip. */
|
|
16
27
|
hide(): void;
|
|
17
28
|
/** Remove the tooltip element and clean up event listeners. */
|
|
@@ -51,7 +62,7 @@ export function createTooltipManager(container: HTMLElement): TooltipManager {
|
|
|
51
62
|
};
|
|
52
63
|
document.addEventListener('touchstart', handleDocumentTouch);
|
|
53
64
|
|
|
54
|
-
function show(content: TooltipContent, x: number, y: number): void {
|
|
65
|
+
function show(content: TooltipContent, x: number, y: number, opts?: TooltipShowOptions): void {
|
|
55
66
|
// Fast content identity check: title + field count + first/last field values
|
|
56
67
|
const contentKey = `${content.title}|${content.fields.length}|${content.fields[0]?.value}|${content.fields[content.fields.length - 1]?.value}`;
|
|
57
68
|
|
|
@@ -60,9 +71,14 @@ export function createTooltipManager(container: HTMLElement): TooltipManager {
|
|
|
60
71
|
|
|
61
72
|
let html = '';
|
|
62
73
|
|
|
63
|
-
//
|
|
74
|
+
// Multi-series tooltips put a swatch on each row instead of in the
|
|
75
|
+
// header, so detect that case once.
|
|
76
|
+
const colorRowCount = content.fields.filter((f) => f.color).length;
|
|
77
|
+
const perRowSwatches = colorRowCount > 1;
|
|
78
|
+
|
|
79
|
+
// Title row: header dot only when there's a single color (single-series)
|
|
64
80
|
if (content.title) {
|
|
65
|
-
const titleColor = content.fields.find((f) => f.color)?.color;
|
|
81
|
+
const titleColor = perRowSwatches ? undefined : content.fields.find((f) => f.color)?.color;
|
|
66
82
|
html += '<div class="oc-tooltip-header">';
|
|
67
83
|
if (titleColor) {
|
|
68
84
|
html += `<span class="oc-tooltip-dot" style="background:${esc(titleColor)}"></span>`;
|
|
@@ -76,7 +92,11 @@ export function createTooltipManager(container: HTMLElement): TooltipManager {
|
|
|
76
92
|
html += '<div class="oc-tooltip-body">';
|
|
77
93
|
for (const field of content.fields) {
|
|
78
94
|
html += '<div class="oc-tooltip-row">';
|
|
79
|
-
html +=
|
|
95
|
+
html += '<span class="oc-tooltip-label">';
|
|
96
|
+
if (perRowSwatches && field.color) {
|
|
97
|
+
html += `<span class="oc-tooltip-row-swatch" style="background:${esc(field.color)}"></span>`;
|
|
98
|
+
}
|
|
99
|
+
html += `${esc(field.label)}</span>`;
|
|
80
100
|
html += `<span class="oc-tooltip-value">${esc(field.value)}</span>`;
|
|
81
101
|
html += '</div>';
|
|
82
102
|
}
|
|
@@ -109,8 +129,8 @@ export function createTooltipManager(container: HTMLElement): TooltipManager {
|
|
|
109
129
|
};
|
|
110
130
|
|
|
111
131
|
computePosition(virtualRef, tooltip, {
|
|
112
|
-
placement: 'bottom-start',
|
|
113
|
-
middleware: [offset(TOOLTIP_OFFSET), flip(), shift({ padding:
|
|
132
|
+
placement: opts?.placement ?? 'bottom-start',
|
|
133
|
+
middleware: [offset(TOOLTIP_OFFSET), flip(), shift({ padding: 8 })],
|
|
114
134
|
}).then(({ x: fx, y: fy }) => {
|
|
115
135
|
// Discard stale callbacks from earlier show() calls
|
|
116
136
|
if (positionId !== currentPositionId) return;
|