insomni-plot 0.1.0-alpha.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 (242) hide show
  1. package/LICENSE.md +674 -0
  2. package/README.md +81 -0
  3. package/dist/core.d.mts +340 -0
  4. package/dist/core.mjs +1047 -0
  5. package/dist/index.d.mts +3426 -0
  6. package/dist/index.mjs +12762 -0
  7. package/dist/interactions-DEFL_F4E.mjs +5395 -0
  8. package/dist/range-presets-CzECsu3V.d.mts +1523 -0
  9. package/package.json +34 -0
  10. package/src/annotations.d.ts +121 -0
  11. package/src/annotations.ts +438 -0
  12. package/src/axis.d.ts +184 -0
  13. package/src/axis.test.ts +131 -0
  14. package/src/axis.ts +765 -0
  15. package/src/colorbar.d.ts +69 -0
  16. package/src/colorbar.ts +294 -0
  17. package/src/colors.d.ts +57 -0
  18. package/src/colors.test.ts +28 -0
  19. package/src/colors.ts +486 -0
  20. package/src/core.ts +299 -0
  21. package/src/format.d.ts +54 -0
  22. package/src/format.ts +138 -0
  23. package/src/grammar/accessibility.d.ts +147 -0
  24. package/src/grammar/accessibility.test.ts +199 -0
  25. package/src/grammar/accessibility.ts +443 -0
  26. package/src/grammar/aes.d.ts +35 -0
  27. package/src/grammar/aes.test.ts +75 -0
  28. package/src/grammar/aes.ts +120 -0
  29. package/src/grammar/annotations.d.ts +86 -0
  30. package/src/grammar/annotations.test.ts +68 -0
  31. package/src/grammar/annotations.ts +336 -0
  32. package/src/grammar/attach-brush.d.ts +44 -0
  33. package/src/grammar/attach-brush.test.ts +214 -0
  34. package/src/grammar/attach-brush.ts +111 -0
  35. package/src/grammar/attach-presets.d.ts +33 -0
  36. package/src/grammar/attach-presets.test.ts +106 -0
  37. package/src/grammar/attach-presets.ts +215 -0
  38. package/src/grammar/chart.d.ts +952 -0
  39. package/src/grammar/chart.test.ts +118 -0
  40. package/src/grammar/chart.ts +1172 -0
  41. package/src/grammar/color-utils.d.ts +29 -0
  42. package/src/grammar/color-utils.test.ts +53 -0
  43. package/src/grammar/color-utils.ts +66 -0
  44. package/src/grammar/constants.d.ts +45 -0
  45. package/src/grammar/constants.ts +61 -0
  46. package/src/grammar/coord.d.ts +183 -0
  47. package/src/grammar/coord.test.ts +355 -0
  48. package/src/grammar/coord.ts +619 -0
  49. package/src/grammar/data/pivot.d.ts +57 -0
  50. package/src/grammar/data/pivot.ts +107 -0
  51. package/src/grammar/emphasis-driver.d.ts +69 -0
  52. package/src/grammar/emphasis-driver.test.ts +199 -0
  53. package/src/grammar/emphasis-driver.ts +205 -0
  54. package/src/grammar/equality.d.ts +3 -0
  55. package/src/grammar/equality.ts +40 -0
  56. package/src/grammar/facet.d.ts +63 -0
  57. package/src/grammar/facet.test.ts +60 -0
  58. package/src/grammar/facet.ts +175 -0
  59. package/src/grammar/geoms/_categorical.d.ts +94 -0
  60. package/src/grammar/geoms/_categorical.ts +0 -0
  61. package/src/grammar/geoms/_distribution.d.ts +52 -0
  62. package/src/grammar/geoms/_distribution.ts +125 -0
  63. package/src/grammar/geoms/_mark.d.ts +69 -0
  64. package/src/grammar/geoms/_mark.ts +136 -0
  65. package/src/grammar/geoms/_shape.d.ts +41 -0
  66. package/src/grammar/geoms/_shape.ts +74 -0
  67. package/src/grammar/geoms/aggregate.d.ts +95 -0
  68. package/src/grammar/geoms/aggregate.test.ts +554 -0
  69. package/src/grammar/geoms/aggregate.ts +840 -0
  70. package/src/grammar/geoms/area.d.ts +32 -0
  71. package/src/grammar/geoms/area.test.ts +165 -0
  72. package/src/grammar/geoms/area.ts +578 -0
  73. package/src/grammar/geoms/band.d.ts +27 -0
  74. package/src/grammar/geoms/band.test.ts +57 -0
  75. package/src/grammar/geoms/band.ts +126 -0
  76. package/src/grammar/geoms/bar.d.ts +56 -0
  77. package/src/grammar/geoms/bar.test.ts +367 -0
  78. package/src/grammar/geoms/bar.ts +1054 -0
  79. package/src/grammar/geoms/boxplot.d.ts +129 -0
  80. package/src/grammar/geoms/boxplot.test.ts +299 -0
  81. package/src/grammar/geoms/boxplot.ts +834 -0
  82. package/src/grammar/geoms/connected-scatter.d.ts +27 -0
  83. package/src/grammar/geoms/connected-scatter.test.ts +157 -0
  84. package/src/grammar/geoms/connected-scatter.ts +63 -0
  85. package/src/grammar/geoms/emphasis.d.ts +76 -0
  86. package/src/grammar/geoms/emphasis.test.ts +135 -0
  87. package/src/grammar/geoms/emphasis.ts +162 -0
  88. package/src/grammar/geoms/histogram.d.ts +75 -0
  89. package/src/grammar/geoms/histogram.test.ts +262 -0
  90. package/src/grammar/geoms/histogram.ts +740 -0
  91. package/src/grammar/geoms/index.d.ts +20 -0
  92. package/src/grammar/geoms/index.ts +77 -0
  93. package/src/grammar/geoms/interval.d.ts +31 -0
  94. package/src/grammar/geoms/interval.test.ts +154 -0
  95. package/src/grammar/geoms/interval.ts +342 -0
  96. package/src/grammar/geoms/line.d.ts +38 -0
  97. package/src/grammar/geoms/line.test.ts +247 -0
  98. package/src/grammar/geoms/line.ts +659 -0
  99. package/src/grammar/geoms/point.d.ts +57 -0
  100. package/src/grammar/geoms/point.test.ts +163 -0
  101. package/src/grammar/geoms/point.ts +545 -0
  102. package/src/grammar/geoms/polar.test.ts +216 -0
  103. package/src/grammar/geoms/ribbon.d.ts +21 -0
  104. package/src/grammar/geoms/ribbon.test.ts +170 -0
  105. package/src/grammar/geoms/ribbon.ts +87 -0
  106. package/src/grammar/geoms/ridgeline.d.ts +89 -0
  107. package/src/grammar/geoms/ridgeline.test.ts +247 -0
  108. package/src/grammar/geoms/ridgeline.ts +1164 -0
  109. package/src/grammar/geoms/rolling.d.ts +43 -0
  110. package/src/grammar/geoms/rolling.test.ts +217 -0
  111. package/src/grammar/geoms/rolling.ts +387 -0
  112. package/src/grammar/geoms/rug.d.ts +28 -0
  113. package/src/grammar/geoms/rug.test.ts +126 -0
  114. package/src/grammar/geoms/rug.ts +214 -0
  115. package/src/grammar/geoms/rule.d.ts +23 -0
  116. package/src/grammar/geoms/rule.test.ts +69 -0
  117. package/src/grammar/geoms/rule.ts +212 -0
  118. package/src/grammar/geoms/smooth.d.ts +54 -0
  119. package/src/grammar/geoms/smooth.test.ts +78 -0
  120. package/src/grammar/geoms/smooth.ts +337 -0
  121. package/src/grammar/geoms/text.d.ts +29 -0
  122. package/src/grammar/geoms/text.test.ts +64 -0
  123. package/src/grammar/geoms/text.ts +234 -0
  124. package/src/grammar/geoms/tile.d.ts +61 -0
  125. package/src/grammar/geoms/tile.test.ts +157 -0
  126. package/src/grammar/geoms/tile.ts +621 -0
  127. package/src/grammar/geoms/types.d.ts +319 -0
  128. package/src/grammar/geoms/types.ts +362 -0
  129. package/src/grammar/geoms/violin.d.ts +85 -0
  130. package/src/grammar/geoms/violin.test.ts +187 -0
  131. package/src/grammar/geoms/violin.ts +672 -0
  132. package/src/grammar/index.d.ts +22 -0
  133. package/src/grammar/index.ts +269 -0
  134. package/src/grammar/interactions/_disposable.d.ts +5 -0
  135. package/src/grammar/interactions/_disposable.ts +23 -0
  136. package/src/grammar/interactions/_z.d.ts +4 -0
  137. package/src/grammar/interactions/_z.ts +16 -0
  138. package/src/grammar/interactions/brush-selection.test.ts +262 -0
  139. package/src/grammar/interactions/brush.d.ts +63 -0
  140. package/src/grammar/interactions/brush.test.ts +483 -0
  141. package/src/grammar/interactions/brush.ts +452 -0
  142. package/src/grammar/interactions/crosshair.d.ts +19 -0
  143. package/src/grammar/interactions/crosshair.test.ts +127 -0
  144. package/src/grammar/interactions/crosshair.ts +76 -0
  145. package/src/grammar/interactions/hit-layer.d.ts +64 -0
  146. package/src/grammar/interactions/hit-layer.ts +246 -0
  147. package/src/grammar/interactions/legend.d.ts +19 -0
  148. package/src/grammar/interactions/legend.ts +101 -0
  149. package/src/grammar/interactions/menu.d.ts +93 -0
  150. package/src/grammar/interactions/menu.test.ts +373 -0
  151. package/src/grammar/interactions/menu.ts +342 -0
  152. package/src/grammar/interactions/selection.d.ts +25 -0
  153. package/src/grammar/interactions/selection.test.ts +289 -0
  154. package/src/grammar/interactions/selection.ts +142 -0
  155. package/src/grammar/interactions/series-readout.d.ts +91 -0
  156. package/src/grammar/interactions/series-readout.test.ts +668 -0
  157. package/src/grammar/interactions/series-readout.ts +422 -0
  158. package/src/grammar/interactions/series-snap.d.ts +70 -0
  159. package/src/grammar/interactions/series-snap.test.ts +214 -0
  160. package/src/grammar/interactions/series-snap.ts +218 -0
  161. package/src/grammar/interactions/tooltip-axis.test.ts +176 -0
  162. package/src/grammar/interactions/tooltip-touch.browser.test.ts +49 -0
  163. package/src/grammar/interactions/tooltip-touch.test.ts +161 -0
  164. package/src/grammar/interactions/tooltip.d.ts +140 -0
  165. package/src/grammar/interactions/tooltip.test.ts +406 -0
  166. package/src/grammar/interactions/tooltip.ts +622 -0
  167. package/src/grammar/interactions/transitions.d.ts +34 -0
  168. package/src/grammar/interactions/transitions.test.ts +172 -0
  169. package/src/grammar/interactions/transitions.ts +160 -0
  170. package/src/grammar/layout.d.ts +68 -0
  171. package/src/grammar/layout.ts +186 -0
  172. package/src/grammar/legend-merge.test.ts +332 -0
  173. package/src/grammar/mount.d.ts +78 -0
  174. package/src/grammar/mount.test.ts +479 -0
  175. package/src/grammar/mount.ts +2112 -0
  176. package/src/grammar/palettes.d.ts +54 -0
  177. package/src/grammar/palettes.test.ts +80 -0
  178. package/src/grammar/palettes.ts +167 -0
  179. package/src/grammar/pan-zoom.test.ts +398 -0
  180. package/src/grammar/phylo.d.ts +65 -0
  181. package/src/grammar/phylo.test.ts +59 -0
  182. package/src/grammar/phylo.ts +112 -0
  183. package/src/grammar/pipeline.auto-ticks.test.ts +40 -0
  184. package/src/grammar/pipeline.d.ts +158 -0
  185. package/src/grammar/pipeline.test.ts +463 -0
  186. package/src/grammar/pipeline.ts +1233 -0
  187. package/src/grammar/profiling.d.ts +8 -0
  188. package/src/grammar/profiling.ts +24 -0
  189. package/src/grammar/scales.d.ts +188 -0
  190. package/src/grammar/scales.test.ts +181 -0
  191. package/src/grammar/scales.ts +800 -0
  192. package/src/grammar/svg.d.ts +3 -0
  193. package/src/grammar/svg.ts +39 -0
  194. package/src/grammar/theme.d.ts +261 -0
  195. package/src/grammar/theme.test.ts +105 -0
  196. package/src/grammar/theme.ts +490 -0
  197. package/src/heatmap/cpu.ts +109 -0
  198. package/src/heatmap/gpu.ts +565 -0
  199. package/src/heatmap/types.ts +177 -0
  200. package/src/heatmap.browser.test.ts +308 -0
  201. package/src/heatmap.test.ts +320 -0
  202. package/src/heatmap.ts +123 -0
  203. package/src/index.d.ts +1 -0
  204. package/src/index.ts +8 -0
  205. package/src/interactions.d.ts +48 -0
  206. package/src/interactions.test.ts +226 -0
  207. package/src/interactions.ts +394 -0
  208. package/src/layout/box.d.ts +48 -0
  209. package/src/layout/box.test.ts +107 -0
  210. package/src/layout/box.ts +143 -0
  211. package/src/legend.d.ts +115 -0
  212. package/src/legend.ts +422 -0
  213. package/src/marks/curve.d.ts +43 -0
  214. package/src/marks/curve.ts +244 -0
  215. package/src/marks/stack.d.ts +53 -0
  216. package/src/marks/stack.ts +184 -0
  217. package/src/marks.d.ts +273 -0
  218. package/src/marks.test.ts +541 -0
  219. package/src/marks.ts +1292 -0
  220. package/src/navigator.test.ts +174 -0
  221. package/src/navigator.ts +393 -0
  222. package/src/range-presets.d.ts +113 -0
  223. package/src/range-presets.test.ts +345 -0
  224. package/src/range-presets.ts +349 -0
  225. package/src/scales.d.ts +98 -0
  226. package/src/scales.test.ts +103 -0
  227. package/src/scales.ts +695 -0
  228. package/src/stats/index.d.ts +200 -0
  229. package/src/stats/index.test.ts +349 -0
  230. package/src/stats/index.ts +740 -0
  231. package/src/stats/regression.d.ts +38 -0
  232. package/src/stats/regression.test.ts +56 -0
  233. package/src/stats/regression.ts +396 -0
  234. package/src/stats/rolling-window.d.ts +55 -0
  235. package/src/stats/rolling-window.test.ts +237 -0
  236. package/src/stats/rolling-window.ts +256 -0
  237. package/src/test-setup.ts +19 -0
  238. package/src/viewport/axis-state.d.ts +72 -0
  239. package/src/viewport/axis-state.ts +476 -0
  240. package/src/viewport.d.ts +170 -0
  241. package/src/viewport.test.ts +363 -0
  242. package/src/viewport.ts +510 -0
