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
package/src/axis.ts ADDED
@@ -0,0 +1,765 @@
1
+ import { BLACK, type Color, type GlyphAtlas, type Layer } from "insomni";
2
+ import {
3
+ timeTickFormat,
4
+ timeTicks,
5
+ type AxisScale,
6
+ type BandScale,
7
+ type ContinuousScale,
8
+ type TickFormatter,
9
+ type TimeIntervalUnit,
10
+ type TimeScale,
11
+ } from "./scales.ts";
12
+
13
+ /**
14
+ * Explicit time interval for axis ticks: render one tick per `step × unit`
15
+ * regardless of how many fit. `quarter` is sugar for `month, step: 3`. Pairs
16
+ * with {@link AxisOptions.ticks} on time scales — ignored on numeric / band
17
+ * scales (those fall back to a default tick count).
18
+ */
19
+ export interface TickIntervalSpec {
20
+ interval: TimeIntervalUnit;
21
+ step?: number;
22
+ }
23
+
24
+ /**
25
+ * Tick generation strategy on continuous scales:
26
+ * - `number` — target count (current default behavior).
27
+ * - `"auto"` — derive count from the axis's pixel span (~80px/tick horizontal,
28
+ * ~50px/tick vertical). Cheap proxy for "as many ticks as comfortably fit."
29
+ * - `TickIntervalSpec` — explicit time interval (time scales only).
30
+ *
31
+ * `undefined` keeps the previous default of {@link DEFAULT_TICKS}.
32
+ */
33
+ export type TickSpec = number | "auto" | TickIntervalSpec;
34
+
35
+ /**
36
+ * Loose tick formatter signature consumed by the grammar's `AxisSpec.format`.
37
+ * The runtime calls it with the active scale's tick value — `number` for
38
+ * linear/log/sqrt, `Date` for time, `string` for band scales. Typed loose so
39
+ * consumers can write `(v: number) => ...` or `(v: Date) => ...` directly
40
+ * without a wrapping cast; narrow inside the function to the type your scale
41
+ * produces.
42
+ */
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
+ export type AxisTickFormatter = (value: any) => string;
45
+
46
+ type AxisOrientation = "bottom" | "left" | "top" | "right";
47
+
48
+ export interface AxisOrigin {
49
+ x?: number;
50
+ y?: number;
51
+ }
52
+
53
+ /**
54
+ * Configuration for axis tick labels. Currently only carries `maxWidth` —
55
+ * existing `labelFontSize` / `labelColor` / etc. live as flat keys on
56
+ * `AxisOptions` for back-compat. New per-label knobs go here.
57
+ */
58
+ export interface AxisLabelConfig {
59
+ /**
60
+ * Maximum width in pixels for any tick label. Labels exceeding this width
61
+ * are truncated to the longest prefix that fits with a trailing ellipsis
62
+ * (`…`). Requires `atlas` to measure text. When unset, labels render at
63
+ * their natural width.
64
+ */
65
+ maxWidth?: number;
66
+ }
67
+
68
+ /**
69
+ * Axis title — accepts either a plain string or a config object. The object
70
+ * form lets callers cap the title's rendered width with an ellipsis.
71
+ */
72
+ export type AxisTitle = string | { text: string; maxWidth?: number };
73
+
74
+ export interface AxisOptions<Value> {
75
+ ticks?: TickSpec;
76
+ tickValues?: readonly Value[];
77
+ /**
78
+ * Render shorter, unlabeled minor tick marks between the major ticks.
79
+ * - Number `N`: subdivide each major interval into `N` parts (so `N=2`
80
+ * places one minor halfway between every adjacent major pair).
81
+ * - Array: explicit minor tick values, used as-is.
82
+ *
83
+ * Minor ticks never render labels and don't affect axis measurement.
84
+ */
85
+ minorTicks?: number | readonly Value[];
86
+ /** Length of minor tick marks. Default `tickSize / 2`. */
87
+ minorTickSize?: number;
88
+ /**
89
+ * Render a tick label only every `N`th major tick. Default `1` (every
90
+ * major). Use `2` to label every other tick, etc. The hidden ticks still
91
+ * draw their tick mark and grid line. The first tick is always labeled.
92
+ */
93
+ labelStep?: number;
94
+ /**
95
+ * Automatic label collision avoidance. `"auto"` measures the
96
+ * formatted tick labels and inflates `labelStep` until no two visible
97
+ * labels overlap (with a small padding). Off by default — opt in when
98
+ * the axis density is data-driven (dense band axes, time axes whose
99
+ * domain rescales on zoom). Falls back to `labelStep` when no atlas
100
+ * is available.
101
+ */
102
+ labelCollision?: "auto";
103
+ format?: TickFormatter<Value>;
104
+ atlas?: GlyphAtlas;
105
+ labels?: boolean;
106
+ tickSize?: number;
107
+ tickWidth?: number;
108
+ tickColor?: Color;
109
+ axisLine?: boolean;
110
+ axisLineWidth?: number;
111
+ axisLineColor?: Color;
112
+ /**
113
+ * Override the axis-line extent in pixels along the axis direction. Defaults
114
+ * to the scale's range. Pass `[0, plotFrame.width]` (or `.height`) to draw
115
+ * the spine across the full plot frame even when the data range is inset
116
+ * (framePadding / `prepareRange` reservations).
117
+ */
118
+ axisLineExtent?: readonly [number, number];
119
+ labelPadding?: number;
120
+ labelColor?: Color;
121
+ labelFontSize?: number;
122
+ labelFontFamily?: string;
123
+ labelFontWeight?: string;
124
+ labelFontStyle?: string;
125
+ /** Per-label configuration (currently `maxWidth` for ellipsis truncation). */
126
+ label?: AxisLabelConfig;
127
+ gridLines?: boolean;
128
+ gridLength?: number;
129
+ gridWidth?: number;
130
+ gridColor?: Color;
131
+ /**
132
+ * Axis title. Rendered upright alongside the axis — outside the tick
133
+ * labels for horizontal axes, above the axis line for vertical axes.
134
+ * Requires `atlas`. Skipped when undefined / empty. Pass an object form
135
+ * `{ text, maxWidth }` to cap the title width with ellipsis.
136
+ */
137
+ title?: AxisTitle;
138
+ titlePadding?: number;
139
+ titleFontSize?: number;
140
+ titleFontFamily?: string;
141
+ titleFontWeight?: string;
142
+ titleFontStyle?: string;
143
+ titleColor?: Color;
144
+ }
145
+
146
+ const ELLIPSIS = "…";
147
+
148
+ export interface TruncateFontOptions {
149
+ fontSize: number;
150
+ fontFamily?: string;
151
+ fontWeight?: string;
152
+ fontStyle?: string;
153
+ }
154
+
155
+ /**
156
+ * Truncate `text` so its rendered width fits within `maxWidth`, appending an
157
+ * ellipsis when the original text is too wide. Returns the original text if
158
+ * it already fits or if measurement is impossible. Uses binary search over
159
+ * prefix lengths for O(log n) `measureText` calls.
160
+ */
161
+ export function truncateToWidth(
162
+ atlas: GlyphAtlas | undefined,
163
+ text: string,
164
+ maxWidth: number | undefined,
165
+ font: TruncateFontOptions,
166
+ ): string {
167
+ if (!atlas || !maxWidth || maxWidth <= 0 || text.length === 0) return text;
168
+ const measureOpts = {
169
+ fontSize: font.fontSize,
170
+ fontFamily: font.fontFamily ?? "sans-serif",
171
+ fontWeight: font.fontWeight ?? "normal",
172
+ fontStyle: font.fontStyle ?? "normal",
173
+ simple: true,
174
+ };
175
+ const fullWidth = atlas.measureText(text, measureOpts).width;
176
+ if (fullWidth <= maxWidth) return text;
177
+ const ellipsisWidth = atlas.measureText(ELLIPSIS, measureOpts).width;
178
+ if (ellipsisWidth >= maxWidth) return ELLIPSIS;
179
+ // Binary search the largest prefix length whose width + ellipsis fits.
180
+ let lo = 0;
181
+ let hi = text.length;
182
+ while (lo < hi) {
183
+ const mid = (lo + hi + 1) >> 1;
184
+ const candidate = text.slice(0, mid) + ELLIPSIS;
185
+ if (atlas.measureText(candidate, measureOpts).width <= maxWidth) {
186
+ lo = mid;
187
+ } else {
188
+ hi = mid - 1;
189
+ }
190
+ }
191
+ return lo > 0 ? text.slice(0, lo) + ELLIPSIS : ELLIPSIS;
192
+ }
193
+
194
+ function resolveAxisTitle(title: AxisTitle | undefined): {
195
+ text: string;
196
+ maxWidth: number | undefined;
197
+ } | null {
198
+ if (!title) return null;
199
+ if (typeof title === "string") {
200
+ return title.length > 0 ? { text: title, maxWidth: undefined } : null;
201
+ }
202
+ return title.text.length > 0 ? { text: title.text, maxWidth: title.maxWidth } : null;
203
+ }
204
+
205
+ export interface AxisMeasurement {
206
+ /**
207
+ * Total perpendicular space the axis needs. For horizontal axes this is
208
+ * tick size + label height + (title padding + title height when present),
209
+ * since the title sits below/above the labels. For vertical axes this is
210
+ * tick size + label width only — the title for vertical axes is rendered
211
+ * above the axis (parallel to the value range) and reported separately
212
+ * via `axialTitleOverhead` so callers can reserve vertical space for it.
213
+ */
214
+ readonly thickness: number;
215
+ /** Maximum measured width across all formatted tick labels. */
216
+ readonly maxLabelWidth: number;
217
+ /** Tick label height (font size). */
218
+ readonly labelHeight: number;
219
+ /** Whether a title is present and was measured. */
220
+ readonly hasTitle: boolean;
221
+ /** Measured title font size (0 if no title). */
222
+ readonly titleHeight: number;
223
+ /**
224
+ * Pixels the axis title needs *along* the value-range axis, on top of the
225
+ * axis itself. Non-zero only for vertical axes with a title (which is
226
+ * rendered above the axis line). Horizontal axes return 0 — their title's
227
+ * footprint is already included in `thickness`.
228
+ */
229
+ readonly axialTitleOverhead: number;
230
+ }
231
+
232
+ export interface AxisBuilder<Value> {
233
+ addTo(layer: Layer, origin?: AxisOrigin): Layer;
234
+ /**
235
+ * Measure how much room the axis will occupy on its perpendicular axis.
236
+ * Use the result as `AXIS_MARGIN.bottom/left/...` so callers don't have to
237
+ * hand-tune padding for long tick labels or axis titles.
238
+ *
239
+ * Requires an atlas — pass either `options.atlas` at construction or one
240
+ * via the `atlas` argument here.
241
+ */
242
+ measure(atlas?: GlyphAtlas): AxisMeasurement;
243
+ readonly tickValues: readonly Value[];
244
+ }
245
+
246
+ const DEFAULT_TICKS = 5;
247
+
248
+ function withAlpha(color: Color, alpha: number): Color {
249
+ return { r: color.r, g: color.g, b: color.b, a: alpha };
250
+ }
251
+
252
+ function isBandScale<Value>(scale: AxisScale<Value>): scale is BandScale<Value> {
253
+ return "bandwidth" in scale;
254
+ }
255
+
256
+ function isTimeScale<Value>(scale: AxisScale<Value>): scale is TimeScale {
257
+ return !isBandScale(scale) && scale.domain[0] instanceof Date;
258
+ }
259
+
260
+ function isIntervalSpec(spec: TickSpec | undefined): spec is TickIntervalSpec {
261
+ return typeof spec === "object" && spec !== null && "interval" in spec;
262
+ }
263
+
264
+ const PIXELS_PER_TICK_HORIZONTAL = 80;
265
+ const PIXELS_PER_TICK_VERTICAL = 50;
266
+
267
+ function autoTickCount<Value>(scale: AxisScale<Value>, orientation: AxisOrientation): number {
268
+ const [r0, r1] = scale.range;
269
+ const span = Math.abs(r1 - r0);
270
+ const target =
271
+ orientation === "left" || orientation === "right"
272
+ ? PIXELS_PER_TICK_VERTICAL
273
+ : PIXELS_PER_TICK_HORIZONTAL;
274
+ return Math.max(2, Math.round(span / target));
275
+ }
276
+
277
+ function resolveTickCount<Value>(
278
+ scale: AxisScale<Value>,
279
+ options: AxisOptions<Value>,
280
+ orientation: AxisOrientation,
281
+ ): number {
282
+ const ticks = options.ticks;
283
+ if (typeof ticks === "number") return ticks;
284
+ if (ticks === "auto") return autoTickCount(scale, orientation);
285
+ return DEFAULT_TICKS;
286
+ }
287
+
288
+ function resolveTickValues<Value>(
289
+ scale: AxisScale<Value>,
290
+ options: AxisOptions<Value>,
291
+ orientation: AxisOrientation,
292
+ ): readonly Value[] {
293
+ if (options.tickValues) return options.tickValues;
294
+ if (isBandScale(scale)) {
295
+ return scale.ticks();
296
+ }
297
+ if (isIntervalSpec(options.ticks) && isTimeScale(scale)) {
298
+ return timeTicks(
299
+ scale.domain,
300
+ options.ticks.interval,
301
+ options.ticks.step ?? 1,
302
+ ) as readonly Value[];
303
+ }
304
+ const count = resolveTickCount(scale, options, orientation);
305
+ return (scale as ContinuousScale | TimeScale).ticks(count) as readonly Value[];
306
+ }
307
+
308
+ /**
309
+ * Compute minor tick positions in *axis-local pixel space* (relative to the
310
+ * range origin). Returns an empty array when no minor ticks are configured.
311
+ * Explicit value arrays are routed through the scale; numeric subdivisions
312
+ * are interpolated between adjacent majors.
313
+ */
314
+ function resolveMinorTicks<Value>(
315
+ scale: AxisScale<Value>,
316
+ options: AxisOptions<Value>,
317
+ majors: readonly Value[],
318
+ ): readonly number[] {
319
+ const spec = options.minorTicks;
320
+ if (spec === undefined) return [];
321
+ if (Array.isArray(spec)) {
322
+ const out: number[] = [];
323
+ for (const v of spec) {
324
+ const px = positionForTick(scale, v as Value);
325
+ if (Number.isFinite(px)) out.push(px);
326
+ }
327
+ return out;
328
+ }
329
+ const subdivisions = Math.floor(spec as number);
330
+ if (subdivisions < 2 || majors.length < 2) return [];
331
+ const out: number[] = [];
332
+ for (let i = 0; i < majors.length - 1; i++) {
333
+ const a = positionForTick(scale, majors[i]!);
334
+ const b = positionForTick(scale, majors[i + 1]!);
335
+ if (!Number.isFinite(a) || !Number.isFinite(b)) continue;
336
+ for (let k = 1; k < subdivisions; k++) {
337
+ out.push(a + ((b - a) * k) / subdivisions);
338
+ }
339
+ }
340
+ return out;
341
+ }
342
+
343
+ function resolveFormatter<Value>(
344
+ scale: AxisScale<Value>,
345
+ options: AxisOptions<Value>,
346
+ orientation: AxisOrientation,
347
+ ): TickFormatter<Value> {
348
+ if (options.format) return options.format;
349
+ if (isBandScale(scale)) {
350
+ return scale.tickFormat();
351
+ }
352
+ if (isIntervalSpec(options.ticks) && isTimeScale(scale)) {
353
+ return timeTickFormat(options.ticks.interval, options.ticks.step ?? 1) as TickFormatter<Value>;
354
+ }
355
+ const count = resolveTickCount(scale, options, orientation);
356
+ return (scale as ContinuousScale | TimeScale).tickFormat(count) as TickFormatter<Value>;
357
+ }
358
+
359
+ function resolveAxisExtent<Value>(scale: AxisScale<Value>): readonly [number, number] {
360
+ return scale.range;
361
+ }
362
+
363
+ function resolveGridLength<Value>(scale: AxisScale<Value>, options: AxisOptions<Value>): number {
364
+ if (typeof options.gridLength === "number") return options.gridLength;
365
+ if (!options.gridLines) return 0;
366
+ const [start, stop] = resolveAxisExtent(scale);
367
+ return Math.abs(stop - start);
368
+ }
369
+
370
+ function positionForTick<Value>(scale: AxisScale<Value>, tick: Value): number {
371
+ const raw = scale(tick as never);
372
+ if (isBandScale(scale)) {
373
+ return raw + scale.bandwidth() / 2;
374
+ }
375
+ return raw;
376
+ }
377
+
378
+ function createAxis<Value>(
379
+ orientation: AxisOrientation,
380
+ scale: AxisScale<Value>,
381
+ options: AxisOptions<Value> = {},
382
+ ): AxisBuilder<Value> {
383
+ const tickValues = resolveTickValues(scale, options, orientation);
384
+ const minorPositions = resolveMinorTicks(scale, options, tickValues);
385
+ const format = resolveFormatter(scale, options, orientation);
386
+ const baseLabelStep = Math.max(1, Math.floor(options.labelStep ?? 1));
387
+
388
+ // Cache the post-collision label step so `measure` and `addTo` agree.
389
+ // Lazy because the atlas may arrive via `measure(atlasArg)` rather than
390
+ // `options.atlas`; first caller wins, subsequent callers reuse.
391
+ let cachedLabelStep: number | null = null;
392
+ const resolveLabelStep = (atlas: GlyphAtlas | undefined): number => {
393
+ if (cachedLabelStep !== null) return cachedLabelStep;
394
+ if (options.labelCollision !== "auto" || !atlas || tickValues.length < 2) {
395
+ cachedLabelStep = baseLabelStep;
396
+ return baseLabelStep;
397
+ }
398
+ const labelHeight = options.labelFontSize ?? 12;
399
+ const fontFamily = options.labelFontFamily ?? "sans-serif";
400
+ const fontWeight = options.labelFontWeight ?? "normal";
401
+ const fontStyle = options.labelFontStyle ?? "normal";
402
+ const labelMaxWidth = options.label?.maxWidth;
403
+ const horizontal = orientation === "bottom" || orientation === "top";
404
+
405
+ // Find the smallest gap between adjacent tick positions along the axis.
406
+ // For continuous scales the gap is uniform; for band scales the bandwidth
407
+ // varies only with padding. Sampling adjacents is cheap and correct.
408
+ let minGap = Infinity;
409
+ for (let i = 1; i < tickValues.length; i++) {
410
+ const a = (scale as (v: Value) => number)(tickValues[i - 1]!);
411
+ const b = (scale as (v: Value) => number)(tickValues[i]!);
412
+ const gap = Math.abs(b - a);
413
+ if (gap > 0 && gap < minGap) minGap = gap;
414
+ }
415
+ if (!Number.isFinite(minGap) || minGap <= 0) {
416
+ cachedLabelStep = baseLabelStep;
417
+ return baseLabelStep;
418
+ }
419
+
420
+ // For horizontal axes the constraint is label width vs. tick gap;
421
+ // for vertical axes it's label height vs. tick gap.
422
+ let neededPerTick = 0;
423
+ if (horizontal) {
424
+ let maxLabelWidth = 0;
425
+ for (let i = 0; i < tickValues.length; i++) {
426
+ const tick = tickValues[i]!;
427
+ const text = truncateToWidth(atlas, format(tick), labelMaxWidth, {
428
+ fontSize: labelHeight,
429
+ fontFamily,
430
+ fontWeight,
431
+ fontStyle,
432
+ });
433
+ const m = atlas.measureText(text, {
434
+ fontSize: labelHeight,
435
+ fontFamily,
436
+ fontWeight,
437
+ fontStyle,
438
+ simple: true,
439
+ });
440
+ if (m.width > maxLabelWidth) maxLabelWidth = m.width;
441
+ }
442
+ // Add a small padding so adjacent labels don't visually kiss.
443
+ neededPerTick = maxLabelWidth + 6;
444
+ } else {
445
+ neededPerTick = labelHeight + 4;
446
+ }
447
+ const computed = Math.max(baseLabelStep, Math.ceil(neededPerTick / minGap));
448
+ cachedLabelStep = computed;
449
+ return computed;
450
+ };
451
+
452
+ const labelVisible = (i: number, atlas: GlyphAtlas | undefined): boolean => {
453
+ const step = resolveLabelStep(atlas);
454
+ return step === 1 || i % step === 0;
455
+ };
456
+
457
+ return {
458
+ tickValues,
459
+ measure(atlasArg?: GlyphAtlas): AxisMeasurement {
460
+ const atlas = atlasArg ?? options.atlas;
461
+ const labelHeight = options.labelFontSize ?? 12;
462
+ const titleFontSize = options.titleFontSize ?? labelHeight * 1.1;
463
+ const labelPadding = options.labelPadding ?? 4;
464
+ const titlePadding = options.titlePadding ?? labelPadding * 3;
465
+ const tickSize = options.tickSize ?? 6;
466
+ const showLabels = options.labels ?? true;
467
+ const fontFamily = options.labelFontFamily ?? "sans-serif";
468
+ const fontWeight = options.labelFontWeight ?? "normal";
469
+ const fontStyle = options.labelFontStyle ?? "normal";
470
+ const labelMaxWidth = options.label?.maxWidth;
471
+
472
+ let maxLabelWidth = 0;
473
+ if (showLabels && atlas) {
474
+ for (let i = 0; i < tickValues.length; i++) {
475
+ if (!labelVisible(i, atlas)) continue;
476
+ const tick = tickValues[i]!;
477
+ const text = truncateToWidth(atlas, format(tick), labelMaxWidth, {
478
+ fontSize: labelHeight,
479
+ fontFamily,
480
+ fontWeight,
481
+ fontStyle,
482
+ });
483
+ const m = atlas.measureText(text, {
484
+ fontSize: labelHeight,
485
+ fontFamily,
486
+ fontWeight,
487
+ fontStyle,
488
+ simple: true,
489
+ });
490
+ if (m.width > maxLabelWidth) maxLabelWidth = m.width;
491
+ }
492
+ }
493
+
494
+ const titleSpec = resolveAxisTitle(options.title);
495
+ const hasTitle = titleSpec !== null;
496
+ const titleHeight = hasTitle ? titleFontSize : 0;
497
+
498
+ let thickness: number;
499
+ let axialTitleOverhead = 0;
500
+ if (orientation === "bottom" || orientation === "top") {
501
+ // Axis line + tick + gap + label height (+ title padding + title)
502
+ thickness = tickSize + labelPadding + (showLabels ? labelHeight : 0);
503
+ if (hasTitle) thickness += titlePadding + titleHeight;
504
+ } else {
505
+ // Vertical: thickness covers tick + label width only. The title
506
+ // is drawn above the axis line, so its footprint is parallel to
507
+ // the value range — reported via axialTitleOverhead.
508
+ thickness = tickSize + labelPadding + (showLabels ? maxLabelWidth : 0);
509
+ if (hasTitle) axialTitleOverhead = titlePadding + titleHeight;
510
+ }
511
+
512
+ return {
513
+ thickness,
514
+ maxLabelWidth,
515
+ labelHeight,
516
+ hasTitle,
517
+ titleHeight,
518
+ axialTitleOverhead,
519
+ };
520
+ },
521
+ addTo(layer: Layer, origin: AxisOrigin = {}) {
522
+ const ox = origin.x ?? 0;
523
+ const oy = origin.y ?? 0;
524
+ const tickSize = options.tickSize ?? 6;
525
+ const tickWidth = options.tickWidth ?? 1;
526
+ const tickColor = options.tickColor ?? BLACK;
527
+ const axisLineColor = options.axisLineColor ?? tickColor;
528
+ const axisLineWidth = options.axisLineWidth ?? tickWidth;
529
+ const labelPadding = options.labelPadding ?? 4;
530
+ const labelColor = options.labelColor ?? BLACK;
531
+ const labelFontSize = options.labelFontSize ?? 12;
532
+ const labelFontFamily = options.labelFontFamily ?? "sans-serif";
533
+ const labelFontWeight = options.labelFontWeight ?? "normal";
534
+ const labelFontStyle = options.labelFontStyle ?? "normal";
535
+ const labelMaxWidth = options.label?.maxWidth;
536
+ const labelFont: TruncateFontOptions = {
537
+ fontSize: labelFontSize,
538
+ fontFamily: labelFontFamily,
539
+ fontWeight: labelFontWeight,
540
+ fontStyle: labelFontStyle,
541
+ };
542
+ const showLabels = options.labels ?? true;
543
+ const gridLength = resolveGridLength(scale, options);
544
+ const gridWidth = options.gridWidth ?? 1;
545
+ const gridColor = options.gridColor ?? withAlpha(tickColor, tickColor.a * 0.25);
546
+ const [rangeStart, rangeStop] = resolveAxisExtent(scale);
547
+ const labelAtlas = options.atlas ?? layer.atlas;
548
+
549
+ if (options.axisLine ?? true) {
550
+ const [lineStart, lineStop] = options.axisLineExtent ?? [rangeStart, rangeStop];
551
+ if (orientation === "bottom" || orientation === "top") {
552
+ layer.pushLine({
553
+ x1: ox + lineStart,
554
+ y1: oy,
555
+ x2: ox + lineStop,
556
+ y2: oy,
557
+ color: axisLineColor,
558
+ width: axisLineWidth,
559
+ });
560
+ } else {
561
+ layer.pushLine({
562
+ x1: ox,
563
+ y1: oy + lineStart,
564
+ x2: ox,
565
+ y2: oy + lineStop,
566
+ color: axisLineColor,
567
+ width: axisLineWidth,
568
+ });
569
+ }
570
+ }
571
+
572
+ // `pushText` will throw with a helpful message if neither `options.atlas`
573
+ // nor `layer.atlas` (from `renderer.createLayer()`) resolves an atlas.
574
+
575
+ // Minor tick marks first (under the major ticks if they happen to overlap).
576
+ if (minorPositions.length > 0) {
577
+ const minorSize = options.minorTickSize ?? tickSize / 2;
578
+ for (const position of minorPositions) {
579
+ if (orientation === "bottom" || orientation === "top") {
580
+ const x = ox + position;
581
+ const tickDir = orientation === "bottom" ? 1 : -1;
582
+ layer.pushLine({
583
+ x1: x,
584
+ y1: oy,
585
+ x2: x,
586
+ y2: oy + tickDir * minorSize,
587
+ color: tickColor,
588
+ width: tickWidth,
589
+ });
590
+ } else {
591
+ const y = oy + position;
592
+ const tickDir = orientation === "left" ? -1 : 1;
593
+ layer.pushLine({
594
+ x1: ox,
595
+ y1: y,
596
+ x2: ox + tickDir * minorSize,
597
+ y2: y,
598
+ color: tickColor,
599
+ width: tickWidth,
600
+ });
601
+ }
602
+ }
603
+ }
604
+
605
+ for (let tickIndex = 0; tickIndex < tickValues.length; tickIndex++) {
606
+ const tick = tickValues[tickIndex]!;
607
+ const position = positionForTick(scale, tick);
608
+ const drawLabel = showLabels && labelAtlas != null && labelVisible(tickIndex, options.atlas);
609
+
610
+ if (orientation === "bottom" || orientation === "top") {
611
+ const x = ox + position;
612
+ const tickDir = orientation === "bottom" ? 1 : -1;
613
+ layer.pushLine({
614
+ x1: x,
615
+ y1: oy,
616
+ x2: x,
617
+ y2: oy + tickDir * tickSize,
618
+ color: tickColor,
619
+ width: tickWidth,
620
+ });
621
+
622
+ if (gridLength > 0 && options.gridLines) {
623
+ layer.pushLine({
624
+ x1: x,
625
+ y1: oy,
626
+ x2: x,
627
+ y2: oy - tickDir * gridLength,
628
+ color: gridColor,
629
+ width: gridWidth,
630
+ });
631
+ }
632
+
633
+ if (drawLabel) {
634
+ layer.pushText({
635
+ simple: true,
636
+ text: truncateToWidth(options.atlas, format(tick), labelMaxWidth, labelFont),
637
+ x,
638
+ y:
639
+ orientation === "bottom"
640
+ ? oy + tickSize + labelPadding
641
+ : oy - tickSize - labelPadding - labelFontSize,
642
+ fontSize: labelFontSize,
643
+ color: labelColor,
644
+ align: "center",
645
+ });
646
+ }
647
+
648
+ continue;
649
+ }
650
+
651
+ const y = oy + position;
652
+ const tickDir = orientation === "left" ? -1 : 1;
653
+ layer.pushLine({
654
+ x1: ox,
655
+ y1: y,
656
+ x2: ox + tickDir * tickSize,
657
+ y2: y,
658
+ color: tickColor,
659
+ width: tickWidth,
660
+ });
661
+
662
+ if (gridLength > 0 && options.gridLines) {
663
+ layer.pushLine({
664
+ x1: ox,
665
+ y1: y,
666
+ x2: ox - tickDir * gridLength,
667
+ y2: y,
668
+ color: gridColor,
669
+ width: gridWidth,
670
+ });
671
+ }
672
+
673
+ if (drawLabel) {
674
+ layer.pushText({
675
+ simple: true,
676
+ text: truncateToWidth(options.atlas, format(tick), labelMaxWidth, labelFont),
677
+ x: orientation === "left" ? ox - tickSize - labelPadding : ox + tickSize + labelPadding,
678
+ y: y - labelFontSize / 2,
679
+ fontSize: labelFontSize,
680
+ color: labelColor,
681
+ align: orientation === "left" ? "right" : "left",
682
+ });
683
+ }
684
+ }
685
+
686
+ // ---- Axis title ----
687
+ const titleSpec = resolveAxisTitle(options.title);
688
+ if (titleSpec && labelAtlas != null) {
689
+ const titleFontSize = options.titleFontSize ?? labelFontSize * 1.1;
690
+ const titlePadding = options.titlePadding ?? labelPadding * 3;
691
+ const titleColor = options.titleColor ?? labelColor;
692
+ const titleFontFamily = options.titleFontFamily ?? labelFontFamily;
693
+ const titleFontWeight = options.titleFontWeight ?? "600";
694
+ const titleFontStyle = options.titleFontStyle ?? labelFontStyle;
695
+ const titleText = truncateToWidth(options.atlas, titleSpec.text, titleSpec.maxWidth, {
696
+ fontSize: titleFontSize,
697
+ fontFamily: titleFontFamily,
698
+ fontWeight: titleFontWeight,
699
+ fontStyle: titleFontStyle,
700
+ });
701
+
702
+ if (orientation === "bottom" || orientation === "top") {
703
+ const midX = ox + (rangeStart + rangeStop) / 2;
704
+ const titleY =
705
+ orientation === "bottom"
706
+ ? oy + tickSize + labelPadding + labelFontSize + titlePadding
707
+ : oy - tickSize - labelPadding - labelFontSize - titlePadding - titleFontSize;
708
+ layer.pushText({
709
+ simple: true,
710
+ text: titleText,
711
+ x: midX,
712
+ y: titleY,
713
+ fontSize: titleFontSize,
714
+ color: titleColor,
715
+ align: "center",
716
+ });
717
+ } else {
718
+ // Vertical axes — place title above the axis, aligned with the axis line.
719
+ // (Text rotation isn't supported yet, so we keep it horizontal and short.)
720
+ const axisTop = oy + Math.min(rangeStart, rangeStop);
721
+ const titleY = axisTop - titlePadding - titleFontSize;
722
+ layer.pushText({
723
+ simple: true,
724
+ text: titleText,
725
+ x: ox,
726
+ y: titleY,
727
+ fontSize: titleFontSize,
728
+ color: titleColor,
729
+ align: orientation === "left" ? "left" : "right",
730
+ });
731
+ }
732
+ }
733
+
734
+ return layer;
735
+ },
736
+ };
737
+ }
738
+
739
+ export function bottomAxis<Value>(
740
+ scale: AxisScale<Value>,
741
+ options?: AxisOptions<Value>,
742
+ ): AxisBuilder<Value> {
743
+ return createAxis("bottom", scale, options);
744
+ }
745
+
746
+ export function leftAxis<Value>(
747
+ scale: AxisScale<Value>,
748
+ options?: AxisOptions<Value>,
749
+ ): AxisBuilder<Value> {
750
+ return createAxis("left", scale, options);
751
+ }
752
+
753
+ export function topAxis<Value>(
754
+ scale: AxisScale<Value>,
755
+ options?: AxisOptions<Value>,
756
+ ): AxisBuilder<Value> {
757
+ return createAxis("top", scale, options);
758
+ }
759
+
760
+ export function rightAxis<Value>(
761
+ scale: AxisScale<Value>,
762
+ options?: AxisOptions<Value>,
763
+ ): AxisBuilder<Value> {
764
+ return createAxis("right", scale, options);
765
+ }