@opendata-ai/openchart-vanilla 6.19.3 → 6.21.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 +18 -7
- package/dist/index.js +718 -778
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/gradient-ids.test.ts +159 -0
- package/src/__tests__/resize-timing.test.ts +184 -0
- package/src/gradient-utils.ts +6 -8
- package/src/mount.ts +4 -11
- package/src/renderers/annotations.ts +212 -0
- package/src/renderers/axes.ts +164 -0
- package/src/renderers/brand.ts +75 -0
- package/src/renderers/chrome.ts +96 -0
- package/src/renderers/legend.ts +131 -0
- package/src/renderers/marks.ts +427 -0
- package/src/renderers/svg-dom.ts +66 -0
- package/src/sankey-renderer.ts +6 -43
- package/src/svg-ids.ts +18 -0
- package/src/svg-renderer.ts +64 -1269
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.21.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": "6.
|
|
54
|
-
"@opendata-ai/openchart-engine": "6.
|
|
53
|
+
"@opendata-ai/openchart-core": "6.21.0",
|
|
54
|
+
"@opendata-ai/openchart-engine": "6.21.0",
|
|
55
55
|
"d3-force": "^3.0.0",
|
|
56
56
|
"d3-quadtree": "^3.0.1"
|
|
57
57
|
},
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-chart SVG ID uniqueness: gradients AND clip-paths.
|
|
3
|
+
*
|
|
4
|
+
* Originally locked gradient-counter behavior from commit 73ef048 (fix for
|
|
5
|
+
* random-hex gradient collisions). Step 6 of refactor/v7-cohesion unified
|
|
6
|
+
* gradient and clip-path ID generation under `nextSvgId` in `svg-ids.ts`,
|
|
7
|
+
* so this test now guards both halves.
|
|
8
|
+
*
|
|
9
|
+
* Why it matters: SVG `url(#id)` resolves against the full document. If two
|
|
10
|
+
* charts on the same page generate overlapping IDs, one chart silently
|
|
11
|
+
* inherits the other's gradient fill or clip region. The shared monotonic
|
|
12
|
+
* counter makes uniqueness unconditional.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ChartSpec, LinearGradient } from '@opendata-ai/openchart-core';
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
17
|
+
import { createContainer } from '../__test-fixtures__/dom';
|
|
18
|
+
import { createChart } from '../mount';
|
|
19
|
+
|
|
20
|
+
const barGradient: LinearGradient = {
|
|
21
|
+
gradient: 'linear',
|
|
22
|
+
x1: 0,
|
|
23
|
+
y1: 0,
|
|
24
|
+
x2: 1,
|
|
25
|
+
y2: 0,
|
|
26
|
+
stops: [
|
|
27
|
+
{ offset: 0, color: '#1b7fa3', opacity: 0.4 },
|
|
28
|
+
{ offset: 1, color: '#1b7fa3' },
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// A visibly different gradient so charts have distinct gradient defs, not
|
|
33
|
+
// identical ones that could be deduped by the gradient key system.
|
|
34
|
+
const altGradient: LinearGradient = {
|
|
35
|
+
gradient: 'linear',
|
|
36
|
+
x1: 0,
|
|
37
|
+
y1: 0,
|
|
38
|
+
x2: 1,
|
|
39
|
+
y2: 0,
|
|
40
|
+
stops: [
|
|
41
|
+
{ offset: 0, color: '#cc3366', opacity: 0.2 },
|
|
42
|
+
{ offset: 1, color: '#cc3366' },
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function makeBarSpec(fill: LinearGradient): ChartSpec {
|
|
47
|
+
return {
|
|
48
|
+
mark: { type: 'bar', fill },
|
|
49
|
+
data: [
|
|
50
|
+
{ category: 'A', value: 10 },
|
|
51
|
+
{ category: 'B', value: 20 },
|
|
52
|
+
{ category: 'C', value: 30 },
|
|
53
|
+
],
|
|
54
|
+
encoding: {
|
|
55
|
+
x: { field: 'value', type: 'quantitative' },
|
|
56
|
+
y: { field: 'category', type: 'nominal' },
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function collectGradientIds(root: HTMLElement): string[] {
|
|
62
|
+
const ids: string[] = [];
|
|
63
|
+
for (const el of root.querySelectorAll('linearGradient, radialGradient')) {
|
|
64
|
+
const id = el.getAttribute('id');
|
|
65
|
+
if (id) ids.push(id);
|
|
66
|
+
}
|
|
67
|
+
return ids;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function collectClipPathIds(root: HTMLElement): string[] {
|
|
71
|
+
const ids: string[] = [];
|
|
72
|
+
for (const el of root.querySelectorAll('clipPath')) {
|
|
73
|
+
const id = el.getAttribute('id');
|
|
74
|
+
if (id) ids.push(id);
|
|
75
|
+
}
|
|
76
|
+
return ids;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('SVG ID uniqueness across charts', () => {
|
|
80
|
+
let a: HTMLDivElement;
|
|
81
|
+
let b: HTMLDivElement;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
a = createContainer();
|
|
85
|
+
b = createContainer();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
document.body.innerHTML = '';
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('two charts produce disjoint gradient IDs', () => {
|
|
93
|
+
const chartA = createChart(a, makeBarSpec(barGradient));
|
|
94
|
+
const chartB = createChart(b, makeBarSpec(altGradient));
|
|
95
|
+
|
|
96
|
+
const idsA = collectGradientIds(a);
|
|
97
|
+
const idsB = collectGradientIds(b);
|
|
98
|
+
|
|
99
|
+
// Each chart must have produced at least one gradient def.
|
|
100
|
+
expect(idsA.length).toBeGreaterThan(0);
|
|
101
|
+
expect(idsB.length).toBeGreaterThan(0);
|
|
102
|
+
|
|
103
|
+
// Union has no duplicates: size of the Set equals the total count.
|
|
104
|
+
const all = [...idsA, ...idsB];
|
|
105
|
+
expect(new Set(all).size).toBe(all.length);
|
|
106
|
+
|
|
107
|
+
// Every generated id follows the locked "oc-grad-N" shape.
|
|
108
|
+
for (const id of all) {
|
|
109
|
+
expect(id).toMatch(/^oc-grad-\d+$/);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
chartA.destroy();
|
|
113
|
+
chartB.destroy();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('two charts produce disjoint clip-path IDs', () => {
|
|
117
|
+
// Every chart render creates a <clipPath> for the plot area, so any spec
|
|
118
|
+
// works here. Reuse the gradient specs for consistency.
|
|
119
|
+
const chartA = createChart(a, makeBarSpec(barGradient));
|
|
120
|
+
const chartB = createChart(b, makeBarSpec(altGradient));
|
|
121
|
+
|
|
122
|
+
const idsA = collectClipPathIds(a);
|
|
123
|
+
const idsB = collectClipPathIds(b);
|
|
124
|
+
|
|
125
|
+
expect(idsA.length).toBeGreaterThan(0);
|
|
126
|
+
expect(idsB.length).toBeGreaterThan(0);
|
|
127
|
+
|
|
128
|
+
const all = [...idsA, ...idsB];
|
|
129
|
+
expect(new Set(all).size).toBe(all.length);
|
|
130
|
+
|
|
131
|
+
for (const id of all) {
|
|
132
|
+
expect(id).toMatch(/^oc-clip-\d+$/);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
chartA.destroy();
|
|
136
|
+
chartB.destroy();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('gradient and clip-path IDs never collide across two charts', () => {
|
|
140
|
+
// Core guarantee of the unified nextSvgId counter: even though gradients
|
|
141
|
+
// and clip-paths use different prefixes, the counter values are shared.
|
|
142
|
+
// Collecting every ID from both <defs> trees should yield a strict set.
|
|
143
|
+
const chartA = createChart(a, makeBarSpec(barGradient));
|
|
144
|
+
const chartB = createChart(b, makeBarSpec(altGradient));
|
|
145
|
+
|
|
146
|
+
const all = [
|
|
147
|
+
...collectGradientIds(a),
|
|
148
|
+
...collectClipPathIds(a),
|
|
149
|
+
...collectGradientIds(b),
|
|
150
|
+
...collectClipPathIds(b),
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
expect(all.length).toBeGreaterThan(0);
|
|
154
|
+
expect(new Set(all).size).toBe(all.length);
|
|
155
|
+
|
|
156
|
+
chartA.destroy();
|
|
157
|
+
chartB.destroy();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resize timing behavior.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify that:
|
|
5
|
+
* 1. The inner observer's 16ms debounce coalesces a rapid burst of
|
|
6
|
+
* ResizeObserver entries into a single render (no thrash).
|
|
7
|
+
* 2. First ResizeObserver fire does not blank-flash (SVG stays mounted).
|
|
8
|
+
* 3. Resize events during an entrance animation are deferred and replayed
|
|
9
|
+
* on animation end.
|
|
10
|
+
*
|
|
11
|
+
* happy-dom ships a ResizeObserver stub that does not auto-fire on layout,
|
|
12
|
+
* so we override it with a controllable mock that lets us invoke the
|
|
13
|
+
* observer callback synchronously.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
17
|
+
import { createContainer } from '../__test-fixtures__/dom';
|
|
18
|
+
import { lineSpec } from '../__test-fixtures__/specs';
|
|
19
|
+
import { createChart } from '../mount';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Controllable ResizeObserver mock
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
type ObserverCallback = (entries: ResizeObserverEntry[]) => void;
|
|
26
|
+
|
|
27
|
+
interface FakeObserver {
|
|
28
|
+
element: Element | null;
|
|
29
|
+
callback: ObserverCallback;
|
|
30
|
+
disconnected: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const observers: FakeObserver[] = [];
|
|
34
|
+
|
|
35
|
+
class MockResizeObserver {
|
|
36
|
+
private readonly record: FakeObserver;
|
|
37
|
+
|
|
38
|
+
constructor(callback: ObserverCallback) {
|
|
39
|
+
this.record = { element: null, callback, disconnected: false };
|
|
40
|
+
observers.push(this.record);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
observe(element: Element): void {
|
|
44
|
+
this.record.element = element;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
disconnect(): void {
|
|
48
|
+
this.record.disconnected = true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
unobserve(): void {
|
|
52
|
+
// no-op
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Fire a ResizeObserver callback for the given element with width/height. */
|
|
57
|
+
function fireResize(element: Element, width: number, height: number): void {
|
|
58
|
+
for (const o of observers) {
|
|
59
|
+
if (o.element === element && !o.disconnected) {
|
|
60
|
+
const entry = {
|
|
61
|
+
contentRect: { width, height, top: 0, left: 0, right: width, bottom: height, x: 0, y: 0 },
|
|
62
|
+
target: element,
|
|
63
|
+
} as unknown as ResizeObserverEntry;
|
|
64
|
+
o.callback([entry]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Tests
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
describe('chart resize timing', () => {
|
|
74
|
+
let container: HTMLDivElement;
|
|
75
|
+
let originalRO: typeof globalThis.ResizeObserver;
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
observers.length = 0;
|
|
79
|
+
originalRO = globalThis.ResizeObserver;
|
|
80
|
+
// Use MockResizeObserver so tests can drive observer fires deterministically.
|
|
81
|
+
// Cast via unknown to satisfy the stricter ResizeObserver type.
|
|
82
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
83
|
+
vi.useFakeTimers();
|
|
84
|
+
container = createContainer();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
vi.useRealTimers();
|
|
89
|
+
globalThis.ResizeObserver = originalRO;
|
|
90
|
+
document.body.innerHTML = '';
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('coalesces a rapid burst of resize events into a single render', async () => {
|
|
94
|
+
const chart = createChart(container, lineSpec);
|
|
95
|
+
const initialSvg = container.querySelector('svg');
|
|
96
|
+
expect(initialSvg).not.toBeNull();
|
|
97
|
+
|
|
98
|
+
// Track SVG node identity across renders: each render() replaces the SVG,
|
|
99
|
+
// so counting how many distinct SVG nodes appear tells us how many renders ran.
|
|
100
|
+
let renderCount = 0;
|
|
101
|
+
const mo = new MutationObserver((records) => {
|
|
102
|
+
for (const r of records) {
|
|
103
|
+
for (let i = 0; i < r.addedNodes.length; i++) {
|
|
104
|
+
const node = r.addedNodes[i];
|
|
105
|
+
if (node instanceof Element && node.tagName.toLowerCase() === 'svg') {
|
|
106
|
+
renderCount++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
mo.observe(container, { childList: true });
|
|
112
|
+
|
|
113
|
+
// Fire 20 resize events within a tight window.
|
|
114
|
+
for (let i = 0; i < 20; i++) {
|
|
115
|
+
fireResize(container, 600 + i, 400);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Before the debounce window elapses, no new SVG has been appended.
|
|
119
|
+
expect(renderCount).toBe(0);
|
|
120
|
+
|
|
121
|
+
// Run all pending timers (16ms inner debounce + any outer delay).
|
|
122
|
+
await vi.runAllTimersAsync();
|
|
123
|
+
|
|
124
|
+
// MutationObserver records are delivered as microtasks; flush them.
|
|
125
|
+
mo.takeRecords().forEach((r) => {
|
|
126
|
+
for (let i = 0; i < r.addedNodes.length; i++) {
|
|
127
|
+
const node = r.addedNodes[i];
|
|
128
|
+
if (node instanceof Element && node.tagName.toLowerCase() === 'svg') {
|
|
129
|
+
renderCount++;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Exactly one re-render should have run for the whole burst (no thrash).
|
|
135
|
+
expect(renderCount).toBe(1);
|
|
136
|
+
|
|
137
|
+
mo.disconnect();
|
|
138
|
+
chart.destroy();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('does not blank-flash: SVG remains mounted through first observer fire', async () => {
|
|
142
|
+
const chart = createChart(container, lineSpec);
|
|
143
|
+
|
|
144
|
+
// SVG is painted immediately on mount, before the observer fires.
|
|
145
|
+
const svgBefore = container.querySelector('svg');
|
|
146
|
+
expect(svgBefore).not.toBeNull();
|
|
147
|
+
|
|
148
|
+
// Fire the observer as it would on first layout.
|
|
149
|
+
fireResize(container, 600, 400);
|
|
150
|
+
|
|
151
|
+
// Even before the debounce elapses, the SVG must still be in the DOM.
|
|
152
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
153
|
+
|
|
154
|
+
// And after timers flush, the SVG is still there (possibly a new node).
|
|
155
|
+
await vi.runAllTimersAsync();
|
|
156
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
157
|
+
|
|
158
|
+
chart.destroy();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('disconnects the observer on destroy', () => {
|
|
162
|
+
const chart = createChart(container, lineSpec);
|
|
163
|
+
expect(observers.length).toBe(1);
|
|
164
|
+
expect(observers[0].disconnected).toBe(false);
|
|
165
|
+
|
|
166
|
+
chart.destroy();
|
|
167
|
+
|
|
168
|
+
expect(observers[0].disconnected).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('clears any pending resize timer on destroy (no callback after teardown)', async () => {
|
|
172
|
+
const chart = createChart(container, lineSpec);
|
|
173
|
+
|
|
174
|
+
fireResize(container, 700, 500);
|
|
175
|
+
// Destroy before the debounce elapses.
|
|
176
|
+
chart.destroy();
|
|
177
|
+
|
|
178
|
+
await vi.runAllTimersAsync();
|
|
179
|
+
|
|
180
|
+
// destroy() removes the SVG; if the timer fired after destroy it would
|
|
181
|
+
// try to render a new one. No SVG should be present.
|
|
182
|
+
expect(container.querySelector('svg')).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
});
|
package/src/gradient-utils.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { GradientDef, LinearGradient, RadialGradient } from '@opendata-ai/openchart-core';
|
|
7
7
|
import { isGradientDef } from '@opendata-ai/openchart-core';
|
|
8
|
+
import { nextSvgId } from './svg-ids';
|
|
8
9
|
|
|
9
10
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
10
11
|
|
|
@@ -89,18 +90,15 @@ function appendStop(
|
|
|
89
90
|
parent.appendChild(stopEl);
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
/**
|
|
93
|
-
* Global counter for gradient IDs. Ensures uniqueness across all charts
|
|
94
|
-
* on the same page, since SVG url(#id) resolves globally in the document.
|
|
95
|
-
*/
|
|
96
|
-
let globalGradientCounter = 0;
|
|
97
|
-
|
|
98
93
|
/**
|
|
99
94
|
* Scan all marks for GradientDef fill values, create SVG gradient elements
|
|
100
95
|
* in the provided <defs> node, and return a map from gradient key to element ID.
|
|
101
96
|
*
|
|
102
97
|
* Identical gradients (by key) share a single SVG element within one chart.
|
|
103
|
-
* IDs
|
|
98
|
+
* IDs come from `nextSvgId` so they're globally unique across charts and
|
|
99
|
+
* share the counter with clip-path IDs (see svg-ids.ts). Gradient ID numbers
|
|
100
|
+
* may skip values when clip-paths consume counter slots - still monotonic,
|
|
101
|
+
* still unique.
|
|
104
102
|
*/
|
|
105
103
|
export function buildGradientDefs(
|
|
106
104
|
marks: Array<{ fill?: unknown }>,
|
|
@@ -113,7 +111,7 @@ export function buildGradientDefs(
|
|
|
113
111
|
if (fill && isGradientDef(fill)) {
|
|
114
112
|
const key = gradientKey(fill);
|
|
115
113
|
if (!map.has(key)) {
|
|
116
|
-
const id =
|
|
114
|
+
const id = nextSvgId('oc-grad');
|
|
117
115
|
const el = createGradientElement(fill, id);
|
|
118
116
|
defs.appendChild(el);
|
|
119
117
|
map.set(key, id);
|
package/src/mount.ts
CHANGED
|
@@ -1798,7 +1798,6 @@ export function createChart(
|
|
|
1798
1798
|
let destroyed = false;
|
|
1799
1799
|
let isDragging = false;
|
|
1800
1800
|
let pendingRender = false;
|
|
1801
|
-
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1802
1801
|
|
|
1803
1802
|
// Animation state
|
|
1804
1803
|
let isFirstRender = true;
|
|
@@ -2393,10 +2392,6 @@ export function createChart(
|
|
|
2393
2392
|
}
|
|
2394
2393
|
cancelAnimations(svgElement);
|
|
2395
2394
|
|
|
2396
|
-
if (resizeTimer !== null) {
|
|
2397
|
-
clearTimeout(resizeTimer);
|
|
2398
|
-
resizeTimer = null;
|
|
2399
|
-
}
|
|
2400
2395
|
if (cleanupTooltipEvents) {
|
|
2401
2396
|
cleanupTooltipEvents();
|
|
2402
2397
|
cleanupTooltipEvents = null;
|
|
@@ -2463,14 +2458,12 @@ export function createChart(
|
|
|
2463
2458
|
// Initial render
|
|
2464
2459
|
render();
|
|
2465
2460
|
|
|
2466
|
-
// Set up responsive resize
|
|
2461
|
+
// Set up responsive resize. The observeResize helper already debounces at
|
|
2462
|
+
// ~60fps (16ms) internally, which is sufficient to coalesce a drag-burst
|
|
2463
|
+
// into a single render without additive delay.
|
|
2467
2464
|
if (options?.responsive !== false) {
|
|
2468
2465
|
disconnectResize = observeResize(container, () => {
|
|
2469
|
-
|
|
2470
|
-
resizeTimer = setTimeout(() => {
|
|
2471
|
-
resizeTimer = null;
|
|
2472
|
-
resize();
|
|
2473
|
-
}, 100);
|
|
2466
|
+
resize();
|
|
2474
2467
|
});
|
|
2475
2468
|
}
|
|
2476
2469
|
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Annotation rendering: range rects, reference lines, labels with connectors.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ChartLayout, Point, ResolvedAnnotation } from '@opendata-ai/openchart-core';
|
|
6
|
+
import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Render a curved arrow connector from a label to a data point.
|
|
10
|
+
* Uses a cubic bezier that sweeps outward then curves toward the
|
|
11
|
+
* target, with a triangular arrowhead at the tip.
|
|
12
|
+
*/
|
|
13
|
+
function renderCurvedArrow(parent: SVGElement, from: Point, to: Point, stroke: string): void {
|
|
14
|
+
// Pad above the target so the arrow doesn't sit right on the element.
|
|
15
|
+
const pad = 6;
|
|
16
|
+
const tipY = to.y - pad;
|
|
17
|
+
|
|
18
|
+
const dy = tipY - from.y;
|
|
19
|
+
const dist = Math.sqrt((to.x - from.x) ** 2 + dy ** 2) || 1;
|
|
20
|
+
|
|
21
|
+
// Arrowhead geometry
|
|
22
|
+
const arrowLen = 8;
|
|
23
|
+
const arrowWidth = 4;
|
|
24
|
+
|
|
25
|
+
// cp2 directly above target so arrow arrives pointing straight down.
|
|
26
|
+
const bulge = Math.max(dist * 0.4, 35);
|
|
27
|
+
const cp1x = from.x + bulge;
|
|
28
|
+
const cp1y = from.y + dy * 0.35;
|
|
29
|
+
const cp2x = to.x;
|
|
30
|
+
const cp2y = tipY - Math.abs(dy) * 0.25;
|
|
31
|
+
|
|
32
|
+
// Tangent at the tip (from cp2 → tip), used for arrowhead direction.
|
|
33
|
+
const tx = to.x - cp2x;
|
|
34
|
+
const ty = tipY - cp2y;
|
|
35
|
+
const tLen = Math.sqrt(tx * tx + ty * ty) || 1;
|
|
36
|
+
const ux = tx / tLen;
|
|
37
|
+
const uy = ty / tLen;
|
|
38
|
+
|
|
39
|
+
// End the curve path at the arrowhead BASE so the stroke doesn't
|
|
40
|
+
// poke through the filled triangle.
|
|
41
|
+
const baseX = to.x - ux * arrowLen;
|
|
42
|
+
const baseY = tipY - uy * arrowLen;
|
|
43
|
+
|
|
44
|
+
const path = createSVGElement('path');
|
|
45
|
+
path.setAttribute('class', 'oc-annotation-connector');
|
|
46
|
+
setAttrs(path, {
|
|
47
|
+
d: `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${baseX} ${baseY}`,
|
|
48
|
+
fill: 'none',
|
|
49
|
+
stroke,
|
|
50
|
+
'stroke-width': 1.5,
|
|
51
|
+
});
|
|
52
|
+
parent.appendChild(path);
|
|
53
|
+
|
|
54
|
+
// Arrowhead triangle: perpendicular to tangent direction.
|
|
55
|
+
const px = -uy;
|
|
56
|
+
const py = ux;
|
|
57
|
+
|
|
58
|
+
const arrow = createSVGElement('polygon');
|
|
59
|
+
arrow.setAttribute('class', 'oc-annotation-connector');
|
|
60
|
+
setAttrs(arrow, {
|
|
61
|
+
points: [
|
|
62
|
+
`${to.x},${tipY}`,
|
|
63
|
+
`${baseX + px * arrowWidth},${baseY + py * arrowWidth}`,
|
|
64
|
+
`${baseX - px * arrowWidth},${baseY - py * arrowWidth}`,
|
|
65
|
+
].join(' '),
|
|
66
|
+
fill: stroke,
|
|
67
|
+
});
|
|
68
|
+
parent.appendChild(arrow);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderAnnotation(
|
|
72
|
+
parent: SVGElement,
|
|
73
|
+
annotation: ResolvedAnnotation,
|
|
74
|
+
index: number,
|
|
75
|
+
bgColor?: string,
|
|
76
|
+
): void {
|
|
77
|
+
const g = createSVGElement('g');
|
|
78
|
+
g.setAttribute('class', `oc-annotation oc-annotation-${annotation.type}`);
|
|
79
|
+
g.setAttribute('data-annotation-index', String(index));
|
|
80
|
+
if (annotation.id) {
|
|
81
|
+
g.setAttribute('data-annotation-id', annotation.id);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Range rect
|
|
85
|
+
if (annotation.rect) {
|
|
86
|
+
const rect = createSVGElement('rect');
|
|
87
|
+
rect.setAttribute('class', 'oc-annotation-range');
|
|
88
|
+
setAttrs(rect, {
|
|
89
|
+
x: annotation.rect.x,
|
|
90
|
+
y: annotation.rect.y,
|
|
91
|
+
width: annotation.rect.width,
|
|
92
|
+
height: annotation.rect.height,
|
|
93
|
+
});
|
|
94
|
+
if (annotation.fill) rect.setAttribute('fill', annotation.fill);
|
|
95
|
+
if (annotation.opacity !== undefined) {
|
|
96
|
+
rect.setAttribute('fill-opacity', String(annotation.opacity));
|
|
97
|
+
}
|
|
98
|
+
g.appendChild(rect);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Reference line
|
|
102
|
+
if (annotation.line) {
|
|
103
|
+
const line = createSVGElement('line');
|
|
104
|
+
line.setAttribute('class', 'oc-annotation-line');
|
|
105
|
+
setAttrs(line, {
|
|
106
|
+
x1: annotation.line.start.x,
|
|
107
|
+
y1: annotation.line.start.y,
|
|
108
|
+
x2: annotation.line.end.x,
|
|
109
|
+
y2: annotation.line.end.y,
|
|
110
|
+
'stroke-width': annotation.strokeWidth ?? 1,
|
|
111
|
+
});
|
|
112
|
+
if (annotation.stroke) line.setAttribute('stroke', annotation.stroke);
|
|
113
|
+
if (annotation.strokeDasharray) {
|
|
114
|
+
line.setAttribute('stroke-dasharray', annotation.strokeDasharray);
|
|
115
|
+
}
|
|
116
|
+
g.appendChild(line);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Label with optional connector line
|
|
120
|
+
if (annotation.label?.visible) {
|
|
121
|
+
// Render connector first (behind the label text)
|
|
122
|
+
if (annotation.label.connector) {
|
|
123
|
+
const c = annotation.label.connector;
|
|
124
|
+
if (c.style === 'curve') {
|
|
125
|
+
renderCurvedArrow(g, c.from, c.to, c.stroke);
|
|
126
|
+
} else {
|
|
127
|
+
const connector = createSVGElement('line');
|
|
128
|
+
connector.setAttribute('class', 'oc-annotation-connector');
|
|
129
|
+
setAttrs(connector, {
|
|
130
|
+
x1: c.from.x,
|
|
131
|
+
y1: c.from.y,
|
|
132
|
+
x2: c.to.x,
|
|
133
|
+
y2: c.to.y,
|
|
134
|
+
stroke: c.stroke,
|
|
135
|
+
'stroke-width': 1,
|
|
136
|
+
'stroke-opacity': 0.5,
|
|
137
|
+
});
|
|
138
|
+
g.appendChild(connector);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const text = createSVGElement('text');
|
|
143
|
+
text.setAttribute('class', 'oc-annotation-label');
|
|
144
|
+
setAttrs(text, { x: annotation.label.x, y: annotation.label.y });
|
|
145
|
+
applyTextStyle(text, annotation.label.style);
|
|
146
|
+
|
|
147
|
+
const lines = annotation.label.text.split('\n');
|
|
148
|
+
const fontSize = annotation.label.style.fontSize ?? 12;
|
|
149
|
+
const lineHeight = fontSize * (annotation.label.style.lineHeight ?? 1.3);
|
|
150
|
+
const isMultiLine = lines.length > 1;
|
|
151
|
+
|
|
152
|
+
// Multi-line text uses center alignment for a cleaner look
|
|
153
|
+
if (isMultiLine) {
|
|
154
|
+
text.setAttribute('text-anchor', 'middle');
|
|
155
|
+
for (let i = 0; i < lines.length; i++) {
|
|
156
|
+
const tspan = createSVGElement('tspan');
|
|
157
|
+
setAttrs(tspan, { x: annotation.label.x, dy: i === 0 ? 0 : lineHeight });
|
|
158
|
+
tspan.textContent = lines[i];
|
|
159
|
+
text.appendChild(tspan);
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
text.textContent = annotation.label.text;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Render background rect behind text if specified, otherwise use
|
|
166
|
+
// paint-order stroke halo to knock out lines behind text
|
|
167
|
+
if (annotation.label.background) {
|
|
168
|
+
const charWidth = fontSize * 0.55;
|
|
169
|
+
const maxLineWidth = Math.max(...lines.map((l) => l.length)) * charWidth;
|
|
170
|
+
const totalHeight = lines.length * lineHeight;
|
|
171
|
+
const pad = 3;
|
|
172
|
+
const bgX = isMultiLine
|
|
173
|
+
? annotation.label.x - maxLineWidth / 2 - pad
|
|
174
|
+
: annotation.label.x - pad;
|
|
175
|
+
const bgRect = createSVGElement('rect');
|
|
176
|
+
bgRect.setAttribute('class', 'oc-annotation-bg');
|
|
177
|
+
setAttrs(bgRect, {
|
|
178
|
+
x: bgX,
|
|
179
|
+
y: annotation.label.y - fontSize + (lineHeight - fontSize) / 2 - pad,
|
|
180
|
+
width: maxLineWidth + pad * 2,
|
|
181
|
+
height: totalHeight + pad * 2,
|
|
182
|
+
fill: annotation.label.background,
|
|
183
|
+
rx: 2,
|
|
184
|
+
});
|
|
185
|
+
g.appendChild(bgRect);
|
|
186
|
+
} else if (bgColor) {
|
|
187
|
+
text.style.paintOrder = 'stroke';
|
|
188
|
+
text.style.stroke = bgColor;
|
|
189
|
+
text.style.strokeWidth = `${Math.round(fontSize * 0.3)}px`;
|
|
190
|
+
text.style.strokeLinejoin = 'round';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
g.appendChild(text);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
parent.appendChild(g);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function renderAnnotations(parent: SVGElement, layout: ChartLayout): void {
|
|
200
|
+
if (layout.annotations.length === 0) return;
|
|
201
|
+
|
|
202
|
+
const g = createSVGElement('g');
|
|
203
|
+
g.setAttribute('class', 'oc-annotations');
|
|
204
|
+
|
|
205
|
+
// Annotations are already sorted by zIndex from the engine, so render in order
|
|
206
|
+
const bgColor = layout.theme.colors.background;
|
|
207
|
+
for (let i = 0; i < layout.annotations.length; i++) {
|
|
208
|
+
renderAnnotation(g, layout.annotations[i], i, bgColor);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
parent.appendChild(g);
|
|
212
|
+
}
|