@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,145 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
type Camera,
|
|
4
|
-
camerasClose,
|
|
5
|
-
cameraTransform,
|
|
6
|
-
damp,
|
|
7
|
-
dampCamera,
|
|
8
|
-
FULL_VIEW,
|
|
9
|
-
fitTarget,
|
|
10
|
-
interpolateCamera,
|
|
11
|
-
scrubCamera,
|
|
12
|
-
} from '../camera-math';
|
|
13
|
-
import { easingFns } from '../tween';
|
|
14
|
-
|
|
15
|
-
const VB = { width: 400, height: 340 };
|
|
16
|
-
|
|
17
|
-
describe('fitTarget', () => {
|
|
18
|
-
it('full viewBox target is the identity camera', () => {
|
|
19
|
-
const cam = fitTarget(FULL_VIEW(VB), VB);
|
|
20
|
-
expect(cam).toEqual({ cx: 200, cy: 170, k: 1 });
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('limits by the tighter axis', () => {
|
|
24
|
-
// Wide, short target: width is the limiting axis
|
|
25
|
-
const wide = fitTarget({ x: 0, y: 0, width: 200, height: 20 }, VB);
|
|
26
|
-
expect(wide.k).toBeCloseTo(400 / 200);
|
|
27
|
-
|
|
28
|
-
// Narrow, tall target: height limits
|
|
29
|
-
const tall = fitTarget({ x: 0, y: 0, width: 20, height: 170 }, VB);
|
|
30
|
-
expect(tall.k).toBeCloseTo(340 / 170);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('padding reduces zoom', () => {
|
|
34
|
-
const noPad = fitTarget({ x: 0, y: 0, width: 100, height: 100 }, VB);
|
|
35
|
-
const padded = fitTarget({ x: 0, y: 0, width: 100, height: 100, padding: 20 }, VB);
|
|
36
|
-
expect(padded.k).toBeLessThan(noPad.k);
|
|
37
|
-
expect(padded.k).toBeCloseTo(Math.min(400 / 140, 340 / 140));
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('centers on the target rect', () => {
|
|
41
|
-
const cam = fitTarget({ x: 50, y: 80, width: 100, height: 60 }, VB);
|
|
42
|
-
expect(cam.cx).toBe(100);
|
|
43
|
-
expect(cam.cy).toBe(110);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
describe('interpolateCamera', () => {
|
|
48
|
-
const a: Camera = { cx: 0, cy: 0, k: 1 };
|
|
49
|
-
const b: Camera = { cx: 100, cy: 50, k: 4 };
|
|
50
|
-
|
|
51
|
-
it('endpoints are exact', () => {
|
|
52
|
-
expect(interpolateCamera(a, b, 0)).toEqual(a);
|
|
53
|
-
const end = interpolateCamera(a, b, 1);
|
|
54
|
-
expect(end.cx).toBeCloseTo(100);
|
|
55
|
-
expect(end.k).toBeCloseTo(4);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('zoom midpoint is geometric (log space): 1->4 passes through 2', () => {
|
|
59
|
-
const mid = interpolateCamera(a, b, 0.5);
|
|
60
|
-
expect(mid.k).toBeCloseTo(2); // not 2.5
|
|
61
|
-
expect(mid.cx).toBeCloseTo(50); // pan stays linear
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
describe('cameraTransform', () => {
|
|
66
|
-
function apply(transform: string, x: number, y: number): [number, number] {
|
|
67
|
-
// Parse `translate(tx ty) scale(k) translate(-cx -cy)` and apply.
|
|
68
|
-
const nums = transform.match(/-?\d+(\.\d+)?(e-?\d+)?/g)!.map(Number);
|
|
69
|
-
const [tx, ty, k, ncx, ncy] = nums;
|
|
70
|
-
return [tx! + k! * (x + ncx!), ty! + k! * (y + ncy!)];
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
it('identity camera maps viewBox corners to themselves', () => {
|
|
74
|
-
const t = cameraTransform({ cx: 200, cy: 170, k: 1 }, VB);
|
|
75
|
-
expect(apply(t, 0, 0)).toEqual([0, 0]);
|
|
76
|
-
expect(apply(t, 400, 340)).toEqual([400, 340]);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('zoomed camera maps its center to the viewBox center', () => {
|
|
80
|
-
const t = cameraTransform({ cx: 100, cy: 90, k: 2 }, VB);
|
|
81
|
-
expect(apply(t, 100, 90)).toEqual([200, 170]);
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
describe('damp', () => {
|
|
86
|
-
it('converges toward the target', () => {
|
|
87
|
-
let v = 0;
|
|
88
|
-
for (let i = 0; i < 100; i++) v = damp(v, 100, 100, 16);
|
|
89
|
-
expect(v).toBeCloseTo(100, 1);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('is frame-rate independent: two 8ms steps ~= one 16ms step', () => {
|
|
93
|
-
const one = damp(0, 100, 100, 16);
|
|
94
|
-
const two = damp(damp(0, 100, 100, 8), 100, 100, 8);
|
|
95
|
-
expect(two).toBeCloseTo(one, 6);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('tau of 0 snaps', () => {
|
|
99
|
-
expect(damp(0, 100, 0, 16)).toBe(100);
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
describe('dampCamera / camerasClose', () => {
|
|
104
|
-
it('damps k in log space and converges', () => {
|
|
105
|
-
let cam: Camera = { cx: 0, cy: 0, k: 1 };
|
|
106
|
-
const target: Camera = { cx: 10, cy: 10, k: 4 };
|
|
107
|
-
for (let i = 0; i < 200; i++) cam = dampCamera(cam, target, 100, 16);
|
|
108
|
-
expect(camerasClose(cam, target)).toBe(true);
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
describe('scrubCamera (hold zones)', () => {
|
|
113
|
-
const fitted: Camera[] = [
|
|
114
|
-
{ cx: 200, cy: 170, k: 1 },
|
|
115
|
-
{ cx: 110, cy: 160, k: 1.6 },
|
|
116
|
-
{ cx: 290, cy: 160, k: 1.6 },
|
|
117
|
-
];
|
|
118
|
-
const HOLD = 0.65;
|
|
119
|
-
|
|
120
|
-
it('holds the active target through the hold zone', () => {
|
|
121
|
-
// The camera must DWELL on what the text describes, not depart on entry.
|
|
122
|
-
expect(scrubCamera(1, 0, fitted, HOLD, easingFns.linear)).toEqual(fitted[1]);
|
|
123
|
-
expect(scrubCamera(1, 0.4, fitted, HOLD, easingFns.linear)).toEqual(fitted[1]);
|
|
124
|
-
expect(scrubCamera(1, 0.65, fitted, HOLD, easingFns.linear)).toEqual(fitted[1]);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('interpolates toward the next target only in the tail', () => {
|
|
128
|
-
const mid = scrubCamera(1, 0.825, fitted, HOLD, easingFns.linear);
|
|
129
|
-
expect(mid.cx).toBeCloseTo((110 + 290) / 2);
|
|
130
|
-
const end = scrubCamera(1, 1, fitted, HOLD, easingFns.linear);
|
|
131
|
-
expect(end.cx).toBeCloseTo(290);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('step -1 pins to the first target', () => {
|
|
135
|
-
expect(scrubCamera(-1, 0.5, fitted, HOLD, easingFns.linear)).toEqual(fitted[0]);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('last step never departs', () => {
|
|
139
|
-
expect(scrubCamera(2, 0.99, fitted, HOLD, easingFns.linear)).toEqual(fitted[2]);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('empty targets fall back to identity', () => {
|
|
143
|
-
expect(scrubCamera(0, 0.5, [], HOLD, easingFns.linear)).toEqual({ cx: 0, cy: 0, k: 1 });
|
|
144
|
-
});
|
|
145
|
-
});
|
|
@@ -1,317 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration tests for `createChartStory`, driven through the public API and
|
|
3
|
-
* the real compile/render pipeline (no mocked internal modules). Assertions
|
|
4
|
-
* target observable output: rendered marks, DOM attributes, the marks-group
|
|
5
|
-
* camera transform, and warning side effects.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { ChartSpec } from '@opendata-ai/openchart-core';
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
-
import { createContainer } from '../../__test-fixtures__/dom';
|
|
11
|
-
import { createChartStory } from '../create-chart-story';
|
|
12
|
-
|
|
13
|
-
const baseSpec: ChartSpec = {
|
|
14
|
-
mark: 'line',
|
|
15
|
-
data: [
|
|
16
|
-
{ year: '2008', value: 10, country: 'US' },
|
|
17
|
-
{ year: '2012', value: 40, country: 'US' },
|
|
18
|
-
{ year: '2016', value: 25, country: 'US' },
|
|
19
|
-
{ year: '2008', value: 15, country: 'Germany' },
|
|
20
|
-
{ year: '2012', value: 35, country: 'Germany' },
|
|
21
|
-
{ year: '2016', value: 30, country: 'Germany' },
|
|
22
|
-
],
|
|
23
|
-
encoding: {
|
|
24
|
-
x: { field: 'year', type: 'ordinal' },
|
|
25
|
-
y: { field: 'value', type: 'quantitative' },
|
|
26
|
-
color: { field: 'country', type: 'nominal' },
|
|
27
|
-
},
|
|
28
|
-
chrome: { title: 'Base' },
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
/** Read the current chart title text, or null. */
|
|
32
|
-
function title(container: HTMLElement): string | null {
|
|
33
|
-
return container.querySelector('.oc-title')?.textContent ?? null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Read the camera transform stamped on the marks group, or null. */
|
|
37
|
-
function cameraTransform(container: HTMLElement): string | null {
|
|
38
|
-
return container.querySelector('[data-oc-marks-group]')?.getAttribute('transform') ?? null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The neutral gray the color-scale-range applies to non-highlighted line
|
|
43
|
-
* series when a highlight is active (color-scale-range.ts MUTED_COLOR). Its
|
|
44
|
-
* presence on a line stroke is the observable signal that a highlight is
|
|
45
|
-
* active; its absence means no highlight.
|
|
46
|
-
*/
|
|
47
|
-
const MUTED_STROKE = '#bfc3c8';
|
|
48
|
-
|
|
49
|
-
/** Count line-mark strokes painted the muted gray (i.e. non-highlighted series). */
|
|
50
|
-
function mutedStrokeCount(container: HTMLElement): number {
|
|
51
|
-
const paths = container.querySelectorAll('.oc-mark-line path');
|
|
52
|
-
let count = 0;
|
|
53
|
-
for (const p of paths) {
|
|
54
|
-
if (p.getAttribute('stroke')?.toLowerCase() === MUTED_STROKE) count++;
|
|
55
|
-
}
|
|
56
|
-
return count;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
describe('createChartStory', () => {
|
|
60
|
-
let container: HTMLDivElement;
|
|
61
|
-
|
|
62
|
-
beforeEach(() => {
|
|
63
|
-
container = createContainer();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
afterEach(() => {
|
|
67
|
-
document.body.innerHTML = '';
|
|
68
|
-
vi.unstubAllGlobals();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('mounts the base spec at step 0', () => {
|
|
72
|
-
const story = createChartStory(container, {
|
|
73
|
-
spec: baseSpec,
|
|
74
|
-
steps: [{}, { spec: { chrome: { title: 'Step 1' } } }],
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
expect(container.querySelector('svg')).not.toBeNull();
|
|
78
|
-
expect(title(container)).toBe('Base');
|
|
79
|
-
expect(story.currentStep).toBe(-1);
|
|
80
|
-
expect(story.totalSteps).toBe(2);
|
|
81
|
-
|
|
82
|
-
story.destroy();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('applies cumulative patches: goTo(n) reflects patches 0..n', () => {
|
|
86
|
-
const story = createChartStory(container, {
|
|
87
|
-
spec: baseSpec,
|
|
88
|
-
steps: [
|
|
89
|
-
{},
|
|
90
|
-
{ spec: { chrome: { title: 'One' } } },
|
|
91
|
-
{ spec: { chrome: { subtitle: 'Two' } } },
|
|
92
|
-
],
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
story.goTo(2);
|
|
96
|
-
|
|
97
|
-
expect(title(container)).toBe('One');
|
|
98
|
-
expect(container.querySelector('.oc-subtitle')?.textContent).toBe('Two');
|
|
99
|
-
expect(story.currentStep).toBe(2);
|
|
100
|
-
|
|
101
|
-
story.destroy();
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('goTo(n) from step 0 converges to the same state as stepping 0..n', () => {
|
|
105
|
-
const steps = [
|
|
106
|
-
{},
|
|
107
|
-
{ spec: { chrome: { title: 'A' } } },
|
|
108
|
-
{ spec: { chrome: { subtitle: 'B' } } },
|
|
109
|
-
{ spec: { chrome: { source: 'C' } } },
|
|
110
|
-
{ highlight: ['US'] },
|
|
111
|
-
];
|
|
112
|
-
|
|
113
|
-
const jumpContainer = createContainer();
|
|
114
|
-
const jump = createChartStory(jumpContainer, { spec: baseSpec, steps });
|
|
115
|
-
jump.goTo(4);
|
|
116
|
-
|
|
117
|
-
const stepContainer = createContainer();
|
|
118
|
-
const stepwise = createChartStory(stepContainer, { spec: baseSpec, steps });
|
|
119
|
-
for (let i = 0; i <= 4; i++) stepwise.goTo(i);
|
|
120
|
-
|
|
121
|
-
// Same observable chrome DOM after the jump vs. sequential stepping.
|
|
122
|
-
expect(jumpContainer.querySelector('.oc-title')?.textContent).toBe(
|
|
123
|
-
stepContainer.querySelector('.oc-title')?.textContent,
|
|
124
|
-
);
|
|
125
|
-
expect(jumpContainer.querySelector('.oc-subtitle')?.textContent).toBe(
|
|
126
|
-
stepContainer.querySelector('.oc-subtitle')?.textContent,
|
|
127
|
-
);
|
|
128
|
-
expect(jumpContainer.querySelector('.oc-source')?.textContent).toBe(
|
|
129
|
-
stepContainer.querySelector('.oc-source')?.textContent,
|
|
130
|
-
);
|
|
131
|
-
expect(jump.currentStep).toBe(stepwise.currentStep);
|
|
132
|
-
|
|
133
|
-
jump.destroy();
|
|
134
|
-
stepwise.destroy();
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('highlight sugar sets encoding.color.highlight without a nested spec patch', () => {
|
|
138
|
-
const story = createChartStory(container, {
|
|
139
|
-
spec: baseSpec,
|
|
140
|
-
steps: [{}, { highlight: ['Germany'] }],
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
story.goTo(1);
|
|
144
|
-
|
|
145
|
-
// Highlighting Germany resolves through the color encoding: US (the
|
|
146
|
-
// non-highlighted series) gets the muted gray stroke. So exactly one line
|
|
147
|
-
// stroke should be muted.
|
|
148
|
-
expect(container.querySelector('svg')).not.toBeNull();
|
|
149
|
-
expect(mutedStrokeCount(container)).toBe(1);
|
|
150
|
-
|
|
151
|
-
story.destroy();
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('highlight null clears an earlier highlight', () => {
|
|
155
|
-
const story = createChartStory(container, {
|
|
156
|
-
spec: baseSpec,
|
|
157
|
-
steps: [{}, { highlight: ['Germany'] }, { highlight: null }],
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
// Step 1 highlights Germany, so US is muted.
|
|
161
|
-
story.goTo(1);
|
|
162
|
-
expect(mutedStrokeCount(container)).toBe(1);
|
|
163
|
-
|
|
164
|
-
// Step 2 clears the highlight (null -> []). No series should stay muted:
|
|
165
|
-
// if the clear were a no-op, US would still carry the muted gray stroke.
|
|
166
|
-
story.goTo(2);
|
|
167
|
-
expect(container.querySelector('svg')).not.toBeNull();
|
|
168
|
-
expect(mutedStrokeCount(container)).toBe(0);
|
|
169
|
-
|
|
170
|
-
story.destroy();
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('clamps out-of-range indices', () => {
|
|
174
|
-
const story = createChartStory(container, {
|
|
175
|
-
spec: baseSpec,
|
|
176
|
-
steps: [{}, { spec: { chrome: { title: 'One' } } }],
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
story.goTo(99);
|
|
180
|
-
expect(story.currentStep).toBe(1);
|
|
181
|
-
expect(title(container)).toBe('One');
|
|
182
|
-
|
|
183
|
-
story.goTo(-5);
|
|
184
|
-
expect(story.currentStep).toBe(0);
|
|
185
|
-
expect(title(container)).toBe('Base');
|
|
186
|
-
|
|
187
|
-
story.destroy();
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it('re-encode steps (outside the morph gate) swap without throwing', () => {
|
|
191
|
-
const story = createChartStory(container, {
|
|
192
|
-
spec: baseSpec,
|
|
193
|
-
steps: [{}, { spec: { encoding: { y: { field: 'value', type: 'quantitative' } } } }],
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
// Step 1 re-encodes: this falls outside the morph gate and takes the
|
|
197
|
-
// crossfade path. It must still land on a valid rendered chart.
|
|
198
|
-
story.goTo(1);
|
|
199
|
-
expect(container.querySelector('svg')).not.toBeNull();
|
|
200
|
-
|
|
201
|
-
story.destroy();
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('resolves a data-coordinate camera target to a marks-group transform', () => {
|
|
205
|
-
const story = createChartStory(container, {
|
|
206
|
-
spec: baseSpec,
|
|
207
|
-
steps: [{}, { camera: { x: ['2008', '2012'] } }],
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
// Step 0 has no camera: identity-ish full-view transform (or none yet).
|
|
211
|
-
story.goTo(1);
|
|
212
|
-
|
|
213
|
-
const t = cameraTransform(container);
|
|
214
|
-
expect(t).not.toBeNull();
|
|
215
|
-
// A framed region zooms in (scale > 1), so the transform must contain a
|
|
216
|
-
// scale factor greater than 1.
|
|
217
|
-
const scaleMatch = t?.match(/scale\(([\d.]+)\)/);
|
|
218
|
-
expect(scaleMatch).not.toBeNull();
|
|
219
|
-
expect(Number(scaleMatch?.[1])).toBeGreaterThan(1);
|
|
220
|
-
|
|
221
|
-
story.destroy();
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it('returns the camera to full view on a step with no camera', () => {
|
|
225
|
-
const story = createChartStory(container, {
|
|
226
|
-
spec: baseSpec,
|
|
227
|
-
steps: [{}, { camera: { x: ['2008', '2012'] } }, {}],
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
story.goTo(1);
|
|
231
|
-
const zoomed = cameraTransform(container);
|
|
232
|
-
const zoomedScale = Number(zoomed?.match(/scale\(([\d.]+)\)/)?.[1] ?? '1');
|
|
233
|
-
expect(zoomedScale).toBeGreaterThan(1);
|
|
234
|
-
|
|
235
|
-
story.goTo(2);
|
|
236
|
-
const reset = cameraTransform(container);
|
|
237
|
-
const resetScale = Number(reset?.match(/scale\(([\d.]+)\)/)?.[1] ?? '1');
|
|
238
|
-
// Full-view fit is scale 1 (within rounding).
|
|
239
|
-
expect(resetScale).toBeCloseTo(1, 3);
|
|
240
|
-
|
|
241
|
-
story.destroy();
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it('snaps camera moves immediately under prefers-reduced-motion', () => {
|
|
245
|
-
vi.stubGlobal('matchMedia', (query: string) => ({
|
|
246
|
-
matches: query === '(prefers-reduced-motion: reduce)',
|
|
247
|
-
media: query,
|
|
248
|
-
addEventListener: () => {},
|
|
249
|
-
removeEventListener: () => {},
|
|
250
|
-
}));
|
|
251
|
-
|
|
252
|
-
const story = createChartStory(container, {
|
|
253
|
-
spec: baseSpec,
|
|
254
|
-
steps: [{}, { camera: { x: ['2008', '2012'] } }],
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
story.goTo(1);
|
|
258
|
-
|
|
259
|
-
// Reduced motion snaps: the zoomed transform is present synchronously with
|
|
260
|
-
// no rAF pumping.
|
|
261
|
-
const t = cameraTransform(container);
|
|
262
|
-
const scale = Number(t?.match(/scale\(([\d.]+)\)/)?.[1] ?? '1');
|
|
263
|
-
expect(scale).toBeGreaterThan(1);
|
|
264
|
-
|
|
265
|
-
story.destroy();
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it('warns and disables the drive loop when edit-mode callbacks are passed', () => {
|
|
269
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
270
|
-
|
|
271
|
-
const story = createChartStory(
|
|
272
|
-
container,
|
|
273
|
-
{ spec: baseSpec, steps: [{}, { spec: { chrome: { title: 'One' } } }] },
|
|
274
|
-
{ onSelect: () => {} },
|
|
275
|
-
);
|
|
276
|
-
|
|
277
|
-
expect(warn).toHaveBeenCalledWith(expect.stringContaining('mutually exclusive'));
|
|
278
|
-
|
|
279
|
-
// goTo still applies (public control stays live); the scroll driver is what
|
|
280
|
-
// gets disabled.
|
|
281
|
-
story.goTo(1);
|
|
282
|
-
expect(title(container)).toBe('One');
|
|
283
|
-
|
|
284
|
-
warn.mockRestore();
|
|
285
|
-
story.destroy();
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
it('destroy tears down the chart and is idempotent', () => {
|
|
289
|
-
const story = createChartStory(container, {
|
|
290
|
-
spec: baseSpec,
|
|
291
|
-
steps: [{}, { spec: { chrome: { title: 'One' } } }],
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
story.destroy();
|
|
295
|
-
expect(container.querySelector('svg')).toBeNull();
|
|
296
|
-
|
|
297
|
-
// Second destroy is a no-op, not a throw.
|
|
298
|
-
expect(() => story.destroy()).not.toThrow();
|
|
299
|
-
// goTo after destroy is inert.
|
|
300
|
-
story.goTo(1);
|
|
301
|
-
expect(container.querySelector('svg')).toBeNull();
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it('registerStep and setContainer are safe no-ops in edit mode', () => {
|
|
305
|
-
const story = createChartStory(
|
|
306
|
-
container,
|
|
307
|
-
{ spec: baseSpec, steps: [{}, {}] },
|
|
308
|
-
{ onEdit: () => {} },
|
|
309
|
-
);
|
|
310
|
-
|
|
311
|
-
const stepEl = document.createElement('div');
|
|
312
|
-
expect(() => story.registerStep(0, stepEl)).not.toThrow();
|
|
313
|
-
expect(() => story.setContainer(document.createElement('div'))).not.toThrow();
|
|
314
|
-
|
|
315
|
-
story.destroy();
|
|
316
|
-
});
|
|
317
|
-
});
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { computeProgress, framesEqual, quantizeFrame } from '../progress-math';
|
|
3
|
-
|
|
4
|
-
// Three steps: tops at 100, 500, 900; last step ends at 1300.
|
|
5
|
-
const TOPS = [100, 500, 900];
|
|
6
|
-
const LAST_BOTTOM = 1300;
|
|
7
|
-
|
|
8
|
-
describe('computeProgress', () => {
|
|
9
|
-
it('returns -1 sentinel while the trigger line is above step 0', () => {
|
|
10
|
-
expect(computeProgress(TOPS, LAST_BOTTOM, 50)).toEqual({
|
|
11
|
-
step: -1,
|
|
12
|
-
stepProgress: 0,
|
|
13
|
-
progress: 0,
|
|
14
|
-
});
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('returns sentinel for empty steps', () => {
|
|
18
|
-
expect(computeProgress([], 0, 400).step).toBe(-1);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("stepProgress is 0 at a step's top and approaches 1 at its end", () => {
|
|
22
|
-
const atTop = computeProgress(TOPS, LAST_BOTTOM, 100);
|
|
23
|
-
expect(atTop.step).toBe(0);
|
|
24
|
-
expect(atTop.stepProgress).toBe(0);
|
|
25
|
-
|
|
26
|
-
const nearEnd = computeProgress(TOPS, LAST_BOTTOM, 499);
|
|
27
|
-
expect(nearEnd.step).toBe(0);
|
|
28
|
-
expect(nearEnd.stepProgress).toBeCloseTo(0.9975, 3);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('hands off continuously at step boundaries', () => {
|
|
32
|
-
const before = computeProgress(TOPS, LAST_BOTTOM, 500 - 0.001);
|
|
33
|
-
const after = computeProgress(TOPS, LAST_BOTTOM, 500);
|
|
34
|
-
expect(before.step).toBe(0);
|
|
35
|
-
expect(before.stepProgress).toBeCloseTo(1, 3);
|
|
36
|
-
expect(after.step).toBe(1);
|
|
37
|
-
expect(after.stepProgress).toBeCloseTo(0, 3);
|
|
38
|
-
// overall progress is continuous across the boundary
|
|
39
|
-
expect(after.progress - before.progress).toBeLessThan(0.001);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('clamps past the last step bottom', () => {
|
|
43
|
-
const past = computeProgress(TOPS, LAST_BOTTOM, 5000);
|
|
44
|
-
expect(past.step).toBe(2);
|
|
45
|
-
expect(past.stepProgress).toBe(1);
|
|
46
|
-
expect(past.progress).toBe(1);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('last step span runs to lastBottom', () => {
|
|
50
|
-
const mid = computeProgress(TOPS, LAST_BOTTOM, 1100);
|
|
51
|
-
expect(mid.step).toBe(2);
|
|
52
|
-
expect(mid.stepProgress).toBeCloseTo(0.5);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('degenerate spans do not divide by zero', () => {
|
|
56
|
-
const collapsed = computeProgress([100, 100], 100, 100);
|
|
57
|
-
expect(Number.isFinite(collapsed.stepProgress)).toBe(true);
|
|
58
|
-
expect(Number.isFinite(collapsed.progress)).toBe(true);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('overall progress is monotonic in scroll', () => {
|
|
62
|
-
let prev = -1;
|
|
63
|
-
for (let y = 0; y <= 1400; y += 25) {
|
|
64
|
-
const { progress } = computeProgress(TOPS, LAST_BOTTOM, y);
|
|
65
|
-
expect(progress).toBeGreaterThanOrEqual(prev);
|
|
66
|
-
prev = progress;
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe('quantizeFrame', () => {
|
|
72
|
-
it('zeroes stepProgress and quantizes progress to step fractions', () => {
|
|
73
|
-
const q = quantizeFrame({ step: 1, stepProgress: 0.7, progress: 0.55 }, 3);
|
|
74
|
-
expect(q).toEqual({ step: 1, stepProgress: 0, progress: 1 / 3 });
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('keeps the -1 sentinel at zero progress', () => {
|
|
78
|
-
const q = quantizeFrame({ step: -1, stepProgress: 0, progress: 0 }, 3);
|
|
79
|
-
expect(q).toEqual({ step: -1, stepProgress: 0, progress: 0 });
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
describe('framesEqual', () => {
|
|
84
|
-
const frame = { step: 1, stepProgress: 0.5, progress: 0.4, direction: 'down' as const };
|
|
85
|
-
|
|
86
|
-
it('null is never equal', () => {
|
|
87
|
-
expect(framesEqual(null, frame)).toBe(false);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('compares all fields', () => {
|
|
91
|
-
expect(framesEqual({ ...frame }, frame)).toBe(true);
|
|
92
|
-
expect(framesEqual({ ...frame, direction: 'up' }, frame)).toBe(false);
|
|
93
|
-
expect(framesEqual({ ...frame, stepProgress: 0.51 }, frame)).toBe(false);
|
|
94
|
-
});
|
|
95
|
-
});
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Scroll-driver tests, focused on the host that broke it: a page that scrolls
|
|
3
|
-
* an INNER CONTAINER rather than the document.
|
|
4
|
-
*
|
|
5
|
-
* That is not an edge case. Ladle sets `body { overflow: hidden }` and scrolls a
|
|
6
|
-
* nested div; so do modals, dashboard panes, and most app shells. In all of them
|
|
7
|
-
* `window.scrollY` stays pinned at 0 and scroll events never reach a `window`
|
|
8
|
-
* listener (scroll does not bubble), so the driver saw nothing and the story sat
|
|
9
|
-
* frozen on step 0.
|
|
10
|
-
*
|
|
11
|
-
* Both tests below fail against a driver that listens on `window` and reads
|
|
12
|
-
* `window.scrollY`.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
16
|
-
import { createScrollDriver } from '../scroll-driver';
|
|
17
|
-
|
|
18
|
-
const VIEWPORT_H = 800;
|
|
19
|
-
|
|
20
|
-
/** Drive rects from a virtual scroll offset instead of a real layout engine. */
|
|
21
|
-
function makeStep(offsetTop: number, height: number, scrollRef: { y: number }): HTMLElement {
|
|
22
|
-
const el = document.createElement('div');
|
|
23
|
-
el.getBoundingClientRect = () =>
|
|
24
|
-
({
|
|
25
|
-
top: offsetTop - scrollRef.y,
|
|
26
|
-
bottom: offsetTop + height - scrollRef.y,
|
|
27
|
-
height,
|
|
28
|
-
}) as DOMRect;
|
|
29
|
-
return el;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe('createScrollDriver in an inner-container scroller', () => {
|
|
33
|
-
let scroller: HTMLElement;
|
|
34
|
-
let container: HTMLElement;
|
|
35
|
-
let steps: HTMLElement[];
|
|
36
|
-
/** The inner container's scroll offset. `window.scrollY` stays 0 throughout. */
|
|
37
|
-
const scrollRef = { y: 0 };
|
|
38
|
-
|
|
39
|
-
beforeEach(() => {
|
|
40
|
-
scrollRef.y = 0;
|
|
41
|
-
vi.stubGlobal('innerHeight', VIEWPORT_H);
|
|
42
|
-
// The page itself never scrolls. This is the whole point.
|
|
43
|
-
vi.stubGlobal('scrollY', 0);
|
|
44
|
-
// The driver rAF-throttles its scroll handler; run the callback inline so a
|
|
45
|
-
// dispatched scroll event is fully processed by the time the test asserts.
|
|
46
|
-
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
47
|
-
cb(0);
|
|
48
|
-
return 1;
|
|
49
|
-
});
|
|
50
|
-
vi.stubGlobal('cancelAnimationFrame', () => {});
|
|
51
|
-
|
|
52
|
-
scroller = document.createElement('div');
|
|
53
|
-
container = document.createElement('div');
|
|
54
|
-
container.getBoundingClientRect = () =>
|
|
55
|
-
({ top: 0 - scrollRef.y, bottom: 3000 - scrollRef.y, height: 3000 }) as DOMRect;
|
|
56
|
-
|
|
57
|
-
steps = [0, 1000, 2000].map((top) => makeStep(top, 1000, scrollRef));
|
|
58
|
-
for (const s of steps) container.appendChild(s);
|
|
59
|
-
scroller.appendChild(container);
|
|
60
|
-
document.body.appendChild(scroller);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
afterEach(() => {
|
|
64
|
-
document.body.innerHTML = '';
|
|
65
|
-
vi.unstubAllGlobals();
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
/** Scroll the inner container and dispatch the event it would really emit. */
|
|
69
|
-
function scrollInnerTo(y: number) {
|
|
70
|
-
scrollRef.y = y;
|
|
71
|
-
scroller.dispatchEvent(new Event('scroll', { bubbles: false }));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
it('advances the step when an inner container scrolls, not the window', () => {
|
|
75
|
-
const driver = createScrollDriver({ triggerPosition: 0.4 });
|
|
76
|
-
const frames: number[] = [];
|
|
77
|
-
driver.progress.subscribe((f) => frames.push(f.step));
|
|
78
|
-
|
|
79
|
-
driver.setContainer(container);
|
|
80
|
-
steps.forEach((el, i) => {
|
|
81
|
-
driver.registerStep(i, el);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
// Trigger line sits at 0.4 * 800 = 320px. Scrolling the inner container by
|
|
85
|
-
// 1200px puts step 1 (top 1000) at -200 and step 2 (top 2000) at 800, so the
|
|
86
|
-
// last step whose top has crossed the trigger is step 1.
|
|
87
|
-
scrollInnerTo(1200);
|
|
88
|
-
|
|
89
|
-
expect(driver.progress.get().step).toBe(1);
|
|
90
|
-
expect(frames.at(-1)).toBe(1);
|
|
91
|
-
|
|
92
|
-
driver.destroy();
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('reads direction from the container rect, not window.scrollY', () => {
|
|
96
|
-
const driver = createScrollDriver({ triggerPosition: 0.4 });
|
|
97
|
-
driver.progress.subscribe(() => {});
|
|
98
|
-
driver.setContainer(container);
|
|
99
|
-
steps.forEach((el, i) => {
|
|
100
|
-
driver.registerStep(i, el);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
scrollInnerTo(1200);
|
|
104
|
-
expect(driver.progress.get().direction).toBe('down');
|
|
105
|
-
|
|
106
|
-
// Scroll back up. window.scrollY never moved, so a scrollY-based driver
|
|
107
|
-
// would still report 'down' here.
|
|
108
|
-
scrollInnerTo(400);
|
|
109
|
-
expect(driver.progress.get().direction).toBe('up');
|
|
110
|
-
|
|
111
|
-
driver.destroy();
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('stops emitting after destroy', () => {
|
|
115
|
-
const driver = createScrollDriver();
|
|
116
|
-
const seen: number[] = [];
|
|
117
|
-
driver.progress.subscribe((f) => seen.push(f.step));
|
|
118
|
-
driver.setContainer(container);
|
|
119
|
-
steps.forEach((el, i) => {
|
|
120
|
-
driver.registerStep(i, el);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
driver.destroy();
|
|
124
|
-
const countAtDestroy = seen.length;
|
|
125
|
-
|
|
126
|
-
scrollInnerTo(1200);
|
|
127
|
-
|
|
128
|
-
expect(seen.length).toBe(countAtDestroy);
|
|
129
|
-
});
|
|
130
|
-
});
|