@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.
Files changed (145) hide show
  1. package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/chunk-DCODCLWI.js +234 -0
  3. package/dist/chunk-DCODCLWI.js.map +1 -0
  4. package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
  5. package/dist/chunk-EKAUQAAX.js.map +1 -0
  6. package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
  7. package/dist/chunk-KNT5AZ2O.js.map +1 -0
  8. package/dist/chunk-RM5XS6NR.js +113 -0
  9. package/dist/chunk-RM5XS6NR.js.map +1 -0
  10. package/dist/chunk-T32UHMZA.js +282 -0
  11. package/dist/chunk-T32UHMZA.js.map +1 -0
  12. package/dist/export-gif.d.ts +113 -0
  13. package/dist/export-gif.js +21 -0
  14. package/dist/export-gif.js.map +1 -0
  15. package/dist/index.d.ts +283 -24
  16. package/dist/index.js +2012 -763
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.js +7 -69
  21. package/dist/static.js.map +1 -1
  22. package/dist/story/index.d.ts +3 -2
  23. package/dist/story/index.js +5 -3
  24. package/dist/story/index.js.map +1 -1
  25. package/dist/styles.css +1 -1
  26. package/dist/styles.css.map +1 -1
  27. package/package.json +14 -6
  28. package/dist/chunk-5EOV5JQZ.js.map +0 -1
  29. package/dist/chunk-ENXSUXC5.js.map +0 -1
  30. package/src/__test-fixtures__/dom.ts +0 -70
  31. package/src/__test-fixtures__/specs.ts +0 -214
  32. package/src/__tests__/animation.test.ts +0 -358
  33. package/src/__tests__/annotations-render.test.ts +0 -201
  34. package/src/__tests__/compound-labels.test.ts +0 -122
  35. package/src/__tests__/continuous-legend.test.ts +0 -91
  36. package/src/__tests__/crosshair.test.ts +0 -130
  37. package/src/__tests__/edit-events.test.ts +0 -902
  38. package/src/__tests__/events.test.ts +0 -387
  39. package/src/__tests__/export.test.ts +0 -241
  40. package/src/__tests__/font-race.test.ts +0 -199
  41. package/src/__tests__/gradient-ids.test.ts +0 -159
  42. package/src/__tests__/gradient-utils.test.ts +0 -125
  43. package/src/__tests__/known-bugs-render.test.ts +0 -183
  44. package/src/__tests__/map.test.ts +0 -293
  45. package/src/__tests__/mount.test.ts +0 -357
  46. package/src/__tests__/pattern-fills.test.ts +0 -105
  47. package/src/__tests__/resize-timing.test.ts +0 -218
  48. package/src/__tests__/sankey.test.ts +0 -133
  49. package/src/__tests__/selection-events.test.ts +0 -869
  50. package/src/__tests__/series-search.test.ts +0 -311
  51. package/src/__tests__/static.test.ts +0 -141
  52. package/src/__tests__/svg-renderer.test.ts +0 -895
  53. package/src/__tests__/table-keyboard.test.ts +0 -361
  54. package/src/__tests__/table-mount.test.ts +0 -639
  55. package/src/__tests__/tilemap.test.ts +0 -158
  56. package/src/__tests__/tooltip.test.ts +0 -337
  57. package/src/__tests__/transition.test.ts +0 -1777
  58. package/src/__tests__/you-draw-it.test.ts +0 -273
  59. package/src/animation-vars.ts +0 -42
  60. package/src/animation.ts +0 -76
  61. package/src/barlist-mount.ts +0 -383
  62. package/src/barlist-renderer.ts +0 -267
  63. package/src/dom-helpers.ts +0 -17
  64. package/src/export.ts +0 -482
  65. package/src/gradient-utils.ts +0 -138
  66. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  67. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  68. package/src/graph/__tests__/interaction.test.ts +0 -205
  69. package/src/graph/__tests__/keyboard.test.ts +0 -653
  70. package/src/graph/__tests__/search.test.ts +0 -88
  71. package/src/graph/__tests__/simulation.test.ts +0 -233
  72. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  73. package/src/graph/__tests__/zoom.test.ts +0 -203
  74. package/src/graph/canvas-renderer.ts +0 -747
  75. package/src/graph/interaction.ts +0 -367
  76. package/src/graph/keyboard.ts +0 -208
  77. package/src/graph/search.ts +0 -50
  78. package/src/graph/simulation-worker-url.ts +0 -12
  79. package/src/graph/simulation-worker.ts +0 -278
  80. package/src/graph/simulation.ts +0 -422
  81. package/src/graph/spatial-index.ts +0 -121
  82. package/src/graph/types.ts +0 -47
  83. package/src/graph/worker-protocol.ts +0 -70
  84. package/src/graph/zoom.ts +0 -134
  85. package/src/graph-mount.ts +0 -888
  86. package/src/index.ts +0 -76
  87. package/src/interactions/chart-events.ts +0 -139
  88. package/src/interactions/crosshair.ts +0 -233
  89. package/src/interactions/drag-handler.ts +0 -175
  90. package/src/interactions/editing-drags.ts +0 -515
  91. package/src/interactions/index.ts +0 -25
  92. package/src/interactions/keyboard-nav.ts +0 -111
  93. package/src/interactions/legend-interaction.ts +0 -46
  94. package/src/interactions/selection.ts +0 -280
  95. package/src/interactions/tooltip-events.ts +0 -72
  96. package/src/map-camera.ts +0 -88
  97. package/src/map-mount.ts +0 -701
  98. package/src/map-renderer.ts +0 -338
  99. package/src/map-transition.ts +0 -83
  100. package/src/measure-text.ts +0 -134
  101. package/src/mount.ts +0 -1382
  102. package/src/pattern-utils.ts +0 -110
  103. package/src/renderers/annotations.ts +0 -388
  104. package/src/renderers/axes.ts +0 -351
  105. package/src/renderers/brand.ts +0 -87
  106. package/src/renderers/chrome.ts +0 -196
  107. package/src/renderers/endpoint-labels.ts +0 -150
  108. package/src/renderers/legend.ts +0 -311
  109. package/src/renderers/marks.ts +0 -561
  110. package/src/renderers/metrics.ts +0 -63
  111. package/src/renderers/svg-dom.ts +0 -40
  112. package/src/renderers/table-cells.ts +0 -446
  113. package/src/resize-observer.ts +0 -46
  114. package/src/sankey-mount.ts +0 -622
  115. package/src/sankey-renderer.ts +0 -614
  116. package/src/series-search.ts +0 -392
  117. package/src/static.ts +0 -251
  118. package/src/story/__tests__/camera-math.test.ts +0 -145
  119. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  120. package/src/story/__tests__/progress-math.test.ts +0 -95
  121. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  122. package/src/story/__tests__/tween.test.ts +0 -159
  123. package/src/story/camera-math.ts +0 -125
  124. package/src/story/create-chart-story.ts +0 -353
  125. package/src/story/crossfade.ts +0 -70
  126. package/src/story/index.ts +0 -39
  127. package/src/story/progress-math.ts +0 -85
  128. package/src/story/resolve-camera-target.ts +0 -119
  129. package/src/story/scroll-driver.ts +0 -206
  130. package/src/story/story-camera.ts +0 -49
  131. package/src/story/tween.ts +0 -112
  132. package/src/story/types.ts +0 -76
  133. package/src/svg-ids.ts +0 -23
  134. package/src/svg-renderer.ts +0 -383
  135. package/src/table-keyboard.ts +0 -266
  136. package/src/table-mount.ts +0 -567
  137. package/src/table-renderer.ts +0 -414
  138. package/src/text-edit-overlay.ts +0 -255
  139. package/src/theme-tokens.ts +0 -51
  140. package/src/tilemap-mount.ts +0 -511
  141. package/src/tilemap-renderer.ts +0 -396
  142. package/src/tooltip.ts +0 -177
  143. package/src/transition.ts +0 -2816
  144. package/src/you-draw-it.ts +0 -480
  145. /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
- });