@opendata-ai/openchart-vanilla 3.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 +211 -14
- 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 +115 -3
|
@@ -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": "
|
|
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
|
|
|
@@ -306,7 +309,7 @@ function renderAxis(
|
|
|
306
309
|
y2: gridline.position,
|
|
307
310
|
stroke: layout.theme.colors.gridline,
|
|
308
311
|
'stroke-width': 1,
|
|
309
|
-
'stroke-opacity': 0.
|
|
312
|
+
'stroke-opacity': 0.6,
|
|
310
313
|
});
|
|
311
314
|
} else {
|
|
312
315
|
setAttrs(gl, {
|
|
@@ -316,7 +319,7 @@ function renderAxis(
|
|
|
316
319
|
y2: area.y + area.height,
|
|
317
320
|
stroke: layout.theme.colors.gridline,
|
|
318
321
|
'stroke-width': 1,
|
|
319
|
-
'stroke-opacity': 0.
|
|
322
|
+
'stroke-opacity': 0.6,
|
|
320
323
|
});
|
|
321
324
|
}
|
|
322
325
|
g.appendChild(gl);
|
|
@@ -573,12 +576,93 @@ function renderPointMark(mark: PointMark, index: number): SVGElement {
|
|
|
573
576
|
return circle;
|
|
574
577
|
}
|
|
575
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
|
+
|
|
576
657
|
// Register built-in renderers
|
|
577
658
|
registerMarkRenderer('line', renderLineMark as MarkRenderer<Mark>);
|
|
578
659
|
registerMarkRenderer('area', renderAreaMark as MarkRenderer<Mark>);
|
|
579
660
|
registerMarkRenderer('rect', renderRectMark as MarkRenderer<Mark>);
|
|
580
661
|
registerMarkRenderer('arc', renderArcMark as MarkRenderer<Mark>);
|
|
581
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>);
|
|
582
666
|
|
|
583
667
|
/** Extract series name from a mark for legend toggle matching. */
|
|
584
668
|
function getMarkSeries(mark: Mark): string | undefined {
|
|
@@ -1023,10 +1107,10 @@ function renderBrand(parent: SVGElement, layout: ChartLayout): void {
|
|
|
1023
1107
|
setAttrs(text, {
|
|
1024
1108
|
x: rightEdge,
|
|
1025
1109
|
y: chromeY,
|
|
1110
|
+
'dominant-baseline': 'hanging',
|
|
1026
1111
|
'font-family': layout.theme.fonts.family,
|
|
1027
1112
|
'font-size': BRAND_FONT_SIZE,
|
|
1028
1113
|
'text-anchor': 'end',
|
|
1029
|
-
'dominant-baseline': 'hanging',
|
|
1030
1114
|
'fill-opacity': 0.55,
|
|
1031
1115
|
});
|
|
1032
1116
|
(text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
|
|
@@ -1063,6 +1147,12 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
|
|
|
1063
1147
|
setAttrs(svg, {
|
|
1064
1148
|
viewBox: `0 0 ${width} ${height}`,
|
|
1065
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',
|
|
1066
1156
|
});
|
|
1067
1157
|
// Set explicit pixel height via inline style. iOS Safari misresolves CSS
|
|
1068
1158
|
// height:100% when the ancestor chain uses minHeight instead of height,
|
|
@@ -1109,6 +1199,28 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
|
|
|
1109
1199
|
const clippedGroup = createSVGElement('g');
|
|
1110
1200
|
clippedGroup.setAttribute('clip-path', `url(#${clipId})`);
|
|
1111
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
|
+
|
|
1112
1224
|
svg.appendChild(clippedGroup);
|
|
1113
1225
|
|
|
1114
1226
|
renderAnnotations(svg, layout);
|