@opendata-ai/openchart-vanilla 8.0.0-rc.9 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/{chunk-SPILUE5S.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/{chunk-IZQBT6QM.js → chunk-6BUCYCLD.js} +7283 -5881
  3. package/dist/chunk-6BUCYCLD.js.map +1 -0
  4. package/dist/chunk-DCODCLWI.js +234 -0
  5. package/dist/chunk-DCODCLWI.js.map +1 -0
  6. package/dist/{chunk-QW7ZB6NE.js → chunk-EKAUQAAX.js} +49 -45
  7. package/dist/chunk-EKAUQAAX.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 +309 -27
  16. package/dist/index.js +2020 -836
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-CL-nA0f-.d.ts} +85 -2
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.d.ts +2 -0
  21. package/dist/static.js +8 -69
  22. package/dist/static.js.map +1 -1
  23. package/dist/story/index.d.ts +25 -9
  24. package/dist/story/index.js +9 -7
  25. package/dist/story/index.js.map +1 -1
  26. package/dist/styles.css +1 -1
  27. package/dist/styles.css.map +1 -1
  28. package/package.json +14 -6
  29. package/dist/chunk-IZQBT6QM.js.map +0 -1
  30. package/dist/chunk-QW7ZB6NE.js.map +0 -1
  31. package/src/__test-fixtures__/dom.ts +0 -70
  32. package/src/__test-fixtures__/specs.ts +0 -214
  33. package/src/__tests__/animation.test.ts +0 -358
  34. package/src/__tests__/annotations-render.test.ts +0 -201
  35. package/src/__tests__/compound-labels.test.ts +0 -122
  36. package/src/__tests__/continuous-legend.test.ts +0 -91
  37. package/src/__tests__/crosshair.test.ts +0 -130
  38. package/src/__tests__/edit-events.test.ts +0 -1477
  39. package/src/__tests__/editable-prop.test.ts +0 -174
  40. package/src/__tests__/events.test.ts +0 -387
  41. package/src/__tests__/export.test.ts +0 -241
  42. package/src/__tests__/font-race.test.ts +0 -199
  43. package/src/__tests__/gradient-ids.test.ts +0 -159
  44. package/src/__tests__/gradient-utils.test.ts +0 -125
  45. package/src/__tests__/invert.test.ts +0 -187
  46. package/src/__tests__/known-bugs-render.test.ts +0 -183
  47. package/src/__tests__/map.test.ts +0 -636
  48. package/src/__tests__/mount.test.ts +0 -357
  49. package/src/__tests__/pattern-fills.test.ts +0 -105
  50. package/src/__tests__/resize-timing.test.ts +0 -218
  51. package/src/__tests__/sankey.test.ts +0 -133
  52. package/src/__tests__/selection-events.test.ts +0 -1062
  53. package/src/__tests__/series-search.test.ts +0 -311
  54. package/src/__tests__/static.test.ts +0 -141
  55. package/src/__tests__/svg-renderer.test.ts +0 -895
  56. package/src/__tests__/table-keyboard.test.ts +0 -361
  57. package/src/__tests__/table-mount.test.ts +0 -639
  58. package/src/__tests__/tilemap.test.ts +0 -158
  59. package/src/__tests__/tooltip.test.ts +0 -337
  60. package/src/__tests__/transition.test.ts +0 -1792
  61. package/src/__tests__/update-scrolly-matrix.test.ts +0 -563
  62. package/src/__tests__/you-draw-it.test.ts +0 -273
  63. package/src/animation-vars.ts +0 -42
  64. package/src/animation.ts +0 -76
  65. package/src/barlist-mount.ts +0 -383
  66. package/src/barlist-renderer.ts +0 -267
  67. package/src/dom-helpers.ts +0 -17
  68. package/src/export.ts +0 -482
  69. package/src/gradient-utils.ts +0 -138
  70. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  71. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  72. package/src/graph/__tests__/interaction.test.ts +0 -205
  73. package/src/graph/__tests__/keyboard.test.ts +0 -653
  74. package/src/graph/__tests__/search.test.ts +0 -88
  75. package/src/graph/__tests__/simulation.test.ts +0 -233
  76. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  77. package/src/graph/__tests__/zoom.test.ts +0 -203
  78. package/src/graph/canvas-renderer.ts +0 -747
  79. package/src/graph/interaction.ts +0 -367
  80. package/src/graph/keyboard.ts +0 -208
  81. package/src/graph/search.ts +0 -50
  82. package/src/graph/simulation-worker-url.ts +0 -12
  83. package/src/graph/simulation-worker.ts +0 -278
  84. package/src/graph/simulation.ts +0 -422
  85. package/src/graph/spatial-index.ts +0 -121
  86. package/src/graph/types.ts +0 -47
  87. package/src/graph/worker-protocol.ts +0 -70
  88. package/src/graph/zoom.ts +0 -134
  89. package/src/graph-mount.ts +0 -888
  90. package/src/index.ts +0 -76
  91. package/src/interactions/chart-events.ts +0 -139
  92. package/src/interactions/crosshair.ts +0 -233
  93. package/src/interactions/drag-handler.ts +0 -175
  94. package/src/interactions/editing-drags.ts +0 -649
  95. package/src/interactions/index.ts +0 -27
  96. package/src/interactions/invert.ts +0 -54
  97. package/src/interactions/keyboard-nav.ts +0 -111
  98. package/src/interactions/legend-interaction.ts +0 -46
  99. package/src/interactions/selection.ts +0 -286
  100. package/src/interactions/tooltip-events.ts +0 -72
  101. package/src/map-camera.ts +0 -111
  102. package/src/map-mount.ts +0 -797
  103. package/src/map-renderer.ts +0 -476
  104. package/src/map-transition.ts +0 -99
  105. package/src/measure-text.ts +0 -134
  106. package/src/mount.ts +0 -1434
  107. package/src/pattern-utils.ts +0 -110
  108. package/src/renderers/annotations.ts +0 -388
  109. package/src/renderers/axes.ts +0 -351
  110. package/src/renderers/brand.ts +0 -87
  111. package/src/renderers/chrome.ts +0 -196
  112. package/src/renderers/endpoint-labels.ts +0 -150
  113. package/src/renderers/legend.ts +0 -312
  114. package/src/renderers/marks.ts +0 -561
  115. package/src/renderers/metrics.ts +0 -63
  116. package/src/renderers/svg-dom.ts +0 -40
  117. package/src/renderers/table-cells.ts +0 -446
  118. package/src/resize-observer.ts +0 -46
  119. package/src/sankey-mount.ts +0 -622
  120. package/src/sankey-renderer.ts +0 -614
  121. package/src/series-search.ts +0 -392
  122. package/src/static.ts +0 -251
  123. package/src/story/__tests__/camera-math.test.ts +0 -145
  124. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  125. package/src/story/__tests__/progress-math.test.ts +0 -95
  126. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  127. package/src/story/__tests__/tween.test.ts +0 -159
  128. package/src/story/camera-math.ts +0 -125
  129. package/src/story/create-chart-story.ts +0 -353
  130. package/src/story/crossfade.ts +0 -70
  131. package/src/story/index.ts +0 -39
  132. package/src/story/progress-math.ts +0 -85
  133. package/src/story/resolve-camera-target.ts +0 -119
  134. package/src/story/scroll-driver.ts +0 -206
  135. package/src/story/story-camera.ts +0 -49
  136. package/src/story/tween.ts +0 -112
  137. package/src/story/types.ts +0 -76
  138. package/src/svg-ids.ts +0 -23
  139. package/src/svg-renderer.ts +0 -383
  140. package/src/table-keyboard.ts +0 -266
  141. package/src/table-mount.ts +0 -567
  142. package/src/table-renderer.ts +0 -414
  143. package/src/text-edit-overlay.ts +0 -389
  144. package/src/theme-tokens.ts +0 -51
  145. package/src/tilemap-mount.ts +0 -511
  146. package/src/tilemap-renderer.ts +0 -396
  147. package/src/tooltip.ts +0 -177
  148. package/src/transition.ts +0 -2816
  149. package/src/you-draw-it.ts +0 -480
  150. /package/dist/{chunk-SPILUE5S.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
- });