@@ -0,0 +1,355 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, test } from "vite-plus/test";
3
+ import { createFrame } from "insomni";
4
+ import {
5
+ coordCartesian,
6
+ coordPolar,
7
+ coordRadial,
8
+ type CoordViewportHandle,
9
+ type Coord,
10
+ } from "./coord.ts";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const F = createFrame({ x: 0, y: 0, width: 200, height: 200 });
17
+
18
+ /** Minimal viewport stub that captures pan/zoom calls for assertions. */
19
+ function makeViewportStub() {
20
+ const panCalls: { dx: number; dy: number }[] = [];
21
+ const zoomCalls: { x: number; y: number; factor: number | { x?: number; y?: number } }[] = [];
22
+ const handle: CoordViewportHandle = {
23
+ panBy(dx, dy) {
24
+ panCalls.push({ dx, dy });
25
+ },
26
+ zoomAt(x, y, factor) {
27
+ zoomCalls.push({ x, y, factor });
28
+ },
29
+ };
30
+ return { handle, panCalls, zoomCalls };
31
+ }
32
+
33
+ // Polar coords expose their internal angular state via `__polar__` for testing.
34
+ function polarState(c: Coord) {
35
+ // Cast: every coord returned by `coordPolar` carries the __polar__ readout.
36
+ return (
37
+ c as Coord & {
38
+ __polar__: {
39
+ startAngle: () => number;
40
+ endAngle: () => number;
41
+ innerRadius: () => number;
42
+ outerRadius: () => number;
43
+ angleChannel: "x" | "y";
44
+ direction: 1 | -1;
45
+ };
46
+ }
47
+ ).__polar__;
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // coordPolar — project / unproject
52
+ // ---------------------------------------------------------------------------
53
+
54
+ describe("coordPolar project ↔ unproject", () => {
55
+ test("default polar (angleChannel y, full circle) round-trips", () => {
56
+ // angleChannel default = 'y'. y=0 (bottom) → θ=startAngle=-π/2; y=plotH
57
+ // (top) → θ ≈ startAngle - 2π (full revolution). x is the radial channel:
58
+ // x=0 → r=0; x=plotW → r=outerR.
59
+ const polar = coordPolar();
60
+ polar.bindFrame(F);
61
+ // Pick a few mid-arc points; avoid r=0 (the centre is degenerate under unproject).
62
+ const samples = [
63
+ { x: 50, y: 50 },
64
+ { x: 100, y: 100 },
65
+ { x: 80, y: 20 },
66
+ { x: 150, y: 175 },
67
+ ];
68
+ for (const p of samples) {
69
+ const projected = polar.project(p);
70
+ const back = polar.unproject(projected);
71
+ expect(back).not.toBeNull();
72
+ expect(back!.x).toBeCloseTo(p.x, 4);
73
+ expect(back!.y).toBeCloseTo(p.y, 4);
74
+ }
75
+ });
76
+
77
+ test("unproject returns null outside the outerRadius", () => {
78
+ const polar = coordPolar({ outerRadius: 50 });
79
+ polar.bindFrame(F);
80
+ // (cx, cy) = (100, 100); pick a point at radius 80 > 50.
81
+ expect(polar.unproject({ x: 180, y: 100 })).toBeNull();
82
+ });
83
+
84
+ test("unproject returns null inside the innerRadius", () => {
85
+ const polar = coordPolar({ innerRadius: 40 });
86
+ polar.bindFrame(F);
87
+ // r = 10 < 40.
88
+ expect(polar.unproject({ x: 110, y: 100 })).toBeNull();
89
+ });
90
+
91
+ test("explicit outerRadius overrides the frame-min default", () => {
92
+ const polar = coordPolar({ outerRadius: 40 });
93
+ polar.bindFrame(F);
94
+ // y=0 → θ=startAngle=-π/2; x=plotW → r=outerR=40.
95
+ // (cx, cy) = (100, 100); θ=-π/2 → (cx, cy - r) = (100, 60).
96
+ const p = polar.project({ x: 200, y: 0 });
97
+ expect(p.x).toBeCloseTo(100, 4);
98
+ expect(p.y).toBeCloseTo(60, 4);
99
+ });
100
+ });
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // coordPolar — segment tessellation
104
+ // ---------------------------------------------------------------------------
105
+
106
+ describe("coordPolar segment", () => {
107
+ test("radial line (shared θ) emits exactly two endpoints", () => {
108
+ const polar = coordPolar();
109
+ polar.bindFrame(F);
110
+ // Same y → same θ; vary x (the radial channel).
111
+ const points = polar.segment({ x: 20, y: 100 }, { x: 180, y: 100 });
112
+ expect(points).toHaveLength(2);
113
+ });
114
+
115
+ test("arc (shared r) tessellates with ~1° steps and angular monotonicity", () => {
116
+ const polar = coordPolar();
117
+ polar.bindFrame(F);
118
+ // Same x → same r (≠ 0); arc from y=20 to y=180.
119
+ const points = polar.segment({ x: 100, y: 20 }, { x: 100, y: 180 });
120
+ expect(points.length).toBeGreaterThan(20); // ~160° of arc at 1° step.
121
+ // Re-derive (θ, r) for each projected point: dx = x - cx, dy = y - cy.
122
+ // The arc should keep r approximately constant.
123
+ const radii = points.map((p) => Math.hypot(p.x - 100, p.y - 100));
124
+ const r0 = radii[0]!;
125
+ for (const r of radii) {
126
+ expect(r).toBeCloseTo(r0, 3);
127
+ }
128
+ // Angular monotonicity: consecutive angles strictly increase (or strictly
129
+ // decrease) — no backtracking.
130
+ const angles = points.map((p) => Math.atan2(p.y - 100, p.x - 100));
131
+ // Unwrap once across the ±π boundary.
132
+ const unwrapped = [angles[0]!];
133
+ for (let i = 1; i < angles.length; i++) {
134
+ let a = angles[i]!;
135
+ const prev = unwrapped[i - 1]!;
136
+ while (a - prev > Math.PI) a -= 2 * Math.PI;
137
+ while (a - prev < -Math.PI) a += 2 * Math.PI;
138
+ unwrapped.push(a);
139
+ }
140
+ const dir = Math.sign(unwrapped[1]! - unwrapped[0]!);
141
+ expect(dir).not.toBe(0);
142
+ for (let i = 1; i < unwrapped.length; i++) {
143
+ expect(Math.sign(unwrapped[i]! - unwrapped[i - 1]!)).toBe(dir);
144
+ }
145
+ });
146
+ });
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // coordPolar — renderAxes
150
+ // ---------------------------------------------------------------------------
151
+
152
+ describe("coordPolar renderAxes", () => {
153
+ test("draws one line per angular tick (spokes)", () => {
154
+ const polar = coordPolar();
155
+ polar.bindFrame(F);
156
+ const lines: unknown[] = [];
157
+ const layer = makeFakeLayer(lines);
158
+ const angleTicks = [0, 25, 50, 75, 100];
159
+ const angleScale = makeFakePositionScale(angleTicks, [0, 200]);
160
+ const radiusScale = makeFakePositionScale([0, 50, 100], [200, 0]);
161
+ polar.renderAxes({
162
+ axisLayer: layer,
163
+ // Only `scales.x.axisScale` / `scales.x.fn` / `scales.y.*` are read by polar.
164
+ scales: { x: radiusScale, y: angleScale } as never,
165
+ plotFrame: F,
166
+ hasX: true,
167
+ hasY: true,
168
+ xAxisOptions: {},
169
+ yAxisOptions: {},
170
+ atlas: undefined,
171
+ });
172
+ // Default angleChannel is 'y'. There should be exactly `angleTicks.length`
173
+ // spokes — each one a single line from inner radius to outer radius.
174
+ // Concentric circles are tessellated into many lines, but a spoke is a
175
+ // single line. Counting lines via line-length: a circle's lines all share
176
+ // a (cx,cy)-centred radius, while spokes pass through the centre.
177
+ const spokeCount = countSpokes(lines, F.width / 2, F.height / 2);
178
+ expect(spokeCount).toBe(angleTicks.length);
179
+ });
180
+ });
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // coordCartesian — handlePan / handleZoom regression
184
+ // ---------------------------------------------------------------------------
185
+
186
+ describe("coordCartesian pan/zoom forwarding", () => {
187
+ test("handlePan calls viewport.panBy with the same delta", () => {
188
+ const cart = coordCartesian();
189
+ const { handle, panCalls } = makeViewportStub();
190
+ cart.handlePan({ dx: 7, dy: -3, plotFrame: F, viewport: handle });
191
+ expect(panCalls).toEqual([{ dx: 7, dy: -3 }]);
192
+ });
193
+
194
+ test("handleZoom calls viewport.zoomAt with the same anchor + factor", () => {
195
+ const cart = coordCartesian();
196
+ const { handle, zoomCalls } = makeViewportStub();
197
+ cart.handleZoom({ factor: 1.25, cx: 80, cy: 90, plotFrame: F, viewport: handle });
198
+ expect(zoomCalls).toEqual([{ x: 80, y: 90, factor: 1.25 }]);
199
+ });
200
+
201
+ test("bindFrame is a no-op (does not throw, leaves project identity)", () => {
202
+ const cart = coordCartesian();
203
+ cart.bindFrame(F);
204
+ expect(cart.project({ x: 12, y: 34 })).toEqual({ x: 12, y: 34 });
205
+ expect(cart.unproject({ x: 12, y: 34 })).toEqual({ x: 12, y: 34 });
206
+ });
207
+ });
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // coordPolar — handlePan rotation + handleZoom radial scaling
211
+ // ---------------------------------------------------------------------------
212
+
213
+ describe("coordPolar handlePan", () => {
214
+ test("tangential delta (dx) rotates startAngle by (dx / width) * arc", () => {
215
+ const polar = coordPolar();
216
+ polar.bindFrame(F);
217
+ const stateBefore = polarState(polar).startAngle();
218
+ const { handle } = makeViewportStub();
219
+ polar.handlePan({ dx: 50, dy: 0, plotFrame: F, viewport: handle });
220
+ const stateAfter = polarState(polar).startAngle();
221
+ // arc span = 2π by default; rotation = (50 / 200) * 2π = π/2 in default
222
+ // direction = 1.
223
+ expect(stateAfter - stateBefore).toBeCloseTo(Math.PI / 2, 4);
224
+ });
225
+
226
+ test("radial delta (dy) translates the radius scale via viewport.panBy(0, dy)", () => {
227
+ const polar = coordPolar();
228
+ polar.bindFrame(F);
229
+ const { handle, panCalls } = makeViewportStub();
230
+ polar.handlePan({ dx: 0, dy: -12, plotFrame: F, viewport: handle });
231
+ // Default angleChannel='y', so radius is the x channel. The current impl
232
+ // translates the *radius* domain — radius corresponds to viewport's x
233
+ // channel when angleChannel='y'.
234
+ expect(panCalls).toEqual([{ dx: -12, dy: 0 }]);
235
+ });
236
+
237
+ test("angleChannel='x' inverts which viewport axis receives the radial pan", () => {
238
+ const polar = coordPolar({ angleChannel: "x" });
239
+ polar.bindFrame(F);
240
+ const { handle, panCalls } = makeViewportStub();
241
+ polar.handlePan({ dx: 0, dy: 5, plotFrame: F, viewport: handle });
242
+ // With angleChannel='x', radius is the y channel.
243
+ expect(panCalls).toEqual([{ dx: 0, dy: 5 }]);
244
+ });
245
+
246
+ test("non-full-circle (openAngle) still rotates startAngle on tangential pan", () => {
247
+ const polar = coordPolar({ openAngle: Math.PI / 2 }); // arc = 3π/2
248
+ polar.bindFrame(F);
249
+ const before = polarState(polar).startAngle();
250
+ const { handle } = makeViewportStub();
251
+ polar.handlePan({ dx: 50, dy: 0, plotFrame: F, viewport: handle });
252
+ const after = polarState(polar).startAngle();
253
+ // rotation = (50/200) * (3π/2) = 3π/8.
254
+ expect(after - before).toBeCloseTo((3 * Math.PI) / 8, 4);
255
+ });
256
+ });
257
+
258
+ describe("coordPolar handleZoom", () => {
259
+ test("zooms only the radius scale (angle axis unchanged)", () => {
260
+ const polar = coordPolar();
261
+ polar.bindFrame(F);
262
+ const { handle, zoomCalls } = makeViewportStub();
263
+ polar.handleZoom({ factor: 1.5, cx: 130, cy: 100, plotFrame: F, viewport: handle });
264
+ // angleChannel='y', so radius is x.
265
+ expect(zoomCalls).toHaveLength(1);
266
+ expect(zoomCalls[0]!.x).toBe(130);
267
+ expect(zoomCalls[0]!.y).toBe(100);
268
+ expect(zoomCalls[0]!.factor).toEqual({ x: 1.5 });
269
+ });
270
+
271
+ test("startAngle is unchanged by zoom", () => {
272
+ const polar = coordPolar();
273
+ polar.bindFrame(F);
274
+ const before = polarState(polar).startAngle();
275
+ const { handle } = makeViewportStub();
276
+ polar.handleZoom({ factor: 2, cx: 0, cy: 0, plotFrame: F, viewport: handle });
277
+ const after = polarState(polar).startAngle();
278
+ expect(after).toBe(before);
279
+ });
280
+ });
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // coordRadial = coordPolar({ openAngle: 0, innerRadius: 0 })
284
+ // ---------------------------------------------------------------------------
285
+
286
+ describe("coordRadial", () => {
287
+ test("full circle, zero inner radius", () => {
288
+ const radial = coordRadial();
289
+ radial.bindFrame(F);
290
+ const ps = polarState(radial);
291
+ expect(ps.innerRadius()).toBe(0);
292
+ expect(ps.endAngle() - ps.startAngle()).toBeCloseTo(2 * Math.PI, 6);
293
+ });
294
+ });
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Fake-layer helpers
298
+ // ---------------------------------------------------------------------------
299
+
300
+ /** A minimal Layer stub that only captures pushLine shapes. */
301
+ function makeFakeLayer(sink: unknown[]) {
302
+ const noop = () => layer;
303
+ const layer = {
304
+ pushLine: (s: unknown) => {
305
+ sink.push(s);
306
+ return layer;
307
+ },
308
+ pushCircle: noop,
309
+ pushEllipse: noop,
310
+ pushRect: noop,
311
+ pushTriangles: noop,
312
+ pushPolygon: noop,
313
+ pushText: () => ({ width: 0, height: 0, baseline: 0, lines: [] }),
314
+ pushString: noop,
315
+ setClipRect: noop,
316
+ } as unknown as import("insomni").Layer;
317
+ return layer;
318
+ }
319
+
320
+ /** Build a fake PositionScale whose `axisScale.ticks()` returns the supplied values. */
321
+ function makeFakePositionScale(ticks: number[], range: [number, number]) {
322
+ const [r0, r1] = range;
323
+ const dom: [number, number] = [Math.min(...ticks), Math.max(...ticks)];
324
+ const apply = (v: number) => r0 + ((v - dom[0]) / (dom[1] - dom[0])) * (r1 - r0);
325
+ return {
326
+ kind: "position",
327
+ type: "linear" as const,
328
+ dataType: "continuous" as const,
329
+ fn: (v: unknown) => apply(v as number),
330
+ axisScale: {
331
+ domain: dom,
332
+ range,
333
+ ticks: () => ticks,
334
+ tickFormat: () => String,
335
+ invert: (px: number) => dom[0] + ((px - r0) / (r1 - r0)) * (dom[1] - dom[0]),
336
+ } as never,
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Spokes pass through (cx, cy) (one endpoint at the centre or close to it
342
+ * when innerRadius is 0); concentric arcs do not. Count lines whose endpoints
343
+ * straddle the centre.
344
+ */
345
+ function countSpokes(lines: unknown[], cx: number, cy: number): number {
346
+ let count = 0;
347
+ for (const raw of lines) {
348
+ const s = raw as { x1: number; y1: number; x2: number; y2: number };
349
+ const onCentre =
350
+ (Math.abs(s.x1 - cx) < 1e-6 && Math.abs(s.y1 - cy) < 1e-6) ||
351
+ (Math.abs(s.x2 - cx) < 1e-6 && Math.abs(s.y2 - cy) < 1e-6);
352
+ if (onCentre) count++;
353
+ }
354
+ return count;
355
+ }