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,234 @@
1
+ // ---------------------------------------------------------------------------
2
+ // text geom — value labels at (x, y)
3
+ // ---------------------------------------------------------------------------
4
+ // Optional `collisionMode` runs a post-layout pass before the labels are
5
+ // handed to `valueLabelMark`:
6
+ //
7
+ // - `"none"` (default): emit every label as-is.
8
+ // - `"hide"`: greedy first-fit. Labels are processed in data order; a
9
+ // label whose padded bounding rect overlaps any previously kept label
10
+ // is dropped. Stable for stable inputs, but the visible set shifts
11
+ // under pan/zoom — no hysteresis (the proposal calls this out; we ship
12
+ // the simple algorithm and document the trade-off).
13
+ // - `"stagger"`: alternate odd-indexed labels by `fontSize + 2` pixels in
14
+ // y. Doesn't drop labels — gives roughly twice the horizontal density
15
+ // in exchange for vertical real estate.
16
+
17
+ import type { Color } from "insomni";
18
+ import { valueLabelMark, type LabelBoxStyle, type ValueLabelAlign } from "../../annotations.ts";
19
+ import type { Aes } from "../aes.ts";
20
+ import { resolveAes } from "../aes.ts";
21
+ import type { CompileContext, CompiledHitTest, Geom, ResolvedChannelMap } from "./types.ts";
22
+ import { resolveCoord, wrapMark } from "./_mark.ts";
23
+
24
+ export type TextCollisionMode = "none" | "hide" | "stagger";
25
+
26
+ export interface TextChannels<T> {
27
+ x: Aes<T, number | Date | string>;
28
+ y: Aes<T, number | Date | string>;
29
+ text: Aes<T, string>;
30
+ }
31
+
32
+ export interface TextOptions {
33
+ fontSize?: number;
34
+ color?: Color;
35
+ align?: ValueLabelAlign;
36
+ offsetX?: number;
37
+ offsetY?: number;
38
+ /** Optional rounded-rect background. Requires the chart's glyph atlas. */
39
+ box?: LabelBoxStyle;
40
+ label?: string;
41
+ /**
42
+ * Post-layout collision handling. Default `"none"`. `"hide"` drops
43
+ * overlapping labels via a greedy first-fit pass; `"stagger"` adds an
44
+ * alternating y offset so adjacent labels sit on two rows.
45
+ */
46
+ collisionMode?: TextCollisionMode;
47
+ /** Pixel padding around each label's measured rect when checking overlap. Default `2`. */
48
+ collisionPadding?: number;
49
+ }
50
+
51
+ interface LabelRect {
52
+ index: number;
53
+ x: number;
54
+ y: number;
55
+ w: number;
56
+ h: number;
57
+ }
58
+
59
+ function rectsOverlap(a: LabelRect, b: LabelRect, pad: number): boolean {
60
+ return (
61
+ a.x - pad < b.x + b.w + pad &&
62
+ a.x + a.w + pad > b.x - pad &&
63
+ a.y - pad < b.y + b.h + pad &&
64
+ a.y + a.h + pad > b.y - pad
65
+ );
66
+ }
67
+
68
+ function originForAlign(
69
+ align: ValueLabelAlign,
70
+ x: number,
71
+ y: number,
72
+ w: number,
73
+ h: number,
74
+ ): { x: number; y: number } {
75
+ if (align === "left") return { x, y: y - h / 2 };
76
+ if (align === "right") return { x: x - w, y: y - h / 2 };
77
+ return { x: x - w / 2, y: y - h / 2 };
78
+ }
79
+
80
+ export function text<T>(channels: TextChannels<T>, options: TextOptions = {}): Geom<T> {
81
+ return {
82
+ kind: "text",
83
+ channels: { x: channels.x, y: channels.y },
84
+ label: options.label,
85
+ compile(ctx: CompileContext<T>) {
86
+ const { data, scales, plot, theme, atlas } = ctx;
87
+ if (!atlas) return [];
88
+ const coord = resolveCoord(ctx);
89
+ const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
90
+ const yAes = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
91
+ const tAes = resolveAes<T, string>(channels.text);
92
+ const xFn = scales.x.fn;
93
+ const yFn = scales.y.fn;
94
+ const fontSize = options.fontSize ?? theme.marks.labelFontSize;
95
+ const align = options.align ?? "center";
96
+ const offsetX = options.offsetX ?? 0;
97
+ const offsetY = options.offsetY ?? 0;
98
+ const mode: TextCollisionMode = options.collisionMode ?? "none";
99
+ const pad = options.collisionPadding ?? 2;
100
+ const staggerDy = fontSize + 2;
101
+
102
+ // Indices into `data` we'll actually render. For "hide" mode we trim
103
+ // via greedy first-fit; for everything else we keep every row.
104
+ let keptIndices: readonly number[] = data.map((_, i) => i);
105
+ const staggerOffsets = new Map<number, number>();
106
+
107
+ if (mode === "hide" || mode === "stagger") {
108
+ // Measure all labels once. Stagger doesn't strictly need the rects,
109
+ // but unifying the path keeps the code legible.
110
+ const rects: LabelRect[] = [];
111
+ for (let i = 0; i < data.length; i++) {
112
+ const datum = data[i]!;
113
+ const rawX = xFn(xAes.fn(datum, i) as never);
114
+ const rawY = yFn(yAes.fn(datum, i) as never);
115
+ if (!Number.isFinite(rawX) || !Number.isFinite(rawY)) continue;
116
+ const projected = coord.project({ x: rawX, y: rawY });
117
+ const px = projected.x + offsetX;
118
+ const py = projected.y + offsetY;
119
+ const text = tAes.fn(datum, i);
120
+ const m = atlas.measureText(text, { fontSize, simple: true });
121
+ const origin = originForAlign(align, px, py, m.width, fontSize);
122
+ rects.push({ index: i, x: origin.x, y: origin.y, w: m.width, h: fontSize });
123
+ }
124
+
125
+ if (mode === "hide") {
126
+ const kept: LabelRect[] = [];
127
+ for (const r of rects) {
128
+ let collides = false;
129
+ for (const k of kept) {
130
+ if (rectsOverlap(r, k, pad)) {
131
+ collides = true;
132
+ break;
133
+ }
134
+ }
135
+ if (!collides) kept.push(r);
136
+ }
137
+ keptIndices = kept.map((r) => r.index);
138
+ } else {
139
+ // stagger: keep all, alternate odd rows by staggerDy. Tracked by
140
+ // original index so the y accessor below can branch cheaply.
141
+ for (let i = 0; i < rects.length; i++) {
142
+ if (i % 2 === 1) staggerOffsets.set(rects[i]!.index, staggerDy);
143
+ }
144
+ }
145
+ }
146
+
147
+ const renderData = mode === "hide" ? keptIndices.map((i) => data[i]!) : data;
148
+ const renderIndices = mode === "hide" ? keptIndices : null;
149
+ // Per-datum coord projection — under Cartesian, identity. Stagger
150
+ // offsets are post-projection layer-pixel deltas (additive in the
151
+ // current frame), so we apply them after projecting.
152
+ const projectLabel = (d: T, originalIndex: number): { x: number; y: number } => {
153
+ const rawX = xFn(xAes.fn(d, originalIndex) as never);
154
+ const rawY = yFn(yAes.fn(d, originalIndex) as never);
155
+ return coord.project({ x: rawX, y: rawY });
156
+ };
157
+ const mark = valueLabelMark(renderData, {
158
+ x: (d, i) => {
159
+ const originalIndex = renderIndices ? renderIndices[i]! : i;
160
+ return projectLabel(d, originalIndex).x;
161
+ },
162
+ y: (d, i) => {
163
+ const originalIndex = renderIndices ? renderIndices[i]! : i;
164
+ const base = projectLabel(d, originalIndex).y;
165
+ const stagger = mode === "stagger" ? (staggerOffsets.get(originalIndex) ?? 0) : 0;
166
+ return base + stagger;
167
+ },
168
+ text: (d, i) => {
169
+ const originalIndex = renderIndices ? renderIndices[i]! : i;
170
+ return tAes.fn(d, originalIndex);
171
+ },
172
+ fontSize,
173
+ color: options.color ?? theme.text.color,
174
+ align,
175
+ offset: { x: offsetX, y: offsetY },
176
+ box: options.box,
177
+ });
178
+ return [wrapMark(mark, plot.topLeft, renderData.length)];
179
+ },
180
+ compileHitTest(ctx: CompileContext<T>): CompiledHitTest<T> | null {
181
+ const { data, scales, plot, theme } = ctx;
182
+ if (data.length === 0) return null;
183
+ const coord = resolveCoord(ctx);
184
+ const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
185
+ const yAes = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
186
+ const tAes = resolveAes<T, string>(channels.text);
187
+ const xFn = scales.x.fn;
188
+ const yFn = scales.y.fn;
189
+ const ox = plot.topLeft.x;
190
+ const oy = plot.topLeft.y;
191
+ const positions = new Float32Array(data.length * 2);
192
+ const dataIndex = new Int32Array(data.length);
193
+ let n = 0;
194
+ for (let i = 0; i < data.length; i++) {
195
+ const datum = data[i]!;
196
+ const rawX = xFn(xAes.fn(datum, i) as never);
197
+ const rawY = yFn(yAes.fn(datum, i) as never);
198
+ if (!Number.isFinite(rawX) || !Number.isFinite(rawY)) continue;
199
+ const projected = coord.project({ x: rawX, y: rawY });
200
+ positions[n * 2] = ox + projected.x;
201
+ positions[n * 2 + 1] = oy + projected.y;
202
+ dataIndex[n] = i;
203
+ n++;
204
+ }
205
+ if (n === 0) return null;
206
+ // Pick within a glyph-sized halo. Errors on the side of forgiving so
207
+ // labels remain easy to hover (they're typically sparse vs. point clouds).
208
+ const fontSize = options.fontSize ?? theme.marks.labelFontSize;
209
+ // Surface the text string in the tooltip via a synthetic accessor under
210
+ // the `shape` slot — `text` isn't a standard channel, and `shape` is
211
+ // otherwise unused by `text()`. The displayed label resolves from
212
+ // `aes.column` (set to "text"), so the slot choice is invisible to users.
213
+ const textAes = {
214
+ kind: "accessor" as const,
215
+ column: "text" as keyof T & string,
216
+ fn: tAes.fn,
217
+ };
218
+ const channelsMap: ResolvedChannelMap<T> = {
219
+ x: xAes,
220
+ y: yAes,
221
+ shape: textAes as never,
222
+ };
223
+ return {
224
+ geomKind: "text",
225
+ label: options.label,
226
+ positions: positions.subarray(0, n * 2),
227
+ dataIndex: dataIndex.subarray(0, n),
228
+ pickRadius: Math.max(8, fontSize),
229
+ channels: channelsMap,
230
+ data,
231
+ };
232
+ },
233
+ };
234
+ }
@@ -0,0 +1,61 @@
1
+ import type { Color } from "insomni";
2
+ import type { Aes } from "../aes.ts";
3
+ import type { Geom } from "./types.ts";
4
+ export interface TileChannels<T> {
5
+ x: Aes<T, string | number | Date>;
6
+ y: Aes<T, string | number | Date>;
7
+ /**
8
+ * Continuous fill — typically a numeric column. Drives the chart's color
9
+ * scale (legend renders as a continuous color bar). For a per-cell constant
10
+ * color, pass `options.fill` instead and omit this channel.
11
+ */
12
+ fill?: Aes<T, unknown>;
13
+ }
14
+ export interface TileNAOptions {
15
+ /** Fill for missing/non-finite values. When unset, NA cells are skipped. */
16
+ fill?: Color;
17
+ }
18
+ export interface TileOptions<T = unknown> {
19
+ /**
20
+ * Constant cell color when no `fill` channel is mapped. Ignored when
21
+ * `channels.fill` is present (in which case the color scale supplies the
22
+ * fill).
23
+ */
24
+ fill?: Color;
25
+ /** Horizontal/vertical inset between cells. Default `0`. */
26
+ padding?: number;
27
+ /** Per-axis padding override (multi-key form). Wins over `padding`. */
28
+ paddingX?: number;
29
+ paddingY?: number;
30
+ /** Optional border outline per cell. */
31
+ stroke?: Color;
32
+ strokeWidth?: number;
33
+ cornerRadius?: number;
34
+ /**
35
+ * Per-cell text label. Receives the (numeric) fill value when the `fill`
36
+ * channel is mapped; receives `NaN` otherwise (in which case you'll
37
+ * typically derive the label from the datum directly).
38
+ */
39
+ showValues?: (value: number, datum: T, index: number) => string;
40
+ labelColor?: Color;
41
+ labelFontSize?: number;
42
+ /**
43
+ * Skip per-cell labels when the projected cell is below this pixel size on
44
+ * either axis. Labels at small cell sizes are unreadable smudges and the
45
+ * shaping cost dominates frame time at high cell counts. Default: unset
46
+ * (every cell labeled). A value around `1.5 * labelFontSize` is a sensible
47
+ * floor for legibility.
48
+ */
49
+ minLabelCellPx?: number;
50
+ /**
51
+ * For numeric/time x or y axes only — the implicit cell width/height in
52
+ * domain units. When omitted, the cell extent is inferred from the
53
+ * smallest gap between sorted unique values in the data.
54
+ */
55
+ cellWidth?: number;
56
+ cellHeight?: number;
57
+ /** Missing-value handling. */
58
+ na?: TileNAOptions;
59
+ label?: string;
60
+ }
61
+ export declare function tile<T>(channels: TileChannels<T>, options?: TileOptions<T>): Geom<T>;
@@ -0,0 +1,157 @@
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 { tile } from "./tile.ts";
8
+ import { emphasisKeyFor, geomEmphasisBase } from "./emphasis.ts";
9
+ import type { CompileContext } from "./types.ts";
10
+
11
+ interface Row {
12
+ x: string;
13
+ y: string;
14
+ v: number;
15
+ }
16
+
17
+ const data: Row[] = [
18
+ { x: "a", y: "p", v: 1 },
19
+ { x: "b", y: "p", v: 2 },
20
+ { x: "a", y: "q", v: 3 },
21
+ { x: "b", y: "q", v: 4 },
22
+ ];
23
+
24
+ function makeCtx(rows: readonly Row[]): CompileContext<Row> {
25
+ const xAes = resolveAes<Row, unknown>("x");
26
+ const yAes = resolveAes<Row, unknown>("y");
27
+ const xScale = buildPositionScale(xAes, rows, [0, 100], { type: "band", padding: 0 });
28
+ const yScale = buildPositionScale(yAes, rows, [0, 200], { type: "band", padding: 0 });
29
+ const scales: ScaleBundle = { x: xScale, y: yScale };
30
+ return {
31
+ data: rows,
32
+ scales,
33
+ plot: createFrame({ x: 50, y: 30, width: 100, height: 200 }),
34
+ theme: themeDefault,
35
+ atlas: undefined,
36
+ emphasisBase: geomEmphasisBase(0),
37
+ };
38
+ }
39
+
40
+ function captureRects(builders: ReturnType<ReturnType<typeof tile<Row>>["compile"]>) {
41
+ const rects: Array<{ fill?: unknown; emphasisKey?: number }> = [];
42
+ const layer = { pushRect: (s: { fill?: unknown; emphasisKey?: number }) => rects.push(s) };
43
+ for (const b of builders) b.addTo(layer as never);
44
+ return rects;
45
+ }
46
+
47
+ describe("tile geom — emphasis-key tagging (P5-T3)", () => {
48
+ test("tags each cell with a per-row key (ordinal = dataIndex); no hover dim/halo", () => {
49
+ const geom = tile<Row>({ x: "x", y: "y", fill: "v" });
50
+ const base = geomEmphasisBase(0);
51
+ const plain = captureRects(geom.compile(makeCtx(data)));
52
+ expect(plain.length).toBe(4);
53
+ expect(plain.map((r) => r.emphasisKey)).toEqual([
54
+ emphasisKeyFor(base, 0),
55
+ emphasisKeyFor(base, 1),
56
+ emphasisKeyFor(base, 2),
57
+ emphasisKeyFor(base, 3),
58
+ ]);
59
+ // Fills are stable across hover (compile no longer reads ctx.hovered).
60
+ const hovered = captureRects(
61
+ geom.compile({
62
+ ...makeCtx(data),
63
+ hovered: { geomKind: "tile", dataIndex: 1, data, x: 0, y: 0 },
64
+ }),
65
+ );
66
+ expect(hovered.map((r) => r.fill)).toEqual(plain.map((r) => r.fill));
67
+ });
68
+
69
+ test("resolver maps a hit's dataIndex to the tagged key", () => {
70
+ const geom = tile<Row>({ x: "x", y: "y", fill: "v" });
71
+ const res = geom.emphasisResolution!(makeCtx(data))!;
72
+ expect(res.resolve({ geomKind: "tile", dataIndex: 3, data, x: 0, y: 0 })).toBe(
73
+ emphasisKeyFor(geomEmphasisBase(0), 3),
74
+ );
75
+ // A hit from another geom is not ours.
76
+ expect(res.resolve({ geomKind: "bar", dataIndex: 0, data, x: 0, y: 0 })).toBeNull();
77
+ });
78
+
79
+ test("no emphasisBase (SSR/SVG) → cells untagged", () => {
80
+ const geom = tile<Row>({ x: "x", y: "y", fill: "v" });
81
+ const ctx = { ...makeCtx(data), emphasisBase: undefined };
82
+ const rects = captureRects(geom.compile(ctx));
83
+ expect(rects.every((r) => r.emphasisKey === undefined)).toBe(true);
84
+ });
85
+ });
86
+
87
+ describe("tile geom — compileHitTest", () => {
88
+ test("emits one hit per cell at center", () => {
89
+ const geom = tile<Row>({ x: "x", y: "y", fill: "v" });
90
+ const hits = geom.compileHitTest!(makeCtx(data))!;
91
+ expect(hits.geomKind).toBe("tile");
92
+ expect(hits.dataIndex.length).toBe(4);
93
+ // Band x range = [0,100], two bands "a","b" → bandwidth 50, centers at 25, 75.
94
+ // First row x="a" → center x = 25; +plot.x(50) = 75.
95
+ expect(hits.positions[0]).toBeCloseTo(75, 3);
96
+ // Band y range = [0,200], two bands "p","q" → bandwidth 100, centers at 50, 150.
97
+ // First row y="p" → center y = 50; +plot.y(30) = 80.
98
+ expect(hits.positions[1]).toBeCloseTo(80, 3);
99
+ });
100
+
101
+ test("pickRadius is half the smallest cell extent", () => {
102
+ const geom = tile<Row>({ x: "x", y: "y", fill: "v" });
103
+ const hits = geom.compileHitTest!(makeCtx(data))!;
104
+ // min(bandwidth_x=50, bandwidth_y=100) / 2 = 25.
105
+ expect(hits.pickRadius).toBeCloseTo(25, 3);
106
+ });
107
+
108
+ test("returns null for empty data", () => {
109
+ const geom = tile<Row>({ x: "x", y: "y", fill: "v" });
110
+ expect(geom.compileHitTest!(makeCtx([]))).toBeNull();
111
+ });
112
+
113
+ test("exposes x/y/color (fill) channels", () => {
114
+ const geom = tile<Row>({ x: "x", y: "y", fill: "v" });
115
+ const hits = geom.compileHitTest!(makeCtx(data))!;
116
+ expect(hits.channels.x?.column).toBe("x");
117
+ expect(hits.channels.y?.column).toBe("y");
118
+ expect(hits.channels.color?.column).toBe("v");
119
+ });
120
+ });
121
+
122
+ describe("tile geom — focus halo decorator (Gap 2)", () => {
123
+ function decorate(dataIndex: number, hitData: readonly unknown[] = data) {
124
+ const geom = tile<Row>({ x: "x", y: "y", fill: "v" });
125
+ const deco = geom.hoverDecoration!(makeCtx(data))!;
126
+ const rects: Array<{
127
+ x: number;
128
+ y: number;
129
+ width: number;
130
+ height: number;
131
+ fill?: unknown;
132
+ stroke?: unknown;
133
+ strokeWidth?: number;
134
+ emphasisKey?: number;
135
+ }> = [];
136
+ const layer = { pushRect: (s: never) => rects.push(s) };
137
+ deco.decorate({ geomKind: "tile", dataIndex, data: hitData, x: 0, y: 0 }, layer as never);
138
+ return { deco, rects };
139
+ }
140
+
141
+ test("emits a stroke-only halo rect at the hovered cell, exempt from dim", () => {
142
+ const { deco, rects } = decorate(2);
143
+ expect(deco.geomKind).toBe("tile");
144
+ expect(rects.length).toBe(1);
145
+ expect(rects[0]!.fill).toBeUndefined();
146
+ expect(rects[0]!.stroke).toBeDefined();
147
+ expect(rects[0]!.strokeWidth).toBe(themeDefault.interactions.hover.haloStrokeWidth);
148
+ expect(rects[0]!.emphasisKey).toBeUndefined();
149
+ // The halo covers row 2's cell (x="a", y="q") — top-left at plot origin.
150
+ expect(rects[0]!.width).toBeGreaterThan(0);
151
+ expect(rects[0]!.height).toBeGreaterThan(0);
152
+ });
153
+
154
+ test("hover on a foreign data array emits nothing", () => {
155
+ expect(decorate(0, []).rects.length).toBe(0);
156
+ });
157
+ });