@blotch/dolan 0.0.0-stage → 22.2.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 (273) hide show
  1. package/dist/commands/add.d.ts +22 -0
  2. package/dist/commands/add.js +132 -0
  3. package/dist/commands/add.js.map +1 -0
  4. package/dist/commands/add.test.d.ts +1 -0
  5. package/dist/commands/add.test.js +209 -0
  6. package/dist/commands/add.test.js.map +1 -0
  7. package/dist/commands/diff.d.ts +13 -0
  8. package/dist/commands/diff.js +155 -0
  9. package/dist/commands/diff.js.map +1 -0
  10. package/dist/commands/diff.test.d.ts +1 -0
  11. package/dist/commands/diff.test.js +327 -0
  12. package/dist/commands/diff.test.js.map +1 -0
  13. package/dist/commands/doctor.d.ts +15 -0
  14. package/dist/commands/doctor.js +248 -0
  15. package/dist/commands/doctor.js.map +1 -0
  16. package/dist/commands/doctor.test.d.ts +1 -0
  17. package/dist/commands/doctor.test.js +301 -0
  18. package/dist/commands/doctor.test.js.map +1 -0
  19. package/dist/commands/eject.d.ts +11 -0
  20. package/dist/commands/eject.js +55 -0
  21. package/dist/commands/eject.js.map +1 -0
  22. package/dist/commands/eject.test.d.ts +1 -0
  23. package/dist/commands/eject.test.js +151 -0
  24. package/dist/commands/eject.test.js.map +1 -0
  25. package/dist/commands/info.d.ts +1 -0
  26. package/dist/commands/info.js +73 -0
  27. package/dist/commands/info.js.map +1 -0
  28. package/dist/commands/info.test.d.ts +1 -0
  29. package/dist/commands/info.test.js +143 -0
  30. package/dist/commands/info.test.js.map +1 -0
  31. package/dist/commands/init.d.ts +1 -0
  32. package/dist/commands/init.js +98 -0
  33. package/dist/commands/init.js.map +1 -0
  34. package/dist/commands/init.test.d.ts +1 -0
  35. package/dist/commands/init.test.js +154 -0
  36. package/dist/commands/init.test.js.map +1 -0
  37. package/dist/commands/list.d.ts +16 -0
  38. package/dist/commands/list.js +124 -0
  39. package/dist/commands/list.js.map +1 -0
  40. package/dist/commands/list.test.d.ts +1 -0
  41. package/dist/commands/list.test.js +285 -0
  42. package/dist/commands/list.test.js.map +1 -0
  43. package/dist/commands/outdated.d.ts +13 -0
  44. package/dist/commands/outdated.js +116 -0
  45. package/dist/commands/outdated.js.map +1 -0
  46. package/dist/commands/outdated.test.d.ts +1 -0
  47. package/dist/commands/outdated.test.js +208 -0
  48. package/dist/commands/outdated.test.js.map +1 -0
  49. package/dist/commands/remove.d.ts +18 -0
  50. package/dist/commands/remove.js +161 -0
  51. package/dist/commands/remove.js.map +1 -0
  52. package/dist/commands/remove.test.d.ts +1 -0
  53. package/dist/commands/remove.test.js +361 -0
  54. package/dist/commands/remove.test.js.map +1 -0
  55. package/dist/commands/theme.d.ts +1 -0
  56. package/dist/commands/theme.js +66 -0
  57. package/dist/commands/theme.js.map +1 -0
  58. package/dist/commands/theme.test.d.ts +1 -0
  59. package/dist/commands/theme.test.js +133 -0
  60. package/dist/commands/theme.test.js.map +1 -0
  61. package/dist/commands/update.d.ts +31 -0
  62. package/dist/commands/update.js +601 -0
  63. package/dist/commands/update.js.map +1 -0
  64. package/dist/commands/update.test.d.ts +1 -0
  65. package/dist/commands/update.test.js +1233 -0
  66. package/dist/commands/update.test.js.map +1 -0
  67. package/dist/config.d.ts +11 -0
  68. package/dist/config.js +19 -0
  69. package/dist/config.js.map +1 -0
  70. package/dist/config.test.d.ts +1 -0
  71. package/dist/config.test.js +48 -0
  72. package/dist/config.test.js.map +1 -0
  73. package/dist/index.d.ts +2 -0
  74. package/dist/index.js +89 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/lockfile.d.ts +21 -0
  77. package/dist/lockfile.js +39 -0
  78. package/dist/lockfile.js.map +1 -0
  79. package/dist/registry.d.ts +14 -0
  80. package/dist/registry.js +85 -0
  81. package/dist/registry.js.map +1 -0
  82. package/dist/registry.test.d.ts +1 -0
  83. package/dist/registry.test.js +95 -0
  84. package/dist/registry.test.js.map +1 -0
  85. package/dist/test-utils.d.ts +16 -0
  86. package/dist/test-utils.js +65 -0
  87. package/dist/test-utils.js.map +1 -0
  88. package/dist/ui/components/accordion/accordion.test.ts +194 -0
  89. package/dist/ui/components/accordion/accordion.ts +173 -0
  90. package/dist/ui/components/accordion/index.ts +6 -0
  91. package/dist/ui/components/action-sheet/action-sheet.test.ts +211 -0
  92. package/dist/ui/components/action-sheet/action-sheet.ts +353 -0
  93. package/dist/ui/components/action-sheet/index.ts +6 -0
  94. package/dist/ui/components/alert/alert.test.ts +48 -0
  95. package/dist/ui/components/alert/alert.ts +104 -0
  96. package/dist/ui/components/alert/index.ts +1 -0
  97. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +141 -0
  98. package/dist/ui/components/alert-dialog/alert-dialog.ts +243 -0
  99. package/dist/ui/components/alert-dialog/index.ts +7 -0
  100. package/dist/ui/components/area-chart/area-chart-render.test.ts +121 -0
  101. package/dist/ui/components/area-chart/area-chart.test.ts +158 -0
  102. package/dist/ui/components/area-chart/area-chart.ts +349 -0
  103. package/dist/ui/components/area-chart/index.ts +6 -0
  104. package/dist/ui/components/aspect-ratio/aspect-ratio.test.ts +10 -0
  105. package/dist/ui/components/aspect-ratio/aspect-ratio.ts +28 -0
  106. package/dist/ui/components/aspect-ratio/index.ts +1 -0
  107. package/dist/ui/components/avatar/avatar.test.ts +69 -0
  108. package/dist/ui/components/avatar/avatar.ts +131 -0
  109. package/dist/ui/components/avatar/index.ts +1 -0
  110. package/dist/ui/components/badge/badge.test.ts +49 -0
  111. package/dist/ui/components/badge/badge.ts +97 -0
  112. package/dist/ui/components/badge/index.ts +1 -0
  113. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +174 -0
  114. package/dist/ui/components/bar-chart/bar-chart.test.ts +73 -0
  115. package/dist/ui/components/bar-chart/bar-chart.ts +327 -0
  116. package/dist/ui/components/bar-chart/index.ts +6 -0
  117. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +163 -0
  118. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +339 -0
  119. package/dist/ui/components/bottom-sheet/index.ts +1 -0
  120. package/dist/ui/components/button/button-render.test.ts +113 -0
  121. package/dist/ui/components/button/button.test.ts +70 -0
  122. package/dist/ui/components/button/button.ts +170 -0
  123. package/dist/ui/components/button/index.ts +6 -0
  124. package/dist/ui/components/button-group/button-group-render.test.ts +146 -0
  125. package/dist/ui/components/button-group/button-group.test.ts +54 -0
  126. package/dist/ui/components/button-group/button-group.ts +209 -0
  127. package/dist/ui/components/button-group/index.ts +8 -0
  128. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +174 -0
  129. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +96 -0
  130. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +355 -0
  131. package/dist/ui/components/candlestick-chart/index.ts +7 -0
  132. package/dist/ui/components/card/card.test.ts +110 -0
  133. package/dist/ui/components/card/card.ts +162 -0
  134. package/dist/ui/components/card/index.ts +8 -0
  135. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +188 -0
  136. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +549 -0
  137. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +1140 -0
  138. package/dist/ui/components/cartesian-chart/index.ts +18 -0
  139. package/dist/ui/components/checkbox/checkbox.test.ts +59 -0
  140. package/dist/ui/components/checkbox/checkbox.ts +96 -0
  141. package/dist/ui/components/checkbox/index.ts +1 -0
  142. package/dist/ui/components/collapsible/collapsible.test.ts +112 -0
  143. package/dist/ui/components/collapsible/collapsible.ts +122 -0
  144. package/dist/ui/components/collapsible/index.ts +5 -0
  145. package/dist/ui/components/dialog/dialog.test.ts +157 -0
  146. package/dist/ui/components/dialog/dialog.ts +248 -0
  147. package/dist/ui/components/dialog/index.ts +7 -0
  148. package/dist/ui/components/empty-state/empty-state.test.ts +60 -0
  149. package/dist/ui/components/empty-state/empty-state.ts +46 -0
  150. package/dist/ui/components/empty-state/index.ts +1 -0
  151. package/dist/ui/components/icon/icon.test.ts +52 -0
  152. package/dist/ui/components/icon/icon.ts +54 -0
  153. package/dist/ui/components/icon/icons.ts +569 -0
  154. package/dist/ui/components/icon/index.ts +2 -0
  155. package/dist/ui/components/input/index.ts +1 -0
  156. package/dist/ui/components/input/input.test.ts +134 -0
  157. package/dist/ui/components/input/input.ts +189 -0
  158. package/dist/ui/components/label/index.ts +1 -0
  159. package/dist/ui/components/label/label.test.ts +38 -0
  160. package/dist/ui/components/label/label.ts +24 -0
  161. package/dist/ui/components/line-chart/index.ts +6 -0
  162. package/dist/ui/components/line-chart/line-chart-render.test.ts +121 -0
  163. package/dist/ui/components/line-chart/line-chart.test.ts +81 -0
  164. package/dist/ui/components/line-chart/line-chart.ts +273 -0
  165. package/dist/ui/components/list/index.ts +1 -0
  166. package/dist/ui/components/list/list.test.ts +35 -0
  167. package/dist/ui/components/list/list.ts +71 -0
  168. package/dist/ui/components/nav-drawer/index.ts +8 -0
  169. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +267 -0
  170. package/dist/ui/components/nav-drawer/nav-drawer.ts +504 -0
  171. package/dist/ui/components/pagination/index.ts +1 -0
  172. package/dist/ui/components/pagination/pagination.test.ts +96 -0
  173. package/dist/ui/components/pagination/pagination.ts +147 -0
  174. package/dist/ui/components/progress/index.ts +1 -0
  175. package/dist/ui/components/progress/progress.test.ts +71 -0
  176. package/dist/ui/components/progress/progress.ts +118 -0
  177. package/dist/ui/components/radio-group/index.ts +1 -0
  178. package/dist/ui/components/radio-group/radio-group.test.ts +115 -0
  179. package/dist/ui/components/radio-group/radio-group.ts +129 -0
  180. package/dist/ui/components/scatter-chart/index.ts +6 -0
  181. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +157 -0
  182. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +57 -0
  183. package/dist/ui/components/scatter-chart/scatter-chart.ts +277 -0
  184. package/dist/ui/components/scroll-area/index.ts +1 -0
  185. package/dist/ui/components/scroll-area/scroll-area.test.ts +40 -0
  186. package/dist/ui/components/scroll-area/scroll-area.ts +73 -0
  187. package/dist/ui/components/select/index.ts +1 -0
  188. package/dist/ui/components/select/select.test.ts +107 -0
  189. package/dist/ui/components/select/select.ts +146 -0
  190. package/dist/ui/components/separator/index.ts +1 -0
  191. package/dist/ui/components/separator/separator.test.ts +26 -0
  192. package/dist/ui/components/separator/separator.ts +45 -0
  193. package/dist/ui/components/sheet/index.ts +7 -0
  194. package/dist/ui/components/sheet/sheet.test.ts +44 -0
  195. package/dist/ui/components/sheet/sheet.ts +114 -0
  196. package/dist/ui/components/skeleton/index.ts +1 -0
  197. package/dist/ui/components/skeleton/skeleton.test.ts +13 -0
  198. package/dist/ui/components/skeleton/skeleton.ts +45 -0
  199. package/dist/ui/components/spinner/index.ts +1 -0
  200. package/dist/ui/components/spinner/spinner.test.ts +39 -0
  201. package/dist/ui/components/spinner/spinner.ts +83 -0
  202. package/dist/ui/components/switch/index.ts +1 -0
  203. package/dist/ui/components/switch/switch.test.ts +63 -0
  204. package/dist/ui/components/switch/switch.ts +98 -0
  205. package/dist/ui/components/tabs/index.ts +1 -0
  206. package/dist/ui/components/tabs/tabs.test.ts +199 -0
  207. package/dist/ui/components/tabs/tabs.ts +317 -0
  208. package/dist/ui/components/textarea/index.ts +1 -0
  209. package/dist/ui/components/textarea/textarea.test.ts +138 -0
  210. package/dist/ui/components/textarea/textarea.ts +221 -0
  211. package/dist/ui/components/toast/index.ts +9 -0
  212. package/dist/ui/components/toast/toast-state.test.ts +54 -0
  213. package/dist/ui/components/toast/toast-state.ts +46 -0
  214. package/dist/ui/components/toast/toast.test.ts +342 -0
  215. package/dist/ui/components/toast/toast.ts +527 -0
  216. package/dist/ui/components/toggle/index.ts +7 -0
  217. package/dist/ui/components/toggle/toggle.test.ts +186 -0
  218. package/dist/ui/components/toggle/toggle.ts +218 -0
  219. package/dist/ui/components/typography/index.ts +5 -0
  220. package/dist/ui/components/typography/typography.test.ts +55 -0
  221. package/dist/ui/components/typography/typography.ts +44 -0
  222. package/dist/ui/theme/dark.css +41 -0
  223. package/dist/ui/theme/default.css +56 -0
  224. package/dist/ui/theme/tailwind-plugin.test.ts +124 -0
  225. package/dist/ui/theme/tailwind-plugin.ts +151 -0
  226. package/dist/ui/utils/animate.test.ts +324 -0
  227. package/dist/ui/utils/animate.ts +544 -0
  228. package/dist/ui/utils/cn.test.ts +27 -0
  229. package/dist/ui/utils/cn.ts +6 -0
  230. package/dist/utils/analyze.d.ts +32 -0
  231. package/dist/utils/analyze.js +109 -0
  232. package/dist/utils/analyze.js.map +1 -0
  233. package/dist/utils/analyze.test.d.ts +1 -0
  234. package/dist/utils/analyze.test.js +146 -0
  235. package/dist/utils/analyze.test.js.map +1 -0
  236. package/dist/utils/animate.d.ts +210 -0
  237. package/dist/utils/animate.js +423 -0
  238. package/dist/utils/animate.js.map +1 -0
  239. package/dist/utils/animate.test.d.ts +1 -0
  240. package/dist/utils/animate.test.js +365 -0
  241. package/dist/utils/animate.test.js.map +1 -0
  242. package/dist/utils/banner.d.ts +1 -0
  243. package/dist/utils/banner.js +15 -0
  244. package/dist/utils/banner.js.map +1 -0
  245. package/dist/utils/banner.test.d.ts +1 -0
  246. package/dist/utils/banner.test.js +29 -0
  247. package/dist/utils/banner.test.js.map +1 -0
  248. package/dist/utils/cn.d.ts +2 -0
  249. package/dist/utils/cn.js +6 -0
  250. package/dist/utils/cn.js.map +1 -0
  251. package/dist/utils/cn.test.d.ts +1 -0
  252. package/dist/utils/cn.test.js +21 -0
  253. package/dist/utils/cn.test.js.map +1 -0
  254. package/dist/utils/format.d.ts +31 -0
  255. package/dist/utils/format.js +95 -0
  256. package/dist/utils/format.js.map +1 -0
  257. package/dist/utils/format.test.d.ts +1 -0
  258. package/dist/utils/format.test.js +71 -0
  259. package/dist/utils/format.test.js.map +1 -0
  260. package/dist/utils/logger.d.ts +6 -0
  261. package/dist/utils/logger.js +16 -0
  262. package/dist/utils/logger.js.map +1 -0
  263. package/dist/utils/logger.test.d.ts +1 -0
  264. package/dist/utils/logger.test.js +36 -0
  265. package/dist/utils/logger.test.js.map +1 -0
  266. package/dist/utils/resolve-paths.d.ts +12 -0
  267. package/dist/utils/resolve-paths.js +29 -0
  268. package/dist/utils/resolve-paths.js.map +1 -0
  269. package/dist/utils/resolve-paths.test.d.ts +1 -0
  270. package/dist/utils/resolve-paths.test.js +52 -0
  271. package/dist/utils/resolve-paths.test.js.map +1 -0
  272. package/package.json +56 -4
  273. package/README.md +0 -3
