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,800 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Auto-scale inference
3
+ // ---------------------------------------------------------------------------
4
+ // Given resolved aesthetics + raw data + a channel role, return a scale that
5
+ // the geom can call as `scale(value) -> pixel/visual`. Domain is computed
6
+ // from the data (or from explicit override). Type is picked from data type.
7
+
8
+ import type { Color } from "insomni";
9
+ import {
10
+ bandScale,
11
+ linearScale,
12
+ logScale,
13
+ numericTicks,
14
+ sqrtScale,
15
+ timeScale,
16
+ type BandScale,
17
+ type ContinuousScale,
18
+ type NumericDomain,
19
+ type NumericRange,
20
+ type TimeScale,
21
+ } from "../scales.ts";
22
+ import { colorScale, type CategoricalPalette, type ContinuousPalette } from "../colors.ts";
23
+ import { quantile } from "../stats/index.ts";
24
+ import { POINT_SHAPE_PALETTE, type PointBorderStyle, type PointShapeKind } from "../marks.ts";
25
+ import { inferDataType, materialize, type ChannelDataType, type ResolvedAes } from "./aes.ts";
26
+ import type { Theme } from "./theme.ts";
27
+
28
+ // Re-exported for convenience: `ScaleBundle` now lives in `./geoms/types.ts`
29
+ // but historically was importable from this module (and many tests still do).
30
+ export type { ScaleBundle } from "./geoms/types.ts";
31
+
32
+ export type Channel =
33
+ | "x"
34
+ | "y"
35
+ | "color"
36
+ | "size"
37
+ | "shape"
38
+ | "alpha"
39
+ | "borderStyle"
40
+ | "overlayGlyph";
41
+
42
+ export type PositionScaleType = "linear" | "log" | "sqrt" | "time" | "band";
43
+ export type ColorScaleType = "categorical" | "continuous" | "diverging";
44
+
45
+ interface BasePositionScaleOptions {
46
+ range?: NumericRange;
47
+ nice?: boolean;
48
+ padding?: number;
49
+ }
50
+
51
+ /**
52
+ * `"nice"` is sugar for the existing `nice: true` flag — set as the domain
53
+ * shortcut so consumers don't need a second toggle when the only reason to
54
+ * touch `domain` was to pad it to round values. The data extent still
55
+ * derives the underlying numbers; `"nice"` just toggles the rounding.
56
+ */
57
+ export type NumericDomainShortcut = "nice";
58
+
59
+ export interface NumericPositionScaleOptions extends BasePositionScaleOptions {
60
+ type?: "linear" | "log" | "sqrt";
61
+ domain?: readonly [number, number] | NumericDomainShortcut;
62
+ }
63
+
64
+ export interface TimePositionScaleOptions extends BasePositionScaleOptions {
65
+ type?: "time";
66
+ domain?: readonly [Date, Date];
67
+ }
68
+
69
+ export interface BandPositionScaleOptions extends BasePositionScaleOptions {
70
+ type?: "band";
71
+ domain?: readonly string[];
72
+ }
73
+
74
+ export type PositionScaleOptions =
75
+ | NumericPositionScaleOptions
76
+ | TimePositionScaleOptions
77
+ | BandPositionScaleOptions;
78
+
79
+ export interface CategoricalColorScaleOptions<T> {
80
+ type?: "categorical";
81
+ domain?: readonly T[];
82
+ palette?: CategoricalPalette;
83
+ }
84
+
85
+ /**
86
+ * Color-scale domain shortcuts.
87
+ *
88
+ * - `"nice"`: alias for the existing `nice: true` flag on continuous color
89
+ * scales — pad the data extent to round values.
90
+ * - `"quantile"`: bucket the data into N quantile bins (default 5). The
91
+ * returned scale's `type` becomes `"categorical"` with bucket-label
92
+ * domain entries (`"Q1".."QN"`), and the palette is sampled at N evenly
93
+ * spaced stops along the chart's continuous palette so the color
94
+ * gradient reads as ordinal magnitude rather than nominal category.
95
+ */
96
+ export type ColorDomainShortcut = "nice" | "quantile";
97
+
98
+ export interface ContinuousColorScaleOptions {
99
+ type: "continuous" | "diverging";
100
+ domain?: readonly [number, number] | ColorDomainShortcut;
101
+ palette?: ContinuousPalette;
102
+ /**
103
+ * Extend the inferred domain outward to "nice" round values (e.g. data
104
+ * extent `[-2.8, 1.9]` → `[-3, 2]`). Same algorithm position scales use
105
+ * via `nice: true`. Ignored when `domain` is set explicitly to a tuple.
106
+ * Equivalent to `domain: "nice"` — keep one form per chart for clarity.
107
+ */
108
+ nice?: boolean;
109
+ /**
110
+ * Number of quantile buckets to use when `domain: "quantile"`. Default 5
111
+ * (quintiles). Ignored otherwise.
112
+ */
113
+ quantiles?: number;
114
+ /**
115
+ * Override the color space used to interpolate the palette stops.
116
+ * Falls back to `theme.paletteBlendSpace` (default `"oklch"`).
117
+ */
118
+ blendSpace?: import("insomni").BlendSpace;
119
+ }
120
+
121
+ export type ColorScaleOptions<T> = CategoricalColorScaleOptions<T> | ContinuousColorScaleOptions;
122
+
123
+ export interface SizeScaleOptions {
124
+ type?: "linear" | "sqrt";
125
+ domain?: readonly [number, number];
126
+ range?: readonly [number, number];
127
+ }
128
+
129
+ export interface AlphaScaleOptions {
130
+ domain?: readonly [number, number];
131
+ range?: readonly [number, number];
132
+ }
133
+
134
+ export interface ShapeScaleOptions {
135
+ /** Explicit category order (default: order of first appearance). */
136
+ domain?: readonly unknown[];
137
+ /** Override the shape palette. Default: `POINT_SHAPE_PALETTE` from `marks`. */
138
+ palette?: readonly PointShapeKind[];
139
+ }
140
+
141
+ export interface BorderStyleScaleOptions {
142
+ /** Explicit category order (default: order of first appearance). */
143
+ domain?: readonly unknown[];
144
+ /** Override the border-style palette. Default: `DEFAULT_BORDER_STYLE_PALETTE`. */
145
+ palette?: readonly PointBorderStyle[];
146
+ }
147
+
148
+ export interface OverlayGlyphScaleOptions {
149
+ /** Explicit category order (default: order of first appearance). */
150
+ domain?: readonly unknown[];
151
+ /**
152
+ * Override the overlay palette. Default: a small subset of
153
+ * `POINT_SHAPE_PALETTE` excluding `circle` so the overlay is visually
154
+ * distinct from the typical base shape. Use `null` entries to suppress an
155
+ * overlay for that domain value.
156
+ */
157
+ palette?: readonly (PointShapeKind | null)[];
158
+ }
159
+
160
+ export type ScaleOptions =
161
+ | PositionScaleOptions
162
+ | ColorScaleOptions<unknown>
163
+ | SizeScaleOptions
164
+ | AlphaScaleOptions
165
+ | ShapeScaleOptions
166
+ | BorderStyleScaleOptions
167
+ | OverlayGlyphScaleOptions;
168
+
169
+ /** A scale callable: any value → number (or color, for color channels). */
170
+ export type ScaleFn<In, Out> = (value: In) => Out;
171
+
172
+ export interface PositionScale {
173
+ readonly kind: "position";
174
+ readonly type: PositionScaleType;
175
+ readonly dataType: ChannelDataType;
176
+ /** Pixel-space transform within the plot frame. */
177
+ readonly fn: ScaleFn<unknown, number>;
178
+ /** Underlying scale for axis builders. */
179
+ readonly axisScale: ContinuousScale | TimeScale | BandScale<string>;
180
+ }
181
+
182
+ export interface ColorScale {
183
+ readonly kind: "color";
184
+ readonly dataType: ChannelDataType;
185
+ readonly type: ColorScaleType;
186
+ readonly fn: ScaleFn<unknown, Color>;
187
+ readonly domain: readonly unknown[];
188
+ /** Original palette — present for continuous/diverging scales (used by the color-bar legend). */
189
+ readonly palette?: ContinuousPalette | CategoricalPalette;
190
+ }
191
+
192
+ export interface SizeScale {
193
+ readonly kind: "size";
194
+ readonly fn: ScaleFn<unknown, number>;
195
+ readonly domain: readonly [number, number];
196
+ readonly range: readonly [number, number];
197
+ }
198
+
199
+ export interface AlphaScale {
200
+ readonly kind: "alpha";
201
+ readonly fn: ScaleFn<unknown, number>;
202
+ readonly domain: readonly [number, number];
203
+ readonly range: readonly [number, number];
204
+ }
205
+
206
+ export interface ShapeScale {
207
+ readonly kind: "shape";
208
+ readonly fn: ScaleFn<unknown, PointShapeKind>;
209
+ readonly domain: readonly unknown[];
210
+ readonly palette: readonly PointShapeKind[];
211
+ }
212
+
213
+ export interface BorderStyleScale {
214
+ readonly kind: "borderStyle";
215
+ readonly fn: ScaleFn<unknown, PointBorderStyle>;
216
+ readonly domain: readonly unknown[];
217
+ readonly palette: readonly PointBorderStyle[];
218
+ }
219
+
220
+ export interface OverlayGlyphScale {
221
+ readonly kind: "overlayGlyph";
222
+ readonly fn: ScaleFn<unknown, PointShapeKind | null>;
223
+ readonly domain: readonly unknown[];
224
+ readonly palette: readonly (PointShapeKind | null)[];
225
+ }
226
+
227
+ export type ResolvedScale =
228
+ | PositionScale
229
+ | ColorScale
230
+ | SizeScale
231
+ | AlphaScale
232
+ | ShapeScale
233
+ | BorderStyleScale
234
+ | OverlayGlyphScale;
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Domain inference
238
+ // ---------------------------------------------------------------------------
239
+
240
+ /**
241
+ * Numeric extent over an arbitrary value array. Non-numeric values are skipped.
242
+ * Falls back to `[0, 1]` for empty / non-numeric input. Expands a degenerate
243
+ * single-point domain by ±1 so downstream scales don't divide by zero.
244
+ */
245
+ export function numericExtent(values: readonly unknown[]): [number, number] {
246
+ let lo = Infinity;
247
+ let hi = -Infinity;
248
+ for (const v of values) {
249
+ if (typeof v !== "number" || !Number.isFinite(v)) continue;
250
+ if (v < lo) lo = v;
251
+ if (v > hi) hi = v;
252
+ }
253
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [0, 1];
254
+ if (lo === hi) return [lo - 1, hi + 1];
255
+ return [lo, hi];
256
+ }
257
+
258
+ function dateExtent(values: readonly unknown[]): [Date, Date] {
259
+ let lo = Infinity;
260
+ let hi = -Infinity;
261
+ for (const v of values) {
262
+ if (!(v instanceof Date)) continue;
263
+ const t = v.getTime();
264
+ if (!Number.isFinite(t)) continue;
265
+ if (t < lo) lo = t;
266
+ if (t > hi) hi = t;
267
+ }
268
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) {
269
+ const now = Date.now();
270
+ return [new Date(now), new Date(now + 1)];
271
+ }
272
+ if (lo === hi) return [new Date(lo - 1), new Date(hi + 1)];
273
+ return [new Date(lo), new Date(hi)];
274
+ }
275
+
276
+ // Categorical domain builder. Null / undefined are dropped per the policy in
277
+ // `aes.ts` ("Null / undefined policy for categorical channels") — they do not
278
+ // become a `"null"` category, never throw. Geoms grouping by the same channel
279
+ // should call `dropNullCategoricalIndices` so rendered groups stay aligned
280
+ // with this domain.
281
+ function uniqueStrings(values: readonly unknown[]): string[] {
282
+ const seen = new Set<string>();
283
+ const out: string[] = [];
284
+ for (const v of values) {
285
+ if (v === null || v === undefined) continue;
286
+ // oxlint-disable-next-line no-base-to-string -- categorical data can be any scalar; String() is intentional for non-object values after null/undefined filter
287
+ const s = String(v);
288
+ if (seen.has(s)) continue;
289
+ seen.add(s);
290
+ out.push(s);
291
+ }
292
+ return out;
293
+ }
294
+
295
+ function niceNumeric(domain: [number, number]): [number, number] {
296
+ const [lo, hi] = domain;
297
+ if (lo === hi) return [lo - 1, hi + 1];
298
+ // Snap to a "nice" tick step, then *only* extend outward when the data
299
+ // overshoots the nearest inner tick by more than half a step. So
300
+ // [-2.7, 1.9] (step 1) → [-3, 2], but [-2.2, 2.2] stays put — and the
301
+ // half-step boundary itself (e.g. ±2.5) keeps the original extent.
302
+ const min = Math.min(lo, hi);
303
+ const max = Math.max(lo, hi);
304
+ const ticks = numericTicks(min, max, 5);
305
+ if (ticks.length < 2) return [lo, hi];
306
+ const step = Math.abs(ticks[1]! - ticks[0]!);
307
+ if (!Number.isFinite(step) || step === 0) return [lo, hi];
308
+ const eps = step * 1e-9;
309
+ const innerLow = Math.ceil((min - eps) / step) * step; // first tick ≥ min
310
+ const innerHigh = Math.floor((max + eps) / step) * step; // last tick ≤ max
311
+ const half = step / 2;
312
+ const niceMin = innerLow - min > half + eps ? innerLow - step : min;
313
+ const niceMax = max - innerHigh > half + eps ? innerHigh + step : max;
314
+ const round = (v: number): number => Number.parseFloat(v.toPrecision(12));
315
+ return lo <= hi ? [round(niceMin), round(niceMax)] : [round(niceMax), round(niceMin)];
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Position scales (x / y)
320
+ // ---------------------------------------------------------------------------
321
+
322
+ // Pan-zoom reads the user's scale config to seed initial bounds and clamp
323
+ // behavior. Lives here (not in mount.ts) because the parsing rules are
324
+ // scale-shape concerns, not mount-time wiring.
325
+ export function readNumericDomain(
326
+ scale: PositionScaleOptions | undefined,
327
+ axis: "x" | "y",
328
+ ): readonly [number, number] {
329
+ const dom = scale?.domain;
330
+ if (!dom || dom.length !== 2) {
331
+ throw new Error(
332
+ `panZoom: ${axis} scale must have an explicit numeric domain (set .scale("${axis}", { type: "linear", domain: [a, b] })).`,
333
+ );
334
+ }
335
+ const [a, b] = dom as readonly [unknown, unknown];
336
+ const an = a instanceof Date ? a.getTime() : (a as number);
337
+ const bn = b instanceof Date ? b.getTime() : (b as number);
338
+ if (
339
+ typeof an !== "number" ||
340
+ typeof bn !== "number" ||
341
+ !Number.isFinite(an) ||
342
+ !Number.isFinite(bn)
343
+ ) {
344
+ throw new Error(
345
+ `panZoom: ${axis} domain must be a finite [number, number] (got ${JSON.stringify(dom)}).`,
346
+ );
347
+ }
348
+ return [an, bn];
349
+ }
350
+
351
+ export function readContinuousType(
352
+ scale: PositionScaleOptions | undefined,
353
+ ): "linear" | "log" | "sqrt" {
354
+ const t = scale?.type;
355
+ if (t === "log" || t === "sqrt") return t;
356
+ if (t === "band" || t === "time") {
357
+ throw new Error(
358
+ `panZoom: only linear / log / sqrt scales are supported (got "${t}"). Use a continuous numeric domain.`,
359
+ );
360
+ }
361
+ return "linear";
362
+ }
363
+
364
+ export function buildPositionScale<T>(
365
+ aes: ResolvedAes<T, unknown>,
366
+ data: readonly T[],
367
+ range: NumericRange,
368
+ options: PositionScaleOptions = {},
369
+ ): PositionScale {
370
+ const values = materialize(aes, data);
371
+ const dataType = inferDataType(values);
372
+ const explicitType = options.type;
373
+ const type: PositionScaleType = explicitType ?? defaultPositionType(dataType);
374
+
375
+ // When `type` is omitted the caller's intent is encoded in `options.domain`'s
376
+ // shape (string[] → band, [Date, Date] → time, [number, number] → numeric).
377
+ // We've already inferred `type` from the data; trust the explicit domain
378
+ // when it's present rather than re-narrowing through `options.type`.
379
+ if (type === "band") {
380
+ const domain = (options.domain as readonly string[] | undefined) ?? uniqueStrings(values);
381
+ const scale = bandScale<string>(domain, range, { padding: options.padding ?? 0.1 });
382
+ const half = scale.bandwidth() / 2;
383
+ return {
384
+ kind: "position",
385
+ type: "band",
386
+ dataType,
387
+ axisScale: scale,
388
+ // Centered within band so points/lines anchor mid-cell.
389
+ fn: (v: unknown) => scale(String(v)) + half,
390
+ };
391
+ }
392
+
393
+ if (type === "time" || dataType === "date") {
394
+ const domain = (options.domain as readonly [Date, Date] | undefined) ?? dateExtent(values);
395
+ const scale = timeScale(domain, range);
396
+ return {
397
+ kind: "position",
398
+ type: "time",
399
+ dataType,
400
+ axisScale: scale,
401
+ fn: (v: unknown) => scale(v as Date),
402
+ };
403
+ }
404
+
405
+ // `domain: "nice"` is sugar for the existing `nice: true` flag — apply
406
+ // the same niceNumeric pass as if the consumer had set both. Tuple
407
+ // domains still win over the data extent; the `"nice"` string is a
408
+ // marker that means "use the data extent, then nice it."
409
+ const rawDomain = options.domain;
410
+ const isNiceShortcut = rawDomain === "nice";
411
+ let domain =
412
+ rawDomain && !isNiceShortcut ? (rawDomain as readonly [number, number]) : numericExtent(values);
413
+ if (options.nice || isNiceShortcut) domain = niceNumeric([domain[0], domain[1]]);
414
+
415
+ let resolvedType: "linear" | "log" | "sqrt" = "linear";
416
+ let factory: (domain: NumericDomain, range: NumericRange) => ContinuousScale;
417
+ if (type === "log") {
418
+ // Log scales require strictly positive (or strictly negative) domains.
419
+ // When the inferred domain straddles or touches zero we can't return a
420
+ // valid log scale, so surface the constraint at construction rather
421
+ // than letting `logScale` throw a less-actionable error downstream.
422
+ if (domain[0] === 0 || domain[1] === 0 || Math.sign(domain[0]) !== Math.sign(domain[1])) {
423
+ throw new Error(
424
+ `Log scale requires a domain with non-zero, same-sign endpoints (got [${domain[0]}, ${domain[1]}]). ` +
425
+ `Set an explicit positive domain via .scale(channel, { type: "log", domain: [lo, hi] }).`,
426
+ );
427
+ }
428
+ factory = logScale;
429
+ resolvedType = "log";
430
+ } else if (type === "sqrt") {
431
+ factory = sqrtScale;
432
+ resolvedType = "sqrt";
433
+ } else {
434
+ factory = linearScale;
435
+ }
436
+ const scale = factory(domain, range);
437
+ return {
438
+ kind: "position",
439
+ type: resolvedType,
440
+ dataType,
441
+ axisScale: scale,
442
+ fn: (v: unknown) => scale(v as number),
443
+ };
444
+ }
445
+
446
+ function defaultPositionType(dataType: ChannelDataType): PositionScaleType {
447
+ if (dataType === "date") return "time";
448
+ if (dataType === "string" || dataType === "boolean") return "band";
449
+ return "linear";
450
+ }
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Color scale
454
+ // ---------------------------------------------------------------------------
455
+
456
+ export function buildColorScale<T>(
457
+ aes: ResolvedAes<T, unknown>,
458
+ data: readonly T[],
459
+ theme: Theme,
460
+ options: ColorScaleOptions<unknown> = {},
461
+ ): ColorScale {
462
+ const values = materialize(aes, data);
463
+ const dataType = inferDataType(values);
464
+ const type: ColorScaleType =
465
+ options.type ?? (dataType === "number" ? "continuous" : "categorical");
466
+
467
+ if (type === "categorical") {
468
+ return buildCategoricalColorScale(uniqueStrings(values), theme, options, dataType);
469
+ }
470
+
471
+ const contOpts =
472
+ options.type === "continuous" || options.type === "diverging"
473
+ ? (options as ContinuousColorScaleOptions)
474
+ : undefined;
475
+ let palette: ContinuousPalette =
476
+ contOpts?.palette ??
477
+ (type === "diverging" ? theme.palettes.diverging : theme.palettes.continuous);
478
+ const space = contOpts?.blendSpace ?? theme.paletteBlendSpace;
479
+ if (palette.blendSpace !== space) palette = palette.withBlendSpace(space);
480
+
481
+ // Quantile shortcut: collapse the continuous scale to a categorical scale
482
+ // whose buckets are quantile bins of the data. Palette is sampled at N
483
+ // evenly spaced stops along the continuous palette so the gradient still
484
+ // reads as ordinal magnitude (low → high) rather than nominal category.
485
+ if (contOpts?.domain === "quantile") {
486
+ return buildQuantileColorScale(values, palette, contOpts, dataType);
487
+ }
488
+
489
+ const isNiceShortcut = contOpts?.domain === "nice";
490
+ // `domain === "quantile"` already returned above, so by here the only
491
+ // shortcut left to exclude is `"nice"`; what remains is an explicit tuple.
492
+ const explicitDomain =
493
+ contOpts?.domain && contOpts.domain !== "nice"
494
+ ? (contOpts.domain as readonly [number, number])
495
+ : undefined;
496
+ let domain: readonly [number, number] = explicitDomain ?? numericExtent(values);
497
+ if ((contOpts?.nice || isNiceShortcut) && !explicitDomain) {
498
+ domain = niceNumeric([domain[0], domain[1]]);
499
+ }
500
+ const fn = colorScale(palette, domain);
501
+ return {
502
+ kind: "color",
503
+ type,
504
+ dataType,
505
+ domain,
506
+ fn: (v: unknown) => fn(v as number),
507
+ palette,
508
+ };
509
+ }
510
+
511
+ /**
512
+ * Build a quantile-bucketed color scale. Data values are sorted, N-1
513
+ * breakpoints are computed at evenly spaced quantiles (default N=5), and
514
+ * each input value is mapped to its bucket index via a binary-search-free
515
+ * sweep (breakpoints are monotonic so a linear scan stays cheap). The
516
+ * returned scale is shaped as `type: "categorical"` so the rest of the
517
+ * pipeline (legend, color-bar) treats it as discrete; bucket labels
518
+ * (`"Q1".."QN"`) live in `domain`.
519
+ */
520
+ function buildQuantileColorScale(
521
+ values: readonly unknown[],
522
+ palette: ContinuousPalette,
523
+ contOpts: ContinuousColorScaleOptions,
524
+ dataType: ChannelDataType,
525
+ ): ColorScale {
526
+ const requested = Math.max(1, Math.floor(contOpts.quantiles ?? 5));
527
+ const numeric: number[] = [];
528
+ for (const v of values) {
529
+ if (typeof v === "number" && Number.isFinite(v)) numeric.push(v);
530
+ }
531
+ numeric.sort((a, b) => a - b);
532
+
533
+ // Internal breakpoints at p = 1/N, 2/N, …, (N-1)/N. Empty array when N=1.
534
+ // Low-cardinality data can make adjacent quantiles tie (e.g. many values at
535
+ // the same level), producing duplicate breakpoints. Duplicate or extremal
536
+ // breakpoints create unreachable buckets: a span between two equal
537
+ // breakpoints can never be entered, and a breakpoint sitting on the data
538
+ // min/max leaves the bucket beyond it empty — both skew the palette. Keep
539
+ // only the *strictly interior* distinct breakpoints (min < bp < max) so D
540
+ // such breakpoints yield exactly D+1 reachable buckets and palette/labels
541
+ // match that count.
542
+ const lo = numeric.length > 0 ? numeric[0]! : 0;
543
+ const hi = numeric.length > 0 ? numeric[numeric.length - 1]! : 0;
544
+ const rawBreakpoints: number[] = [];
545
+ if (numeric.length > 0) {
546
+ for (let i = 1; i < requested; i++) {
547
+ rawBreakpoints.push(quantile(numeric, i / requested));
548
+ }
549
+ }
550
+ const breakpoints: number[] = [];
551
+ for (const bp of rawBreakpoints) {
552
+ if (bp <= lo || bp >= hi) continue; // drop extremal → empty boundary buckets
553
+ if (breakpoints.length === 0 || breakpoints[breakpoints.length - 1]! !== bp) {
554
+ breakpoints.push(bp);
555
+ }
556
+ }
557
+
558
+ // Reachable buckets = interior breakpoints + 1.
559
+ const buckets = breakpoints.length + 1;
560
+
561
+ // Sample N palette stops at bucket centers (0.5/N, 1.5/N, …, (N-0.5)/N)
562
+ // — keeps the first/last colors a little inside the palette's extremes,
563
+ // which tends to read better than hitting the very ends.
564
+ const bucketColors: Color[] = Array.from({ length: buckets });
565
+ for (let i = 0; i < buckets; i++) {
566
+ const t = buckets === 1 ? 0.5 : (i + 0.5) / buckets;
567
+ bucketColors[i] = palette(t);
568
+ }
569
+
570
+ const domain: readonly string[] = Array.from({ length: buckets }, (_, i) => `Q${i + 1}`);
571
+
572
+ return {
573
+ kind: "color",
574
+ type: "categorical",
575
+ dataType,
576
+ domain,
577
+ fn: (value: unknown) => {
578
+ const num = typeof value === "number" ? value : Number(value);
579
+ if (!Number.isFinite(num)) return bucketColors[0]!;
580
+ // Linear scan over interior, monotonic breakpoints. Buckets are
581
+ // left-closed: a value exactly equal to a breakpoint falls into the lower
582
+ // bucket (`>`), so the data minimum always lands in Q1 and ties resolve to
583
+ // a single well-defined bucket.
584
+ let bucket = 0;
585
+ for (let i = 0; i < breakpoints.length; i++) {
586
+ if (num > breakpoints[i]!) bucket = i + 1;
587
+ else break;
588
+ }
589
+ return bucketColors[bucket]!;
590
+ },
591
+ };
592
+ }
593
+
594
+ /**
595
+ * Build a categorical color scale from an explicit key list. Shared between
596
+ * `buildColorScale` (data-derived domain) and the chart pipeline's implicit
597
+ * series scale (keys = column names). User-supplied `options.palette` /
598
+ * `options.domain` win over the inferred values.
599
+ */
600
+ export function buildCategoricalColorScale(
601
+ keys: readonly unknown[],
602
+ theme: Theme,
603
+ options: ColorScaleOptions<unknown> = {},
604
+ dataType: ChannelDataType = "string",
605
+ ): ColorScale {
606
+ const catOpts =
607
+ options.type === "continuous" || options.type === "diverging"
608
+ ? undefined
609
+ : (options as CategoricalColorScaleOptions<unknown>);
610
+ const palette: CategoricalPalette = catOpts?.palette ?? theme.palettes.categorical;
611
+ const domain: readonly unknown[] = catOpts?.domain ?? keys;
612
+ const fn = colorScale(palette, domain);
613
+ return {
614
+ kind: "color",
615
+ type: "categorical",
616
+ dataType,
617
+ domain,
618
+ fn: (v: unknown) => fn(v),
619
+ palette,
620
+ };
621
+ }
622
+
623
+ // ---------------------------------------------------------------------------
624
+ // Size scale
625
+ // ---------------------------------------------------------------------------
626
+
627
+ /**
628
+ * Default range pair for size/alpha when neither the user nor the theme
629
+ * supplies one. The `buildSizeScale` / `buildAlphaScale` callers pass a theme
630
+ * override via the chart pipeline; this constant exists so direct callers
631
+ * (tests, ad-hoc usage) get a sensible default.
632
+ */
633
+ const defaultSizeRange: readonly [number, number] = [2, 14];
634
+ const defaultAlphaRange: readonly [number, number] = [0.2, 1];
635
+
636
+ export function buildSizeScale<T>(
637
+ aes: ResolvedAes<T, number>,
638
+ data: readonly T[],
639
+ options: SizeScaleOptions = {},
640
+ ): SizeScale {
641
+ const values = materialize(aes, data);
642
+ const domain = options.domain ?? numericExtent(values);
643
+ const range = options.range ?? defaultSizeRange;
644
+ const type = options.type ?? "sqrt";
645
+ const factory = type === "sqrt" ? sqrtScale : linearScale;
646
+ const scale = factory(domain, range);
647
+ return {
648
+ kind: "size",
649
+ fn: (v: unknown) => scale(v as number),
650
+ domain,
651
+ range,
652
+ };
653
+ }
654
+
655
+ // ---------------------------------------------------------------------------
656
+ // Alpha scale
657
+ // ---------------------------------------------------------------------------
658
+
659
+ export function buildAlphaScale<T>(
660
+ aes: ResolvedAes<T, number>,
661
+ data: readonly T[],
662
+ options: AlphaScaleOptions = {},
663
+ ): AlphaScale {
664
+ const values = materialize(aes, data);
665
+ const domain = options.domain ?? numericExtent(values);
666
+ const range = options.range ?? defaultAlphaRange;
667
+ const scale = linearScale(domain, range);
668
+ return {
669
+ kind: "alpha",
670
+ fn: (v: unknown) => scale(v as number),
671
+ domain,
672
+ range,
673
+ };
674
+ }
675
+
676
+ // ---------------------------------------------------------------------------
677
+ // Shape scale
678
+ // ---------------------------------------------------------------------------
679
+
680
+ function uniqueValues(values: readonly unknown[]): unknown[] {
681
+ const seen = new Set<unknown>();
682
+ const out: unknown[] = [];
683
+ for (const v of values) {
684
+ if (v === null || v === undefined) continue;
685
+ // oxlint-disable-next-line no-base-to-string -- String() only reached for non-object scalars (number, boolean, string) after the typeof guard
686
+ const key = typeof v === "object" ? v : String(v);
687
+ if (seen.has(key)) continue;
688
+ seen.add(key);
689
+ out.push(v);
690
+ }
691
+ return out;
692
+ }
693
+
694
+ export function buildShapeScale<T>(
695
+ aes: ResolvedAes<T, unknown>,
696
+ data: readonly T[],
697
+ options: ShapeScaleOptions = {},
698
+ ): ShapeScale {
699
+ const values = materialize(aes, data);
700
+ const domain = options.domain ?? uniqueValues(values);
701
+ const palette = options.palette ?? POINT_SHAPE_PALETTE;
702
+ const lookup = new Map<unknown, PointShapeKind>();
703
+ for (let i = 0; i < domain.length; i++) {
704
+ // oxlint-disable-next-line no-base-to-string -- String() only reached for non-object scalars after the typeof guard
705
+ const key = typeof domain[i] === "object" ? domain[i] : String(domain[i]);
706
+ lookup.set(key, palette[i % palette.length]!);
707
+ }
708
+ const fallback = palette[0] ?? "circle";
709
+ return {
710
+ kind: "shape",
711
+ domain,
712
+ palette,
713
+ fn: (v: unknown) => {
714
+ // oxlint-disable-next-line no-base-to-string -- String() only reached for non-object scalars after the typeof guard
715
+ const key = typeof v === "object" ? v : String(v);
716
+ return lookup.get(key) ?? fallback;
717
+ },
718
+ };
719
+ }
720
+
721
+ // ---------------------------------------------------------------------------
722
+ // Border-style scale
723
+ // ---------------------------------------------------------------------------
724
+
725
+ export const DEFAULT_BORDER_STYLE_PALETTE: readonly PointBorderStyle[] = [
726
+ "solid",
727
+ "open",
728
+ "dashed",
729
+ "dotted",
730
+ ];
731
+
732
+ export function buildBorderStyleScale<T>(
733
+ aes: ResolvedAes<T, unknown>,
734
+ data: readonly T[],
735
+ options: BorderStyleScaleOptions = {},
736
+ ): BorderStyleScale {
737
+ const values = materialize(aes, data);
738
+ const domain = options.domain ?? uniqueValues(values);
739
+ const palette = options.palette ?? DEFAULT_BORDER_STYLE_PALETTE;
740
+ const lookup = new Map<unknown, PointBorderStyle>();
741
+ for (let i = 0; i < domain.length; i++) {
742
+ // oxlint-disable-next-line no-base-to-string -- String() only reached for non-object scalars after the typeof guard
743
+ const key = typeof domain[i] === "object" ? domain[i] : String(domain[i]);
744
+ lookup.set(key, palette[i % palette.length]!);
745
+ }
746
+ const fallback = palette[0] ?? "solid";
747
+ return {
748
+ kind: "borderStyle",
749
+ domain,
750
+ palette,
751
+ fn: (v: unknown) => {
752
+ // oxlint-disable-next-line no-base-to-string -- String() only reached for non-object scalars after the typeof guard
753
+ const key = typeof v === "object" ? v : String(v);
754
+ return lookup.get(key) ?? fallback;
755
+ },
756
+ };
757
+ }
758
+
759
+ // ---------------------------------------------------------------------------
760
+ // Overlay-glyph scale
761
+ // ---------------------------------------------------------------------------
762
+
763
+ /**
764
+ * Default overlay palette. First slot is `null` so the most-common category
765
+ * gets no overlay — overlays are typically a minority styling for flagged /
766
+ * exceptional rows. The remaining slots are visually distinct glyphs.
767
+ */
768
+ export const DEFAULT_OVERLAY_GLYPH_PALETTE: readonly (PointShapeKind | null)[] = [
769
+ null,
770
+ "plus",
771
+ "cross",
772
+ "star",
773
+ "diamond",
774
+ ];
775
+
776
+ export function buildOverlayGlyphScale<T>(
777
+ aes: ResolvedAes<T, unknown>,
778
+ data: readonly T[],
779
+ options: OverlayGlyphScaleOptions = {},
780
+ ): OverlayGlyphScale {
781
+ const values = materialize(aes, data);
782
+ const domain = options.domain ?? uniqueValues(values);
783
+ const palette = options.palette ?? DEFAULT_OVERLAY_GLYPH_PALETTE;
784
+ const lookup = new Map<unknown, PointShapeKind | null>();
785
+ for (let i = 0; i < domain.length; i++) {
786
+ // oxlint-disable-next-line no-base-to-string -- String() only reached for non-object scalars after the typeof guard
787
+ const key = typeof domain[i] === "object" ? domain[i] : String(domain[i]);
788
+ lookup.set(key, palette[i % palette.length] ?? null);
789
+ }
790
+ return {
791
+ kind: "overlayGlyph",
792
+ domain,
793
+ palette,
794
+ fn: (v: unknown) => {
795
+ // oxlint-disable-next-line no-base-to-string -- String() only reached for non-object scalars after the typeof guard
796
+ const key = typeof v === "object" ? v : String(v);
797
+ return lookup.has(key) ? (lookup.get(key) ?? null) : null;
798
+ },
799
+ };
800
+ }