@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-vanilla",
3
- "version": "6.19.2",
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.19.2",
54
- "@opendata-ai/openchart-engine": "6.19.2",
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
+ });
@@ -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 are globally unique across charts to avoid SVG url(#id) collisions.
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 = `oc-grad-${globalGradientCounter++}`;
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 with debounce to avoid full SVG rebuild on every frame
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
- if (resizeTimer !== null) clearTimeout(resizeTimer);
2470
- resizeTimer = setTimeout(() => {
2471
- resizeTimer = null;
2472
- resize();
2473
- }, 100);
2466
+ resize();
2474
2467
  });
2475
2468
  }
2476
2469
 
@@ -15,7 +15,12 @@ import type {
15
15
  SankeyNodeMark,
16
16
  TextStyle,
17
17
  } from '@opendata-ai/openchart-core';
18
- import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
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
+ }
@@ -29,9 +29,15 @@ import type {
29
29
  TextStyle,
30
30
  TickMarkLayout,
31
31
  } from '@opendata-ai/openchart-core';
32
- import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
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 = `oc-clip-${Math.random().toString(36).slice(2, 8)}`;
1248
+ const clipId = nextSvgId('oc-clip');
1325
1249
  const defs = createSVGElement('defs');
1326
1250
  const clipPath = createSVGElement('clipPath');
1327
1251
  clipPath.setAttribute('id', clipId);