@@ -0,0 +1,549 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { ChartDomain } from './cartesian-chart';
3
+
4
+ // Mock Angular so importing the component module doesn't require the framework
5
+ // runtime. Only the exported pure geometry fns are exercised here; the class
6
+ // field initializers (input/computed) never run because we never construct the
7
+ // component.
8
+ vi.mock('@angular/core', () => ({
9
+ Component: () => () => {},
10
+ ViewEncapsulation: { None: 0 },
11
+ computed: () => () => {},
12
+ input: () => () => {},
13
+ signal: () => () => {},
14
+ }));
15
+
16
+ // The module now imports the gesture primitives at top level. They're only used
17
+ // inside class field initializers (which never run — we don't construct the
18
+ // component), so bare stubs are enough to satisfy the imports.
19
+ vi.mock('@blotch/angular-lynx', () => ({
20
+ LYNX_ELEMENTS: [],
21
+ LynxGestureDetector: class {},
22
+ PanGesture: class {},
23
+ PinchGesture: class {},
24
+ Gesture: { Simultaneous: () => ({}) },
25
+ }));
26
+
27
+ const {
28
+ linearScale,
29
+ invertLinear,
30
+ niceNum,
31
+ niceScale,
32
+ generateTicks,
33
+ generateAlignedTicks,
34
+ isValueOutsideDomain,
35
+ gridlineOffset,
36
+ sampleSmoothLine,
37
+ clampWindow,
38
+ panWindow,
39
+ zoomWindow,
40
+ } = await import('./cartesian-chart');
41
+
42
+ describe('linearScale', () => {
43
+ it('interpolates a value across the range', () => {
44
+ const scale = linearScale([0, 10], [0, 100]);
45
+ expect(scale(0)).toBe(0);
46
+ expect(scale(5)).toBe(50);
47
+ expect(scale(10)).toBe(100);
48
+ });
49
+
50
+ it('supports a descending range (flipped y-axis)', () => {
51
+ const scale = linearScale([0, 100], [180, 0]);
52
+ expect(scale(0)).toBe(180);
53
+ expect(scale(50)).toBe(90);
54
+ expect(scale(100)).toBe(0);
55
+ });
56
+
57
+ it('collapses a zero-width domain to the range midpoint', () => {
58
+ const scale = linearScale([5, 5], [0, 200]);
59
+ expect(scale(5)).toBe(100);
60
+ expect(scale(999)).toBe(100);
61
+ });
62
+ });
63
+
64
+ describe('niceNum', () => {
65
+ it('rounds a range up to a nice number', () => {
66
+ expect(niceNum(97, false)).toBe(100);
67
+ expect(niceNum(85, false)).toBe(100);
68
+ expect(niceNum(10, false)).toBe(10);
69
+ });
70
+
71
+ it('snaps a step to the nearest nice number', () => {
72
+ expect(niceNum(25, true)).toBe(20);
73
+ expect(niceNum(20, true)).toBe(20);
74
+ expect(niceNum(2, true)).toBe(2);
75
+ });
76
+
77
+ it('rounds down small-fraction values to 1 (round=true) and 1 (round=false)', () => {
78
+ // fraction < 1.5 branch under round=true.
79
+ expect(niceNum(11, true)).toBe(10);
80
+ // fraction <= 1 branch under round=false.
81
+ expect(niceNum(9, false)).toBe(10);
82
+ expect(niceNum(1, false)).toBe(1);
83
+ });
84
+
85
+ it('rounds fractions 3–7 to 5 and larger to 10 (round=true)', () => {
86
+ // fraction=4.5 → nice 5.
87
+ expect(niceNum(45, true)).toBe(50);
88
+ // fraction=8 → nice 10.
89
+ expect(niceNum(80, true)).toBe(100);
90
+ });
91
+
92
+ it('handles round=false fraction branches 2, 5, and 10', () => {
93
+ // fraction <= 2 → nice 2 (round=false).
94
+ expect(niceNum(15, false)).toBe(20);
95
+ // fraction <= 5 → nice 5 (round=false).
96
+ expect(niceNum(45, false)).toBe(50);
97
+ // fraction > 5 → nice 10 (round=false).
98
+ expect(niceNum(60, false)).toBe(100);
99
+ });
100
+
101
+ it('returns 0 for non-positive input', () => {
102
+ expect(niceNum(0, true)).toBe(0);
103
+ expect(niceNum(-5, false)).toBe(0);
104
+ });
105
+ });
106
+
107
+ describe('niceScale', () => {
108
+ it('produces a rounded domain that contains the data', () => {
109
+ const { domain } = niceScale(3, 88, 5);
110
+ expect(domain[0]).toBeLessThanOrEqual(3);
111
+ expect(domain[1]).toBeGreaterThanOrEqual(88);
112
+ });
113
+
114
+ it('generates ticks that span the domain endpoints', () => {
115
+ const { domain, ticks, step } = niceScale(0, 100, 5);
116
+ expect(step).toBeGreaterThan(0);
117
+ expect(ticks[0]).toBe(domain[0]);
118
+ expect(ticks[ticks.length - 1]).toBe(domain[1]);
119
+ for (const tick of ticks) {
120
+ expect(tick).toBeGreaterThanOrEqual(domain[0]);
121
+ expect(tick).toBeLessThanOrEqual(domain[1]);
122
+ }
123
+ });
124
+
125
+ it('pads flat data around the single value', () => {
126
+ const { domain } = niceScale(5, 5, 5);
127
+ expect(domain[0]).toBeLessThan(5);
128
+ expect(domain[1]).toBeGreaterThan(5);
129
+ expect(Number.isFinite(domain[0])).toBe(true);
130
+ expect(Number.isFinite(domain[1])).toBe(true);
131
+ });
132
+
133
+ it('pads flat data around zero with an absolute-pad fallback', () => {
134
+ // base === 0 branch: pad defaults to 1.
135
+ const { domain } = niceScale(0, 0, 5);
136
+ expect(domain[0]).toBeLessThan(0);
137
+ expect(domain[1]).toBeGreaterThan(0);
138
+ });
139
+
140
+ it('handles non-finite inputs by falling back to a padded zero-based domain', () => {
141
+ // !Number.isFinite(lo0) branch.
142
+ const { domain } = niceScale(Number.NaN, 10, 5);
143
+ expect(Number.isFinite(domain[0])).toBe(true);
144
+ expect(Number.isFinite(domain[1])).toBe(true);
145
+ });
146
+ });
147
+
148
+ describe('generateTicks', () => {
149
+ it('evenly divides a domain into count ticks', () => {
150
+ expect(generateTicks([0, 10], 5)).toEqual([0, 2.5, 5, 7.5, 10]);
151
+ expect(generateTicks([0, 100], 3)).toEqual([0, 50, 100]);
152
+ });
153
+
154
+ it('returns a single tick when count is 1', () => {
155
+ expect(generateTicks([0, 10], 1)).toEqual([0]);
156
+ });
157
+ });
158
+
159
+ // Regression guard for the "grid stays still while panning" bug. The report:
160
+ // zoomed-in pan moves the plotted content but the gridlines/tick marks stay
161
+ // put — only the axis numbers update. Root cause: the old zoomable-tick
162
+ // generator (generateTicks) divided the CURRENT window into a fixed fraction
163
+ // per tick, so every tick always lands at the same `i/(count-1)` pixel no
164
+ // matter where the window is. generateAlignedTicks fixes this by anchoring
165
+ // each tick to a fixed multiple of a step in DATA space instead.
166
+ describe('generateAlignedTicks', () => {
167
+ it('returns a fixed count of targetCount + 2 regardless of the window', () => {
168
+ expect(generateAlignedTicks([0, 100], 5)).toHaveLength(7);
169
+ expect(generateAlignedTicks([37, 57], 5)).toHaveLength(7);
170
+ expect(generateAlignedTicks([1000, 1050], 5)).toHaveLength(7);
171
+ });
172
+
173
+ it('anchors ticks to fixed data-space multiples of a nice step — panning shifts WHICH multiples are visible, not the multiples themselves', () => {
174
+ // Two windows of the same span (20) as if panned right by 6 data units —
175
+ // a real drag never changes the window's span, only its position.
176
+ const before = generateAlignedTicks([40, 60], 5);
177
+ const after = generateAlignedTicks([46, 66], 5);
178
+ const step = 5; // niceNum(20 / 4, true)
179
+ for (const t of [...before, ...after]) {
180
+ expect(t % step).toBeCloseTo(0, 6);
181
+ }
182
+ // Ticks still inside the post-pan window must appear UNCHANGED in both
183
+ // arrays — proof the grid is anchored to fixed data values, not
184
+ // recomputed as a fraction of the (shifted) window.
185
+ const stillVisible = before.filter((t) => t >= 46 && t <= 60);
186
+ expect(stillVisible.length).toBeGreaterThan(0);
187
+ for (const t of stillVisible) {
188
+ expect(after).toContain(t);
189
+ }
190
+ });
191
+
192
+ it('projects an anchored tick to a shifted pixel after a pan (the actual visual fix)', () => {
193
+ const rangePx = 200;
194
+ const project = (value: number, window: ChartDomain) =>
195
+ linearScale(window, [0, rangePx])(value);
196
+
197
+ const before: ChartDomain = [40, 60];
198
+ const after: ChartDomain = [46, 66]; // panned right by 6 data units
199
+ const common = generateAlignedTicks(before, 5).filter((t) =>
200
+ generateAlignedTicks(after, 5).includes(t),
201
+ );
202
+ expect(common.length).toBeGreaterThan(0);
203
+ for (const t of common) {
204
+ const pxBefore = project(t, before);
205
+ const pxAfter = project(t, after);
206
+ // The old (buggy) generateTicks-based ticks always re-projected to the
207
+ // exact same pixel here; an anchored tick must NOT.
208
+ expect(pxAfter).not.toBeCloseTo(pxBefore, 3);
209
+ // Panning the window +6 data units shifts every fixed value left by
210
+ // 6/20 of the range.
211
+ expect(pxBefore - pxAfter).toBeCloseTo((6 / 20) * rangePx, 6);
212
+ }
213
+ });
214
+
215
+ it('falls back to a step of 1 for a zero-width window instead of dividing by zero', () => {
216
+ expect(() => generateAlignedTicks([5, 5], 5)).not.toThrow();
217
+ expect(generateAlignedTicks([5, 5], 5).every(Number.isFinite)).toBe(true);
218
+ });
219
+ });
220
+
221
+ // Regression guard for the "top axis number cut in half" bug. The zoomable
222
+ // tick generator emits buffer ticks just past each edge of the window; their
223
+ // labels must be hidden, but a legitimate edge label (value exactly on the
224
+ // domain bound) must NOT be — hiding it, or clipping it at the container box,
225
+ // is what sliced the top "100" in half at 1:1.
226
+ describe('isValueOutsideDomain', () => {
227
+ it('treats a value exactly on either bound as inside (edge labels stay visible)', () => {
228
+ expect(isValueOutsideDomain(0, [0, 100])).toBe(false);
229
+ expect(isValueOutsideDomain(100, [0, 100])).toBe(false);
230
+ });
231
+
232
+ it('flags buffer ticks beyond either edge as outside', () => {
233
+ // generateAlignedTicks([0,100], 5) yields e.g. -20 and would extend past
234
+ // 100 — those must hide; the in-range multiples must not.
235
+ expect(isValueOutsideDomain(-20, [0, 100])).toBe(true);
236
+ expect(isValueOutsideDomain(120, [0, 100])).toBe(true);
237
+ expect(isValueOutsideDomain(40, [0, 100])).toBe(false);
238
+ });
239
+
240
+ it('does not exclude an on-bound tick lost to float drift', () => {
241
+ // A window bound that arrived via pan/zoom arithmetic can land a hair off
242
+ // the tick value; the relative epsilon must keep it visible.
243
+ expect(isValueOutsideDomain(100, [0, 99.99999999])).toBe(false);
244
+ expect(isValueOutsideDomain(0, [0.00000001, 100])).toBe(false);
245
+ });
246
+
247
+ it('scales epsilon with the span (a genuinely-outside tick still hides on a large domain)', () => {
248
+ expect(isValueOutsideDomain(1_000_050, [0, 1_000_000])).toBe(true);
249
+ expect(isValueOutsideDomain(1_000_000, [0, 1_000_000])).toBe(false);
250
+ });
251
+ });
252
+
253
+ // Regression guard for the "y-axis 0 gridline isn't visible" bug. The plot needs
254
+ // `overflow: hidden` to clip panned marks, but a 1px gridline whose value sits on
255
+ // the plot's far edge projects to exactly `extent` — one row/column past the box —
256
+ // so the clip eats it. gridlineOffset nudges an in-window edge line back inside;
257
+ // out-of-window buffer ticks stay put so they clip away instead of piling up.
258
+ describe('gridlineOffset', () => {
259
+ it('nudges an in-window far-edge line inward so overflow:hidden cannot clip it', () => {
260
+ // y = domain min projects to `extent` (bottom of a flipped y-scale); a 1px
261
+ // line there is fully outside, so it clamps to extent - thickness.
262
+ expect(gridlineOffset(0, 200, [0, 100], 200)).toBe(199);
263
+ // The near edge (top / left = 0) is already inside and is left untouched.
264
+ expect(gridlineOffset(100, 0, [0, 100], 200)).toBe(0);
265
+ });
266
+
267
+ it('leaves an interior line exactly where it projects', () => {
268
+ expect(gridlineOffset(50, 100, [0, 100], 200)).toBe(100);
269
+ });
270
+
271
+ it('does not clamp an out-of-window buffer tick (it must clip away, not pile on the edge)', () => {
272
+ // generateAlignedTicks emits ticks just past each edge while panning. Below
273
+ // the window it projects past `extent`; above it projects negative. Both are
274
+ // returned as-is so the plot clip hides them rather than dragging them onto
275
+ // the visible edge as a spurious doubled line.
276
+ expect(gridlineOffset(-20, 260, [0, 100], 200)).toBe(260);
277
+ expect(gridlineOffset(120, -60, [0, 100], 200)).toBe(-60);
278
+ });
279
+
280
+ it('honors a custom gridline thickness', () => {
281
+ expect(gridlineOffset(0, 200, [0, 100], 200, 2)).toBe(198);
282
+ });
283
+ });
284
+
285
+ describe('sampleSmoothLine', () => {
286
+ it('returns fewer-than-three points unchanged (no curve possible)', () => {
287
+ expect(sampleSmoothLine([])).toEqual([]);
288
+ expect(sampleSmoothLine([{ x: 1, y: 2 }])).toEqual([{ x: 1, y: 2 }]);
289
+ expect(
290
+ sampleSmoothLine([
291
+ { x: 0, y: 0 },
292
+ { x: 10, y: 5 },
293
+ ]),
294
+ ).toEqual([
295
+ { x: 0, y: 0 },
296
+ { x: 10, y: 5 },
297
+ ]);
298
+ });
299
+
300
+ it('passes exactly through every original data point', () => {
301
+ const points = [
302
+ { x: 0, y: 0 },
303
+ { x: 1, y: 8 },
304
+ { x: 2, y: 3 },
305
+ { x: 3, y: 10 },
306
+ ];
307
+ const curve = sampleSmoothLine(points, 8);
308
+ for (const p of points) {
309
+ const hit = curve.find((c) => Math.abs(c.x - p.x) < 1e-9);
310
+ expect(hit).toBeDefined();
311
+ expect(hit?.y).toBeCloseTo(p.y);
312
+ }
313
+ });
314
+
315
+ it('keeps a straight line straight (collinear points do not bow)', () => {
316
+ // y = 2x sampled at the curve's x values must stay on the line.
317
+ const curve = sampleSmoothLine(
318
+ [
319
+ { x: 0, y: 0 },
320
+ { x: 5, y: 10 },
321
+ { x: 10, y: 20 },
322
+ ],
323
+ 6,
324
+ );
325
+ for (const c of curve) {
326
+ expect(c.y).toBeCloseTo(c.x * 2);
327
+ }
328
+ });
329
+
330
+ it('never overshoots the data range (monotone: no bulge past a peak)', () => {
331
+ const points = [
332
+ { x: 0, y: 0 },
333
+ { x: 1, y: 10 },
334
+ { x: 2, y: 10 },
335
+ { x: 3, y: 0 },
336
+ ];
337
+ const curve = sampleSmoothLine(points, 16);
338
+ const ys = curve.map((c) => c.y);
339
+ // The plateau at 10 is the max and 0 the min — a monotone cubic must stay
340
+ // inside them (Catmull-Rom would overshoot above 10 approaching the plateau).
341
+ expect(Math.max(...ys)).toBeLessThanOrEqual(10 + 1e-9);
342
+ expect(Math.min(...ys)).toBeGreaterThanOrEqual(0 - 1e-9);
343
+ });
344
+
345
+ it('emits a denser, x-ascending point set', () => {
346
+ const curve = sampleSmoothLine(
347
+ [
348
+ { x: 0, y: 0 },
349
+ { x: 1, y: 5 },
350
+ { x: 2, y: 2 },
351
+ ],
352
+ 10,
353
+ );
354
+ // 2 segments × 10 samples + 1 closing point.
355
+ expect(curve).toHaveLength(21);
356
+ for (let i = 1; i < curve.length; i++) {
357
+ expect(curve[i].x).toBeGreaterThanOrEqual(curve[i - 1].x);
358
+ }
359
+ });
360
+
361
+ it('applies the Fritsch-Carlson tangent constraint on steep slopes (monotone clamp)', () => {
362
+ // Data with a steep rise then steep drop triggers the `magnitude > 9`
363
+ // tangent-clamp branch inside sampleSmoothLine.
364
+ const points = [
365
+ { x: 0, y: 0 },
366
+ { x: 1, y: 0.01 },
367
+ { x: 2, y: 100 },
368
+ { x: 3, y: 0.01 },
369
+ { x: 4, y: 0 },
370
+ ];
371
+ const curve = sampleSmoothLine(points, 12);
372
+ expect(curve.length).toBeGreaterThan(0);
373
+ // No sample exceeds the actual data range — the clamp did its job.
374
+ for (const c of curve) {
375
+ expect(c.y).toBeLessThanOrEqual(100 + 1e-9);
376
+ expect(c.y).toBeGreaterThanOrEqual(0 - 1e-9);
377
+ }
378
+ });
379
+
380
+ it('treats a duplicate-x pair (zero horizontal gap) as a flat secant', () => {
381
+ // Covers the `dx === 0 ? 0 : ...` branch. Two consecutive points share an x.
382
+ const curve = sampleSmoothLine(
383
+ [
384
+ { x: 0, y: 0 },
385
+ { x: 5, y: 3 },
386
+ { x: 5, y: 8 },
387
+ { x: 10, y: 10 },
388
+ ],
389
+ 6,
390
+ );
391
+ expect(curve.length).toBeGreaterThan(0);
392
+ for (const c of curve) expect(Number.isFinite(c.y)).toBe(true);
393
+ });
394
+ });
395
+
396
+ describe('invertLinear', () => {
397
+ it('is the inverse of linearScale', () => {
398
+ const domain = [0, 10] as const;
399
+ const range = [0, 100] as const;
400
+ const scale = linearScale(domain, range);
401
+ for (const v of [0, 2.5, 5, 7.5, 10]) {
402
+ expect(invertLinear(domain, range, scale(v))).toBeCloseTo(v);
403
+ }
404
+ });
405
+
406
+ it('inverts a descending (flipped y) range', () => {
407
+ // linearScale([0,100],[180,0])(50) === 90 → invert back to 50.
408
+ expect(invertLinear([0, 100], [180, 0], 90)).toBeCloseTo(50);
409
+ expect(invertLinear([0, 100], [180, 0], 180)).toBeCloseTo(0);
410
+ expect(invertLinear([0, 100], [180, 0], 0)).toBeCloseTo(100);
411
+ });
412
+
413
+ it('collapses a zero-width range to the domain lower bound', () => {
414
+ expect(invertLinear([2, 8], [50, 50], 999)).toBe(2);
415
+ });
416
+ });
417
+
418
+ describe('clampWindow', () => {
419
+ it('leaves a window already inside the base untouched', () => {
420
+ expect(clampWindow([0, 10], [2, 6])).toEqual([2, 6]);
421
+ });
422
+
423
+ it('slides a window back inside the base, preserving its span', () => {
424
+ expect(clampWindow([0, 10], [8, 14])).toEqual([4, 10]);
425
+ expect(clampWindow([0, 10], [-3, 2])).toEqual([0, 5]);
426
+ });
427
+
428
+ it('caps a window wider than the base to the whole base', () => {
429
+ expect(clampWindow([0, 10], [-5, 20])).toEqual([0, 10]);
430
+ });
431
+ });
432
+
433
+ describe('panWindow', () => {
434
+ it('shifts the window by a data delta', () => {
435
+ expect(panWindow([0, 10], [2, 6], 1)).toEqual([3, 7]);
436
+ });
437
+
438
+ it('stops (does not resize) at either edge', () => {
439
+ // Preserves the span of 4, clamped against the right and left edges.
440
+ expect(panWindow([0, 10], [2, 6], 10)).toEqual([6, 10]);
441
+ expect(panWindow([0, 10], [2, 6], -10)).toEqual([0, 4]);
442
+ });
443
+ });
444
+
445
+ // Regression guard for the pan interaction. The bug: on iOS AND Android the
446
+ // native pan handler gates onStart/onEnd behind onBegin having fired, so a pan
447
+ // that registers only onStart/onUpdate/onEnd never snapshots the start window and
448
+ // #onPan re-reads the CURRENT window every frame — making the pan compound instead
449
+ // of tracking the finger 1:1. The fix registers onBegin to anchor the window once
450
+ // at gesture start. These tests lock the geometry that anchoring must produce, and
451
+ // pin down the compounding failure mode so it can't silently return.
452
+ describe('pan tracks the finger 1:1 (window anchored at gesture start)', () => {
453
+ /**
454
+ * Projects a data x into plot pixels through a given visible window, matching how
455
+ * xScale maps the effective domain across the plot range.
456
+ */
457
+ const projectX = (
458
+ dataX: number,
459
+ window: readonly [number, number],
460
+ rangePx: number,
461
+ ) => ((dataX - window[0]) / (window[1] - window[0])) * rangePx;
462
+
463
+ it('moves projected content exactly translationX pixels for any cumulative delta', () => {
464
+ const base: ChartDomain = [0, 100];
465
+ const start: ChartDomain = [40, 60]; // zoomed 5× (span 20 of a 100 domain)
466
+ const rangePx = 200;
467
+ const dataPerPx = (start[1] - start[0]) / rangePx; // 0.1 data units / px
468
+
469
+ const pointX = 45; // any data point inside the window
470
+ const startPx = projectX(pointX, start, rangePx);
471
+
472
+ // A steady drag is reported as a CUMULATIVE translation (0 → growing). Each
473
+ // frame maps it against the FIXED start window (what onBegin snapshots).
474
+ for (const translationX of [5, 12, 40, -18, -35]) {
475
+ const window = panWindow(base, start, -translationX * dataPerPx);
476
+ const nowPx = projectX(pointX, window, rangePx);
477
+ // 1:1: the point moved on screen by exactly the finger's travel.
478
+ expect(nowPx - startPx).toBeCloseTo(translationX, 6);
479
+ }
480
+ });
481
+
482
+ it('compounds (the bug) when the window is re-read every frame instead of anchored', () => {
483
+ const base: ChartDomain = [0, 100];
484
+ const rangePx = 200;
485
+ const dataPerPx = 20 / rangePx; // 0.1
486
+
487
+ // Correct path: fixed start window, cumulative translation. A 10 px/frame drag
488
+ // over 3 frames (cumulative 10, 20, 30) shifts the window left by 30·0.1 = 3.
489
+ const start: ChartDomain = [40, 60];
490
+ const anchored = panWindow(base, start, -30 * dataPerPx);
491
+ expect(anchored[0]).toBeCloseTo(37, 6);
492
+
493
+ // Buggy path: use the CURRENT window as the base each frame (because the start
494
+ // was never snapshotted). The same drag drives the window much further left —
495
+ // the shift accumulates super-linearly.
496
+ let window: ChartDomain = [40, 60];
497
+ for (const cumulative of [10, 20, 30]) {
498
+ window = panWindow(base, window, -cumulative * dataPerPx);
499
+ }
500
+ expect(window[0]).toBeLessThan(37);
501
+ // Concretely: 40 → 39 → 37 → 34, i.e. 2× the correct shift after 3 frames.
502
+ expect(window[0]).toBeCloseTo(34, 6);
503
+ });
504
+ });
505
+
506
+ describe('zoomWindow', () => {
507
+ it('zooms in around the focal value, keeping it fixed', () => {
508
+ // 2× zoom on the center halves the span and stays centered.
509
+ expect(zoomWindow([0, 10], [0, 10], 2, 5, 8)).toEqual([2.5, 7.5]);
510
+ // Focal at the left edge keeps that edge pinned.
511
+ expect(zoomWindow([0, 10], [0, 10], 2, 0, 8)).toEqual([0, 5]);
512
+ });
513
+
514
+ it('clamps zoom-in to maxZoom (min span = baseSpan / maxZoom)', () => {
515
+ const [lo, hi] = zoomWindow([0, 10], [0, 10], 100, 5, 8);
516
+ expect(hi - lo).toBeCloseTo(10 / 8); // 1.25, not 0.1
517
+ });
518
+
519
+ it('never zooms out past the full base domain', () => {
520
+ expect(zoomWindow([0, 10], [2.5, 7.5], 0.1, 5, 8)).toEqual([0, 10]);
521
+ });
522
+
523
+ it('honors minZoom as an upper bound on the visible span', () => {
524
+ // minZoom 2 → the window can never be wider than baseSpan / 2 = 5.
525
+ const [lo, hi] = zoomWindow([0, 10], [3, 4], 0.01, 3.5, 8, 2);
526
+ expect(hi - lo).toBeCloseTo(5);
527
+ });
528
+
529
+ it('falls back to factor=1 when the caller passes 0 (defensive divide-by-zero guard)', () => {
530
+ // Covers the `factor || 1` branch.
531
+ const window = zoomWindow([0, 10], [0, 10], 0, 5, 8);
532
+ // With factor=0 → treated as 1, the span stays.
533
+ expect(window[1] - window[0]).toBeCloseTo(10);
534
+ });
535
+
536
+ it('centers a zero-span current window at the focal (fallback fraction 0.5)', () => {
537
+ // Covers the `span === 0 ? 0.5 : ...` branch.
538
+ const window = zoomWindow([0, 10], [5, 5], 1, 5, 8);
539
+ expect(window[0]).toBeLessThanOrEqual(5);
540
+ expect(window[1]).toBeGreaterThanOrEqual(5);
541
+ });
542
+
543
+ it('falls back to baseSpan when maxZoom or minZoom are non-positive', () => {
544
+ // Covers `maxZoom > 0` and `minZoom > 0` false branches.
545
+ expect(() => zoomWindow([0, 10], [0, 10], 1, 5, 0, 1)).not.toThrow();
546
+ expect(() => zoomWindow([0, 10], [0, 10], 1, 5, 8, 0)).not.toThrow();
547
+ expect(() => zoomWindow([0, 10], [0, 10], 1, 5, -1, -1)).not.toThrow();
548
+ });
549
+ });