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,398 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, test } from "vite-plus/test";
3
+ import { createFrame } from "insomni";
4
+ import { __test__ } from "./mount.ts";
5
+ import { coordCartesian, coordPolar, coordRadial } from "./coord.ts";
6
+ import { createDataViewport } from "../viewport.ts";
7
+ import { linearScale } from "../scales.ts";
8
+ import type { ScaleBundle } from "./scales.ts";
9
+
10
+ const { resolvePanZoom, computeVisibleYExtent, wrapViewportThroughCoord, screenToData } = __test__;
11
+
12
+ // ============ Fixtures ============
13
+
14
+ interface Row {
15
+ x: number;
16
+ y: number;
17
+ }
18
+
19
+ const fixture = {
20
+ series: (): Row[] => [
21
+ { x: 0, y: 10 },
22
+ { x: 10, y: 20 },
23
+ { x: 20, y: 5 },
24
+ { x: 30, y: 30 },
25
+ { x: 40, y: 8 },
26
+ ],
27
+ /** A "geom"-ish stub — only `channels.x` / `channels.y` are read. */
28
+ pointGeom: (xKey: keyof Row = "x", yKey: keyof Row = "y") => ({
29
+ channels: { x: xKey as unknown, y: yKey as unknown },
30
+ }),
31
+ };
32
+
33
+ // ============ resolvePanZoom ============
34
+
35
+ describe("resolvePanZoom", () => {
36
+ test("input false returns null (panZoom off)", () => {
37
+ expect(resolvePanZoom(false)).toBeNull();
38
+ expect(resolvePanZoom(undefined)).toBeNull();
39
+ });
40
+
41
+ test("input true uses chart-tightened defaults, not viewport defaults", () => {
42
+ const r = resolvePanZoom(true)!;
43
+ expect(r.minZoom).toBe(1);
44
+ expect(r.maxZoom).toBe(100);
45
+ expect(r.pan).toBe("xy");
46
+ expect(r.zoom).toBe("xy");
47
+ expect(r.yFitPadding).toBeNull();
48
+ expect(r.panBounds).toEqual({ overshoot: 0.1 });
49
+ });
50
+
51
+ test("explicit overrides win over defaults", () => {
52
+ const r = resolvePanZoom({ minZoom: 0.5, maxZoom: 50, pan: "x", zoom: "x" })!;
53
+ expect(r.minZoom).toBe(0.5);
54
+ expect(r.maxZoom).toBe(50);
55
+ expect(r.pan).toBe("x");
56
+ expect(r.zoom).toBe("x");
57
+ });
58
+
59
+ test("panBounds: false disables clamping (undefined at viewport)", () => {
60
+ expect(resolvePanZoom({ panBounds: false })!.panBounds).toBeUndefined();
61
+ });
62
+
63
+ test("yFit forces x-only pan/zoom and default 5% padding", () => {
64
+ const r = resolvePanZoom({ yFit: true })!;
65
+ expect(r.pan).toBe("x");
66
+ expect(r.zoom).toBe("x");
67
+ expect(r.yFitPadding).toBe(0.05);
68
+ });
69
+
70
+ test("yFit overrides user-supplied pan/zoom — Y is read-only", () => {
71
+ const r = resolvePanZoom({ yFit: true, pan: "xy", zoom: "xy" })!;
72
+ expect(r.pan).toBe("x");
73
+ expect(r.zoom).toBe("x");
74
+ });
75
+
76
+ test("yFit accepts custom padding within [0, 0.5]", () => {
77
+ expect(resolvePanZoom({ yFit: { padding: 0 } })!.yFitPadding).toBe(0);
78
+ expect(resolvePanZoom({ yFit: { padding: 0.2 } })!.yFitPadding).toBe(0.2);
79
+ });
80
+
81
+ test("yFit padding out of range throws", () => {
82
+ expect(() => resolvePanZoom({ yFit: { padding: -0.1 } })).toThrow(/padding/);
83
+ expect(() => resolvePanZoom({ yFit: { padding: 0.6 } })).toThrow(/padding/);
84
+ });
85
+ });
86
+
87
+ // ============ computeVisibleYExtent ============
88
+
89
+ describe("computeVisibleYExtent", () => {
90
+ test("returns padded extent of points whose x falls in the window", () => {
91
+ // Visible x ∈ [5, 25] selects rows at x=10 (y=20) and x=20 (y=5).
92
+ const ext = computeVisibleYExtent([fixture.pointGeom()], fixture.series(), [5, 25], 0.1)!;
93
+ // span = 15, pad = 1.5 → [5 - 1.5, 20 + 1.5]
94
+ expect(ext[0]).toBeCloseTo(3.5, 6);
95
+ expect(ext[1]).toBeCloseTo(21.5, 6);
96
+ });
97
+
98
+ test("inclusive on both edges of the X window", () => {
99
+ const ext = computeVisibleYExtent([fixture.pointGeom()], fixture.series(), [0, 40], 0)!;
100
+ // Whole series, no padding.
101
+ expect(ext).toEqual([5, 30]);
102
+ });
103
+
104
+ test("returns null when no point falls inside the window", () => {
105
+ const ext = computeVisibleYExtent([fixture.pointGeom()], fixture.series(), [100, 200], 0.05);
106
+ expect(ext).toBeNull();
107
+ });
108
+
109
+ test("returns null on empty data or no layers", () => {
110
+ expect(computeVisibleYExtent([fixture.pointGeom()], [], [0, 10], 0.05)).toBeNull();
111
+ expect(computeVisibleYExtent([], fixture.series(), [0, 10], 0.05)).toBeNull();
112
+ });
113
+
114
+ test("singleton y collapses to a baseline-padded range, not a zero span", () => {
115
+ const data: Row[] = [{ x: 10, y: 7 }];
116
+ const ext = computeVisibleYExtent([fixture.pointGeom()], data, [0, 20], 0.1)!;
117
+ // pad = |7| * 0.1 = 0.7
118
+ expect(ext[0]).toBeCloseTo(6.3, 6);
119
+ expect(ext[1]).toBeCloseTo(7.7, 6);
120
+ });
121
+
122
+ test("unions extent across multiple layers", () => {
123
+ const a: Row[] = [{ x: 5, y: 100 }];
124
+ // Use same data array but second layer reads a different field — simulate
125
+ // by providing a function accessor that yields 200.
126
+ const geom2 = { channels: { x: "x", y: (() => 200) as unknown } };
127
+ const ext = computeVisibleYExtent([fixture.pointGeom(), geom2], a, [0, 10], 0)!;
128
+ expect(ext).toEqual([100, 200]);
129
+ });
130
+
131
+ test("array-shaped channels (stacked / multi-series) are skipped in v1", () => {
132
+ const geom = { channels: { x: ["a", "b"], y: ["a", "b"] } };
133
+ const data = [{ a: 1, b: 1 }];
134
+ expect(computeVisibleYExtent([geom], data, [0, 10], 0)).toBeNull();
135
+ });
136
+
137
+ test("treats x window endpoints in any order (lo/hi swap-safe)", () => {
138
+ const ext = computeVisibleYExtent([fixture.pointGeom()], fixture.series(), [25, 5], 0)!;
139
+ // Same selection as [5, 25].
140
+ expect(ext).toEqual([5, 20]);
141
+ });
142
+ });
143
+
144
+ // ============ wrapViewportThroughCoord ============
145
+ //
146
+ // Verifies the mount-time wrapper that routes `panBy` / `zoomAt` through
147
+ // `coord.handlePan` / `coord.handleZoom`. Cartesian must be byte-identical
148
+ // to a direct `panBy` / `zoomAt` call; polar must rotate `startAngle` on
149
+ // horizontal pan and only zoom the radius scale.
150
+
151
+ describe("wrapViewportThroughCoord", () => {
152
+ function makeViewport() {
153
+ return createDataViewport<number, number>({
154
+ frame: createFrame({ x: 0, y: 0, width: 200, height: 200 }),
155
+ x: { type: "linear", domain: [0, 100] },
156
+ y: { type: "linear", domain: [0, 100] },
157
+ minZoom: 0.01,
158
+ maxZoom: 100,
159
+ });
160
+ }
161
+
162
+ type PolarReadout = { __polar__: { startAngle: () => number } };
163
+
164
+ test("cartesian: panBy delegates to underlying viewport with the same delta", () => {
165
+ const vp = makeViewport();
166
+ // Control viewport: a direct panBy on a parallel instance gives us the
167
+ // expected domains.
168
+ const control = makeViewport();
169
+ control.panBy(10, -5);
170
+ const xCtrl = control.visibleXDomain;
171
+ const yCtrl = control.visibleYDomain;
172
+
173
+ let invalidated = 0;
174
+ const wrapped = wrapViewportThroughCoord(
175
+ vp,
176
+ () => coordCartesian(),
177
+ () => {
178
+ invalidated++;
179
+ },
180
+ );
181
+ wrapped.panBy(10, -5);
182
+
183
+ expect(vp.visibleXDomain).toEqual(xCtrl);
184
+ expect(vp.visibleYDomain).toEqual(yCtrl);
185
+ expect(invalidated).toBe(1);
186
+ });
187
+
188
+ test("cartesian: zoomAt delegates with the same anchor + factor", () => {
189
+ const vp = makeViewport();
190
+ const control = makeViewport();
191
+ control.zoomAt(120, 80, 1.5);
192
+ const xCtrl = control.visibleXDomain;
193
+ const yCtrl = control.visibleYDomain;
194
+
195
+ const wrapped = wrapViewportThroughCoord(
196
+ vp,
197
+ () => coordCartesian(),
198
+ () => {},
199
+ );
200
+ wrapped.zoomAt(120, 80, 1.5);
201
+
202
+ expect(vp.visibleXDomain).toEqual(xCtrl);
203
+ expect(vp.visibleYDomain).toEqual(yCtrl);
204
+ });
205
+
206
+ test("polar: horizontal pan rotates startAngle and leaves the viewport untouched", () => {
207
+ const vp = makeViewport();
208
+ const xBefore = vp.visibleXDomain;
209
+ const yBefore = vp.visibleYDomain;
210
+ const polar = coordPolar();
211
+ polar.bindFrame(vp.frame);
212
+ const polarState = (polar as unknown as PolarReadout).__polar__;
213
+ const startBefore = polarState.startAngle();
214
+
215
+ let invalidated = 0;
216
+ const wrapped = wrapViewportThroughCoord(
217
+ vp,
218
+ () => polar,
219
+ () => {
220
+ invalidated++;
221
+ },
222
+ );
223
+ wrapped.panBy(50, 0);
224
+
225
+ // dx=50, width=200, arc=2π → rotation = π/2.
226
+ expect(polarState.startAngle() - startBefore).toBeCloseTo(Math.PI / 2, 4);
227
+ // No radial component — viewport domains stay put.
228
+ expect(vp.visibleXDomain).toEqual(xBefore);
229
+ expect(vp.visibleYDomain).toEqual(yBefore);
230
+ expect(invalidated).toBe(1);
231
+ });
232
+
233
+ test("polar: vertical pan translates the radius (x) domain, startAngle unchanged", () => {
234
+ const vp = makeViewport();
235
+ const polar = coordPolar(); // angleChannel='y'; radius axis = x.
236
+ polar.bindFrame(vp.frame);
237
+ const polarState = (polar as unknown as PolarReadout).__polar__;
238
+ const startBefore = polarState.startAngle();
239
+ const xBefore = vp.visibleXDomain;
240
+ const yBefore = vp.visibleYDomain;
241
+
242
+ const wrapped = wrapViewportThroughCoord(
243
+ vp,
244
+ () => polar,
245
+ () => {},
246
+ );
247
+ wrapped.panBy(0, 10);
248
+
249
+ expect(polarState.startAngle()).toBe(startBefore);
250
+ expect(vp.visibleXDomain).not.toEqual(xBefore);
251
+ expect(vp.visibleYDomain).toEqual(yBefore);
252
+ });
253
+
254
+ test("polar: zoom only affects the radius (x) domain — y stays put", () => {
255
+ const vp = makeViewport();
256
+ const polar = coordPolar();
257
+ polar.bindFrame(vp.frame);
258
+ const xBefore = vp.visibleXDomain;
259
+ const yBefore = vp.visibleYDomain;
260
+
261
+ const wrapped = wrapViewportThroughCoord(
262
+ vp,
263
+ () => polar,
264
+ () => {},
265
+ );
266
+ // bindDataViewport issues per-axis factors. Both should collapse to the
267
+ // radial channel under polar (angle stays fixed under zoom).
268
+ wrapped.zoomAt(100, 100, { x: 1.5, y: 1.5 });
269
+
270
+ expect(vp.visibleXDomain).not.toEqual(xBefore);
271
+ expect(vp.visibleYDomain).toEqual(yBefore);
272
+ });
273
+
274
+ test("getCoord is read at-event-time so chart.update() can swap coords", () => {
275
+ const vp = makeViewport();
276
+ let current = coordCartesian();
277
+ const wrapped = wrapViewportThroughCoord(
278
+ vp,
279
+ () => current,
280
+ () => {},
281
+ );
282
+
283
+ // First pan under cartesian — applies as a straight delta.
284
+ wrapped.panBy(5, 0);
285
+ const xAfterCart = vp.visibleXDomain;
286
+
287
+ // Swap to polar — same delta should now rotate, not pan.
288
+ const polar = coordPolar();
289
+ polar.bindFrame(vp.frame);
290
+ current = polar;
291
+ const polarState = (polar as unknown as PolarReadout).__polar__;
292
+ const startBefore = polarState.startAngle();
293
+ wrapped.panBy(5, 0);
294
+
295
+ expect(vp.visibleXDomain).toEqual(xAfterCart);
296
+ expect(polarState.startAngle()).not.toBe(startBefore);
297
+ });
298
+ });
299
+
300
+ // ============ screenToData (MountedPlot.pickAt primitive) ============
301
+
302
+ describe("screenToData", () => {
303
+ // Build a minimal scale bundle good enough for the helper. Only `x.axisScale`
304
+ // / `y.axisScale` (their `invert`) and the `kind`/`type`/`dataType`/`fn`
305
+ // identity are read.
306
+ function makeScales(
307
+ xDomain: [number, number],
308
+ yDomain: [number, number],
309
+ plotW = 100,
310
+ plotH = 100,
311
+ ): ScaleBundle {
312
+ const xRange: [number, number] = [0, plotW];
313
+ const yRange: [number, number] = [plotH, 0]; // flipped (y grows down on screen)
314
+ const xAxis = linearScale(xDomain, xRange);
315
+ const yAxis = linearScale(yDomain, yRange);
316
+ return {
317
+ x: {
318
+ kind: "position" as const,
319
+ type: "linear" as const,
320
+ dataType: "number" as const,
321
+ fn: (v: unknown) => xAxis(v as number),
322
+ axisScale: xAxis,
323
+ },
324
+ y: {
325
+ kind: "position" as const,
326
+ type: "linear" as const,
327
+ dataType: "number" as const,
328
+ fn: (v: unknown) => yAxis(v as number),
329
+ axisScale: yAxis,
330
+ },
331
+ };
332
+ }
333
+
334
+ test("returns null when scales are absent (pre-first-draw)", () => {
335
+ const frame = createFrame({ x: 10, y: 20, width: 100, height: 100 });
336
+ expect(screenToData(50, 50, { frame, scales: null, coord: coordCartesian() })).toBeNull();
337
+ });
338
+
339
+ test("returns null for a point outside the plot frame", () => {
340
+ const frame = createFrame({ x: 10, y: 20, width: 100, height: 100 });
341
+ const scales = makeScales([0, 10], [0, 10]);
342
+ expect(screenToData(0, 0, { frame, scales, coord: coordCartesian() })).toBeNull();
343
+ expect(screenToData(200, 200, { frame, scales, coord: coordCartesian() })).toBeNull();
344
+ });
345
+
346
+ test("Cartesian — canvas px maps to data via axis.invert (frame-relative)", () => {
347
+ const frame = createFrame({ x: 10, y: 20, width: 100, height: 100 });
348
+ // xDomain [0..10] mapped to [0..100], y flipped: yDomain [0..10] → [100..0].
349
+ const scales = makeScales([0, 10], [0, 10]);
350
+ // Canvas (60, 70) → plot-frame (50, 50) → coord identity → invert:
351
+ // dataX = 50 / 100 * 10 = 5, dataY = (100 - 50) / 100 * 10 = 5.
352
+ const got = screenToData(60, 70, { frame, scales, coord: coordCartesian() });
353
+ expect(got).not.toBeNull();
354
+ expect(got!.dataX).toBeCloseTo(5, 6);
355
+ expect(got!.dataY).toBeCloseTo(5, 6);
356
+ expect(got!.plotFrameX).toBeCloseTo(50, 6);
357
+ expect(got!.plotFrameY).toBeCloseTo(50, 6);
358
+ });
359
+
360
+ test("polar — canvas px routes through coord.unproject before invert", () => {
361
+ // A 200×200 frame at origin; coordRadial uses min(w, h)/2 = 100 as
362
+ // outerRadius. coordRadial defaults: angleChannel='y', startAngle=-π/2.
363
+ // The 'y' channel is the angular axis. Pick a point on the +x axis from
364
+ // the centre at r = outerR/2 = 50 → unproject lands on plot-frame
365
+ // (plotW * t_radial, plotH - plotH * t_angle), where t_radial = 0.5 and
366
+ // t_angle corresponds to θ = 0 (one quarter-turn CCW from start θ=-π/2):
367
+ // t_angle = 0.25 → plot-frame y = plotH * (1 - 0.25) = 150.
368
+ // plot-frame x = plotW * t_radial = 100.
369
+ const frame = createFrame({ x: 0, y: 0, width: 200, height: 200 });
370
+ // Same domains the chart would supply: x → [0..plotW], y → [0..plotH].
371
+ const scales = makeScales([0, 200], [0, 200], 200, 200);
372
+ const coord = coordRadial();
373
+ coord.bindFrame(frame);
374
+ // Centre (cx, cy) = (100, 100). Move 50 px along +x.
375
+ const got = screenToData(150, 100, { frame, scales, coord });
376
+ expect(got).not.toBeNull();
377
+ // After unproject, plot-frame y maps to top-half of frame.
378
+ expect(got!.plotFrameX).toBeCloseTo(100, 4);
379
+ expect(got!.plotFrameY).toBeCloseTo(150, 4);
380
+ // And the inverted scales recover those domain values 1:1 here
381
+ // (domain == range orientation modulo y flip).
382
+ expect(got!.dataX).toBeCloseTo(100, 4);
383
+ expect(got!.dataY).toBeCloseTo(50, 4);
384
+ });
385
+
386
+ test("polar — returns null when canvas point falls outside the outerRadius", () => {
387
+ const frame = createFrame({ x: 0, y: 0, width: 200, height: 200 });
388
+ const scales = makeScales([0, 200], [0, 200], 200, 200);
389
+ const coord = coordRadial();
390
+ coord.bindFrame(frame);
391
+ // (100, 100) is the centre; (199, 100) is at r=99, just inside.
392
+ // (10, 100) is at r=90, also inside. (1, 1) is at the corner of the frame
393
+ // — inside the rect but r ≈ √(99²+99²) ≈ 140 > 100 outerR. Within frame
394
+ // bounds but outside the polar disc → unproject returns null.
395
+ const got = screenToData(1, 1, { frame, scales, coord });
396
+ expect(got).toBeNull();
397
+ });
398
+ });
@@ -0,0 +1,65 @@
1
+ import type { Color } from "insomni";
2
+ import type { TextAnnotationSpec } from "./annotations.ts";
3
+ import type { BandPositionScaleOptions } from "./scales.ts";
4
+ /**
5
+ * Subset of `@phylon/renderer`'s `TipAxis` consumed by the bridge. Any object
6
+ * with this shape (typically the `tipAxis` field on a `RectLayout`) works.
7
+ */
8
+ export interface TipAxisLike {
9
+ /** Number of tips. */
10
+ count: number;
11
+ /** Tip node ids in display order (top→bottom for rectilinear). */
12
+ order: ArrayLike<number>;
13
+ /** Tip node id → label, or `null` if the tip is unnamed. */
14
+ name(tipId: number): string | null;
15
+ }
16
+ export interface TipScaleOptions {
17
+ /**
18
+ * Band padding in [0, 1). Default `0` — heatmaps and tile-style geoms want
19
+ * tight rows. Bump to e.g. `0.1` for visible gaps between bars.
20
+ */
21
+ padding?: number;
22
+ /**
23
+ * Replacement label for unnamed tips. Receives the tip's node id and its
24
+ * row index. Default: `(id) => "tip_" + id`.
25
+ */
26
+ unnamed?: (tipId: number, row: number) => string;
27
+ }
28
+ /**
29
+ * Build a y-channel `PositionScaleOptions` from a phylo `TipAxis`. The
30
+ * resulting scale is a band scale whose domain is the tip names in tree
31
+ * display order (top→bottom). Geoms whose `y` aesthetic resolves to a tip
32
+ * name will land on the matching tree row.
33
+ *
34
+ * Tip labels in the data must match `axis.name(tipId)`. Use `joinByTip`
35
+ * (phylo) to align metadata rows to tip names ahead of time.
36
+ */
37
+ export declare function tipScale(axis: TipAxisLike, opts?: TipScaleOptions): BandPositionScaleOptions;
38
+ /**
39
+ * Structural shape of a phylo `CladeStrip` — neutral data describing a clade
40
+ * range plus a label. Mirrors `@phylon/renderer`'s exported `CladeStrip` so the
41
+ * plot package doesn't have to depend on phylo to consume them.
42
+ */
43
+ export interface CladeStripLike {
44
+ label: string;
45
+ tipCenter: string;
46
+ color?: Color;
47
+ offsetX?: number;
48
+ }
49
+ export interface CladeStripAnnotationOptions {
50
+ /**
51
+ * Pixel offset added to every strip's annotation. Default `8` — pushes the
52
+ * label clear of the chart's right edge / tip labels. Per-strip
53
+ * `offsetX` is added on top of this.
54
+ */
55
+ offsetX?: number;
56
+ fontSize?: number;
57
+ fontStyle?: string;
58
+ }
59
+ /**
60
+ * Convert phylo `CladeStrip[]` into plot `AnnotationSpec[]`. Labels anchor at
61
+ * the chart's right frame edge, vertically aligned to the clade's y-center
62
+ * (resolved by the y-channel's `tipScale`). v1 emits text-only annotations;
63
+ * brackets are a follow-up that needs a non-text annotation kind.
64
+ */
65
+ export declare function cladeStripsToAnnotations(strips: readonly CladeStripLike[], opts?: CladeStripAnnotationOptions): TextAnnotationSpec[];
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "vite-plus/test";
2
+ import { cladeStripsToAnnotations, tipScale, type TipAxisLike } from "./phylo.ts";
3
+
4
+ function makeAxis(names: readonly (string | null)[]): TipAxisLike {
5
+ return {
6
+ count: names.length,
7
+ order: names.map((_, i) => i),
8
+ name: (id: number) => names[id] ?? null,
9
+ };
10
+ }
11
+
12
+ describe("tipScale", () => {
13
+ test("emits a band scale whose domain is tip names in tree order", () => {
14
+ const axis = makeAxis(["C", "B", "A"]);
15
+ const opts = tipScale(axis);
16
+ expect(opts.type).toBe("band");
17
+ expect(opts.domain).toEqual(["C", "B", "A"]);
18
+ expect(opts.padding).toBe(0);
19
+ });
20
+
21
+ test("padding override is forwarded", () => {
22
+ const axis = makeAxis(["x", "y"]);
23
+ expect(tipScale(axis, { padding: 0.25 }).padding).toBe(0.25);
24
+ });
25
+
26
+ test("unnamed tips fall back to a synthetic id", () => {
27
+ const axis = makeAxis(["A", null, "C"]);
28
+ const opts = tipScale(axis);
29
+ expect(opts.domain).toEqual(["A", "tip_1", "C"]);
30
+ });
31
+
32
+ test("custom unnamed fallback receives id and row", () => {
33
+ const axis = makeAxis([null, null]);
34
+ const opts = tipScale(axis, { unnamed: (id, row) => `r${row}_id${id}` });
35
+ expect(opts.domain).toEqual(["r0_id0", "r1_id1"]);
36
+ });
37
+ });
38
+
39
+ describe("cladeStripsToAnnotations", () => {
40
+ test("anchors each strip at the right frame edge with tipCenter as y", () => {
41
+ const annotations = cladeStripsToAnnotations([
42
+ { label: "AB", tipCenter: "B" },
43
+ { label: "CD", tipCenter: "D", offsetX: 4 },
44
+ ]);
45
+ expect(annotations).toHaveLength(2);
46
+ expect(annotations[0]!.x).toBe("right");
47
+ expect(annotations[0]!.y).toBe("B");
48
+ expect(annotations[0]!.align).toBe("left");
49
+ expect(annotations[0]!.offsetX).toBe(8);
50
+ // Per-strip offsetX adds on top of the base.
51
+ expect(annotations[1]!.offsetX).toBe(12);
52
+ expect(annotations[1]!.text).toBe("CD");
53
+ });
54
+
55
+ test("base offsetX option overrides the default", () => {
56
+ const annotations = cladeStripsToAnnotations([{ label: "X", tipCenter: "T" }], { offsetX: 20 });
57
+ expect(annotations[0]!.offsetX).toBe(20);
58
+ });
59
+ });
@@ -0,0 +1,112 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Bridge: phylo ↔ plot
3
+ // ---------------------------------------------------------------------------
4
+ // `tipScale(axis)` returns a `PositionScaleOptions` whose discrete domain is
5
+ // the tree's tip names in display order. Hand it to a chart's y-channel to
6
+ // align any geom (heatmap, bar, point, …) to the tip rows of a phylogeny.
7
+ //
8
+ // The bridge is plot-side so phylo can stay dependency-free. To avoid the
9
+ // reverse dependency, plot does not import from phylo — instead it consumes
10
+ // a structural `TipAxisLike` (the subset of `phylo.TipAxis` we actually use).
11
+
12
+ import type { Color } from "insomni";
13
+ import type { TextAnnotationSpec } from "./annotations.ts";
14
+ import type { BandPositionScaleOptions } from "./scales.ts";
15
+
16
+ /**
17
+ * Subset of `@phylon/renderer`'s `TipAxis` consumed by the bridge. Any object
18
+ * with this shape (typically the `tipAxis` field on a `RectLayout`) works.
19
+ */
20
+ export interface TipAxisLike {
21
+ /** Number of tips. */
22
+ count: number;
23
+ /** Tip node ids in display order (top→bottom for rectilinear). */
24
+ order: ArrayLike<number>;
25
+ /** Tip node id → label, or `null` if the tip is unnamed. */
26
+ name(tipId: number): string | null;
27
+ }
28
+
29
+ export interface TipScaleOptions {
30
+ /**
31
+ * Band padding in [0, 1). Default `0` — heatmaps and tile-style geoms want
32
+ * tight rows. Bump to e.g. `0.1` for visible gaps between bars.
33
+ */
34
+ padding?: number;
35
+ /**
36
+ * Replacement label for unnamed tips. Receives the tip's node id and its
37
+ * row index. Default: `(id) => "tip_" + id`.
38
+ */
39
+ unnamed?: (tipId: number, row: number) => string;
40
+ }
41
+
42
+ /**
43
+ * Build a y-channel `PositionScaleOptions` from a phylo `TipAxis`. The
44
+ * resulting scale is a band scale whose domain is the tip names in tree
45
+ * display order (top→bottom). Geoms whose `y` aesthetic resolves to a tip
46
+ * name will land on the matching tree row.
47
+ *
48
+ * Tip labels in the data must match `axis.name(tipId)`. Use `joinByTip`
49
+ * (phylo) to align metadata rows to tip names ahead of time.
50
+ */
51
+ export function tipScale(axis: TipAxisLike, opts: TipScaleOptions = {}): BandPositionScaleOptions {
52
+ const fallback = opts.unnamed ?? ((id: number) => `tip_${id}`);
53
+ const domain: string[] = Array.from({ length: axis.count });
54
+ for (let row = 0; row < axis.count; row++) {
55
+ const id = axis.order[row]!;
56
+ domain[row] = axis.name(id) ?? fallback(id, row);
57
+ }
58
+ return {
59
+ type: "band",
60
+ domain,
61
+ padding: opts.padding ?? 0,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Structural shape of a phylo `CladeStrip` — neutral data describing a clade
67
+ * range plus a label. Mirrors `@phylon/renderer`'s exported `CladeStrip` so the
68
+ * plot package doesn't have to depend on phylo to consume them.
69
+ */
70
+ export interface CladeStripLike {
71
+ label: string;
72
+ tipCenter: string;
73
+ color?: Color;
74
+ offsetX?: number;
75
+ }
76
+
77
+ export interface CladeStripAnnotationOptions {
78
+ /**
79
+ * Pixel offset added to every strip's annotation. Default `8` — pushes the
80
+ * label clear of the chart's right edge / tip labels. Per-strip
81
+ * `offsetX` is added on top of this.
82
+ */
83
+ offsetX?: number;
84
+ fontSize?: number;
85
+ fontStyle?: string;
86
+ }
87
+
88
+ /**
89
+ * Convert phylo `CladeStrip[]` into plot `AnnotationSpec[]`. Labels anchor at
90
+ * the chart's right frame edge, vertically aligned to the clade's y-center
91
+ * (resolved by the y-channel's `tipScale`). v1 emits text-only annotations;
92
+ * brackets are a follow-up that needs a non-text annotation kind.
93
+ */
94
+ export function cladeStripsToAnnotations(
95
+ strips: readonly CladeStripLike[],
96
+ opts: CladeStripAnnotationOptions = {},
97
+ ): TextAnnotationSpec[] {
98
+ const baseOffset = opts.offsetX ?? 8;
99
+ return strips.map(
100
+ (s): TextAnnotationSpec => ({
101
+ kind: "text",
102
+ text: s.label,
103
+ x: "right",
104
+ y: s.tipCenter,
105
+ align: "left",
106
+ offsetX: baseOffset + (s.offsetX ?? 0),
107
+ color: s.color,
108
+ fontSize: opts.fontSize,
109
+ fontStyle: opts.fontStyle,
110
+ }),
111
+ );
112
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { makeAxisOptions } from "./pipeline.ts";
3
+ import { themeDefault } from "./theme.ts";
4
+
5
+ describe("makeAxisOptions — default ticks and labelCollision", () => {
6
+ it("defaults ticks to 'auto' when no spec is provided", () => {
7
+ const opts = makeAxisOptions(undefined, themeDefault, undefined);
8
+ expect(opts.ticks).toBe("auto");
9
+ });
10
+
11
+ it("defaults ticks to 'auto' when spec has no ticks field", () => {
12
+ const opts = makeAxisOptions({}, themeDefault, undefined);
13
+ expect(opts.ticks).toBe("auto");
14
+ });
15
+
16
+ it("respects an explicit numeric ticks value", () => {
17
+ const opts = makeAxisOptions({ ticks: 5 }, themeDefault, undefined);
18
+ expect(opts.ticks).toBe(5);
19
+ });
20
+
21
+ it("respects an explicit 'auto' ticks value (no-op, same result)", () => {
22
+ const opts = makeAxisOptions({ ticks: "auto" }, themeDefault, undefined);
23
+ expect(opts.ticks).toBe("auto");
24
+ });
25
+
26
+ it("defaults labelCollision to 'auto' when no spec is provided", () => {
27
+ const opts = makeAxisOptions(undefined, themeDefault, undefined);
28
+ expect(opts.labelCollision).toBe("auto");
29
+ });
30
+
31
+ it("defaults labelCollision to 'auto' when spec has no labelCollision field", () => {
32
+ const opts = makeAxisOptions({}, themeDefault, undefined);
33
+ expect(opts.labelCollision).toBe("auto");
34
+ });
35
+
36
+ it("respects an explicit labelCollision value", () => {
37
+ const opts = makeAxisOptions({ labelCollision: "auto" }, themeDefault, undefined);
38
+ expect(opts.labelCollision).toBe("auto");
39
+ });
40
+ });