@opendata-ai/openchart-vanilla 6.0.0 → 6.1.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 +3 -3
- package/dist/index.js +212 -19
- package/dist/index.js.map +1 -1
- package/dist/simulation-worker.js +0 -38
- package/package.json +3 -3
- package/src/__test-fixtures__/specs.ts +7 -7
- package/src/__tests__/svg-renderer.test.ts +2 -2
- package/src/mount.ts +169 -14
- package/src/svg-renderer.ts +116 -12
|
@@ -1,42 +1,4 @@
|
|
|
1
1
|
(() => {
|
|
2
|
-
var __create = Object.create;
|
|
3
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
4
|
-
var __defProp = Object.defineProperty;
|
|
5
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
-
function __accessProp(key) {
|
|
8
|
-
return this[key];
|
|
9
|
-
}
|
|
10
|
-
var __toESMCache_node;
|
|
11
|
-
var __toESMCache_esm;
|
|
12
|
-
var __toESM = (mod, isNodeMode, target) => {
|
|
13
|
-
var canCache = mod != null && typeof mod === "object";
|
|
14
|
-
if (canCache) {
|
|
15
|
-
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
|
|
16
|
-
var cached = cache.get(mod);
|
|
17
|
-
if (cached)
|
|
18
|
-
return cached;
|
|
19
|
-
}
|
|
20
|
-
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
21
|
-
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
22
|
-
for (let key of __getOwnPropNames(mod))
|
|
23
|
-
if (!__hasOwnProp.call(to, key))
|
|
24
|
-
__defProp(to, key, {
|
|
25
|
-
get: __accessProp.bind(mod, key),
|
|
26
|
-
enumerable: true
|
|
27
|
-
});
|
|
28
|
-
if (canCache)
|
|
29
|
-
cache.set(mod, to);
|
|
30
|
-
return to;
|
|
31
|
-
};
|
|
32
|
-
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
33
|
-
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
34
|
-
}) : x)(function(x) {
|
|
35
|
-
if (typeof require !== "undefined")
|
|
36
|
-
return require.apply(this, arguments);
|
|
37
|
-
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
2
|
// ../../node_modules/.bun/d3-force@3.0.0/node_modules/d3-force/src/center.js
|
|
41
3
|
function center_default(x, y) {
|
|
42
4
|
var nodes, strength = 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"description": "Vanilla JS renderer for openchart: SVG charts, HTML tables, force-directed graphs",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Riley Hilliard",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@floating-ui/dom": "^1.7.6",
|
|
53
|
-
"@opendata-ai/openchart-core": "
|
|
54
|
-
"@opendata-ai/openchart-engine": "
|
|
53
|
+
"@opendata-ai/openchart-core": "workspace:*",
|
|
54
|
+
"@opendata-ai/openchart-engine": "workspace:*",
|
|
55
55
|
"d3-force": "^3.0.0",
|
|
56
56
|
"d3-quadtree": "^3.0.1"
|
|
57
57
|
},
|
|
@@ -12,7 +12,7 @@ import type { ChartSpec, GraphSpec, TableSpec } from '@opendata-ai/openchart-eng
|
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
|
|
14
14
|
export const lineSpec: ChartSpec = {
|
|
15
|
-
|
|
15
|
+
mark: 'line',
|
|
16
16
|
data: [
|
|
17
17
|
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
18
18
|
{ date: '2021-01-01', value: 40, country: 'US' },
|
|
@@ -32,7 +32,7 @@ export const lineSpec: ChartSpec = {
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
export const singleSeriesLineSpec: ChartSpec = {
|
|
35
|
-
|
|
35
|
+
mark: 'line',
|
|
36
36
|
data: [
|
|
37
37
|
{ date: '2020-01-01', value: 10 },
|
|
38
38
|
{ date: '2021-01-01', value: 40 },
|
|
@@ -49,7 +49,7 @@ export const singleSeriesLineSpec: ChartSpec = {
|
|
|
49
49
|
// ---------------------------------------------------------------------------
|
|
50
50
|
|
|
51
51
|
export const barSpec: ChartSpec = {
|
|
52
|
-
|
|
52
|
+
mark: 'bar',
|
|
53
53
|
data: [
|
|
54
54
|
{ name: 'A', value: 10 },
|
|
55
55
|
{ name: 'B', value: 30 },
|
|
@@ -69,7 +69,7 @@ export const barSpec: ChartSpec = {
|
|
|
69
69
|
// ---------------------------------------------------------------------------
|
|
70
70
|
|
|
71
71
|
export const columnSpec: ChartSpec = {
|
|
72
|
-
|
|
72
|
+
mark: 'bar',
|
|
73
73
|
data: [
|
|
74
74
|
{ category: 'Q1', revenue: 100 },
|
|
75
75
|
{ category: 'Q2', revenue: 200 },
|
|
@@ -89,7 +89,7 @@ export const columnSpec: ChartSpec = {
|
|
|
89
89
|
// ---------------------------------------------------------------------------
|
|
90
90
|
|
|
91
91
|
export const scatterSpec: ChartSpec = {
|
|
92
|
-
|
|
92
|
+
mark: 'point',
|
|
93
93
|
data: [
|
|
94
94
|
{ x: 10, y: 20, group: 'A' },
|
|
95
95
|
{ x: 30, y: 40, group: 'A' },
|
|
@@ -111,7 +111,7 @@ export const scatterSpec: ChartSpec = {
|
|
|
111
111
|
// ---------------------------------------------------------------------------
|
|
112
112
|
|
|
113
113
|
export const pieSpec: ChartSpec = {
|
|
114
|
-
|
|
114
|
+
mark: 'arc',
|
|
115
115
|
data: [
|
|
116
116
|
{ category: 'Red', value: 30 },
|
|
117
117
|
{ category: 'Blue', value: 50 },
|
|
@@ -131,7 +131,7 @@ export const pieSpec: ChartSpec = {
|
|
|
131
131
|
// ---------------------------------------------------------------------------
|
|
132
132
|
|
|
133
133
|
export const multiSeriesBarSpec: ChartSpec = {
|
|
134
|
-
|
|
134
|
+
mark: 'bar',
|
|
135
135
|
data: [
|
|
136
136
|
{ name: 'A', value: 10, group: 'X' },
|
|
137
137
|
{ name: 'B', value: 30, group: 'X' },
|
|
@@ -356,7 +356,7 @@ describe('chart chrome rendering', () => {
|
|
|
356
356
|
|
|
357
357
|
it('wraps long title text into tspan elements at narrow widths', () => {
|
|
358
358
|
const longTitleSpec: ChartSpec = {
|
|
359
|
-
|
|
359
|
+
mark: 'bar',
|
|
360
360
|
data: [
|
|
361
361
|
{ name: 'A', value: 10 },
|
|
362
362
|
{ name: 'B', value: 20 },
|
|
@@ -396,7 +396,7 @@ describe('chart chrome rendering', () => {
|
|
|
396
396
|
|
|
397
397
|
it('chart with no chrome specified renders no chrome text elements', () => {
|
|
398
398
|
const noChrome: ChartSpec = {
|
|
399
|
-
|
|
399
|
+
mark: 'bar',
|
|
400
400
|
data: [{ name: 'A', value: 10 }],
|
|
401
401
|
encoding: {
|
|
402
402
|
x: { field: 'value', type: 'quantitative' },
|
package/src/mount.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
DarkMode,
|
|
19
19
|
ElementEdit,
|
|
20
20
|
GraphSpec,
|
|
21
|
+
LayerSpec,
|
|
21
22
|
MeasureTextFn,
|
|
22
23
|
RangeAnnotation,
|
|
23
24
|
RefLineAnnotation,
|
|
@@ -25,7 +26,8 @@ import type {
|
|
|
25
26
|
ThemeConfig,
|
|
26
27
|
TooltipContent,
|
|
27
28
|
} from '@opendata-ai/openchart-core';
|
|
28
|
-
import {
|
|
29
|
+
import { isLayerSpec } from '@opendata-ai/openchart-core';
|
|
30
|
+
import { compileChart, compileLayer } from '@opendata-ai/openchart-engine';
|
|
29
31
|
import {
|
|
30
32
|
exportCSV,
|
|
31
33
|
exportJPG,
|
|
@@ -60,7 +62,7 @@ export interface ExportOptions extends JPGExportOptions {
|
|
|
60
62
|
|
|
61
63
|
export interface ChartInstance {
|
|
62
64
|
/** Re-compile and re-render with a new spec. */
|
|
63
|
-
update(spec: ChartSpec | GraphSpec): void;
|
|
65
|
+
update(spec: ChartSpec | LayerSpec | GraphSpec): void;
|
|
64
66
|
/** Re-compile at current container dimensions. */
|
|
65
67
|
resize(): void;
|
|
66
68
|
/** Export the chart. */
|
|
@@ -203,6 +205,140 @@ function wireTooltipEvents(
|
|
|
203
205
|
};
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Voronoi overlay tooltip wiring (nearest-point lookup for line/area charts)
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/** A single data point with pixel coordinates, datum, and pre-computed tooltip. */
|
|
213
|
+
interface VoronoiPoint {
|
|
214
|
+
x: number;
|
|
215
|
+
y: number;
|
|
216
|
+
datum: Record<string, unknown>;
|
|
217
|
+
tooltip?: TooltipContent;
|
|
218
|
+
color: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Collect all dataPoints from line and area marks for nearest-point lookup.
|
|
223
|
+
*/
|
|
224
|
+
function collectVoronoiPoints(layout: ChartLayout): VoronoiPoint[] {
|
|
225
|
+
const points: VoronoiPoint[] = [];
|
|
226
|
+
for (const mark of layout.marks) {
|
|
227
|
+
if ((mark.type === 'line' || mark.type === 'area') && mark.dataPoints) {
|
|
228
|
+
const color = mark.type === 'line' ? mark.stroke : mark.fill;
|
|
229
|
+
for (const dp of mark.dataPoints) {
|
|
230
|
+
points.push({ ...dp, color });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return points;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Find the nearest VoronoiPoint to a given (x, y) position using linear scan.
|
|
239
|
+
* Returns null if no points exist.
|
|
240
|
+
*/
|
|
241
|
+
function findNearestPoint(points: VoronoiPoint[], x: number, y: number): VoronoiPoint | null {
|
|
242
|
+
if (points.length === 0) return null;
|
|
243
|
+
|
|
244
|
+
let nearest = points[0];
|
|
245
|
+
let minDist = (points[0].x - x) ** 2 + (points[0].y - y) ** 2;
|
|
246
|
+
|
|
247
|
+
for (let i = 1; i < points.length; i++) {
|
|
248
|
+
const dist = (points[i].x - x) ** 2 + (points[i].y - y) ** 2;
|
|
249
|
+
if (dist < minDist) {
|
|
250
|
+
minDist = dist;
|
|
251
|
+
nearest = points[i];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return nearest;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Wire voronoi overlay tooltip events for line/area charts.
|
|
260
|
+
* Uses a transparent overlay rect with nearest-point lookup instead of
|
|
261
|
+
* per-point event listeners, eliminating DOM bloat.
|
|
262
|
+
* Returns a cleanup function.
|
|
263
|
+
*/
|
|
264
|
+
function wireVoronoiTooltipEvents(
|
|
265
|
+
svg: SVGElement,
|
|
266
|
+
layout: ChartLayout,
|
|
267
|
+
tooltipManager: TooltipManager,
|
|
268
|
+
): () => void {
|
|
269
|
+
const overlay = svg.querySelector('[data-voronoi-overlay]');
|
|
270
|
+
if (!overlay) return () => {};
|
|
271
|
+
|
|
272
|
+
const voronoiPoints = collectVoronoiPoints(layout);
|
|
273
|
+
if (voronoiPoints.length === 0) return () => {};
|
|
274
|
+
|
|
275
|
+
const cleanups: Array<() => void> = [];
|
|
276
|
+
|
|
277
|
+
const handleMouseMove = (e: Event) => {
|
|
278
|
+
const mouseEvent = e as MouseEvent;
|
|
279
|
+
const svgEl = svg as unknown as SVGSVGElement;
|
|
280
|
+
const svgRect = svgEl.getBoundingClientRect();
|
|
281
|
+
const viewBox = svgEl.viewBox?.baseVal;
|
|
282
|
+
|
|
283
|
+
// Convert client coordinates to SVG viewBox coordinates
|
|
284
|
+
const scaleX = viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1;
|
|
285
|
+
const scaleY = viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1;
|
|
286
|
+
const svgX = (mouseEvent.clientX - svgRect.left) * scaleX;
|
|
287
|
+
const svgY = (mouseEvent.clientY - svgRect.top) * scaleY;
|
|
288
|
+
|
|
289
|
+
const nearest = findNearestPoint(voronoiPoints, svgX, svgY);
|
|
290
|
+
if (!nearest?.tooltip) return;
|
|
291
|
+
|
|
292
|
+
// Show tooltip at the mouse position (relative to container, not SVG viewBox)
|
|
293
|
+
const containerX = mouseEvent.clientX - svgRect.left;
|
|
294
|
+
const containerY = mouseEvent.clientY - svgRect.top;
|
|
295
|
+
tooltipManager.show(nearest.tooltip, containerX, containerY);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const handleMouseLeave = () => {
|
|
299
|
+
tooltipManager.hide();
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// Touch support
|
|
303
|
+
const handleTouchStart = (e: Event) => {
|
|
304
|
+
const touchEvent = e as TouchEvent;
|
|
305
|
+
if (touchEvent.touches.length > 0) {
|
|
306
|
+
const touch = touchEvent.touches[0];
|
|
307
|
+
const svgEl = svg as unknown as SVGSVGElement;
|
|
308
|
+
const svgRect = svgEl.getBoundingClientRect();
|
|
309
|
+
const viewBox = svgEl.viewBox?.baseVal;
|
|
310
|
+
|
|
311
|
+
const scaleX = viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1;
|
|
312
|
+
const scaleY = viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1;
|
|
313
|
+
const svgX = (touch.clientX - svgRect.left) * scaleX;
|
|
314
|
+
const svgY = (touch.clientY - svgRect.top) * scaleY;
|
|
315
|
+
|
|
316
|
+
const nearest = findNearestPoint(voronoiPoints, svgX, svgY);
|
|
317
|
+
if (!nearest?.tooltip) return;
|
|
318
|
+
|
|
319
|
+
const containerX = touch.clientX - svgRect.left;
|
|
320
|
+
const containerY = touch.clientY - svgRect.top;
|
|
321
|
+
tooltipManager.show(nearest.tooltip, containerX, containerY);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
overlay.addEventListener('mousemove', handleMouseMove);
|
|
326
|
+
overlay.addEventListener('mouseleave', handleMouseLeave);
|
|
327
|
+
overlay.addEventListener('touchstart', handleTouchStart);
|
|
328
|
+
|
|
329
|
+
cleanups.push(() => {
|
|
330
|
+
overlay.removeEventListener('mousemove', handleMouseMove);
|
|
331
|
+
overlay.removeEventListener('mouseleave', handleMouseLeave);
|
|
332
|
+
overlay.removeEventListener('touchstart', handleTouchStart);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
return () => {
|
|
336
|
+
for (const cleanup of cleanups) {
|
|
337
|
+
cleanup();
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
206
342
|
// ---------------------------------------------------------------------------
|
|
207
343
|
// Chart event wiring (click, hover, leave on marks; legend toggle; annotation click)
|
|
208
344
|
// ---------------------------------------------------------------------------
|
|
@@ -692,10 +828,10 @@ function wireConnectorEndpointDrag(
|
|
|
692
828
|
// Determine connector endpoint positions from the connector element
|
|
693
829
|
let fromX: number, fromY: number, toX: number, toY: number;
|
|
694
830
|
if (connectorLine) {
|
|
695
|
-
fromX = Number(connectorLine.getAttribute('x1'));
|
|
696
|
-
fromY = Number(connectorLine.getAttribute('y1'));
|
|
697
|
-
toX = Number(connectorLine.getAttribute('x2'));
|
|
698
|
-
toY = Number(connectorLine.getAttribute('y2'));
|
|
831
|
+
fromX = Number(connectorLine.getAttribute('x1')) || 0;
|
|
832
|
+
fromY = Number(connectorLine.getAttribute('y1')) || 0;
|
|
833
|
+
toX = Number(connectorLine.getAttribute('x2')) || 0;
|
|
834
|
+
toY = Number(connectorLine.getAttribute('y2')) || 0;
|
|
699
835
|
} else {
|
|
700
836
|
// For curved connectors, get positions from the path data
|
|
701
837
|
// The path starts at M x y, so parse the first coordinates
|
|
@@ -708,8 +844,8 @@ function wireConnectorEndpointDrag(
|
|
|
708
844
|
const points = arrowhead?.getAttribute('points') ?? '';
|
|
709
845
|
const firstPoint = points.split(' ')[0] ?? '0,0';
|
|
710
846
|
const [px, py] = firstPoint.split(',');
|
|
711
|
-
toX = Number(px);
|
|
712
|
-
toY = Number(py);
|
|
847
|
+
toX = Number(px) || 0;
|
|
848
|
+
toY = Number(py) || 0;
|
|
713
849
|
}
|
|
714
850
|
|
|
715
851
|
// Create handles dynamically
|
|
@@ -721,6 +857,9 @@ function wireConnectorEndpointDrag(
|
|
|
721
857
|
const createdHandles: SVGCircleElement[] = [];
|
|
722
858
|
|
|
723
859
|
for (const ep of endpoints) {
|
|
860
|
+
// Skip endpoints with invalid coordinates to prevent NaN in SVG attributes
|
|
861
|
+
if (!Number.isFinite(ep.cx) || !Number.isFinite(ep.cy)) continue;
|
|
862
|
+
|
|
724
863
|
const handleEl = document.createElementNS(SVG_NS, 'circle') as SVGCircleElement;
|
|
725
864
|
handleEl.setAttribute('class', 'viz-connector-handle');
|
|
726
865
|
handleEl.setAttribute('data-endpoint', ep.name);
|
|
@@ -1372,15 +1511,16 @@ function createScreenReaderTable(
|
|
|
1372
1511
|
*/
|
|
1373
1512
|
export function createChart(
|
|
1374
1513
|
container: HTMLElement,
|
|
1375
|
-
spec: ChartSpec | GraphSpec,
|
|
1514
|
+
spec: ChartSpec | LayerSpec | GraphSpec,
|
|
1376
1515
|
options?: MountOptions,
|
|
1377
1516
|
): ChartInstance {
|
|
1378
|
-
let currentSpec: ChartSpec | GraphSpec = spec;
|
|
1517
|
+
let currentSpec: ChartSpec | LayerSpec | GraphSpec = spec;
|
|
1379
1518
|
let currentLayout: ChartLayout;
|
|
1380
1519
|
let svgElement: SVGElement | null = null;
|
|
1381
1520
|
let tooltipManager: TooltipManager | null = null;
|
|
1382
1521
|
let disconnectResize: (() => void) | null = null;
|
|
1383
1522
|
let cleanupTooltipEvents: (() => void) | null = null;
|
|
1523
|
+
let cleanupVoronoiEvents: (() => void) | null = null;
|
|
1384
1524
|
let cleanupKeyboardNav: (() => void) | null = null;
|
|
1385
1525
|
let cleanupLegend: (() => void) | null = null;
|
|
1386
1526
|
let cleanupChartEvents: (() => void) | null = null;
|
|
@@ -1406,7 +1546,10 @@ export function createChart(
|
|
|
1406
1546
|
measureText,
|
|
1407
1547
|
};
|
|
1408
1548
|
|
|
1409
|
-
|
|
1549
|
+
if (isLayerSpec(currentSpec)) {
|
|
1550
|
+
return compileLayer(currentSpec as LayerSpec, compileOpts);
|
|
1551
|
+
}
|
|
1552
|
+
return compileChart(currentSpec as ChartSpec | GraphSpec, compileOpts);
|
|
1410
1553
|
}
|
|
1411
1554
|
|
|
1412
1555
|
function getContainerDimensions(): { width: number; height: number } {
|
|
@@ -1429,6 +1572,10 @@ export function createChart(
|
|
|
1429
1572
|
cleanupTooltipEvents();
|
|
1430
1573
|
cleanupTooltipEvents = null;
|
|
1431
1574
|
}
|
|
1575
|
+
if (cleanupVoronoiEvents) {
|
|
1576
|
+
cleanupVoronoiEvents();
|
|
1577
|
+
cleanupVoronoiEvents = null;
|
|
1578
|
+
}
|
|
1432
1579
|
if (cleanupKeyboardNav) {
|
|
1433
1580
|
cleanupKeyboardNav();
|
|
1434
1581
|
cleanupKeyboardNav = null;
|
|
@@ -1471,6 +1618,9 @@ export function createChart(
|
|
|
1471
1618
|
tooltipManager,
|
|
1472
1619
|
);
|
|
1473
1620
|
|
|
1621
|
+
// Wire voronoi overlay tooltip events for line/area charts
|
|
1622
|
+
cleanupVoronoiEvents = wireVoronoiTooltipEvents(svgElement, currentLayout, tooltipManager);
|
|
1623
|
+
|
|
1474
1624
|
// Wire keyboard navigation
|
|
1475
1625
|
cleanupKeyboardNav = wireKeyboardNav(
|
|
1476
1626
|
svgElement,
|
|
@@ -1543,13 +1693,14 @@ export function createChart(
|
|
|
1543
1693
|
);
|
|
1544
1694
|
|
|
1545
1695
|
// Chrome text drag
|
|
1546
|
-
|
|
1696
|
+
const editSpec = currentSpec as ChartSpec | GraphSpec;
|
|
1697
|
+
editCleanups.push(wireChromeDrag(svgElement, editSpec, options.onEdit, setDragging));
|
|
1547
1698
|
|
|
1548
1699
|
// Legend drag
|
|
1549
|
-
editCleanups.push(wireLegendDrag(svgElement,
|
|
1700
|
+
editCleanups.push(wireLegendDrag(svgElement, editSpec, options.onEdit, setDragging));
|
|
1550
1701
|
|
|
1551
1702
|
// Series label drag
|
|
1552
|
-
editCleanups.push(wireSeriesLabelDrag(svgElement,
|
|
1703
|
+
editCleanups.push(wireSeriesLabelDrag(svgElement, editSpec, options.onEdit, setDragging));
|
|
1553
1704
|
|
|
1554
1705
|
cleanupEditDrags = () => {
|
|
1555
1706
|
for (const cleanup of editCleanups) {
|
|
@@ -1625,6 +1776,10 @@ export function createChart(
|
|
|
1625
1776
|
cleanupTooltipEvents();
|
|
1626
1777
|
cleanupTooltipEvents = null;
|
|
1627
1778
|
}
|
|
1779
|
+
if (cleanupVoronoiEvents) {
|
|
1780
|
+
cleanupVoronoiEvents();
|
|
1781
|
+
cleanupVoronoiEvents = null;
|
|
1782
|
+
}
|
|
1628
1783
|
if (cleanupKeyboardNav) {
|
|
1629
1784
|
cleanupKeyboardNav();
|
|
1630
1785
|
cleanupKeyboardNav = null;
|
package/src/svg-renderer.ts
CHANGED
|
@@ -22,7 +22,10 @@ import type {
|
|
|
22
22
|
RectMark,
|
|
23
23
|
ResolvedAnnotation,
|
|
24
24
|
ResolvedChromeElement,
|
|
25
|
+
RuleMarkLayout,
|
|
26
|
+
TextMarkLayout,
|
|
25
27
|
TextStyle,
|
|
28
|
+
TickMarkLayout,
|
|
26
29
|
} from '@opendata-ai/openchart-core';
|
|
27
30
|
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
28
31
|
|
|
@@ -80,15 +83,7 @@ function applyTextStyle(el: SVGElement, style: TextStyle): void {
|
|
|
80
83
|
el.setAttribute('text-anchor', style.textAnchor);
|
|
81
84
|
}
|
|
82
85
|
if (style.dominantBaseline) {
|
|
83
|
-
|
|
84
|
-
// where the bounding box extends above y=0, causing the SVG's default
|
|
85
|
-
// overflow:hidden to clip the text. Use a dy offset instead, which
|
|
86
|
-
// achieves the same visual positioning without the bbox issue.
|
|
87
|
-
if (style.dominantBaseline === 'hanging') {
|
|
88
|
-
el.setAttribute('dy', `${style.fontSize * 0.8}px`);
|
|
89
|
-
} else {
|
|
90
|
-
el.setAttribute('dominant-baseline', style.dominantBaseline);
|
|
91
|
-
}
|
|
86
|
+
el.setAttribute('dominant-baseline', style.dominantBaseline);
|
|
92
87
|
}
|
|
93
88
|
if (style.fontVariant) {
|
|
94
89
|
el.setAttribute('font-variant', style.fontVariant);
|
|
@@ -314,7 +309,7 @@ function renderAxis(
|
|
|
314
309
|
y2: gridline.position,
|
|
315
310
|
stroke: layout.theme.colors.gridline,
|
|
316
311
|
'stroke-width': 1,
|
|
317
|
-
'stroke-opacity': 0.
|
|
312
|
+
'stroke-opacity': 0.6,
|
|
318
313
|
});
|
|
319
314
|
} else {
|
|
320
315
|
setAttrs(gl, {
|
|
@@ -324,7 +319,7 @@ function renderAxis(
|
|
|
324
319
|
y2: area.y + area.height,
|
|
325
320
|
stroke: layout.theme.colors.gridline,
|
|
326
321
|
'stroke-width': 1,
|
|
327
|
-
'stroke-opacity': 0.
|
|
322
|
+
'stroke-opacity': 0.6,
|
|
328
323
|
});
|
|
329
324
|
}
|
|
330
325
|
g.appendChild(gl);
|
|
@@ -581,12 +576,93 @@ function renderPointMark(mark: PointMark, index: number): SVGElement {
|
|
|
581
576
|
return circle;
|
|
582
577
|
}
|
|
583
578
|
|
|
579
|
+
function renderTextMark(mark: TextMarkLayout, index: number): SVGElement {
|
|
580
|
+
const text = createSVGElement('text');
|
|
581
|
+
text.setAttribute('data-mark-id', `textMark-${index}`);
|
|
582
|
+
text.setAttribute('class', 'viz-mark viz-mark-text');
|
|
583
|
+
setAttrs(text, {
|
|
584
|
+
x: mark.x,
|
|
585
|
+
y: mark.y,
|
|
586
|
+
'font-size': mark.fontSize,
|
|
587
|
+
'text-anchor': mark.textAnchor,
|
|
588
|
+
});
|
|
589
|
+
(text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', mark.fill);
|
|
590
|
+
if (mark.fontWeight) {
|
|
591
|
+
text.setAttribute('font-weight', String(mark.fontWeight));
|
|
592
|
+
}
|
|
593
|
+
if (mark.fontFamily) {
|
|
594
|
+
text.setAttribute('font-family', mark.fontFamily);
|
|
595
|
+
}
|
|
596
|
+
if (mark.angle) {
|
|
597
|
+
text.setAttribute('transform', `rotate(${mark.angle}, ${mark.x}, ${mark.y})`);
|
|
598
|
+
}
|
|
599
|
+
text.textContent = mark.text;
|
|
600
|
+
return text;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function renderRuleMark(mark: RuleMarkLayout, index: number): SVGElement {
|
|
604
|
+
const line = createSVGElement('line');
|
|
605
|
+
line.setAttribute('data-mark-id', `rule-${index}`);
|
|
606
|
+
line.setAttribute('class', 'viz-mark viz-mark-rule');
|
|
607
|
+
setAttrs(line, {
|
|
608
|
+
x1: mark.x1,
|
|
609
|
+
y1: mark.y1,
|
|
610
|
+
x2: mark.x2,
|
|
611
|
+
y2: mark.y2,
|
|
612
|
+
stroke: mark.stroke,
|
|
613
|
+
'stroke-width': mark.strokeWidth,
|
|
614
|
+
});
|
|
615
|
+
if (mark.strokeDasharray) {
|
|
616
|
+
line.setAttribute('stroke-dasharray', mark.strokeDasharray);
|
|
617
|
+
}
|
|
618
|
+
if (mark.opacity != null) {
|
|
619
|
+
line.setAttribute('opacity', String(mark.opacity));
|
|
620
|
+
}
|
|
621
|
+
return line;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function renderTickMark(mark: TickMarkLayout, index: number): SVGElement {
|
|
625
|
+
const line = createSVGElement('line');
|
|
626
|
+
line.setAttribute('data-mark-id', `tick-${index}`);
|
|
627
|
+
line.setAttribute('class', 'viz-mark viz-mark-tick');
|
|
628
|
+
|
|
629
|
+
// Tick is a short line segment centered at (x, y)
|
|
630
|
+
const half = mark.length / 2;
|
|
631
|
+
if (mark.orient === 'vertical') {
|
|
632
|
+
setAttrs(line, {
|
|
633
|
+
x1: mark.x,
|
|
634
|
+
y1: mark.y - half,
|
|
635
|
+
x2: mark.x,
|
|
636
|
+
y2: mark.y + half,
|
|
637
|
+
stroke: mark.stroke,
|
|
638
|
+
'stroke-width': mark.strokeWidth,
|
|
639
|
+
});
|
|
640
|
+
} else {
|
|
641
|
+
setAttrs(line, {
|
|
642
|
+
x1: mark.x - half,
|
|
643
|
+
y1: mark.y,
|
|
644
|
+
x2: mark.x + half,
|
|
645
|
+
y2: mark.y,
|
|
646
|
+
stroke: mark.stroke,
|
|
647
|
+
'stroke-width': mark.strokeWidth,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (mark.opacity != null) {
|
|
652
|
+
line.setAttribute('opacity', String(mark.opacity));
|
|
653
|
+
}
|
|
654
|
+
return line;
|
|
655
|
+
}
|
|
656
|
+
|
|
584
657
|
// Register built-in renderers
|
|
585
658
|
registerMarkRenderer('line', renderLineMark as MarkRenderer<Mark>);
|
|
586
659
|
registerMarkRenderer('area', renderAreaMark as MarkRenderer<Mark>);
|
|
587
660
|
registerMarkRenderer('rect', renderRectMark as MarkRenderer<Mark>);
|
|
588
661
|
registerMarkRenderer('arc', renderArcMark as MarkRenderer<Mark>);
|
|
589
662
|
registerMarkRenderer('point', renderPointMark as MarkRenderer<Mark>);
|
|
663
|
+
registerMarkRenderer('textMark', renderTextMark as MarkRenderer<Mark>);
|
|
664
|
+
registerMarkRenderer('rule', renderRuleMark as MarkRenderer<Mark>);
|
|
665
|
+
registerMarkRenderer('tick', renderTickMark as MarkRenderer<Mark>);
|
|
590
666
|
|
|
591
667
|
/** Extract series name from a mark for legend toggle matching. */
|
|
592
668
|
function getMarkSeries(mark: Mark): string | undefined {
|
|
@@ -1031,7 +1107,7 @@ function renderBrand(parent: SVGElement, layout: ChartLayout): void {
|
|
|
1031
1107
|
setAttrs(text, {
|
|
1032
1108
|
x: rightEdge,
|
|
1033
1109
|
y: chromeY,
|
|
1034
|
-
|
|
1110
|
+
'dominant-baseline': 'hanging',
|
|
1035
1111
|
'font-family': layout.theme.fonts.family,
|
|
1036
1112
|
'font-size': BRAND_FONT_SIZE,
|
|
1037
1113
|
'text-anchor': 'end',
|
|
@@ -1071,6 +1147,12 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
|
|
|
1071
1147
|
setAttrs(svg, {
|
|
1072
1148
|
viewBox: `0 0 ${width} ${height}`,
|
|
1073
1149
|
xmlns: SVG_NS,
|
|
1150
|
+
// WebKit/iOS Safari getBBox() bug: text with dominant-baseline:hanging
|
|
1151
|
+
// reports bounding boxes extending above y=0. The SVG spec default
|
|
1152
|
+
// overflow is "hidden", which clips this phantom extent. Setting
|
|
1153
|
+
// overflow:visible prevents the clipping. Chart marks are already
|
|
1154
|
+
// constrained by a clipPath, so nothing bleeds out.
|
|
1155
|
+
overflow: 'visible',
|
|
1074
1156
|
});
|
|
1075
1157
|
// Set explicit pixel height via inline style. iOS Safari misresolves CSS
|
|
1076
1158
|
// height:100% when the ancestor chain uses minHeight instead of height,
|
|
@@ -1117,6 +1199,28 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
|
|
|
1117
1199
|
const clippedGroup = createSVGElement('g');
|
|
1118
1200
|
clippedGroup.setAttribute('clip-path', `url(#${clipId})`);
|
|
1119
1201
|
renderMarks(clippedGroup, layout);
|
|
1202
|
+
|
|
1203
|
+
// Add transparent overlay rect for line/area charts to enable voronoi tooltip lookup.
|
|
1204
|
+
// Only added when there are line or area marks with dataPoints, and no explicit
|
|
1205
|
+
// PointMark objects (which use per-element event handling instead).
|
|
1206
|
+
const hasLineOrAreaWithDataPoints = layout.marks.some(
|
|
1207
|
+
(m) => (m.type === 'line' || m.type === 'area') && m.dataPoints && m.dataPoints.length > 0,
|
|
1208
|
+
);
|
|
1209
|
+
const hasPointMarks = layout.marks.some((m) => m.type === 'point');
|
|
1210
|
+
if (hasLineOrAreaWithDataPoints && !hasPointMarks) {
|
|
1211
|
+
const overlay = createSVGElement('rect');
|
|
1212
|
+
setAttrs(overlay, {
|
|
1213
|
+
x: layout.area.x,
|
|
1214
|
+
y: layout.area.y,
|
|
1215
|
+
width: layout.area.width,
|
|
1216
|
+
height: layout.area.height,
|
|
1217
|
+
fill: 'transparent',
|
|
1218
|
+
});
|
|
1219
|
+
overlay.setAttribute('class', 'viz-voronoi-overlay');
|
|
1220
|
+
overlay.setAttribute('data-voronoi-overlay', 'true');
|
|
1221
|
+
clippedGroup.appendChild(overlay);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1120
1224
|
svg.appendChild(clippedGroup);
|
|
1121
1225
|
|
|
1122
1226
|
renderAnnotations(svg, layout);
|