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,126 @@
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 { rug } from "./rug.ts";
8
+ import { emphasisKeyFor, geomEmphasisBase } from "./emphasis.ts";
9
+ import type { CompileContext } from "./types.ts";
10
+
11
+ interface Row {
12
+ x: number;
13
+ y: number;
14
+ }
15
+
16
+ const data: Row[] = [
17
+ { x: 0, y: 10 },
18
+ { x: 50, y: 20 },
19
+ { x: 100, y: 30 },
20
+ ];
21
+
22
+ function makeCtx(rows: readonly Row[]): CompileContext<Row> {
23
+ const xAes = resolveAes<Row, unknown>("x");
24
+ const yAes = resolveAes<Row, unknown>("y");
25
+ const xScale = buildPositionScale(xAes, rows, [0, 100]);
26
+ const yScale = buildPositionScale(yAes, rows, [200, 0]);
27
+ const scales: ScaleBundle = { x: xScale, y: yScale };
28
+ return {
29
+ data: rows,
30
+ scales,
31
+ plot: createFrame({ x: 50, y: 30, width: 100, height: 200 }),
32
+ theme: themeDefault,
33
+ atlas: undefined,
34
+ };
35
+ }
36
+
37
+ describe("rug geom — compileHitTest", () => {
38
+ test("x rug emits one hit per row anchored at the bottom edge", () => {
39
+ const geom = rug<Row>({ x: "x" }, { length: 8 });
40
+ const hits = geom.compileHitTest!(makeCtx(data))!;
41
+ expect(hits.geomKind).toBe("rug");
42
+ expect(hits.dataIndex.length).toBe(3);
43
+ // First row x=0 → range start 0 + plot.x(50) = 50.
44
+ expect(hits.positions[0]).toBeCloseTo(50, 3);
45
+ // y anchor = bottom (oy+plot.height = 30+200 = 230) - length/2 = 226.
46
+ expect(hits.positions[1]).toBeCloseTo(226, 3);
47
+ });
48
+
49
+ test("y rug emits hits along the left edge", () => {
50
+ const geom = rug<Row>({ y: "y" }, { length: 6 });
51
+ const hits = geom.compileHitTest!(makeCtx(data))!;
52
+ expect(hits.dataIndex.length).toBe(3);
53
+ // First row y=10 → range start 200 + plot.y(30) = 230. (domain auto = [10,30])
54
+ expect(hits.positions[1]).toBeCloseTo(230, 3);
55
+ // x anchor = ox(50) + length/2 = 53.
56
+ expect(hits.positions[0]).toBeCloseTo(53, 3);
57
+ });
58
+
59
+ test("both sides emits 2× rows of hits", () => {
60
+ const geom = rug<Row>({ x: "x", y: "y" });
61
+ const hits = geom.compileHitTest!(makeCtx(data))!;
62
+ expect(hits.dataIndex.length).toBe(6);
63
+ });
64
+
65
+ test("returns null on empty data", () => {
66
+ const geom = rug<Row>({ x: "x" });
67
+ expect(geom.compileHitTest!(makeCtx([]))).toBeNull();
68
+ });
69
+ });
70
+
71
+ describe("rug geom — GPU hover emphasis (P5-T3 Gap 1)", () => {
72
+ function withEmphasis(rows: readonly Row[], emphasis = true): CompileContext<Row> {
73
+ return { ...makeCtx(rows), emphasisBase: emphasis ? geomEmphasisBase(0) : undefined };
74
+ }
75
+ function captureLines(geom: ReturnType<typeof rug<Row>>, ctx: CompileContext<Row>) {
76
+ const lines: Array<{ emphasisKey?: number }> = [];
77
+ const layer = { pushLine: (s: { emphasisKey?: number }) => lines.push(s) };
78
+ for (const b of geom.compile(ctx)) b.addTo(layer as never);
79
+ return lines;
80
+ }
81
+
82
+ test("tags each tick with a per-row key (ordinal = row index = hit dataIndex)", () => {
83
+ const geom = rug<Row>({ x: "x" });
84
+ const base = geomEmphasisBase(0);
85
+ const lines = captureLines(geom, withEmphasis(data));
86
+ expect(lines.length).toBe(3);
87
+ expect(lines.map((l) => l.emphasisKey)).toEqual([
88
+ emphasisKeyFor(base, 0),
89
+ emphasisKeyFor(base, 1),
90
+ emphasisKeyFor(base, 2),
91
+ ]);
92
+ });
93
+
94
+ test("both edges of one row share that row's key (whole-row single key)", () => {
95
+ const geom = rug<Row>({ x: "x", y: "y" });
96
+ const base = geomEmphasisBase(0);
97
+ const lines = captureLines(geom, withEmphasis(data));
98
+ // 3 rows × 2 edges; per-row both edges carry the row's single key.
99
+ expect(lines.length).toBe(6);
100
+ expect(lines.map((l) => l.emphasisKey)).toEqual([
101
+ emphasisKeyFor(base, 0),
102
+ emphasisKeyFor(base, 0),
103
+ emphasisKeyFor(base, 1),
104
+ emphasisKeyFor(base, 1),
105
+ emphasisKeyFor(base, 2),
106
+ emphasisKeyFor(base, 2),
107
+ ]);
108
+ });
109
+
110
+ test("emphasisResolution maps a hit's dataIndex to the tagged key", () => {
111
+ const geom = rug<Row>({ x: "x" });
112
+ const res = geom.emphasisResolution!(withEmphasis(data))!;
113
+ expect(res.geomKind).toBe("rug");
114
+ expect(res.resolve({ geomKind: "rug", dataIndex: 2, data, x: 0, y: 0 })).toBe(
115
+ emphasisKeyFor(geomEmphasisBase(0), 2),
116
+ );
117
+ expect(res.resolve({ geomKind: "bar", dataIndex: 0, data, x: 0, y: 0 })).toBeNull();
118
+ });
119
+
120
+ test("no emphasisBase (SSR/SVG) → ticks untagged, no resolver key", () => {
121
+ const geom = rug<Row>({ x: "x" });
122
+ const lines = captureLines(geom, withEmphasis(data, false));
123
+ expect(lines.every((l) => l.emphasisKey === undefined)).toBe(true);
124
+ expect(geom.emphasisResolution!(withEmphasis(data, false))).toBeNull();
125
+ });
126
+ });
@@ -0,0 +1,214 @@
1
+ // ---------------------------------------------------------------------------
2
+ // rug geom — short marginal ticks along an axis
3
+ // ---------------------------------------------------------------------------
4
+ // `rug({ x: "..." })` draws one short vertical tick per datum along the
5
+ // bottom edge of the plot frame; `rug({ y: "..." })` does the same along
6
+ // the left edge. Pass both channels (or `side: "both"`) for marginal ticks
7
+ // on both axes.
8
+
9
+ import type { Color, Layer } from "insomni";
10
+ import { withAlpha } from "../color-utils.ts";
11
+ import type { Aes } from "../aes.ts";
12
+ import { resolveAes } from "../aes.ts";
13
+ import type { CompileContext, CompiledHitTest, Geom, ResolvedChannelMap } from "./types.ts";
14
+ import { emphasisContext } from "./emphasis.ts";
15
+
16
+ export interface RugChannels<T> {
17
+ x?: Aes<T, number | Date>;
18
+ y?: Aes<T, number | Date>;
19
+ /** Categorical color split for the ticks. */
20
+ color?: Aes<T, unknown>;
21
+ }
22
+
23
+ export type RugSide = "x" | "y" | "both";
24
+
25
+ export interface RugOptions {
26
+ /**
27
+ * Which edges receive ticks. Defaults to whichever channels are wired —
28
+ * with both `x` and `y`, both edges; otherwise just the matching edge.
29
+ * Use `"both"` to force both edges (mirrors x onto bottom, y onto left).
30
+ */
31
+ side?: RugSide;
32
+ /** Tick length in pixels. Default `6`. */
33
+ length?: number;
34
+ /** Tick stroke width. Default `1`. */
35
+ strokeWidth?: number;
36
+ /** Override stroke color (defaults to theme.axis.color). */
37
+ stroke?: Color;
38
+ /** Multiplier on the resolved stroke alpha. Default `0.6`. */
39
+ opacity?: number;
40
+ label?: string;
41
+ }
42
+
43
+ function pickSide(hasX: boolean, hasY: boolean, side: RugSide | undefined): RugSide {
44
+ if (side) return side;
45
+ if (hasX && hasY) return "both";
46
+ if (hasY) return "y";
47
+ return "x";
48
+ }
49
+
50
+ export function rug<T>(channels: RugChannels<T>, options: RugOptions = {}): Geom<T> {
51
+ if (!channels.x && !channels.y) {
52
+ throw new Error("rug() requires at least one of `x` or `y` channels");
53
+ }
54
+ return {
55
+ kind: "rug",
56
+ channels: { x: channels.x, y: channels.y, color: channels.color },
57
+ label: options.label,
58
+ compile(ctx: CompileContext<T>) {
59
+ const { data, scales, plot, theme } = ctx;
60
+ const length = options.length ?? 6;
61
+ const width = options.strokeWidth ?? 1;
62
+ const opacity = options.opacity ?? 0.6;
63
+ const baseStroke = options.stroke ?? theme.axis.color;
64
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
65
+ const colorScale = scales.color?.fn;
66
+ const xAes = channels.x ? resolveAes<T, unknown>(channels.x as Aes<T, unknown>) : undefined;
67
+ const yAes = channels.y ? resolveAes<T, unknown>(channels.y as Aes<T, unknown>) : undefined;
68
+ const side = pickSide(xAes !== undefined, yAes !== undefined, options.side);
69
+ const drawX = (side === "x" || side === "both") && xAes !== undefined;
70
+ const drawY = (side === "y" || side === "both") && yAes !== undefined;
71
+
72
+ const strokeFor = (datum: T, i: number): Color => {
73
+ const c = colorAes && colorScale ? colorScale(colorAes.fn(datum, i)) : baseStroke;
74
+ return withAlpha(c, (c.a ?? 1) * opacity);
75
+ };
76
+
77
+ // Hover dim rides the core's GPU emphasis uniform (P5-T3). Ticks ARE
78
+ // segments (already taggable), so each row's x/y tick gets the SAME key
79
+ // keyed by the row index `i` — exactly the `dataIndex` compileHitTest
80
+ // reports — so the mount's emphasisResolution maps a hover hit to this key
81
+ // with no recompile. (The prior snap path only thickened the active tick;
82
+ // the GPU path can only dim, so non-hovered ticks fade instead — see the
83
+ // P5-T3 report's rug note.)
84
+ const emph = emphasisContext(ctx, "rug");
85
+ return [
86
+ {
87
+ length: data.length * (Number(drawX) + Number(drawY)),
88
+ addTo: (layer: Layer) => {
89
+ const ox = plot.topLeft.x;
90
+ const oy = plot.topLeft.y;
91
+ const bottom = oy + plot.height;
92
+ const right = ox + plot.width;
93
+
94
+ for (let i = 0; i < data.length; i++) {
95
+ const datum = data[i]!;
96
+ const stroke = strokeFor(datum, i);
97
+ const emphasisKey = emph?.keyFor(i);
98
+
99
+ if (drawX && xAes) {
100
+ const px = ox + scales.x.fn(xAes.fn(datum, i));
101
+ if (px >= ox && px <= right) {
102
+ layer.pushLine({
103
+ x1: px,
104
+ y1: bottom - length,
105
+ x2: px,
106
+ y2: bottom,
107
+ color: stroke,
108
+ width,
109
+ emphasisKey,
110
+ });
111
+ }
112
+ }
113
+ if (drawY && yAes) {
114
+ const py = oy + scales.y.fn(yAes.fn(datum, i));
115
+ if (py >= oy && py <= bottom) {
116
+ layer.pushLine({
117
+ x1: ox,
118
+ y1: py,
119
+ x2: ox + length,
120
+ y2: py,
121
+ color: stroke,
122
+ width,
123
+ emphasisKey,
124
+ });
125
+ }
126
+ }
127
+ }
128
+ return layer;
129
+ },
130
+ },
131
+ ];
132
+ },
133
+ emphasisResolution(ctx) {
134
+ // Ordinal = the hit's `dataIndex` = the row index `i` the compile path
135
+ // tags each tick with.
136
+ return emphasisContext(ctx, "rug")?.resolver() ?? null;
137
+ },
138
+ compileHitTest(ctx: CompileContext<T>): CompiledHitTest<T> | null {
139
+ const { data, scales, plot } = ctx;
140
+ if (data.length === 0) return null;
141
+ const length = options.length ?? 6;
142
+ const xAes = channels.x ? resolveAes<T, unknown>(channels.x as Aes<T, unknown>) : undefined;
143
+ const yAes = channels.y ? resolveAes<T, unknown>(channels.y as Aes<T, unknown>) : undefined;
144
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
145
+ const side = pickSide(xAes !== undefined, yAes !== undefined, options.side);
146
+ const drawX = (side === "x" || side === "both") && xAes !== undefined;
147
+ const drawY = (side === "y" || side === "both") && yAes !== undefined;
148
+ if (!drawX && !drawY) return null;
149
+
150
+ const ox = plot.topLeft.x;
151
+ const oy = plot.topLeft.y;
152
+ const bottom = oy + plot.height;
153
+ // Cap upper bound at one hit per row per drawn edge.
154
+ const cap = data.length * (Number(drawX) + Number(drawY));
155
+ const positions = new Float32Array(cap * 2);
156
+ const rects = new Float32Array(cap * 4);
157
+ const dataIndex = new Int32Array(cap);
158
+ // Half-width of the hoverable band around each tick (in pixels).
159
+ // Wide enough to click comfortably; equals ~half the pick radius.
160
+ const hitHalfBand = 4;
161
+ let n = 0;
162
+ for (let i = 0; i < data.length; i++) {
163
+ const datum = data[i]!;
164
+ if (drawX && xAes) {
165
+ const px = ox + scales.x.fn(xAes.fn(datum, i) as never);
166
+ if (Number.isFinite(px) && px >= ox && px <= ox + plot.width) {
167
+ // Anchor the hit at the tick's midpoint for tooltip placement.
168
+ positions[n * 2] = px;
169
+ positions[n * 2 + 1] = bottom - length / 2;
170
+ // Thin vertical band around the tick line, spanning full tick length.
171
+ rects[n * 4] = px - hitHalfBand;
172
+ rects[n * 4 + 1] = bottom - length;
173
+ rects[n * 4 + 2] = hitHalfBand * 2;
174
+ rects[n * 4 + 3] = length;
175
+ dataIndex[n] = i;
176
+ n++;
177
+ }
178
+ }
179
+ if (drawY && yAes) {
180
+ const py = oy + scales.y.fn(yAes.fn(datum, i) as never);
181
+ if (Number.isFinite(py) && py >= oy && py <= bottom) {
182
+ positions[n * 2] = ox + length / 2;
183
+ positions[n * 2 + 1] = py;
184
+ // Thin horizontal band around the tick line, spanning full tick length.
185
+ rects[n * 4] = ox;
186
+ rects[n * 4 + 1] = py - hitHalfBand;
187
+ rects[n * 4 + 2] = length;
188
+ rects[n * 4 + 3] = hitHalfBand * 2;
189
+ dataIndex[n] = i;
190
+ n++;
191
+ }
192
+ }
193
+ }
194
+ if (n === 0) return null;
195
+ const channelsMap: ResolvedChannelMap<T> = {
196
+ x: xAes,
197
+ y: yAes,
198
+ color: colorAes,
199
+ };
200
+ // Tick width is small; size the pickRadius to the tick's full length so
201
+ // the cursor anywhere over a tick (or just past it) picks it.
202
+ return {
203
+ geomKind: "rug",
204
+ label: options.label,
205
+ positions: positions.subarray(0, n * 2),
206
+ rects: rects.subarray(0, n * 4),
207
+ dataIndex: dataIndex.subarray(0, n),
208
+ pickRadius: Math.max(6, length),
209
+ channels: channelsMap,
210
+ data,
211
+ };
212
+ },
213
+ };
214
+ }
@@ -0,0 +1,23 @@
1
+ import type { Color } from "insomni";
2
+ import type { Aes } from "../aes.ts";
3
+ import { type ColorOrAccent } from "../color-utils.ts";
4
+ import type { Geom } from "./types.ts";
5
+ export interface RuleChannels<T> {
6
+ x?: Aes<T, number | Date>;
7
+ y?: Aes<T, number | Date>;
8
+ }
9
+ export interface RuleOptions {
10
+ /**
11
+ * Stroke 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
+ stroke?: ColorOrAccent;
16
+ strokeWidth?: number;
17
+ dashPattern?: readonly number[];
18
+ /** Optional inline rule label (only meaningful for non-data rules). */
19
+ label?: string;
20
+ labelColor?: Color;
21
+ labelInset?: number;
22
+ }
23
+ export declare function rule<T>(channels: RuleChannels<T>, options?: RuleOptions): Geom<T>;
@@ -0,0 +1,69 @@
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 { rule } from "./rule.ts";
8
+ import type { CompileContext } from "./types.ts";
9
+
10
+ interface Row {
11
+ threshold: number;
12
+ }
13
+
14
+ const data: Row[] = [{ threshold: 25 }, { threshold: 75 }];
15
+
16
+ function makeCtx(rows: readonly Row[]): CompileContext<Row> {
17
+ const xAes = resolveAes<Row, unknown>("threshold");
18
+ const yAes = resolveAes<Row, unknown>("threshold");
19
+ const xScale = buildPositionScale(xAes, rows, [0, 100]);
20
+ const yScale = buildPositionScale(yAes, rows, [200, 0]);
21
+ const scales: ScaleBundle = { x: xScale, y: yScale };
22
+ return {
23
+ data: rows,
24
+ scales,
25
+ plot: createFrame({ x: 50, y: 30, width: 100, height: 200 }),
26
+ theme: themeDefault,
27
+ atlas: undefined,
28
+ };
29
+ }
30
+
31
+ describe("rule geom — compileHitTest", () => {
32
+ test("vertical rule emits one hit per row, pickAxis 'x'", () => {
33
+ const geom = rule<Row>({ x: "threshold" });
34
+ const hits = geom.compileHitTest!(makeCtx(data))!;
35
+ expect(hits.geomKind).toBe("rule");
36
+ expect(hits.pickAxis).toBe("x");
37
+ expect(hits.dataIndex.length).toBe(2);
38
+ // domain auto-derives to [25, 75]; threshold=25 maps to range start 0,
39
+ // plus plot.x(50) = 50.
40
+ expect(hits.positions[0]).toBeCloseTo(50, 3);
41
+ // y centered in plot: 30 + 100 = 130.
42
+ expect(hits.positions[1]).toBeCloseTo(130, 3);
43
+ });
44
+
45
+ test("horizontal rule emits pickAxis 'y' with x centered in plot", () => {
46
+ const geom = rule<Row>({ y: "threshold" });
47
+ const hits = geom.compileHitTest!(makeCtx(data))!;
48
+ expect(hits.pickAxis).toBe("y");
49
+ // x centered: 50 + 50 = 100.
50
+ expect(hits.positions[0]).toBeCloseTo(100, 3);
51
+ });
52
+
53
+ test("pickRadius is a small perpendicular tolerance — pickAxis ignores the rest", () => {
54
+ // pickAxis="x" (vertical rule) means the picker only considers x-distance,
55
+ // so the cursor's y can be anywhere along the rule and still hit. The
56
+ // radius therefore constrains the *perpendicular* (x) distance to the
57
+ // line itself; it should NOT be plot-sized, otherwise the rule swallows
58
+ // hits intended for other geoms (legend, etc.) sharing on-axis position.
59
+ const geom = rule<Row>({ x: "threshold" });
60
+ const hits = geom.compileHitTest!(makeCtx(data))!;
61
+ expect(hits.pickRadius).toBeLessThan(50);
62
+ expect(hits.pickRadius).toBeGreaterThanOrEqual(8);
63
+ });
64
+
65
+ test("returns null when no rule positions resolve", () => {
66
+ const geom = rule<Row>({ x: "threshold" });
67
+ expect(geom.compileHitTest!(makeCtx([]))).toBeNull();
68
+ });
69
+ });
@@ -0,0 +1,212 @@
1
+ // ---------------------------------------------------------------------------
2
+ // rule geom — vertical or horizontal reference lines
3
+ // ---------------------------------------------------------------------------
4
+ // `rule({ x: <value> })` → vertical rule(s) at the given x value(s)
5
+ // `rule({ y: <value> })` → horizontal rule(s) at the given y value(s)
6
+ //
7
+ // The value can be a constant (one rule), a column key (one rule per row),
8
+ // or an accessor. Data is optional — if not given, the chart's data is used,
9
+ // or `[null]` for a single-rule case.
10
+
11
+ import type { Color } from "insomni";
12
+ import { ruleMark } from "../../annotations.ts";
13
+ import type { Aes } from "../aes.ts";
14
+ import { resolveAes } from "../aes.ts";
15
+ import { resolveAccent, type ColorOrAccent } from "../color-utils.ts";
16
+ import type { CompileContext, CompiledHitTest, Geom, ResolvedChannelMap } from "./types.ts";
17
+ import { inlineMark, resolveCoord, wrapMark } from "./_mark.ts";
18
+
19
+ export interface RuleChannels<T> {
20
+ x?: Aes<T, number | Date>;
21
+ y?: Aes<T, number | Date>;
22
+ }
23
+
24
+ export interface RuleOptions {
25
+ /**
26
+ * Stroke color. Accepts a literal {@link Color} or a theme accent key
27
+ * (`"positive" | "negative" | "warn" | "info"`); accent keys resolve through
28
+ * `theme.accents`.
29
+ */
30
+ stroke?: ColorOrAccent;
31
+ strokeWidth?: number;
32
+ dashPattern?: readonly number[];
33
+ /** Optional inline rule label (only meaningful for non-data rules). */
34
+ label?: string;
35
+ labelColor?: Color;
36
+ labelInset?: number;
37
+ }
38
+
39
+ export function rule<T>(channels: RuleChannels<T>, options: RuleOptions = {}): Geom<T> {
40
+ if (!channels.x && !channels.y) {
41
+ throw new Error("rule(): one of `x` or `y` must be provided");
42
+ }
43
+ const isVertical = channels.x !== undefined;
44
+ return {
45
+ kind: "rule",
46
+ channels: { x: channels.x, y: channels.y },
47
+ label: options.label,
48
+ compile(ctx: CompileContext<T>) {
49
+ const { data, scales, plot, theme } = ctx;
50
+ const coord = resolveCoord(ctx);
51
+ const stroke = resolveAccent(options.stroke, theme) ?? theme.text.color;
52
+ const strokeWidth = options.strokeWidth ?? theme.marks.ruleStrokeWidth;
53
+
54
+ const out: ReturnType<typeof wrapMark>[] = [];
55
+ const labelColor = options.labelColor ?? theme.subtitle.color;
56
+ const labelInset = options.labelInset ?? theme.marks.ruleLabelInset;
57
+ const fontSize = theme.marks.annotationFontSize;
58
+
59
+ const isCartesian = coord.kind === "cartesian";
60
+ if (isVertical) {
61
+ const aes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
62
+ const fn = scales.x.fn;
63
+ const rows = aes.kind === "constant" ? [data[0] ?? (undefined as unknown as T)] : data;
64
+ for (let i = 0; i < rows.length; i++) {
65
+ const xPx = fn(aes.fn(rows[i] as T, i));
66
+ if (isCartesian) {
67
+ // Route through `coord.project`. Under Cartesian this is the
68
+ // identity; rules stay axis-aligned.
69
+ const projected = coord.project({ x: xPx, y: 0 });
70
+ const mark = ruleMark({
71
+ x: projected.x,
72
+ extent: [0, plot.height],
73
+ stroke,
74
+ strokeWidth,
75
+ dashPattern: options.dashPattern,
76
+ label: i === 0 ? options.label : undefined,
77
+ labelInset,
78
+ labelColor,
79
+ fontSize,
80
+ });
81
+ out.push(wrapMark(mark, plot.topLeft, 1));
82
+ } else {
83
+ // Non-cartesian (polar): a "vertical" rule (constant x) sweeps
84
+ // the full off-axis (y) extent. Tessellate that sweep through
85
+ // `coord.segment` — under the default `angleChannel: "y"` polar
86
+ // this becomes a constant-radius arc (a circle for full y).
87
+ const tess = coord.segment({ x: xPx, y: 0 }, { x: xPx, y: plot.height });
88
+ out.push(
89
+ buildPolarRulePolyline(tess, plot.topLeft, stroke, strokeWidth, options.dashPattern),
90
+ );
91
+ }
92
+ }
93
+ } else {
94
+ const aes = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
95
+ const fn = scales.y.fn;
96
+ const rows = aes.kind === "constant" ? [data[0] ?? (undefined as unknown as T)] : data;
97
+ for (let i = 0; i < rows.length; i++) {
98
+ const yPx = fn(aes.fn(rows[i] as T, i));
99
+ if (isCartesian) {
100
+ const projected = coord.project({ x: 0, y: yPx });
101
+ const mark = ruleMark({
102
+ y: projected.y,
103
+ extent: [0, plot.width],
104
+ stroke,
105
+ strokeWidth,
106
+ dashPattern: options.dashPattern,
107
+ label: i === 0 ? options.label : undefined,
108
+ labelInset,
109
+ labelColor,
110
+ fontSize,
111
+ });
112
+ out.push(wrapMark(mark, plot.topLeft, 1));
113
+ } else {
114
+ // "Horizontal" rule (constant y) sweeps full x extent. Under the
115
+ // default polar this becomes a constant-θ spoke (straight radial
116
+ // line). `coord.segment` short-circuits to a 2-point polyline
117
+ // for same-angle inputs, so the output is a single straight line.
118
+ const tess = coord.segment({ x: 0, y: yPx }, { x: plot.width, y: yPx });
119
+ out.push(
120
+ buildPolarRulePolyline(tess, plot.topLeft, stroke, strokeWidth, options.dashPattern),
121
+ );
122
+ }
123
+ }
124
+ }
125
+ return out;
126
+ },
127
+ compileHitTest(ctx: CompileContext<T>): CompiledHitTest<T> | null {
128
+ const { data, scales, plot, theme } = ctx;
129
+ const coord = resolveCoord(ctx);
130
+ // Pick the perpendicular axis: vertical rule → cursor's x picks; horizontal → y.
131
+ const pickAxis: "x" | "y" = isVertical ? "x" : "y";
132
+ const aes = resolveAes<T, unknown>((isVertical ? channels.x : channels.y) as Aes<T, unknown>);
133
+ const fn = (isVertical ? scales.x : scales.y).fn;
134
+ const rows = aes.kind === "constant" ? [data[0] ?? (undefined as unknown as T)] : data;
135
+ const ox = plot.topLeft.x;
136
+ const oy = plot.topLeft.y;
137
+ const positions = new Float32Array(rows.length * 2);
138
+ const dataIndex = new Int32Array(rows.length);
139
+ let n = 0;
140
+ for (let i = 0; i < rows.length; i++) {
141
+ const v = aes.fn(rows[i] as T, i);
142
+ const px = fn(v as never);
143
+ if (!Number.isFinite(px)) continue;
144
+ // Project the anchor point (mid-extent on the off axis) so tooltips
145
+ // land on the visible rule under any coord. Cartesian: identity.
146
+ if (isVertical) {
147
+ const projected = coord.project({ x: px, y: plot.height / 2 });
148
+ positions[n * 2] = ox + projected.x;
149
+ positions[n * 2 + 1] = oy + projected.y;
150
+ } else {
151
+ const projected = coord.project({ x: plot.width / 2, y: px });
152
+ positions[n * 2] = ox + projected.x;
153
+ positions[n * 2 + 1] = oy + projected.y;
154
+ }
155
+ // For constant aes, all hits map back to data row 0; for column-driven
156
+ // rules, hit i corresponds to data row i.
157
+ dataIndex[n] = aes.kind === "constant" ? 0 : i;
158
+ n++;
159
+ }
160
+ if (n === 0) return null;
161
+ const channelsMap: ResolvedChannelMap<T> = isVertical ? { x: aes } : { y: aes };
162
+ return {
163
+ geomKind: "rule",
164
+ label: options.label,
165
+ positions: positions.subarray(0, n * 2),
166
+ dataIndex: dataIndex.subarray(0, n),
167
+ // pickAxis ignores the off-axis entirely (along-the-rule extent is
168
+ // unbounded that way), so pickRadius constrains the *perpendicular*
169
+ // distance to the rule line. Keep it tight — a few pixels of
170
+ // tolerance — so the rule doesn't claim hits over the rest of the
171
+ // chart (legend, other geoms) just because they share an x or y.
172
+ pickRadius: Math.max(8, (options.strokeWidth ?? theme.marks.ruleStrokeWidth) * 2 + 4),
173
+ pickAxis,
174
+ channels: channelsMap,
175
+ // Use the chart's data array (not the locally-built `rows`). The
176
+ // hit-layer's fast-path keys reuse on `data` identity; allocating a
177
+ // fresh `[data[0]]` each compile would tear down PointCloud nodes
178
+ // mid-hover and leave the tooltip stuck (no leave event ever fires).
179
+ // dataIndex already maps to row 0 of `data` for the constant case.
180
+ data,
181
+ };
182
+ },
183
+ };
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Polar helper — build a polyline mark builder from a tessellated segment.
188
+ // ---------------------------------------------------------------------------
189
+
190
+ interface PolarRulePolyline {
191
+ length: number;
192
+ addTo: (layer: import("insomni").Layer) => import("insomni").Layer;
193
+ }
194
+
195
+ function buildPolarRulePolyline(
196
+ tess: readonly { x: number; y: number }[],
197
+ origin: { x: number; y: number },
198
+ stroke: Color,
199
+ strokeWidth: number,
200
+ dashPattern: readonly number[] | undefined,
201
+ ): PolarRulePolyline {
202
+ return inlineMark((layer) => {
203
+ if (tess.length < 2) return;
204
+ const pts = tess.map((p) => ({ x: origin.x + p.x, y: origin.y + p.y }));
205
+ layer.pushPolyline({
206
+ points: pts,
207
+ color: stroke,
208
+ width: strokeWidth,
209
+ dashPattern,
210
+ });
211
+ }) as PolarRulePolyline;
212
+ }