@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21
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/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
- package/dist/chunk-DCODCLWI.js +234 -0
- package/dist/chunk-DCODCLWI.js.map +1 -0
- package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
- package/dist/chunk-EKAUQAAX.js.map +1 -0
- package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
- package/dist/chunk-KNT5AZ2O.js.map +1 -0
- package/dist/chunk-RM5XS6NR.js +113 -0
- package/dist/chunk-RM5XS6NR.js.map +1 -0
- package/dist/chunk-T32UHMZA.js +282 -0
- package/dist/chunk-T32UHMZA.js.map +1 -0
- package/dist/export-gif.d.ts +113 -0
- package/dist/export-gif.js +21 -0
- package/dist/export-gif.js.map +1 -0
- package/dist/index.d.ts +283 -24
- package/dist/index.js +2012 -763
- package/dist/index.js.map +1 -1
- package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
- package/dist/simulation-worker.js +83 -2
- package/dist/static.js +7 -69
- package/dist/static.js.map +1 -1
- package/dist/story/index.d.ts +3 -2
- package/dist/story/index.js +5 -3
- package/dist/story/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +14 -6
- package/dist/chunk-5EOV5JQZ.js.map +0 -1
- package/dist/chunk-ENXSUXC5.js.map +0 -1
- package/src/__test-fixtures__/dom.ts +0 -70
- package/src/__test-fixtures__/specs.ts +0 -214
- package/src/__tests__/animation.test.ts +0 -358
- package/src/__tests__/annotations-render.test.ts +0 -201
- package/src/__tests__/compound-labels.test.ts +0 -122
- package/src/__tests__/continuous-legend.test.ts +0 -91
- package/src/__tests__/crosshair.test.ts +0 -130
- package/src/__tests__/edit-events.test.ts +0 -902
- package/src/__tests__/events.test.ts +0 -387
- package/src/__tests__/export.test.ts +0 -241
- package/src/__tests__/font-race.test.ts +0 -199
- package/src/__tests__/gradient-ids.test.ts +0 -159
- package/src/__tests__/gradient-utils.test.ts +0 -125
- package/src/__tests__/known-bugs-render.test.ts +0 -183
- package/src/__tests__/map.test.ts +0 -293
- package/src/__tests__/mount.test.ts +0 -357
- package/src/__tests__/pattern-fills.test.ts +0 -105
- package/src/__tests__/resize-timing.test.ts +0 -218
- package/src/__tests__/sankey.test.ts +0 -133
- package/src/__tests__/selection-events.test.ts +0 -869
- package/src/__tests__/series-search.test.ts +0 -311
- package/src/__tests__/static.test.ts +0 -141
- package/src/__tests__/svg-renderer.test.ts +0 -895
- package/src/__tests__/table-keyboard.test.ts +0 -361
- package/src/__tests__/table-mount.test.ts +0 -639
- package/src/__tests__/tilemap.test.ts +0 -158
- package/src/__tests__/tooltip.test.ts +0 -337
- package/src/__tests__/transition.test.ts +0 -1777
- package/src/__tests__/you-draw-it.test.ts +0 -273
- package/src/animation-vars.ts +0 -42
- package/src/animation.ts +0 -76
- package/src/barlist-mount.ts +0 -383
- package/src/barlist-renderer.ts +0 -267
- package/src/dom-helpers.ts +0 -17
- package/src/export.ts +0 -482
- package/src/gradient-utils.ts +0 -138
- package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
- package/src/graph/__tests__/graph-mount.test.ts +0 -213
- package/src/graph/__tests__/interaction.test.ts +0 -205
- package/src/graph/__tests__/keyboard.test.ts +0 -653
- package/src/graph/__tests__/search.test.ts +0 -88
- package/src/graph/__tests__/simulation.test.ts +0 -233
- package/src/graph/__tests__/spatial-index.test.ts +0 -142
- package/src/graph/__tests__/zoom.test.ts +0 -203
- package/src/graph/canvas-renderer.ts +0 -747
- package/src/graph/interaction.ts +0 -367
- package/src/graph/keyboard.ts +0 -208
- package/src/graph/search.ts +0 -50
- package/src/graph/simulation-worker-url.ts +0 -12
- package/src/graph/simulation-worker.ts +0 -278
- package/src/graph/simulation.ts +0 -422
- package/src/graph/spatial-index.ts +0 -121
- package/src/graph/types.ts +0 -47
- package/src/graph/worker-protocol.ts +0 -70
- package/src/graph/zoom.ts +0 -134
- package/src/graph-mount.ts +0 -888
- package/src/index.ts +0 -76
- package/src/interactions/chart-events.ts +0 -139
- package/src/interactions/crosshair.ts +0 -233
- package/src/interactions/drag-handler.ts +0 -175
- package/src/interactions/editing-drags.ts +0 -515
- package/src/interactions/index.ts +0 -25
- package/src/interactions/keyboard-nav.ts +0 -111
- package/src/interactions/legend-interaction.ts +0 -46
- package/src/interactions/selection.ts +0 -280
- package/src/interactions/tooltip-events.ts +0 -72
- package/src/map-camera.ts +0 -88
- package/src/map-mount.ts +0 -701
- package/src/map-renderer.ts +0 -338
- package/src/map-transition.ts +0 -83
- package/src/measure-text.ts +0 -134
- package/src/mount.ts +0 -1382
- package/src/pattern-utils.ts +0 -110
- package/src/renderers/annotations.ts +0 -388
- package/src/renderers/axes.ts +0 -351
- package/src/renderers/brand.ts +0 -87
- package/src/renderers/chrome.ts +0 -196
- package/src/renderers/endpoint-labels.ts +0 -150
- package/src/renderers/legend.ts +0 -311
- package/src/renderers/marks.ts +0 -561
- package/src/renderers/metrics.ts +0 -63
- package/src/renderers/svg-dom.ts +0 -40
- package/src/renderers/table-cells.ts +0 -446
- package/src/resize-observer.ts +0 -46
- package/src/sankey-mount.ts +0 -622
- package/src/sankey-renderer.ts +0 -614
- package/src/series-search.ts +0 -392
- package/src/static.ts +0 -251
- package/src/story/__tests__/camera-math.test.ts +0 -145
- package/src/story/__tests__/create-chart-story.test.ts +0 -317
- package/src/story/__tests__/progress-math.test.ts +0 -95
- package/src/story/__tests__/scroll-driver.test.ts +0 -130
- package/src/story/__tests__/tween.test.ts +0 -159
- package/src/story/camera-math.ts +0 -125
- package/src/story/create-chart-story.ts +0 -353
- package/src/story/crossfade.ts +0 -70
- package/src/story/index.ts +0 -39
- package/src/story/progress-math.ts +0 -85
- package/src/story/resolve-camera-target.ts +0 -119
- package/src/story/scroll-driver.ts +0 -206
- package/src/story/story-camera.ts +0 -49
- package/src/story/tween.ts +0 -112
- package/src/story/types.ts +0 -76
- package/src/svg-ids.ts +0 -23
- package/src/svg-renderer.ts +0 -383
- package/src/table-keyboard.ts +0 -266
- package/src/table-mount.ts +0 -567
- package/src/table-renderer.ts +0 -414
- package/src/text-edit-overlay.ts +0 -255
- package/src/theme-tokens.ts +0 -51
- package/src/tilemap-mount.ts +0 -511
- package/src/tilemap-renderer.ts +0 -396
- package/src/tooltip.ts +0 -177
- package/src/transition.ts +0 -2816
- package/src/you-draw-it.ts +0 -480
- /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Font-race regression tests.
|
|
3
|
-
*
|
|
4
|
-
* On real devices the primary webfont (Inter via display=swap) swaps in after
|
|
5
|
-
* first paint, changing text metrics. These tests cover the two halves of the
|
|
6
|
-
* fix: the measurer honors the provided font family, and the mount recompiles
|
|
7
|
-
* exactly once when a late FontFaceSet resolves.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
-
import { createContainer } from '../__test-fixtures__/dom';
|
|
12
|
-
import { lineSpec } from '../__test-fixtures__/specs';
|
|
13
|
-
import { createMeasureText } from '../measure-text';
|
|
14
|
-
import { createChart } from '../mount';
|
|
15
|
-
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
// Canvas ctx stub: happy-dom's getContext returns null, so stub it to capture
|
|
18
|
-
// the font string createMeasureText writes.
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
interface FakeCtx {
|
|
22
|
-
font: string;
|
|
23
|
-
measureText(text: string): { width: number };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function stubCanvasContext(): FakeCtx {
|
|
27
|
-
const ctx: FakeCtx = {
|
|
28
|
-
font: '',
|
|
29
|
-
measureText: (text: string) => ({ width: text.length * 7 }),
|
|
30
|
-
};
|
|
31
|
-
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
|
32
|
-
ctx as unknown as CanvasRenderingContext2D,
|
|
33
|
-
);
|
|
34
|
-
return ctx;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
// Controllable FontFaceSet stub. check() reports the font missing; ready is a
|
|
39
|
-
// promise we resolve on demand to simulate a late font swap.
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
interface FakeFontFaceSet {
|
|
43
|
-
check: () => boolean;
|
|
44
|
-
ready: Promise<unknown>;
|
|
45
|
-
resolveReady: () => void;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function makeFontFaceSet(loaded: boolean): FakeFontFaceSet {
|
|
49
|
-
let resolveReady: () => void = () => {};
|
|
50
|
-
const ready = new Promise<void>((resolve) => {
|
|
51
|
-
resolveReady = resolve;
|
|
52
|
-
});
|
|
53
|
-
return {
|
|
54
|
-
check: () => loaded,
|
|
55
|
-
ready,
|
|
56
|
-
resolveReady,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function installFonts(set: FakeFontFaceSet | undefined): void {
|
|
61
|
-
Object.defineProperty(document, 'fonts', {
|
|
62
|
-
configurable: true,
|
|
63
|
-
value: set,
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
describe('createMeasureText font family', () => {
|
|
68
|
-
afterEach(() => {
|
|
69
|
-
vi.restoreAllMocks();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('uses the provided font family in ctx.font', () => {
|
|
73
|
-
const ctx = stubCanvasContext();
|
|
74
|
-
const measure = createMeasureText('"My Font", Georgia, serif');
|
|
75
|
-
|
|
76
|
-
measure('hello', 14, 600);
|
|
77
|
-
|
|
78
|
-
expect(ctx.font).toBe('600 14px "My Font", Georgia, serif');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('defaults to the Inter fallback stack when no family is given', () => {
|
|
82
|
-
const ctx = stubCanvasContext();
|
|
83
|
-
const measure = createMeasureText();
|
|
84
|
-
|
|
85
|
-
measure('hi', 12);
|
|
86
|
-
|
|
87
|
-
expect(ctx.font).toBe('400 12px Inter, sans-serif');
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
describe('createChart font-load recompile', () => {
|
|
92
|
-
let container: HTMLDivElement;
|
|
93
|
-
const originalFonts = Object.getOwnPropertyDescriptor(document, 'fonts');
|
|
94
|
-
|
|
95
|
-
beforeEach(() => {
|
|
96
|
-
container = createContainer();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
afterEach(() => {
|
|
100
|
-
vi.restoreAllMocks();
|
|
101
|
-
if (originalFonts) {
|
|
102
|
-
Object.defineProperty(document, 'fonts', originalFonts);
|
|
103
|
-
} else {
|
|
104
|
-
// happy-dom has no document.fonts by default; remove our stub.
|
|
105
|
-
Object.defineProperty(document, 'fonts', { configurable: true, value: undefined });
|
|
106
|
-
}
|
|
107
|
-
document.body.innerHTML = '';
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('marks pending then ready and re-renders once when fonts resolve late', async () => {
|
|
111
|
-
const fonts = makeFontFaceSet(false);
|
|
112
|
-
installFonts(fonts);
|
|
113
|
-
|
|
114
|
-
const chart = createChart(container, lineSpec);
|
|
115
|
-
|
|
116
|
-
expect(container.dataset.ocFontsState).toBe('pending');
|
|
117
|
-
const genBefore = Number(container.dataset.ocRenderGen);
|
|
118
|
-
expect(genBefore).toBeGreaterThanOrEqual(1);
|
|
119
|
-
|
|
120
|
-
fonts.resolveReady();
|
|
121
|
-
await fonts.ready;
|
|
122
|
-
// Let the .then() microtask flush.
|
|
123
|
-
await Promise.resolve();
|
|
124
|
-
|
|
125
|
-
expect(container.dataset.ocFontsState).toBe('ready');
|
|
126
|
-
const genAfter = Number(container.dataset.ocRenderGen);
|
|
127
|
-
expect(genAfter).toBe(genBefore + 1);
|
|
128
|
-
|
|
129
|
-
chart.destroy();
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('marks ready immediately and does not re-render when fonts already loaded', async () => {
|
|
133
|
-
const fonts = makeFontFaceSet(true);
|
|
134
|
-
installFonts(fonts);
|
|
135
|
-
|
|
136
|
-
const chart = createChart(container, lineSpec);
|
|
137
|
-
|
|
138
|
-
expect(container.dataset.ocFontsState).toBe('ready');
|
|
139
|
-
const genBefore = Number(container.dataset.ocRenderGen);
|
|
140
|
-
|
|
141
|
-
fonts.resolveReady();
|
|
142
|
-
await Promise.resolve();
|
|
143
|
-
|
|
144
|
-
expect(Number(container.dataset.ocRenderGen)).toBe(genBefore);
|
|
145
|
-
|
|
146
|
-
chart.destroy();
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('defers ready until the post-animation recompile, not the deferred resize', async () => {
|
|
150
|
-
// Entrance animation running -> resize() defers to pendingResize. The
|
|
151
|
-
// fonts-ready flip must wait for the recompile that actually happens on the
|
|
152
|
-
// pendingResize replay, so 'ready' never claims final-font layout early.
|
|
153
|
-
vi.useFakeTimers();
|
|
154
|
-
try {
|
|
155
|
-
const fonts = makeFontFaceSet(false);
|
|
156
|
-
installFonts(fonts);
|
|
157
|
-
|
|
158
|
-
const animatedSpec = { ...lineSpec, animation: true as const };
|
|
159
|
-
const chart = createChart(container, animatedSpec);
|
|
160
|
-
expect(container.dataset.ocFontsState).toBe('pending');
|
|
161
|
-
const genBefore = Number(container.dataset.ocRenderGen);
|
|
162
|
-
|
|
163
|
-
fonts.resolveReady();
|
|
164
|
-
await fonts.ready;
|
|
165
|
-
await Promise.resolve();
|
|
166
|
-
|
|
167
|
-
// resize() deferred (animation in flight): no recompile yet, still pending.
|
|
168
|
-
expect(container.dataset.ocRenderGen).toBe(String(genBefore));
|
|
169
|
-
expect(container.dataset.ocFontsState).toBe('pending');
|
|
170
|
-
|
|
171
|
-
// Animation completes -> pendingResize replays -> recompile -> ready.
|
|
172
|
-
vi.runAllTimers();
|
|
173
|
-
expect(Number(container.dataset.ocRenderGen)).toBe(genBefore + 1);
|
|
174
|
-
expect(container.dataset.ocFontsState).toBe('ready');
|
|
175
|
-
|
|
176
|
-
chart.destroy();
|
|
177
|
-
} finally {
|
|
178
|
-
vi.useRealTimers();
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('does not re-render after the chart is destroyed', async () => {
|
|
183
|
-
const fonts = makeFontFaceSet(false);
|
|
184
|
-
installFonts(fonts);
|
|
185
|
-
|
|
186
|
-
const chart = createChart(container, lineSpec);
|
|
187
|
-
expect(container.dataset.ocFontsState).toBe('pending');
|
|
188
|
-
const genBefore = Number(container.dataset.ocRenderGen);
|
|
189
|
-
|
|
190
|
-
chart.destroy();
|
|
191
|
-
|
|
192
|
-
fonts.resolveReady();
|
|
193
|
-
await fonts.ready;
|
|
194
|
-
await Promise.resolve();
|
|
195
|
-
|
|
196
|
-
// Destroyed chart must not bump the render generation.
|
|
197
|
-
expect(Number(container.dataset.ocRenderGen)).toBe(genBefore);
|
|
198
|
-
});
|
|
199
|
-
});
|
|
@@ -1,159 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import type { GradientDef } from '@opendata-ai/openchart-core';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import { buildGradientDefs, resolveMarkFill } from '../gradient-utils';
|
|
4
|
-
|
|
5
|
-
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
6
|
-
|
|
7
|
-
function createDefs(): SVGElement {
|
|
8
|
-
return document.createElementNS(SVG_NS, 'defs') as SVGElement;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const linearGrad: GradientDef = {
|
|
12
|
-
gradient: 'linear',
|
|
13
|
-
stops: [
|
|
14
|
-
{ offset: 0, color: '#f00' },
|
|
15
|
-
{ offset: 1, color: '#00f' },
|
|
16
|
-
],
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
const radialGrad: GradientDef = {
|
|
20
|
-
gradient: 'radial',
|
|
21
|
-
stops: [
|
|
22
|
-
{ offset: 0, color: '#fff' },
|
|
23
|
-
{ offset: 1, color: '#000' },
|
|
24
|
-
],
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
describe('buildGradientDefs', () => {
|
|
28
|
-
it('creates a linearGradient element for a linear gradient fill', () => {
|
|
29
|
-
const defs = createDefs();
|
|
30
|
-
buildGradientDefs([{ fill: linearGrad }], defs);
|
|
31
|
-
const el = defs.querySelector('linearGradient');
|
|
32
|
-
expect(el).not.toBeNull();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('creates a radialGradient element for a radial gradient fill', () => {
|
|
36
|
-
const defs = createDefs();
|
|
37
|
-
buildGradientDefs([{ fill: radialGrad }], defs);
|
|
38
|
-
const el = defs.querySelector('radialGradient');
|
|
39
|
-
expect(el).not.toBeNull();
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('deduplicates identical gradients', () => {
|
|
43
|
-
const defs = createDefs();
|
|
44
|
-
buildGradientDefs([{ fill: linearGrad }, { fill: linearGrad }], defs);
|
|
45
|
-
const els = defs.querySelectorAll('linearGradient');
|
|
46
|
-
expect(els.length).toBe(1);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('creates separate gradient elements for different gradients', () => {
|
|
50
|
-
const defs = createDefs();
|
|
51
|
-
buildGradientDefs([{ fill: linearGrad }, { fill: radialGrad }], defs);
|
|
52
|
-
expect(defs.querySelector('linearGradient')).not.toBeNull();
|
|
53
|
-
expect(defs.querySelector('radialGradient')).not.toBeNull();
|
|
54
|
-
expect(defs.children.length).toBe(2);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('skips marks with string fills', () => {
|
|
58
|
-
const defs = createDefs();
|
|
59
|
-
buildGradientDefs([{ fill: '#ff0000' }, { fill: 'steelblue' }], defs);
|
|
60
|
-
expect(defs.children.length).toBe(0);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('handles empty marks array', () => {
|
|
64
|
-
const defs = createDefs();
|
|
65
|
-
const map = buildGradientDefs([], defs);
|
|
66
|
-
expect(map.size).toBe(0);
|
|
67
|
-
expect(defs.children.length).toBe(0);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('sets correct default attributes on linearGradient', () => {
|
|
71
|
-
const defs = createDefs();
|
|
72
|
-
buildGradientDefs([{ fill: linearGrad }], defs);
|
|
73
|
-
const el = defs.querySelector('linearGradient')!;
|
|
74
|
-
expect(el.getAttribute('x1')).toBe('0');
|
|
75
|
-
expect(el.getAttribute('y1')).toBe('0');
|
|
76
|
-
expect(el.getAttribute('x2')).toBe('0');
|
|
77
|
-
expect(el.getAttribute('y2')).toBe('1');
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('sets correct stop attributes', () => {
|
|
81
|
-
const grad: GradientDef = {
|
|
82
|
-
gradient: 'linear',
|
|
83
|
-
stops: [
|
|
84
|
-
{ offset: 0, color: '#f00', opacity: 0.5 },
|
|
85
|
-
{ offset: 1, color: '#00f' },
|
|
86
|
-
],
|
|
87
|
-
};
|
|
88
|
-
const defs = createDefs();
|
|
89
|
-
buildGradientDefs([{ fill: grad }], defs);
|
|
90
|
-
const stops = defs.querySelectorAll('stop');
|
|
91
|
-
expect(stops.length).toBe(2);
|
|
92
|
-
expect(stops[0].getAttribute('offset')).toBe('0');
|
|
93
|
-
expect(stops[0].getAttribute('stop-color')).toBe('#f00');
|
|
94
|
-
expect(stops[0].getAttribute('stop-opacity')).toBe('0.5');
|
|
95
|
-
expect(stops[1].getAttribute('offset')).toBe('1');
|
|
96
|
-
expect(stops[1].getAttribute('stop-color')).toBe('#00f');
|
|
97
|
-
expect(stops[1].hasAttribute('stop-opacity')).toBe(false);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('sets gradientUnits="objectBoundingBox"', () => {
|
|
101
|
-
const defs = createDefs();
|
|
102
|
-
buildGradientDefs([{ fill: linearGrad }], defs);
|
|
103
|
-
const el = defs.querySelector('linearGradient')!;
|
|
104
|
-
expect(el.getAttribute('gradientUnits')).toBe('objectBoundingBox');
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
describe('resolveMarkFill', () => {
|
|
109
|
-
it('returns the string directly for string fills', () => {
|
|
110
|
-
const map = new Map<string, string>();
|
|
111
|
-
expect(resolveMarkFill('#ff0000', map)).toBe('#ff0000');
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('returns url(#id) for gradient fills that are in the map', () => {
|
|
115
|
-
const defs = createDefs();
|
|
116
|
-
const map = buildGradientDefs([{ fill: linearGrad }], defs);
|
|
117
|
-
const result = resolveMarkFill(linearGrad, map);
|
|
118
|
-
expect(result).toMatch(/^url\(#oc-grad-\d+\)$/);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('returns #000000 for gradient fills not in the map', () => {
|
|
122
|
-
const map = new Map<string, string>();
|
|
123
|
-
expect(resolveMarkFill(linearGrad, map)).toBe('#000000');
|
|
124
|
-
});
|
|
125
|
-
});
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Red-locked tests for known layout bugs.
|
|
3
|
-
*
|
|
4
|
-
* These use `test.fails(...)` so they pass today (the assertion is inverted).
|
|
5
|
-
* When the underlying bug is fixed, the test will start failing, signaling
|
|
6
|
-
* that the red-lock can be converted to a normal passing test.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { CategoricalLegendLayout } from '@opendata-ai/openchart-core';
|
|
10
|
-
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
11
|
-
import { compileChart } from '@opendata-ai/openchart-engine';
|
|
12
|
-
import { describe, expect, test } from 'vitest';
|
|
13
|
-
import { renderLegend } from '../renderers/legend';
|
|
14
|
-
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
// Helpers
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
|
|
19
|
-
/** Collect distinct y-attribute values from <text> elements, sorted ascending. */
|
|
20
|
-
function distinctLabelYValues(svg: SVGElement): number[] {
|
|
21
|
-
const texts = svg.querySelectorAll('text');
|
|
22
|
-
const ySet = new Set<number>();
|
|
23
|
-
for (const t of texts) {
|
|
24
|
-
const y = t.getAttribute('y');
|
|
25
|
-
if (y != null) ySet.add(Number(y));
|
|
26
|
-
}
|
|
27
|
-
return [...ySet].sort((a, b) => a - b);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
// Constants (mirrors packages/engine/src/legend/wrap.ts)
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
|
|
34
|
-
const SWATCH_SIZE = 12;
|
|
35
|
-
const SWATCH_GAP = 6;
|
|
36
|
-
const ENTRY_GAP = 16;
|
|
37
|
-
const ENGINE_ROW_HEIGHT = SWATCH_SIZE + 4; // 16 - what the engine reserves
|
|
38
|
-
const LEGEND_PADDING = 8;
|
|
39
|
-
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
// Fixtures
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
|
|
44
|
-
const entries: CategoricalLegendLayout['entries'] = [
|
|
45
|
-
{ label: 'United States of America', color: '#1f77b4', shape: 'square', active: true },
|
|
46
|
-
{ label: 'United Kingdom', color: '#ff7f0e', shape: 'square', active: true },
|
|
47
|
-
{ label: 'Federal Republic of Germany', color: '#2ca02c', shape: 'square', active: true },
|
|
48
|
-
{ label: 'French Republic', color: '#d62728', shape: 'square', active: true },
|
|
49
|
-
{ label: 'Kingdom of Spain', color: '#9467bd', shape: 'square', active: true },
|
|
50
|
-
{ label: 'Republic of Italy', color: '#8c564b', shape: 'square', active: true },
|
|
51
|
-
{ label: 'Kingdom of Netherlands', color: '#e377c2', shape: 'square', active: true },
|
|
52
|
-
{ label: 'Swiss Confederation', color: '#7f7f7f', shape: 'square', active: true },
|
|
53
|
-
];
|
|
54
|
-
|
|
55
|
-
const labelStyle = {
|
|
56
|
-
fontSize: 11,
|
|
57
|
-
lineHeight: 1.3,
|
|
58
|
-
fontWeight: 400 as const,
|
|
59
|
-
fontFamily: 'sans-serif',
|
|
60
|
-
fill: '#333',
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Compute row count by simulating the same wrap logic the engine uses
|
|
65
|
-
* (measureLegendWrap in packages/engine/src/legend/wrap.ts).
|
|
66
|
-
*/
|
|
67
|
-
function computeRowCount(maxWidth: number): number {
|
|
68
|
-
let rowCount = 1;
|
|
69
|
-
let rowWidth = 0;
|
|
70
|
-
for (const entry of entries) {
|
|
71
|
-
const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
72
|
-
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
|
|
73
|
-
if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
|
|
74
|
-
rowCount++;
|
|
75
|
-
rowWidth = entryWidth;
|
|
76
|
-
} else {
|
|
77
|
-
rowWidth += entryWidth;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
return rowCount;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
// Tests
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
describe('known layout bugs', () => {
|
|
88
|
-
// Red-locked: fixed by docs/plans/04-resolved-layout-contract.md
|
|
89
|
-
test.fails('5a.1: legend row advancement matches engine row height', () => {
|
|
90
|
-
const boundsWidth = 400;
|
|
91
|
-
const rowCount = computeRowCount(boundsWidth);
|
|
92
|
-
const boundsHeight = rowCount * ENGINE_ROW_HEIGHT + LEGEND_PADDING * 2;
|
|
93
|
-
|
|
94
|
-
const legendFixture: CategoricalLegendLayout = {
|
|
95
|
-
position: 'top',
|
|
96
|
-
entries,
|
|
97
|
-
swatchSize: SWATCH_SIZE,
|
|
98
|
-
swatchGap: SWATCH_GAP,
|
|
99
|
-
entryGap: ENTRY_GAP,
|
|
100
|
-
swatchChipFill: '#f0f0f0',
|
|
101
|
-
labelStyle,
|
|
102
|
-
bounds: { x: 0, y: 0, width: boundsWidth, height: boundsHeight },
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
106
|
-
renderLegend(svg, legendFixture);
|
|
107
|
-
|
|
108
|
-
const yValues = distinctLabelYValues(svg);
|
|
109
|
-
// With 4 rows we expect 4 distinct y-values. Consecutive rows should
|
|
110
|
-
// differ by ENGINE_ROW_HEIGHT (16). The renderer uses swatchSize + 6 = 18,
|
|
111
|
-
// so this assertion will fail until the drift is fixed.
|
|
112
|
-
expect(yValues.length).toBeGreaterThanOrEqual(2);
|
|
113
|
-
for (let i = 1; i < yValues.length; i++) {
|
|
114
|
-
const gap = yValues[i] - yValues[i - 1];
|
|
115
|
-
expect(gap).toBe(ENGINE_ROW_HEIGHT);
|
|
116
|
-
}
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// Red-locked: fixed by docs/plans/04-resolved-layout-contract.md and docs/plans/03-measure-then-freeze-layout.md
|
|
120
|
-
test.fails('5a.2: rows drawn by renderer match rows reserved by engine', () => {
|
|
121
|
-
// Build a line spec with 8 series whose labels vary in length.
|
|
122
|
-
const countries = [
|
|
123
|
-
'United States of America',
|
|
124
|
-
'United Kingdom',
|
|
125
|
-
'Federal Republic of Germany',
|
|
126
|
-
'French Republic',
|
|
127
|
-
'Kingdom of Spain',
|
|
128
|
-
'Republic of Italy',
|
|
129
|
-
'Kingdom of Netherlands',
|
|
130
|
-
'Swiss Confederation',
|
|
131
|
-
];
|
|
132
|
-
|
|
133
|
-
const data = countries.flatMap((country) => [
|
|
134
|
-
{ date: '2020-01-01', value: 10, country },
|
|
135
|
-
{ date: '2021-01-01', value: 20, country },
|
|
136
|
-
]);
|
|
137
|
-
|
|
138
|
-
const spec = {
|
|
139
|
-
mark: 'line' as const,
|
|
140
|
-
data,
|
|
141
|
-
encoding: {
|
|
142
|
-
x: { field: 'date', type: 'temporal' as const },
|
|
143
|
-
y: { field: 'value', type: 'quantitative' as const },
|
|
144
|
-
color: { field: 'country', type: 'nominal' as const },
|
|
145
|
-
},
|
|
146
|
-
legend: { position: 'top' as const },
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
// Try widths 500-700 to find one where the legend wraps AND the
|
|
150
|
-
// bounds.width narrowing causes extra rows in the renderer.
|
|
151
|
-
let foundMismatch = false;
|
|
152
|
-
|
|
153
|
-
for (let width = 500; width <= 700; width += 10) {
|
|
154
|
-
const layout = compileChart(spec, { width, height: 400 });
|
|
155
|
-
const legend = layout.legend as CategoricalLegendLayout;
|
|
156
|
-
if (!legend || !legend.entries || legend.entries.length === 0) continue;
|
|
157
|
-
|
|
158
|
-
// Rows reserved by the engine
|
|
159
|
-
const effectivePadding = width < 420 ? 2 : LEGEND_PADDING;
|
|
160
|
-
const rowsReserved = Math.round(
|
|
161
|
-
(legend.bounds.height - effectivePadding * 2) / ENGINE_ROW_HEIGHT,
|
|
162
|
-
);
|
|
163
|
-
if (rowsReserved <= 1) continue; // only interesting when wrapping occurs
|
|
164
|
-
|
|
165
|
-
// Render and count rows drawn
|
|
166
|
-
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
167
|
-
renderLegend(svg, legend);
|
|
168
|
-
const yValues = distinctLabelYValues(svg);
|
|
169
|
-
const rowsDrawn = yValues.length;
|
|
170
|
-
|
|
171
|
-
if (rowsDrawn !== rowsReserved) {
|
|
172
|
-
foundMismatch = true;
|
|
173
|
-
// This is the bug: the renderer drew more rows than the engine reserved.
|
|
174
|
-
expect(rowsDrawn).toBe(rowsReserved);
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// If no width triggered a mismatch, fail so test.fails still inverts.
|
|
180
|
-
// If this path fires, widen the width range above.
|
|
181
|
-
expect(foundMismatch).toBe(true);
|
|
182
|
-
});
|
|
183
|
-
});
|