@opendata-ai/openchart-vanilla 6.19.2 → 6.20.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.js +23 -106
- 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/sankey-renderer.ts +6 -43
- package/src/svg-ids.ts +18 -0
- package/src/svg-renderer.ts +8 -84
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.20.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.20.0",
|
|
54
|
+
"@opendata-ai/openchart-engine": "6.20.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
|
|
package/src/sankey-renderer.ts
CHANGED
|
@@ -15,7 +15,12 @@ import type {
|
|
|
15
15
|
SankeyNodeMark,
|
|
16
16
|
TextStyle,
|
|
17
17
|
} from '@opendata-ai/openchart-core';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
BRAND_FONT_SIZE,
|
|
20
|
+
BRAND_MIN_WIDTH,
|
|
21
|
+
estimateTextWidth,
|
|
22
|
+
wrapText,
|
|
23
|
+
} from '@opendata-ai/openchart-core';
|
|
19
24
|
import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
|
|
20
25
|
|
|
21
26
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
@@ -79,48 +84,6 @@ function stampAnimationAttrs(
|
|
|
79
84
|
// Chrome rendering
|
|
80
85
|
// ---------------------------------------------------------------------------
|
|
81
86
|
|
|
82
|
-
/**
|
|
83
|
-
* Break text into lines that fit within maxWidth using word wrapping.
|
|
84
|
-
*/
|
|
85
|
-
function wrapText(text: string, fontSize: number, fontWeight: number, maxWidth: number): string[] {
|
|
86
|
-
if (maxWidth <= 0) return [text];
|
|
87
|
-
|
|
88
|
-
const AVG_CHAR_WIDTH = 0.57;
|
|
89
|
-
const WEIGHT_FACTORS: Record<number, number> = {
|
|
90
|
-
100: 0.9,
|
|
91
|
-
200: 0.92,
|
|
92
|
-
300: 0.95,
|
|
93
|
-
400: 1.0,
|
|
94
|
-
500: 1.02,
|
|
95
|
-
600: 1.05,
|
|
96
|
-
700: 1.08,
|
|
97
|
-
800: 1.1,
|
|
98
|
-
900: 1.12,
|
|
99
|
-
};
|
|
100
|
-
const weightFactor = WEIGHT_FACTORS[fontWeight] ?? 1.0;
|
|
101
|
-
const charWidth = fontSize * AVG_CHAR_WIDTH * weightFactor;
|
|
102
|
-
const maxChars = Math.floor(maxWidth / charWidth);
|
|
103
|
-
|
|
104
|
-
if (text.length <= maxChars) return [text];
|
|
105
|
-
|
|
106
|
-
const words = text.split(' ');
|
|
107
|
-
const lines: string[] = [];
|
|
108
|
-
let current = '';
|
|
109
|
-
|
|
110
|
-
for (const word of words) {
|
|
111
|
-
const candidate = current ? `${current} ${word}` : word;
|
|
112
|
-
if (candidate.length > maxChars && current) {
|
|
113
|
-
lines.push(current);
|
|
114
|
-
current = word;
|
|
115
|
-
} else {
|
|
116
|
-
current = candidate;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
if (current) lines.push(current);
|
|
120
|
-
|
|
121
|
-
return lines;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
87
|
function renderChromeElement(
|
|
125
88
|
parent: SVGElement,
|
|
126
89
|
element: ResolvedChromeElement,
|
package/src/svg-ids.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monotonic SVG ID generator shared by gradient and clipPath defs.
|
|
3
|
+
*
|
|
4
|
+
* Why a single counter: SVG `url(#id)` references resolve against the full
|
|
5
|
+
* document, so any ID collision (across charts on the same page) silently
|
|
6
|
+
* cross-wires one chart's fill/clip into another. Random-hex suffixes have a
|
|
7
|
+
* small-but-real collision probability that surfaced in production; a global
|
|
8
|
+
* monotonic counter makes uniqueness unconditional.
|
|
9
|
+
*
|
|
10
|
+
* Gradient and clip-path IDs share one counter so we can't regress one half of
|
|
11
|
+
* the system by accident. Consumers just pick a prefix.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let counter = 0;
|
|
15
|
+
|
|
16
|
+
export function nextSvgId(prefix: string): string {
|
|
17
|
+
return `${prefix}-${counter++}`;
|
|
18
|
+
}
|
package/src/svg-renderer.ts
CHANGED
|
@@ -29,9 +29,15 @@ import type {
|
|
|
29
29
|
TextStyle,
|
|
30
30
|
TickMarkLayout,
|
|
31
31
|
} from '@opendata-ai/openchart-core';
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
BRAND_FONT_SIZE,
|
|
34
|
+
BRAND_MIN_WIDTH,
|
|
35
|
+
estimateTextWidth,
|
|
36
|
+
wrapText,
|
|
37
|
+
} from '@opendata-ai/openchart-core';
|
|
33
38
|
import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
|
|
34
39
|
import { buildGradientDefs, resolveMarkFill } from './gradient-utils';
|
|
40
|
+
import { nextSvgId } from './svg-ids';
|
|
35
41
|
|
|
36
42
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
37
43
|
|
|
@@ -132,88 +138,6 @@ function applyTextStyle(el: SVGElement, style: TextStyle): void {
|
|
|
132
138
|
// Chrome rendering
|
|
133
139
|
// ---------------------------------------------------------------------------
|
|
134
140
|
|
|
135
|
-
/**
|
|
136
|
-
* Break text into lines that fit within maxWidth using word wrapping.
|
|
137
|
-
* Uses a character-width heuristic (same as text-measure.ts).
|
|
138
|
-
*/
|
|
139
|
-
function wrapText(
|
|
140
|
-
text: string,
|
|
141
|
-
fontSize: number,
|
|
142
|
-
fontWeight: number,
|
|
143
|
-
maxWidth: number,
|
|
144
|
-
measureText?: MeasureTextFn,
|
|
145
|
-
): string[] {
|
|
146
|
-
if (maxWidth <= 0) return [text];
|
|
147
|
-
|
|
148
|
-
// Split on explicit newlines first
|
|
149
|
-
const segments = text.split('\n');
|
|
150
|
-
if (segments.length > 1) {
|
|
151
|
-
return segments.flatMap((segment) =>
|
|
152
|
-
segment.length === 0 ? [''] : wrapText(segment, fontSize, fontWeight, maxWidth, measureText),
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Use real text measurement when available
|
|
157
|
-
if (measureText) {
|
|
158
|
-
const textWidth = measureText(text, fontSize, fontWeight).width;
|
|
159
|
-
if (textWidth <= maxWidth) return [text];
|
|
160
|
-
|
|
161
|
-
const words = text.split(' ');
|
|
162
|
-
const lines: string[] = [];
|
|
163
|
-
let current = '';
|
|
164
|
-
|
|
165
|
-
for (const word of words) {
|
|
166
|
-
const candidate = current ? `${current} ${word}` : word;
|
|
167
|
-
const candidateWidth = measureText(candidate, fontSize, fontWeight).width;
|
|
168
|
-
if (candidateWidth > maxWidth && current) {
|
|
169
|
-
lines.push(current);
|
|
170
|
-
current = word;
|
|
171
|
-
} else {
|
|
172
|
-
current = candidate;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
if (current) lines.push(current);
|
|
176
|
-
|
|
177
|
-
return lines;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Heuristic character width matching text-measure.ts
|
|
181
|
-
const AVG_CHAR_WIDTH = 0.57;
|
|
182
|
-
const WEIGHT_FACTORS: Record<number, number> = {
|
|
183
|
-
100: 0.9,
|
|
184
|
-
200: 0.92,
|
|
185
|
-
300: 0.95,
|
|
186
|
-
400: 1.0,
|
|
187
|
-
500: 1.02,
|
|
188
|
-
600: 1.05,
|
|
189
|
-
700: 1.08,
|
|
190
|
-
800: 1.1,
|
|
191
|
-
900: 1.12,
|
|
192
|
-
};
|
|
193
|
-
const weightFactor = WEIGHT_FACTORS[fontWeight] ?? 1.0;
|
|
194
|
-
const charWidth = fontSize * AVG_CHAR_WIDTH * weightFactor;
|
|
195
|
-
const maxChars = Math.floor(maxWidth / charWidth);
|
|
196
|
-
|
|
197
|
-
if (text.length <= maxChars) return [text];
|
|
198
|
-
|
|
199
|
-
const words = text.split(' ');
|
|
200
|
-
const lines: string[] = [];
|
|
201
|
-
let current = '';
|
|
202
|
-
|
|
203
|
-
for (const word of words) {
|
|
204
|
-
const candidate = current ? `${current} ${word}` : word;
|
|
205
|
-
if (candidate.length > maxChars && current) {
|
|
206
|
-
lines.push(current);
|
|
207
|
-
current = word;
|
|
208
|
-
} else {
|
|
209
|
-
current = candidate;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
if (current) lines.push(current);
|
|
213
|
-
|
|
214
|
-
return lines;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
141
|
function renderChromeElement(
|
|
218
142
|
parent: SVGElement,
|
|
219
143
|
element: ResolvedChromeElement,
|
|
@@ -1321,7 +1245,7 @@ export function renderChartSVG(
|
|
|
1321
1245
|
// Clip path to prevent marks (especially area fills) from overflowing
|
|
1322
1246
|
// into the chrome region (title/subtitle). Extends full width so
|
|
1323
1247
|
// end-of-line labels aren't clipped, but constrains vertically.
|
|
1324
|
-
const clipId =
|
|
1248
|
+
const clipId = nextSvgId('oc-clip');
|
|
1325
1249
|
const defs = createSVGElement('defs');
|
|
1326
1250
|
const clipPath = createSVGElement('clipPath');
|
|
1327
1251
|
clipPath.setAttribute('id', clipId);
|