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,490 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Theme
3
+ // ---------------------------------------------------------------------------
4
+ // Visual style for a chart. Themes are values; live-toggle works because the
5
+ // chart owns a `Signal<Theme>` (see chart.ts) and re-renders on `.set(...)`.
6
+
7
+ import {
8
+ easeInOutCubic,
9
+ easeOutCubic,
10
+ easeOutQuad,
11
+ linear as linearEasing,
12
+ rgba,
13
+ type BlendSpace,
14
+ type Color,
15
+ type Easing,
16
+ } from "insomni";
17
+ import { category10, viridis, type CategoricalPalette, type ContinuousPalette } from "../colors.ts";
18
+ import { DEFAULT_ACCESSIBILITY, type Accessibility, type TextEffects } from "./accessibility.ts";
19
+
20
+ export type { Accessibility, AccessibilityMode, TextEffects } from "./accessibility.ts";
21
+
22
+ export interface ThemeText {
23
+ color: Color;
24
+ fontFamily: string;
25
+ }
26
+
27
+ export interface ThemeTitle {
28
+ fontSize: number;
29
+ fontWeight: string;
30
+ color: Color;
31
+ }
32
+
33
+ export interface ThemeSubtitle {
34
+ fontSize: number;
35
+ color: Color;
36
+ }
37
+
38
+ export interface ThemeAxis {
39
+ color: Color;
40
+ gridColor: Color;
41
+ labelFontSize: number;
42
+ labelColor: Color;
43
+ titleFontSize: number;
44
+ titleColor: Color;
45
+ }
46
+
47
+ export interface ThemeLegend {
48
+ fontSize: number;
49
+ labelColor: Color;
50
+ swatchGap: number;
51
+ entryGap: number;
52
+ }
53
+
54
+ export interface ThemeMarks {
55
+ pointRadius: number;
56
+ pointStroke: Color | undefined;
57
+ pointStrokeWidth: number;
58
+ /**
59
+ * Default stroke width for line-based marks (`line`, `smooth`, `statRolling`,
60
+ * `area` outline, `aggregate` line geom). Geom-level `strokeWidth` options
61
+ * override this token per layer.
62
+ */
63
+ strokeWidth: number;
64
+ barCornerRadius: number;
65
+ fillAlpha: number;
66
+ /** Font size for value/total labels rendered alongside marks (bar, text). */
67
+ labelFontSize: number;
68
+ /** Default stroke width for `rule()` lines. */
69
+ ruleStrokeWidth: number;
70
+ /** Default label inset for `rule()` annotations. */
71
+ ruleLabelInset: number;
72
+ /** Default font size for inline annotations (rule, band labels). */
73
+ annotationFontSize: number;
74
+ /** Default fill alpha for `ribbon()`. */
75
+ ribbonFillAlpha: number;
76
+ /** Default fill alpha for `band()` highlight regions. */
77
+ bandFillAlpha: number;
78
+ /** Default size-channel pixel range. */
79
+ sizeRange: readonly [number, number];
80
+ /** Default alpha-channel range. */
81
+ alphaRange: readonly [number, number];
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Motion tokens
86
+ // ---------------------------------------------------------------------------
87
+ // Durations are milliseconds. Geoms and interaction primitives read these
88
+ // names rather than hard-coded numbers so a single theme switch retunes the
89
+ // whole chart's motion. `enabled: false` is the master kill — interactions
90
+ // should snap rather than animate. Use `applyReducedMotion(theme)` to derive
91
+ // a theme honoring `prefers-reduced-motion`.
92
+
93
+ export type ThemeDurationKey = "fast" | "base" | "slow";
94
+ export type ThemeEasingKey = "standard" | "emphasized" | "decelerate" | "linear";
95
+
96
+ export interface ThemeDurations {
97
+ fast: number;
98
+ base: number;
99
+ slow: number;
100
+ }
101
+
102
+ export interface ThemeEasings {
103
+ standard: Easing;
104
+ emphasized: Easing;
105
+ decelerate: Easing;
106
+ linear: Easing;
107
+ }
108
+
109
+ export interface ThemeMotionChannel {
110
+ duration: ThemeDurationKey;
111
+ easing: ThemeEasingKey;
112
+ }
113
+
114
+ export interface ThemeMotionTooltip {
115
+ showDelay: number;
116
+ hideDelay: number;
117
+ fadeMs: number;
118
+ /**
119
+ * Hover-intent settle (ms). Rapid enter/leave/switch events are coalesced —
120
+ * the tooltip only commits a show/hide after the cursor holds a target this
121
+ * long. Prevents an overlay re-render per cell while sweeping across a dense
122
+ * mark grid or through gaps. 0 disables (commit immediately).
123
+ */
124
+ settleDelay: number;
125
+ }
126
+
127
+ export interface ThemeMotion {
128
+ /** Master switch. False → snap, no scheduled motion. */
129
+ enabled: boolean;
130
+ duration: ThemeDurations;
131
+ easing: ThemeEasings;
132
+ /** Data update / scale change transitions. */
133
+ data: ThemeMotionChannel;
134
+ /** Axis tick / domain transitions. */
135
+ axis: ThemeMotionChannel;
136
+ tooltip: ThemeMotionTooltip;
137
+ }
138
+
139
+ export const defaultMotion: ThemeMotion = {
140
+ enabled: true,
141
+ duration: { fast: 120, base: 240, slow: 480 },
142
+ easing: {
143
+ standard: easeInOutCubic,
144
+ emphasized: easeOutCubic,
145
+ decelerate: easeOutQuad,
146
+ linear: linearEasing,
147
+ },
148
+ data: { duration: "base", easing: "emphasized" },
149
+ axis: { duration: "base", easing: "standard" },
150
+ tooltip: { showDelay: 0, hideDelay: 50, fadeMs: 80, settleDelay: 30 },
151
+ };
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Interaction visuals — hover / selection emphasis tokens
155
+ // ---------------------------------------------------------------------------
156
+ // Geoms read these to render their hover halo and (optionally) dim the
157
+ // non-hovered rows. Two complementary techniques compose: a halo that draws
158
+ // the active row in a stronger stroke on top, and a dim factor applied to the
159
+ // fill alpha of the others. Both are off by default for sparse marks (point,
160
+ // line) and on for shape-based marks (bar, histogram, ridgeline, tile,
161
+ // boxplot, violin) where overlap makes a point-only halo hard to read.
162
+ //
163
+ // `dim` is a multiplier applied to the resolved fill alpha of non-hovered
164
+ // rows (1 = no dim, 0 = invisible). `haloStrokeWidth` is in CSS px.
165
+
166
+ export interface ThemeInteractionEmphasis {
167
+ /** Master enable. `false` skips both halo and dim. */
168
+ enabled: boolean;
169
+ /** Multiplier on non-active rows' fill alpha. `1` = no dim. */
170
+ dim: number;
171
+ /** Halo stroke width in CSS px. `0` skips the halo. */
172
+ haloStrokeWidth: number;
173
+ /**
174
+ * Halo ring color. When omitted, geoms fall back to a high-contrast
175
+ * foreground (`theme.text.color`) so the focus ring reads against the
176
+ * marks rather than blending in with the datum's own color. Set a `Color`
177
+ * to pin the ring (e.g. a brand accent).
178
+ */
179
+ haloColor?: Color;
180
+ /**
181
+ * Duration (ms) of the GPU dim animation driven through the core's emphasis
182
+ * uniform (P5-T3). On a hover hit-change over a dim-participating geom the
183
+ * mount ramps the emphasis `t` 0→1 (ease-out cubic) over this window; on
184
+ * hover exit it ramps t→0. Only consumed by `hover` (selection dim stays a
185
+ * compile-time treatment). `<= 0` snaps. Default ~120ms.
186
+ */
187
+ durationMs?: number;
188
+ }
189
+
190
+ /**
191
+ * Semantic row-tint tokens for tooltip rows that use the
192
+ * `accent: "positive" | "negative" | "neutral"` shorthand. `neutral` is
193
+ * intentionally omitted — it means "no override, use the default row text
194
+ * color from the underlying tooltip style." Consumers can also pass a raw
195
+ * `Color` per row to bypass these tokens entirely.
196
+ */
197
+ export interface ThemeTooltipAccents {
198
+ positive: Color;
199
+ negative: Color;
200
+ }
201
+
202
+ export interface ThemeInteractions {
203
+ /** Visual treatment applied while a row is hovered. */
204
+ hover: ThemeInteractionEmphasis;
205
+ /** Visual treatment applied while rows are selected. */
206
+ selection: ThemeInteractionEmphasis;
207
+ /**
208
+ * Grace window (ms) applied when hover transitions to "nothing" before
209
+ * propagating the null state to dim/halo and crosshair. If a new hit lands
210
+ * within this window, the swap goes directly from prev → next without
211
+ * passing through null — eliminates the un-dim → re-dim flash when the
212
+ * cursor crosses between adjacent or near-adjacent geoms. `0` disables.
213
+ */
214
+ hoverSwapGraceMs: number;
215
+ /** Semantic row colors for the tooltip `accent` shorthand. */
216
+ tooltipAccents: ThemeTooltipAccents;
217
+ }
218
+
219
+ export const defaultInteractions: ThemeInteractions = {
220
+ hover: { enabled: true, dim: 0.45, haloStrokeWidth: 2, durationMs: 120 },
221
+ selection: { enabled: true, dim: 0.3, haloStrokeWidth: 2 },
222
+ hoverSwapGraceMs: 50,
223
+ tooltipAccents: {
224
+ positive: rgba(0.45, 0.83, 0.58, 1),
225
+ negative: rgba(0.96, 0.55, 0.55, 1),
226
+ },
227
+ };
228
+
229
+ export interface ThemePalettes {
230
+ categorical: CategoricalPalette;
231
+ continuous: ContinuousPalette;
232
+ diverging: ContinuousPalette;
233
+ }
234
+
235
+ /**
236
+ * Semantic accent palette consumed by chart-level marks that carry meaning
237
+ * (reference regions, threshold rules, callouts, tooltip rows). Geoms that
238
+ * accept an accent key (`fill: "positive"`, `stroke: "warn"`) resolve through
239
+ * this block; literal `Color` values still bypass it.
240
+ *
241
+ * Naming exception to the "name geoms by what they are, not what they do"
242
+ * principle: these tokens exist explicitly to carry semantics. The names
243
+ * follow conventional status vocabulary so any reader can predict the role.
244
+ *
245
+ * `neutral` is intentionally omitted — it means "no override, use the
246
+ * surrounding default" (mark fill, axis label color, …) so consumers can fall
247
+ * back without spelling out a token.
248
+ *
249
+ * Distinct from {@link ThemeTooltipAccents} (under `interactions.tooltipAccents`)
250
+ * which is sized for tooltip-text contrast and may differ in luminance.
251
+ */
252
+ export interface ThemeAccents {
253
+ positive: Color;
254
+ negative: Color;
255
+ warn: Color;
256
+ info: Color;
257
+ }
258
+
259
+ export type ThemeAccentKey = keyof ThemeAccents;
260
+
261
+ export interface Theme {
262
+ background: Color;
263
+ text: ThemeText;
264
+ title: ThemeTitle;
265
+ subtitle: ThemeSubtitle;
266
+ axis: ThemeAxis;
267
+ legend: ThemeLegend;
268
+ marks: ThemeMarks;
269
+ palettes: ThemePalettes;
270
+ /** Semantic accent palette for chart-level marks (reference regions, threshold rules, callouts). */
271
+ accents: ThemeAccents;
272
+ /**
273
+ * Text-readability policy. Applied at every draw site where the chart
274
+ * knows the local background color (titles/axis/legend versus the panel
275
+ * background; tile labels versus their cell color).
276
+ */
277
+ accessibility: Accessibility;
278
+ /**
279
+ * Default text effects (outline / drop shadow). API stub today — the SDF
280
+ * text path doesn't render these yet. See
281
+ * dev_docs/2026-04-30-text-accessibility.md.
282
+ */
283
+ textEffects?: TextEffects;
284
+ /**
285
+ * Color space used to interpolate continuous palettes (`viridis`, etc.)
286
+ * when sampled by color scales and color bars. `oklch` is the default —
287
+ * perceptually uniform with hue preserved along the gradient. Override
288
+ * per chart (`theme({ paletteBlendSpace: "srgb" })`) or per call (the
289
+ * `blendSpace` option on color-scale and `colorBar` specs).
290
+ */
291
+ paletteBlendSpace: BlendSpace;
292
+ /**
293
+ * Motion tokens — durations, easings, and per-channel defaults consumed by
294
+ * interactions (hover, tooltip), data transitions, and axis updates. See
295
+ * {@link defaultMotion} and {@link applyReducedMotion}.
296
+ */
297
+ motion: ThemeMotion;
298
+ /**
299
+ * Hover / selection emphasis tokens. Geoms read these to render the active
300
+ * row's halo and optionally dim the rest. See {@link defaultInteractions}.
301
+ */
302
+ interactions: ThemeInteractions;
303
+ }
304
+
305
+ const FONT_STACK = "system-ui, -apple-system, Inter, sans-serif";
306
+
307
+ export const themeDefault: Theme = {
308
+ background: rgba(0.02, 0.04, 0.07, 1),
309
+ text: {
310
+ color: rgba(0.85, 0.9, 0.98, 1),
311
+ fontFamily: FONT_STACK,
312
+ },
313
+ title: {
314
+ fontSize: 14,
315
+ fontWeight: "600",
316
+ color: rgba(0.92, 0.95, 1, 1),
317
+ },
318
+ subtitle: {
319
+ fontSize: 14,
320
+ color: rgba(0.7, 0.78, 0.9, 0.9),
321
+ },
322
+ axis: {
323
+ color: rgba(0.6, 0.68, 0.78, 0.85),
324
+ gridColor: rgba(0.38, 0.5, 0.62, 0.18),
325
+ labelFontSize: 14,
326
+ labelColor: rgba(0.6, 0.68, 0.78, 0.85),
327
+ titleFontSize: 14,
328
+ titleColor: rgba(0.85, 0.9, 0.98, 1),
329
+ },
330
+ legend: {
331
+ fontSize: 14,
332
+ labelColor: rgba(0.85, 0.9, 0.98, 1),
333
+ swatchGap: 6,
334
+ entryGap: 14,
335
+ },
336
+ marks: {
337
+ pointRadius: 3,
338
+ pointStroke: rgba(1, 1, 1, 0.18),
339
+ pointStrokeWidth: 0.75,
340
+ strokeWidth: 1.5,
341
+ barCornerRadius: 1,
342
+ fillAlpha: 0.85,
343
+ labelFontSize: 14,
344
+ ruleStrokeWidth: 1,
345
+ ruleLabelInset: 4,
346
+ annotationFontSize: 14,
347
+ ribbonFillAlpha: 0.18,
348
+ bandFillAlpha: 0.08,
349
+ sizeRange: [2, 14],
350
+ alphaRange: [0.2, 1],
351
+ },
352
+ palettes: {
353
+ categorical: category10,
354
+ continuous: viridis,
355
+ diverging: viridis,
356
+ },
357
+ accents: {
358
+ positive: rgba(0.45, 0.83, 0.58, 1),
359
+ negative: rgba(0.96, 0.55, 0.55, 1),
360
+ warn: rgba(0.98, 0.78, 0.36, 1),
361
+ info: rgba(0.5, 0.72, 0.96, 1),
362
+ },
363
+ accessibility: DEFAULT_ACCESSIBILITY,
364
+ paletteBlendSpace: "oklch",
365
+ motion: defaultMotion,
366
+ interactions: defaultInteractions,
367
+ };
368
+
369
+ /**
370
+ * Light/minimal theme — white plot background, soft gray gridlines, dark
371
+ * labels. Pairs well with the `category10` palette and is a good default for
372
+ * scatter / smooth charts that should read like a ggplot2 / R reference.
373
+ */
374
+ export const themeMinimalGrid: Theme = {
375
+ ...themeDefault,
376
+ background: rgba(1, 1, 1, 1),
377
+ text: { color: rgba(0.2, 0.22, 0.27, 1), fontFamily: FONT_STACK },
378
+ title: { fontSize: 14, fontWeight: "600", color: rgba(0.15, 0.17, 0.22, 1) },
379
+ subtitle: { fontSize: 14, color: rgba(0.4, 0.43, 0.5, 1) },
380
+ axis: {
381
+ color: rgba(0.6, 0.62, 0.68, 1),
382
+ gridColor: rgba(0.85, 0.87, 0.9, 1),
383
+ labelFontSize: 14,
384
+ labelColor: rgba(0.35, 0.38, 0.45, 1),
385
+ titleFontSize: 14,
386
+ titleColor: rgba(0.2, 0.22, 0.27, 1),
387
+ },
388
+ legend: {
389
+ fontSize: 14,
390
+ labelColor: rgba(0.2, 0.22, 0.27, 1),
391
+ swatchGap: 6,
392
+ entryGap: 14,
393
+ },
394
+ marks: {
395
+ ...themeDefault.marks,
396
+ pointStroke: undefined,
397
+ pointStrokeWidth: 0,
398
+ fillAlpha: 1,
399
+ },
400
+ accents: {
401
+ positive: rgba(0.16, 0.62, 0.36, 1),
402
+ negative: rgba(0.78, 0.22, 0.22, 1),
403
+ warn: rgba(0.85, 0.55, 0.1, 1),
404
+ info: rgba(0.18, 0.45, 0.78, 1),
405
+ },
406
+ };
407
+
408
+ export type DeepPartial<T> = {
409
+ [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
410
+ };
411
+
412
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
413
+ return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date);
414
+ }
415
+
416
+ function deepMerge<T>(base: T, override: DeepPartial<T>): T {
417
+ if (!isPlainObject(base) || !isPlainObject(override)) {
418
+ return (override as T) ?? base;
419
+ }
420
+ const out = { ...base } as Record<string, unknown>;
421
+ for (const key of Object.keys(override)) {
422
+ const a = (base as Record<string, unknown>)[key];
423
+ const b = (override as Record<string, unknown>)[key];
424
+ if (b === undefined) continue;
425
+ if (isPlainObject(a) && isPlainObject(b)) {
426
+ out[key] = deepMerge(a, b as DeepPartial<unknown>);
427
+ } else {
428
+ out[key] = b;
429
+ }
430
+ }
431
+ return out as T;
432
+ }
433
+
434
+ /**
435
+ * Compose a theme by deep-merging overrides into a base theme. Accepts a
436
+ * shallow override on any nested group (e.g. `{ marks: { strokeWidth: 2 } }`).
437
+ */
438
+ export function theme(overrides: DeepPartial<Theme>, base: Theme = themeDefault): Theme {
439
+ return deepMerge(base, overrides);
440
+ }
441
+
442
+ // ---------------------------------------------------------------------------
443
+ // Motion helpers
444
+ // ---------------------------------------------------------------------------
445
+
446
+ export interface ResolvedMotion {
447
+ /** Duration in milliseconds. 0 when motion is disabled. */
448
+ durationMs: number;
449
+ easing: Easing;
450
+ }
451
+
452
+ /**
453
+ * Resolve a motion channel into concrete numbers using the theme's duration
454
+ * and easing tables. When `motion.enabled` is false, duration collapses to 0
455
+ * so callers can branch on `durationMs <= 0` to snap.
456
+ */
457
+ export function resolveMotion(motion: ThemeMotion, channel: ThemeMotionChannel): ResolvedMotion {
458
+ return {
459
+ durationMs: motion.enabled ? motion.duration[channel.duration] : 0,
460
+ easing: motion.easing[channel.easing],
461
+ };
462
+ }
463
+
464
+ /**
465
+ * True when the runtime reports `prefers-reduced-motion: reduce`. SSR-safe:
466
+ * returns false when `window`/`matchMedia` are unavailable.
467
+ */
468
+ export function prefersReducedMotion(): boolean {
469
+ if (typeof globalThis === "undefined") return false;
470
+ const w = (globalThis as { matchMedia?: (q: string) => { matches: boolean } }).matchMedia;
471
+ if (typeof w !== "function") return false;
472
+ return w("(prefers-reduced-motion: reduce)").matches;
473
+ }
474
+
475
+ /**
476
+ * Return a theme with motion disabled and tooltip delays/fade zeroed. Apply
477
+ * to honor the user's `prefers-reduced-motion` setting. Keeps easings intact
478
+ * so callers that ignore `enabled` and read durations directly still work.
479
+ */
480
+ export function applyReducedMotion(t: Theme): Theme {
481
+ return {
482
+ ...t,
483
+ motion: {
484
+ ...t.motion,
485
+ enabled: false,
486
+ duration: { fast: 0, base: 0, slow: 0 },
487
+ tooltip: { showDelay: 0, hideDelay: 0, fadeMs: 0, settleDelay: 0 },
488
+ },
489
+ };
490
+ }
@@ -0,0 +1,109 @@
1
+ // ---------------------------------------------------------------------------
2
+ // CPU heatmap renderer — used by the SVGRenderer path.
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { createLayer, type Layer } from "insomni";
6
+ import { normalizeValue, type ResolvedSpec } from "./types.ts";
7
+
8
+ /**
9
+ * CPU heatmap output: v3 `Layer`s of vector rects, consumed by the SVG export
10
+ * path and device-less consumers.
11
+ *
12
+ * The v1-only rasterized `ImageLayer` fast-path (dense grids / `svgExport:
13
+ * "image"`) was dropped in the v3 migration: v3's SVG renderer walks
14
+ * `Layer.pack` only and cannot emit `<image>`/sprite drawables, so that path
15
+ * silently rendered nothing post-migration. Dense grids now always emit vector
16
+ * rects (correct, if heavier). Re-introducing image export is future work on
17
+ * the v3 SVG backend (sprite → `<image>`).
18
+ */
19
+ export type HeatmapCpuDrawable = Layer;
20
+
21
+ export class HeatmapCpuRenderer<T> {
22
+ private readonly spec: ResolvedSpec<T>;
23
+ private data: readonly T[];
24
+ private bins: Float64Array;
25
+ private max = 0;
26
+ private dirty = true;
27
+
28
+ constructor(spec: ResolvedSpec<T>, data: readonly T[]) {
29
+ this.spec = spec;
30
+ this.data = data;
31
+ this.bins = new Float64Array(spec.nx * spec.ny);
32
+ }
33
+
34
+ setData(data: readonly T[]): void {
35
+ this.data = data;
36
+ this.dirty = true;
37
+ }
38
+
39
+ markDirty(): void {
40
+ this.dirty = true;
41
+ }
42
+
43
+ /** Largest bin weight after the most recent rebin. Useful for legend domains. */
44
+ getMax(): number {
45
+ if (this.dirty) this.rebin();
46
+ return this.max;
47
+ }
48
+
49
+ build(): readonly HeatmapCpuDrawable[] {
50
+ if (this.dirty) this.rebin();
51
+ const { nx, ny, frame, colorMap, normalize } = this.spec;
52
+ const max = this.max;
53
+
54
+ const layer = createLayer({ space: "ui" });
55
+ if (max <= 0) return [layer];
56
+
57
+ const cellW = frame.width / nx;
58
+ const cellH = frame.height / ny;
59
+ for (let iy = 0; iy < ny; iy++) {
60
+ for (let ix = 0; ix < nx; ix++) {
61
+ const v = this.bins[iy * nx + ix]!;
62
+ if (v <= 0) continue;
63
+ layer.pushRect({
64
+ x: frame.x + ix * cellW,
65
+ y: frame.y + iy * cellH,
66
+ width: cellW,
67
+ height: cellH,
68
+ fill: colorMap(normalizeValue(v, max, normalize)),
69
+ });
70
+ }
71
+ }
72
+ return [layer];
73
+ }
74
+
75
+ private rebin(): void {
76
+ const spec = this.spec;
77
+ const { nx, ny, x0, x1, y0, y1 } = spec;
78
+ const dx = x1 - x0;
79
+ const dy = y1 - y0;
80
+ const bins = this.bins;
81
+ bins.fill(0);
82
+
83
+ if (dx === 0 || dy === 0) {
84
+ this.max = 0;
85
+ this.dirty = false;
86
+ return;
87
+ }
88
+
89
+ let max = 0;
90
+ for (const datum of this.data) {
91
+ const xv = spec.x(datum);
92
+ const yv = spec.y(datum);
93
+ const tx = (xv - x0) / dx;
94
+ const ty = (yv - y0) / dy;
95
+ if (tx < 0 || tx >= 1 || ty < 0 || ty >= 1) continue;
96
+ const ix = Math.min(nx - 1, Math.floor(tx * nx));
97
+ // Flip iy so row 0 is the *top* of the frame visually — matches axis
98
+ // convention where higher data-y is drawn higher on screen.
99
+ const iy = ny - 1 - Math.min(ny - 1, Math.floor(ty * ny));
100
+ const idx = iy * nx + ix;
101
+ const w = spec.weight(datum);
102
+ const next = bins[idx]! + w;
103
+ bins[idx] = next;
104
+ if (next > max) max = next;
105
+ }
106
+ this.max = max;
107
+ this.dirty = false;
108
+ }
109
+ }