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,216 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Polar smoke tests for generic plot geoms (Phase 3b).
3
+ // ---------------------------------------------------------------------------
4
+ // These tests pin the polar projection behavior of `point`, `text`, `line`,
5
+ // `rule`, and `tile` under a known `coordPolar({...})` config. The goal is
6
+ // to catch regressions in:
7
+ //
8
+ // 1. Per-vertex projection (point / text): hit-test positions land on the
9
+ // visible mark, not at the unprojected plot-frame coords.
10
+ // 2. Per-segment tessellation (line): consecutive vertices generate more
11
+ // than two points in the emitted polyline (arc samples), not a single
12
+ // chord.
13
+ // 3. Polar-correct rule shape: a constant-x rule becomes a closed
14
+ // multi-sample arc (circle for full y), not a 2-point line.
15
+ // 4. Tile gracefully throws the friendly polar-unsupported error.
16
+ //
17
+ // We pick a 200 × 200 plot frame so the polar centre is at (100, 100) in
18
+ // plot-frame space, and the outerRadius defaults to 100. With
19
+ // `startAngle: -π/2`, a point at the *top* of the data domain (max y under
20
+ // the default `angleChannel: 'y'`) projects to (cx, cy - outerR) = (100, 0)
21
+ // in plot-frame px (plus the plot origin for hit positions).
22
+
23
+ import { createFrame } from "insomni";
24
+ import { describe, expect, test } from "vite-plus/test";
25
+
26
+ import { resolveAes } from "../aes.ts";
27
+ import { coordPolar, type Coord } from "../coord.ts";
28
+ import { buildPositionScale, type ScaleBundle } from "../scales.ts";
29
+ import { themeDefault } from "../theme.ts";
30
+ import { line } from "./line.ts";
31
+ import { point } from "./point.ts";
32
+ import { rule } from "./rule.ts";
33
+ import { tile } from "./tile.ts";
34
+ import type { CompileContext } from "./types.ts";
35
+
36
+ interface Row {
37
+ x: number;
38
+ y: number;
39
+ }
40
+
41
+ const PLOT_X = 50;
42
+ const PLOT_Y = 30;
43
+ const PLOT_W = 200;
44
+ const PLOT_H = 200;
45
+
46
+ function makePolarCoord(): Coord {
47
+ const c = coordPolar({
48
+ startAngle: -Math.PI / 2,
49
+ openAngle: 0,
50
+ direction: 1,
51
+ angleChannel: "y",
52
+ });
53
+ c.bindFrame(createFrame({ x: 0, y: 0, width: PLOT_W, height: PLOT_H }));
54
+ return c;
55
+ }
56
+
57
+ function makeCtx<T extends Row>(rows: readonly T[]): CompileContext<T> {
58
+ const xAes = resolveAes<T, unknown>("x");
59
+ const yAes = resolveAes<T, unknown>("y");
60
+ // x scale: data [0, 1] → px [0, PLOT_W] (radius channel)
61
+ // y scale: data [0, 1] → px [PLOT_H, 0] (angle channel, flipped per plot
62
+ // convention so y=1 lands at the top)
63
+ const xScale = buildPositionScale(xAes, rows, [0, PLOT_W], { domain: [0, 1] });
64
+ const yScale = buildPositionScale(yAes, rows, [PLOT_H, 0], { domain: [0, 1] });
65
+ const scales: ScaleBundle = { x: xScale, y: yScale };
66
+ return {
67
+ data: rows,
68
+ scales,
69
+ plot: createFrame({ x: PLOT_X, y: PLOT_Y, width: PLOT_W, height: PLOT_H }),
70
+ theme: themeDefault,
71
+ atlas: undefined,
72
+ coord: makePolarCoord(),
73
+ };
74
+ }
75
+
76
+ describe("polar — point geom", () => {
77
+ test("hit-test positions project through coord, not the raw plot-frame px", () => {
78
+ // y = 1 maps to plot-frame y=0 (top of plot). Under polar with
79
+ // startAngle=-π/2, angleChannel=y, the angle is computed from
80
+ // `plotH - p.y = 200`, so t = 200/200 = 1, theta = -π/2 + 2π = 3π/2 ≡ -π/2.
81
+ // r at x=1: t=1, r = outerR = 100.
82
+ // Projected (in plot-frame px): (cx + r·cos(-π/2), cy + r·sin(-π/2))
83
+ // = (100 + 0, 100 - 100) = (100, 0).
84
+ // Absolute hit position: (PLOT_X + 100, PLOT_Y + 0) = (150, 30).
85
+ const geom = point<Row>({ x: "x", y: "y" });
86
+ const hits = geom.compileHitTest!(makeCtx([{ x: 1, y: 1 }]))!;
87
+ expect(hits.dataIndex.length).toBe(1);
88
+ expect(hits.positions[0]).toBeCloseTo(PLOT_X + 100, 1);
89
+ expect(hits.positions[1]).toBeCloseTo(PLOT_Y + 0, 1);
90
+ });
91
+
92
+ test("inner radius point at x=0 projects to the polar centre", () => {
93
+ // x = 0 → r = 0 → projected = (cx, cy) = (100, 100); absolute (150, 130).
94
+ const geom = point<Row>({ x: "x", y: "y" });
95
+ const hits = geom.compileHitTest!(makeCtx([{ x: 0, y: 0.5 }]))!;
96
+ expect(hits.positions[0]).toBeCloseTo(PLOT_X + 100, 1);
97
+ expect(hits.positions[1]).toBeCloseTo(PLOT_Y + 100, 1);
98
+ });
99
+ });
100
+
101
+ describe("polar — line geom", () => {
102
+ test("emits an arc-tessellated polyline between vertices on the same radius", () => {
103
+ // Two points at the same x (r=outerR=100) but at different y (different
104
+ // θ). Picking y=0 vs y=0.5 gives a half-circle sweep — the segment
105
+ // tessellator should emit ~180 samples (one per ~1°).
106
+ //
107
+ // We deliberately avoid the full sweep (0 → 1) because under full-circle
108
+ // polar the endpoints are the same physical point (start == end), which
109
+ // would degenerate to a 2-point segment.
110
+ const polylines: { points: readonly { x: number; y: number }[] }[] = [];
111
+ const layer = {
112
+ pushPolyline(shape: { points: readonly { x: number; y: number }[] }) {
113
+ polylines.push({ points: [...shape.points] });
114
+ },
115
+ };
116
+ const ctx = makeCtx<Row>([
117
+ { x: 1, y: 0 },
118
+ { x: 1, y: 0.5 },
119
+ ]);
120
+ const geom = line<Row>({ x: "x", y: "y" });
121
+ const builders = geom.compile(ctx);
122
+ for (const b of builders) b.addTo(layer as never);
123
+ expect(polylines).toHaveLength(1);
124
+ // Half-circle sweep at ~1° step → roughly 180 samples (well over 50).
125
+ expect(polylines[0]!.points.length).toBeGreaterThan(50);
126
+ // All samples should lie on a circle of radius `outerR` around the
127
+ // polar centre (cx, cy) = (PLOT_X + 100, PLOT_Y + 100).
128
+ for (const p of polylines[0]!.points) {
129
+ const dx = p.x - (PLOT_X + 100);
130
+ const dy = p.y - (PLOT_Y + 100);
131
+ const r = Math.hypot(dx, dy);
132
+ expect(r).toBeCloseTo(100, 0);
133
+ }
134
+ });
135
+
136
+ test("cartesian path is unchanged when ctx.coord is omitted", () => {
137
+ // Sanity: make a non-polar ctx and confirm we still get a 3-point chord.
138
+ const xAes = resolveAes<Row, unknown>("x");
139
+ const yAes = resolveAes<Row, unknown>("y");
140
+ const xScale = buildPositionScale(xAes, [], [0, 100], { domain: [0, 1] });
141
+ const yScale = buildPositionScale(yAes, [], [200, 0], { domain: [0, 1] });
142
+ const ctx: CompileContext<Row> = {
143
+ data: [
144
+ { x: 0, y: 0 },
145
+ { x: 0.5, y: 0.5 },
146
+ { x: 1, y: 1 },
147
+ ],
148
+ scales: { x: xScale, y: yScale },
149
+ plot: createFrame({ x: 50, y: 30, width: 100, height: 200 }),
150
+ theme: themeDefault,
151
+ atlas: undefined,
152
+ // No coord → defaults to cartesian.
153
+ };
154
+ const polylines: { points: readonly { x: number; y: number }[] }[] = [];
155
+ const layer = {
156
+ pushPolyline(shape: { points: readonly { x: number; y: number }[] }) {
157
+ polylines.push({ points: [...shape.points] });
158
+ },
159
+ };
160
+ const geom = line<Row>({ x: "x", y: "y" });
161
+ const builders = geom.compile(ctx);
162
+ for (const b of builders) b.addTo(layer as never);
163
+ expect(polylines).toHaveLength(1);
164
+ expect(polylines[0]!.points.length).toBe(3);
165
+ });
166
+ });
167
+
168
+ describe("polar — rule geom", () => {
169
+ test("vertical rule at constant x emits an arc polyline (circle for full y)", () => {
170
+ const polylines: { points: readonly { x: number; y: number }[] }[] = [];
171
+ const layer = {
172
+ pushPolyline(shape: { points: readonly { x: number; y: number }[] }) {
173
+ polylines.push({ points: [...shape.points] });
174
+ },
175
+ };
176
+ const geom = rule<Row>({ x: 0.5 });
177
+ const ctx = makeCtx<Row>([{ x: 0.5, y: 0 }]);
178
+ const builders = geom.compile(ctx);
179
+ for (const b of builders) b.addTo(layer as never);
180
+ expect(polylines).toHaveLength(1);
181
+ // r at x=0.5: t=0.5, r = 50. Full sweep → many samples on a circle of
182
+ // radius 50 around the polar centre.
183
+ expect(polylines[0]!.points.length).toBeGreaterThan(100);
184
+ for (const p of polylines[0]!.points) {
185
+ const dx = p.x - (PLOT_X + 100);
186
+ const dy = p.y - (PLOT_Y + 100);
187
+ const r = Math.hypot(dx, dy);
188
+ expect(r).toBeCloseTo(50, 0);
189
+ }
190
+ });
191
+
192
+ test("horizontal rule at constant y emits a 2-point spoke (constant θ)", () => {
193
+ const polylines: { points: readonly { x: number; y: number }[] }[] = [];
194
+ const layer = {
195
+ pushPolyline(shape: { points: readonly { x: number; y: number }[] }) {
196
+ polylines.push({ points: [...shape.points] });
197
+ },
198
+ };
199
+ const geom = rule<Row>({ y: 0.5 });
200
+ const ctx = makeCtx<Row>([{ x: 0, y: 0.5 }]);
201
+ const builders = geom.compile(ctx);
202
+ for (const b of builders) b.addTo(layer as never);
203
+ expect(polylines).toHaveLength(1);
204
+ // y=0.5 fixes θ, so coord.segment shortcuts to a 2-point straight spoke.
205
+ expect(polylines[0]!.points.length).toBe(2);
206
+ });
207
+ });
208
+
209
+ describe("polar — tile geom", () => {
210
+ test("throws a friendly error under polar (v1 unsupported)", () => {
211
+ const geom = tile<Row>({ x: "x", y: "y" });
212
+ expect(() => geom.compile(makeCtx<Row>([{ x: 0.5, y: 0.5 }]))).toThrow(
213
+ /coordPolar.*does not support geomTile/,
214
+ );
215
+ });
216
+ });
@@ -0,0 +1,21 @@
1
+ import type { Aes } from "../aes.ts";
2
+ import { type ColorOrAccent } from "../color-utils.ts";
3
+ import type { Geom } from "./types.ts";
4
+ export interface RibbonChannels<T> {
5
+ x: Aes<T, number | Date>;
6
+ y0: Aes<T, number | Date>;
7
+ y1: Aes<T, number | Date>;
8
+ }
9
+ export interface RibbonOptions {
10
+ /**
11
+ * Fill color. Accepts a literal {@link Color} or a theme accent key
12
+ * (`"positive" | "negative" | "warn" | "info"`); accent keys resolve through
13
+ * `theme.accents`.
14
+ */
15
+ fill?: ColorOrAccent;
16
+ stroke?: ColorOrAccent;
17
+ strokeWidth?: number;
18
+ fillAlpha?: number;
19
+ label?: string;
20
+ }
21
+ export declare function ribbon<T>(channels: RibbonChannels<T>, options?: RibbonOptions): Geom<T>;
@@ -0,0 +1,170 @@
1
+ import { createFrame } from "insomni";
2
+ import { describe, expect, test } from "vite-plus/test";
3
+
4
+ import { resolveAes } from "../aes.ts";
5
+ import { buildPositionScale, type ScaleBundle } from "../scales.ts";
6
+ import { themeDefault } from "../theme.ts";
7
+ import { ribbon } from "./ribbon.ts";
8
+ import type { CompileContext } from "./types.ts";
9
+
10
+ interface Row {
11
+ x: number;
12
+ lo: number;
13
+ hi: number;
14
+ }
15
+
16
+ const data: Row[] = [
17
+ { x: 0, lo: 2, hi: 8 },
18
+ { x: 5, lo: 3, hi: 10 },
19
+ { x: 10, lo: 1, hi: 12 },
20
+ ];
21
+
22
+ // y1 ("hi") is the only registered position channel, so the y position scale is
23
+ // built off `hi`. The domain spans 8..12 from `hi` alone.
24
+ function makeCtx(rows: readonly Row[], yDomain?: [number, number]): CompileContext<Row> {
25
+ const xAes = resolveAes<Row, unknown>("x");
26
+ const yAes = resolveAes<Row, unknown>("hi");
27
+ const xScale = buildPositionScale(xAes, rows, [0, 100]);
28
+ const yScale = buildPositionScale(
29
+ yAes,
30
+ rows,
31
+ [200, 0],
32
+ yDomain ? { domain: yDomain } : undefined,
33
+ );
34
+ const scales: ScaleBundle = { x: xScale, y: yScale };
35
+ return {
36
+ data: rows,
37
+ scales,
38
+ plot: createFrame({ x: 50, y: 30, width: 100, height: 200 }),
39
+ theme: themeDefault,
40
+ atlas: undefined,
41
+ };
42
+ }
43
+
44
+ function capturePolygons(
45
+ ctx: CompileContext<Row>,
46
+ geom = ribbon<Row>({ x: "x", y0: "lo", y1: "hi" }),
47
+ ): Array<{ points: Array<{ x: number; y: number }>; fill?: unknown; stroke?: unknown }> {
48
+ const polygons: Array<{ points: Array<{ x: number; y: number }>; fill?: unknown }> = [];
49
+ const layer = {
50
+ pushPolygon(shape: { points: Array<{ x: number; y: number }> }) {
51
+ polygons.push(shape);
52
+ },
53
+ pushPolyline() {},
54
+ };
55
+ for (const b of geom.compile(ctx)) b.addTo(layer as never);
56
+ return polygons;
57
+ }
58
+
59
+ describe("ribbon geom — contract", () => {
60
+ test("shares kind 'area' so it reuses the area legend swatch", () => {
61
+ const geom = ribbon<Row>({ x: "x", y0: "lo", y1: "hi" });
62
+ expect(geom.kind).toBe("area");
63
+ expect(typeof geom.legendSwatch).toBe("function");
64
+ });
65
+
66
+ test("registers only x and y1 as channels (y0 is internal)", () => {
67
+ const geom = ribbon<Row>({ x: "x", y0: "lo", y1: "hi" });
68
+ expect(geom.channels.x).toBe("x");
69
+ expect(geom.channels.y).toBe("hi");
70
+ });
71
+
72
+ test("passes through an explicit label", () => {
73
+ const geom = ribbon<Row>({ x: "x", y0: "lo", y1: "hi" }, { label: "CI band" });
74
+ expect(geom.label).toBe("CI band");
75
+ });
76
+ });
77
+
78
+ describe("ribbon geom — prepareDomain", () => {
79
+ test("y extend spans both the lower and upper bounds", () => {
80
+ const geom = ribbon<Row>({ x: "x", y0: "lo", y1: "hi" });
81
+ const hints = geom.prepareDomain!(data);
82
+ // lo min = 1, hi max = 12 → union [1, 12].
83
+ expect(hints?.y?.extend).toEqual([1, 12]);
84
+ });
85
+
86
+ test("a lower bound below the y1-derived domain is still included", () => {
87
+ // hi alone is 8..12; lo reaches down to -5 and must widen the extend.
88
+ const rows: Row[] = [
89
+ { x: 0, lo: -5, hi: 8 },
90
+ { x: 10, lo: 0, hi: 12 },
91
+ ];
92
+ const geom = ribbon<Row>({ x: "x", y0: "lo", y1: "hi" });
93
+ const hints = geom.prepareDomain!(rows);
94
+ expect(hints?.y?.extend).toEqual([-5, 12]);
95
+ });
96
+
97
+ test("ignores non-finite bounds", () => {
98
+ const rows: Row[] = [
99
+ { x: 0, lo: NaN, hi: 8 },
100
+ { x: 10, lo: 2, hi: NaN },
101
+ ];
102
+ const geom = ribbon<Row>({ x: "x", y0: "lo", y1: "hi" });
103
+ const hints = geom.prepareDomain!(rows);
104
+ expect(hints?.y?.extend).toEqual([2, 8]);
105
+ });
106
+
107
+ test("empty data yields no hint", () => {
108
+ const geom = ribbon<Row>({ x: "x", y0: "lo", y1: "hi" });
109
+ expect(geom.prepareDomain!([])).toBeUndefined();
110
+ });
111
+
112
+ test("all-non-finite data yields no hint", () => {
113
+ const rows: Row[] = [{ x: 0, lo: NaN, hi: NaN }];
114
+ const geom = ribbon<Row>({ x: "x", y0: "lo", y1: "hi" });
115
+ expect(geom.prepareDomain!(rows)).toBeUndefined();
116
+ });
117
+ });
118
+
119
+ describe("ribbon geom — compile", () => {
120
+ test("emits a single filled polygon: top edge (y1) then bottom edge (y0) reversed", () => {
121
+ // Pin the y domain to [0, 20] so pixel positions are predictable.
122
+ const ctx = makeCtx(data, [0, 20]);
123
+ const polygons = capturePolygons(ctx);
124
+ expect(polygons).toHaveLength(1);
125
+ const pts = polygons[0]!.points;
126
+ // 3 rows → top(3) + bottom(3) = 6 vertices.
127
+ expect(pts).toHaveLength(6);
128
+
129
+ // x: 0,5,10 over [0,10] → [0,100] → 0,50,100; +plot.x(50) = 50,100,150.
130
+ // Top edge is hi values left→right at x = 50,100,150.
131
+ expect(pts.slice(0, 3).map((p) => p.x)).toEqual([50, 100, 150]);
132
+ // y(hi): 8,10,12 over [0,20] → [200,0] → 120,100,80; +plot.y(30) = 150,130,110.
133
+ expect(pts.slice(0, 3).map((p) => p.y)).toEqual([150, 130, 110]);
134
+
135
+ // Bottom edge is lo values right→left at x = 150,100,50.
136
+ expect(pts.slice(3).map((p) => p.x)).toEqual([150, 100, 50]);
137
+ // y(lo): rows reversed → lo = 1,3,2 → over [0,20] → [200,0] → 190,170,180;
138
+ // +plot.y(30) = 220,200,210.
139
+ expect(pts.slice(3).map((p) => p.y)).toEqual([220, 200, 210]);
140
+ });
141
+
142
+ test("applies an alphaized fill", () => {
143
+ const polygons = capturePolygons(makeCtx(data, [0, 20]));
144
+ expect(polygons[0]!.fill).toBeDefined();
145
+ });
146
+
147
+ test("lower == upper collapses to a degenerate (zero-height) band", () => {
148
+ const flat: Row[] = [
149
+ { x: 0, lo: 5, hi: 5 },
150
+ { x: 10, lo: 5, hi: 5 },
151
+ ];
152
+ const ctx = makeCtx(flat, [0, 10]);
153
+ const polygons = capturePolygons(ctx);
154
+ expect(polygons).toHaveLength(1);
155
+ const pts = polygons[0]!.points;
156
+ expect(pts).toHaveLength(4);
157
+ // y(5) over [0,10] → [200,0] → 100; +plot.y(30) = 130. Top and bottom share it.
158
+ expect(pts.every((p) => p.y === 130)).toBe(true);
159
+ });
160
+
161
+ test("a single datum produces no polygon (needs >= 2 points to fill)", () => {
162
+ const single: Row[] = [{ x: 0, lo: 2, hi: 8 }];
163
+ const polygons = capturePolygons(makeCtx(single, [0, 20]));
164
+ expect(polygons).toHaveLength(0);
165
+ });
166
+
167
+ test("empty data produces no polygon", () => {
168
+ expect(capturePolygons(makeCtx([], [0, 20]))).toHaveLength(0);
169
+ });
170
+ });
@@ -0,0 +1,87 @@
1
+ // ---------------------------------------------------------------------------
2
+ // ribbon geom — area between y0 and y1
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { areaMark } from "../../marks.ts";
6
+ import type { Aes } from "../aes.ts";
7
+ import { resolveAes } from "../aes.ts";
8
+ import { alphaize, resolveAccent, type ColorOrAccent } from "../color-utils.ts";
9
+ import { areaSwatch } from "../../legend.ts";
10
+ import type { CompileContext, Geom, ScaleHints } from "./types.ts";
11
+ import { wrapMark } from "./_mark.ts";
12
+
13
+ export interface RibbonChannels<T> {
14
+ x: Aes<T, number | Date>;
15
+ y0: Aes<T, number | Date>;
16
+ y1: Aes<T, number | Date>;
17
+ }
18
+
19
+ export interface RibbonOptions {
20
+ /**
21
+ * Fill color. Accepts a literal {@link Color} or a theme accent key
22
+ * (`"positive" | "negative" | "warn" | "info"`); accent keys resolve through
23
+ * `theme.accents`.
24
+ */
25
+ fill?: ColorOrAccent;
26
+ stroke?: ColorOrAccent;
27
+ strokeWidth?: number;
28
+ fillAlpha?: number;
29
+ label?: string;
30
+ }
31
+
32
+ export function ribbon<T>(channels: RibbonChannels<T>, options: RibbonOptions = {}): Geom<T> {
33
+ return {
34
+ // Shares `kind: "area"` with `area()` so the auto-legend reuses the area
35
+ // swatch — both render as filled regions with the same visual semantics.
36
+ kind: "area",
37
+ channels: { x: channels.x, y: channels.y1 },
38
+ label: options.label,
39
+ legendSwatch: (color) => areaSwatch({ fill: color, width: 14, height: 10 }),
40
+ prepareDomain(data) {
41
+ // Only y1 is registered as a channel, so the pipeline never sees y0.
42
+ // Union both into a single extend hint so a y0 below the y1-derived
43
+ // domain is still visible.
44
+ const y0Aes = resolveAes<T, unknown>(channels.y0 as Aes<T, unknown>);
45
+ const y1Aes = resolveAes<T, unknown>(channels.y1 as Aes<T, unknown>);
46
+ let lo = Number.POSITIVE_INFINITY;
47
+ let hi = Number.NEGATIVE_INFINITY;
48
+ for (let i = 0; i < data.length; i++) {
49
+ const datum = data[i]!;
50
+ const a = y0Aes.fn(datum, i);
51
+ const b = y1Aes.fn(datum, i);
52
+ const an = a instanceof Date ? a.getTime() : (a as number);
53
+ const bn = b instanceof Date ? b.getTime() : (b as number);
54
+ if (Number.isFinite(an)) {
55
+ if (an < lo) lo = an;
56
+ if (an > hi) hi = an;
57
+ }
58
+ if (Number.isFinite(bn)) {
59
+ if (bn < lo) lo = bn;
60
+ if (bn > hi) hi = bn;
61
+ }
62
+ }
63
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) return undefined;
64
+ const hints: ScaleHints = { y: { extend: [lo, hi] } };
65
+ return hints;
66
+ },
67
+ compile(ctx: CompileContext<T>) {
68
+ const { data, scales, plot, theme } = ctx;
69
+ const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
70
+ const y0Aes = resolveAes<T, unknown>(channels.y0 as Aes<T, unknown>);
71
+ const y1Aes = resolveAes<T, unknown>(channels.y1 as Aes<T, unknown>);
72
+ const xFn = scales.x.fn;
73
+ const yFn = scales.y.fn;
74
+ const fill = resolveAccent(options.fill, theme) ?? theme.palettes.categorical(0);
75
+ const alpha = options.fillAlpha ?? theme.marks.ribbonFillAlpha;
76
+ const mark = areaMark(data, {
77
+ x: (d, i) => xFn(xAes.fn(d, i)),
78
+ y0: (d, i) => yFn(y0Aes.fn(d, i)),
79
+ y1: (d, i) => yFn(y1Aes.fn(d, i)),
80
+ fill: alphaize(fill, alpha),
81
+ stroke: resolveAccent(options.stroke, theme),
82
+ strokeWidth: options.strokeWidth,
83
+ });
84
+ return [wrapMark(mark, plot.topLeft, data.length)];
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,89 @@
1
+ import type { Color } from "insomni";
2
+ import { type BinClosed, type BinRule, type HistogramMeasure, type KdeBandwidth, type KdeKernel, type QuantileMethod, type WhiskerRule } from "../../stats/index.ts";
3
+ import { type Aes } from "../aes.ts";
4
+ import type { Geom } from "./types.ts";
5
+ import { type CategoricalLayout } from "./_categorical.ts";
6
+ import { type DensityScale } from "./_distribution.ts";
7
+ export type RidgelineGeom = "kde" | "histogram";
8
+ export type RidgelineScale = DensityScale;
9
+ export type RidgelineInner = "none" | "median" | "quartile" | "mean";
10
+ export type RidgelineFillMode = "solid" | "gradient";
11
+ export interface RidgelineChannels<T> {
12
+ /** Numeric value being distributed. Provide one of x or y; the other is the row category. */
13
+ x: Aes<T, string | number>;
14
+ y: Aes<T, string | number>;
15
+ /** Optional categorical group key. Drives palette fill (chart color scale). */
16
+ color?: Aes<T, unknown>;
17
+ }
18
+ export interface RidgelineOptions {
19
+ /** Auto-detected from scale types when omitted. The default reproduces the common "rows on y, values on x" layout. */
20
+ orientation?: "x" | "y";
21
+ /** Density estimator. Default `"kde"`. */
22
+ geom?: RidgelineGeom;
23
+ /**
24
+ * How tall a ridge can be relative to its band-cell spacing. `1` = no
25
+ * overlap (each ridge stays inside its row); `>1` allows overlap into
26
+ * neighbours. Default `2.5`.
27
+ */
28
+ overlap?: number;
29
+ /**
30
+ * Across-group height normalization (same semantics as `violin`'s `scale`).
31
+ *
32
+ * - `"width"` (default) — each row uses its full allotted height.
33
+ * - `"area"` — heights normalized by the global max density.
34
+ * - `"count"` — heights scaled by sample size on top of `"area"`.
35
+ */
36
+ scale?: RidgelineScale;
37
+ bandwidth?: KdeBandwidth;
38
+ gridSize?: number;
39
+ kernel?: KdeKernel;
40
+ trim?: boolean;
41
+ bins?: number;
42
+ binwidth?: number;
43
+ breaks?: readonly number[];
44
+ rule?: BinRule;
45
+ measure?: HistogramMeasure;
46
+ closed?: BinClosed;
47
+ /**
48
+ * Solid fill (default) uses the chart color scale per category, or the
49
+ * theme's categorical palette if no `color` channel is mapped. Gradient
50
+ * fill ignores the category and applies a value-axis-mapped color ramp,
51
+ * reproducing the "Lincoln NE temperatures" look.
52
+ */
53
+ fillMode?: RidgelineFillMode;
54
+ /**
55
+ * Color ramp for `fillMode: "gradient"`. Receives `t ∈ [0, 1]` mapped from
56
+ * the value-axis range. Default = `theme.palettes.continuous`.
57
+ */
58
+ gradient?: (t01: number) => Color;
59
+ /** Per-row inner annotation. Default `"none"`. */
60
+ inner?: RidgelineInner;
61
+ innerStroke?: Color;
62
+ innerStrokeWidth?: number;
63
+ /** Radius of the median/mean dot when `inner` includes a point. Default `3`. */
64
+ innerDotRadius?: number;
65
+ /** Draw a thin rule along each row's baseline. Default `true`. */
66
+ baseline?: boolean;
67
+ baselineStroke?: Color;
68
+ baselineWidth?: number;
69
+ /** `n=<count>` per row, anchored inline at the row baseline by default. */
70
+ showCounts?: boolean;
71
+ countsAnchor?: "outside" | "inline";
72
+ /** Pixel offset for the counts label. Default 4 (inline) / 28 (outside, vertical) / 32 (outside, horizontal). */
73
+ countsOffset?: number;
74
+ countsFontSize?: number;
75
+ countsColor?: Color;
76
+ /** Solid fill for the silhouette. Default theme palette[0] (overridden by chart color scale when `color` is set). */
77
+ fill?: Color;
78
+ fillAlpha?: number;
79
+ /** Silhouette outline. Default theme text color. */
80
+ stroke?: Color;
81
+ strokeWidth?: number;
82
+ /** Inner-band padding when dodging via `color`. Default `0.05`. */
83
+ groupPadding?: number;
84
+ whisker?: WhiskerRule;
85
+ quantile?: QuantileMethod;
86
+ label?: string;
87
+ }
88
+ export declare function ridgeline<T>(channels: RidgelineChannels<T>, options?: RidgelineOptions): Geom<T>;
89
+ export type { CategoricalLayout };