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,199 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { apcaContrast, contrastRatio, cssHex } from "insomni";
3
+ import {
4
+ DEFAULT_ACCESSIBILITY,
5
+ layerEqualizeTarget,
6
+ resolveTextColor,
7
+ _resetAccessibilityWarnings,
8
+ type Accessibility,
9
+ } from "./accessibility.ts";
10
+ import { themeDefault, type Theme } from "./theme.ts";
11
+
12
+ function themeWith(overrides: Partial<Accessibility>): Theme {
13
+ return {
14
+ ...themeDefault,
15
+ accessibility: { ...DEFAULT_ACCESSIBILITY, ...overrides },
16
+ };
17
+ }
18
+
19
+ // A handful of bg colors spanning the dark/light spectrum so equalize is
20
+ // exercised across both APCA polarities.
21
+ const CELL_FILLS = [
22
+ cssHex("#1a2540"), // dark blue
23
+ cssHex("#3b3654"), // dark purple
24
+ cssHex("#2d6e8a"), // teal
25
+ cssHex("#4ea84e"), // green
26
+ cssHex("#c8d83b"), // chartreuse
27
+ cssHex("#f0e060"), // yellow
28
+ ];
29
+
30
+ describe("resolveTextColor — APCA equalize", () => {
31
+ it("normalizes |Lc| close to target across light and dark backgrounds", () => {
32
+ _resetAccessibilityWarnings();
33
+ const theme = themeWith({
34
+ metric: "apca",
35
+ equalize: true,
36
+ apcaTarget: 75,
37
+ themeBias: 0, // disable bias to isolate the equalize math
38
+ });
39
+ const fg = cssHex("#ffffff");
40
+
41
+ const lcs = CELL_FILLS.map((bg) => {
42
+ const out = resolveTextColor(fg, bg, theme, {
43
+ fontSizePx: 14,
44
+ site: "tile-label",
45
+ markLabel: true,
46
+ });
47
+ return Math.abs(apcaContrast(out, bg));
48
+ });
49
+
50
+ // For cells whose luminance allows reaching the bumped target (82.5),
51
+ // the result lands within ±2 Lc. Mid-luminance cells (green, etc.)
52
+ // can't reach 82.5 with grayscale-only L shifts — equalize picks the
53
+ // best achievable in that case. The spread is still tighter than what
54
+ // WCAG equalize would produce; see the next test.
55
+ for (const lc of lcs) {
56
+ // Below 50 would mean genuinely unreadable; above ~85 means
57
+ // the polarity bump is overshooting.
58
+ expect(lc).toBeGreaterThan(50);
59
+ expect(lc).toBeLessThan(85);
60
+ }
61
+ });
62
+
63
+ it("APCA equalize produces tighter perceptual uniformity than WCAG equalize", () => {
64
+ _resetAccessibilityWarnings();
65
+ const fg = cssHex("#ffffff");
66
+
67
+ const wcagTheme = themeWith({
68
+ metric: "wcag",
69
+ equalize: true,
70
+ wcagLevel: 7,
71
+ themeBias: 0,
72
+ });
73
+ const apcaTheme = themeWith({
74
+ metric: "apca",
75
+ equalize: true,
76
+ apcaTarget: 75,
77
+ themeBias: 0,
78
+ });
79
+
80
+ // Measure each result's |Lc| — the perceptual-weight proxy. APCA
81
+ // equalize should produce a tighter spread of |Lc| than WCAG equalize.
82
+ const measure = (theme: Theme): number[] =>
83
+ CELL_FILLS.map((bg) => {
84
+ const out = resolveTextColor(fg, bg, theme, {
85
+ fontSizePx: 14,
86
+ site: "tile-label",
87
+ markLabel: true,
88
+ });
89
+ return Math.abs(apcaContrast(out, bg));
90
+ });
91
+
92
+ const wcagLcs = measure(wcagTheme);
93
+ const apcaLcs = measure(apcaTheme);
94
+
95
+ const spread = (xs: number[]) => Math.max(...xs) - Math.min(...xs);
96
+ expect(spread(apcaLcs)).toBeLessThan(spread(wcagLcs));
97
+ });
98
+
99
+ it("equalize does not modify chrome (markLabel=false) text", () => {
100
+ _resetAccessibilityWarnings();
101
+ const theme = themeWith({ metric: "apca", equalize: true, apcaTarget: 75 });
102
+ const fg = themeDefault.text.color;
103
+ const bg = themeDefault.background;
104
+
105
+ const out = resolveTextColor(fg, bg, theme, {
106
+ fontSizePx: 14,
107
+ site: "axis-label",
108
+ // markLabel omitted → false; this is chrome
109
+ });
110
+ // Chrome already has good contrast on the panel; resolver should
111
+ // pass it through unmodified.
112
+ expect(out).toEqual(fg);
113
+ });
114
+ });
115
+
116
+ describe("layerEqualizeTarget", () => {
117
+ it("returns the worst cell's max-achievable |Lc|", () => {
118
+ const theme = themeWith({ metric: "apca", equalize: true, themeBias: 0 });
119
+ const target = layerEqualizeTarget(theme, CELL_FILLS);
120
+ // Mid-luminance green (#4ea84e) caps |Lc| around 61 — should be the floor.
121
+ expect(target).not.toBeNull();
122
+ expect(target!).toBeGreaterThan(50);
123
+ expect(target!).toBeLessThan(75);
124
+ });
125
+
126
+ it("returns null when equalize is off", () => {
127
+ const theme = themeWith({ metric: "apca", equalize: false });
128
+ expect(layerEqualizeTarget(theme, CELL_FILLS)).toBeNull();
129
+ });
130
+
131
+ it("returns null when accessibility is disabled", () => {
132
+ const theme = themeWith({ enabled: false });
133
+ expect(layerEqualizeTarget(theme, CELL_FILLS)).toBeNull();
134
+ });
135
+ });
136
+
137
+ describe("resolveTextColor — equalize with layer-floor override", () => {
138
+ it("pulls every cell to the same |Lc| — even cells that could be louder", () => {
139
+ _resetAccessibilityWarnings();
140
+ const theme = themeWith({ metric: "apca", equalize: true, themeBias: 0 });
141
+ const fg = cssHex("#ffffff");
142
+ const target = layerEqualizeTarget(theme, CELL_FILLS)!;
143
+
144
+ const lcs = CELL_FILLS.map((bg) => {
145
+ const out = resolveTextColor(fg, bg, theme, {
146
+ fontSizePx: 14,
147
+ site: "tile-label",
148
+ markLabel: true,
149
+ targetOverride: target,
150
+ });
151
+ return Math.abs(apcaContrast(out, bg));
152
+ });
153
+
154
+ // Tight spread — within a couple Lc of the layer floor.
155
+ const spread = Math.max(...lcs) - Math.min(...lcs);
156
+ expect(spread).toBeLessThan(4);
157
+ // And visibly *less* than a dark cell would naturally produce.
158
+ expect(Math.max(...lcs)).toBeLessThan(80);
159
+ });
160
+ });
161
+
162
+ describe("resolveTextColor — fix-failing only", () => {
163
+ it("APCA fix raises low-contrast labels above target |Lc|", () => {
164
+ _resetAccessibilityWarnings();
165
+ const theme = themeWith({
166
+ metric: "apca",
167
+ equalize: false,
168
+ apcaTarget: 60,
169
+ themeBias: 0,
170
+ });
171
+ // Mid-gray text on a similarly toned background — fails badly.
172
+ const fg = cssHex("#888888");
173
+ const bg = cssHex("#999999");
174
+ const out = resolveTextColor(fg, bg, theme, {
175
+ fontSizePx: 14,
176
+ site: "tile-label",
177
+ markLabel: true,
178
+ });
179
+ expect(Math.abs(apcaContrast(out, bg))).toBeGreaterThanOrEqual(59.5);
180
+ });
181
+
182
+ it("WCAG metric still works (backwards compat)", () => {
183
+ _resetAccessibilityWarnings();
184
+ const theme = themeWith({
185
+ metric: "wcag",
186
+ equalize: false,
187
+ wcagLevel: 4.5,
188
+ themeBias: 0,
189
+ });
190
+ const fg = cssHex("#888888");
191
+ const bg = cssHex("#999999");
192
+ const out = resolveTextColor(fg, bg, theme, {
193
+ fontSizePx: 14,
194
+ site: "tile-label",
195
+ markLabel: true,
196
+ });
197
+ expect(contrastRatio(out, bg)).toBeGreaterThanOrEqual(4.5);
198
+ });
199
+ });
@@ -0,0 +1,443 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Text-readability resolution
3
+ // ---------------------------------------------------------------------------
4
+ // Bridges chart text-draw sites to insomni's contrast primitives. Each draw
5
+ // site passes the foreground it wants and a *known* background; this module
6
+ // returns the color that should actually be used (auto-fixed when needed)
7
+ // and warns when contrast falls short and the user has opted out of fixes.
8
+ //
9
+ // Two metrics are supported:
10
+ // • APCA — perceptual, polarity-aware (default). Equalizing on |Lc| makes
11
+ // labels feel like the same weight regardless of cell color.
12
+ // • WCAG 2.1 ratio — kept for backwards compat; equalizing on ratio is
13
+ // visually unequal across polarities, which is why we changed defaults.
14
+ //
15
+ // Outline / shadow modes are accepted but currently fall back to auto-color
16
+ // because the SDF text path can't render them yet — see
17
+ // dev_docs/2026-04-30-text-accessibility.md for the gap list.
18
+
19
+ import {
20
+ apcaContrast,
21
+ apcaFontLookup,
22
+ BLACK,
23
+ colorAtApca,
24
+ colorAtContrast,
25
+ contrastRatio,
26
+ findAccessibleColor,
27
+ findApcaColor,
28
+ lerpInSpace,
29
+ srgbToOklab,
30
+ wcagMinContrast,
31
+ WHITE,
32
+ type BlendSpace,
33
+ type Color,
34
+ } from "insomni";
35
+ import type { Theme } from "./theme.ts";
36
+
37
+ // Alpha-composite `fg` over `bg`. Both metrics measure the rendered pixel,
38
+ // not the unmixed fg, so we composite first.
39
+ function compositeOver(fg: Color, bg: Color): Color {
40
+ if (fg.a >= 1) return fg;
41
+ const a = fg.a;
42
+ return {
43
+ r: fg.r * a + bg.r * (1 - a),
44
+ g: fg.g * a + bg.g * (1 - a),
45
+ b: fg.b * a + bg.b * (1 - a),
46
+ a: 1,
47
+ };
48
+ }
49
+
50
+ // Perceptual distance in OKLab — finding the "nearest" accent should feel
51
+ // nearest *to the eye*, not in the warped sRGB cube. Squared distance is
52
+ // fine since we only compare candidates against each other.
53
+ function oklabDist2(a: Color, b: Color): number {
54
+ const A = srgbToOklab(a);
55
+ const B = srgbToOklab(b);
56
+ const dL = A.L - B.L;
57
+ const da = A.a - B.a;
58
+ const db = A.b - B.b;
59
+ return dL * dL + da * da + db * db;
60
+ }
61
+
62
+ // Default accent palette derived from the active theme — the colors the
63
+ // reader is *already* seeing in the chart's chrome plus a few palette
64
+ // samples. Cached per-theme since it's stable for a chart's lifetime.
65
+ const _accentCache = new WeakMap<Theme, Color[]>();
66
+ function defaultAccents(theme: Theme): readonly Color[] {
67
+ const cached = _accentCache.get(theme);
68
+ if (cached) return cached;
69
+ const out: Color[] = [
70
+ theme.text.color,
71
+ theme.title.color,
72
+ theme.subtitle.color,
73
+ theme.axis.labelColor,
74
+ theme.axis.titleColor,
75
+ theme.legend.labelColor,
76
+ ];
77
+ const cat = theme.palettes.categorical;
78
+ const N = Math.min(cat.colors.length, 8);
79
+ for (let i = 0; i < N; i++) out.push(cat.colors[i]!);
80
+ _accentCache.set(theme, out);
81
+ return out;
82
+ }
83
+
84
+ export type AccessibilityMode = "auto-color" | "outline" | "shadow" | "none";
85
+ export type ContrastMetric = "wcag" | "apca";
86
+
87
+ export interface Accessibility {
88
+ /**
89
+ * Master switch. When `false`, `resolveTextColor` never alters colors —
90
+ * but if `warn` is also true it logs the failing site once per session so
91
+ * authors can see what would have been fixed.
92
+ */
93
+ enabled: boolean;
94
+ /**
95
+ * Which contrast metric to enforce.
96
+ *
97
+ * `"apca"` (default) uses APCA Lc — perceptually uniform and
98
+ * polarity-aware, so equalizing produces labels that read with the same
99
+ * weight whether they sit on a dark or a light cell.
100
+ *
101
+ * `"wcag"` uses the WCAG 2.1 ratio formula. Kept for backwards
102
+ * compatibility; equalizing on WCAG ratio looks visually unbalanced
103
+ * across polarities.
104
+ */
105
+ metric: ContrastMetric;
106
+ /**
107
+ * WCAG-mode target. `"AA"` = 4.5:1 normal / 3:1 large; `"AAA"` = 7:1 / 4.5:1.
108
+ * A raw number sets an absolute minimum ratio (1–21) applied to all text
109
+ * regardless of size. Ignored when `metric === "apca"`.
110
+ */
111
+ wcagLevel: "AA" | "AAA" | number;
112
+ /**
113
+ * APCA-mode target |Lc|. `"auto"` (default) looks up the recommended
114
+ * minimum from APCA's font/weight table for each site (typical body text
115
+ * lands around Lc 75–90). A raw number applies a single |Lc| floor to
116
+ * every site. Ignored when `metric === "wcag"`.
117
+ */
118
+ apcaTarget: number | "auto";
119
+ /**
120
+ * How to fix low-contrast text. `auto-color` nudges the color toward an
121
+ * accessible shade preserving hue/saturation. `outline` / `shadow` are
122
+ * reserved API hooks — both currently degrade to `auto-color` since the
123
+ * SDF text pipeline doesn't render text effects yet. `none` leaves text
124
+ * untouched (and warns when `warn` is on).
125
+ */
126
+ mode: AccessibilityMode;
127
+ /** Whether to console.warn on contrast failures the system isn't fixing. */
128
+ warn: boolean;
129
+ /**
130
+ * When true, every *mark* label (geom-rendered, sitting on a data-driven
131
+ * fill) is normalized to *exactly* the target contrast — not just `>=`.
132
+ * Equalizing prevents cells with high contrast from visually outweighing
133
+ * neighbors. Hue and saturation are preserved; only lightness shifts.
134
+ *
135
+ * Chrome text (titles, axis, legend) skips this — those sit on the
136
+ * panel background and equalizing them just dims legible chrome.
137
+ */
138
+ equalize: boolean;
139
+ /**
140
+ * How strongly the auto-fixer pulls toward colors already in the theme.
141
+ * `0` keeps the original hue/sat untouched. `1` snaps to the nearest
142
+ * passing theme accent. Intermediate values blend in `blendSpace`,
143
+ * keeping the label feeling "of the same family" as the chart's palette.
144
+ *
145
+ * The pull is only applied when at least one accent independently meets
146
+ * the contrast target — biasing toward an unreadable accent would defeat
147
+ * the fix. When `equalize` is on, the L-channel is re-fit after the blend
148
+ * so the final contrast still lands at the target.
149
+ */
150
+ themeBias: number;
151
+ /**
152
+ * Optional explicit accent palette for the bias. When unset, the
153
+ * resolver derives accents from the active theme (text/title/legend
154
+ * colors plus a sample of the categorical palette).
155
+ */
156
+ accents?: readonly Color[];
157
+ /**
158
+ * Color space used for the theme-bias blend. Defaults to `"oklch"` —
159
+ * perceptually uniform with hue preserved along the blend.
160
+ */
161
+ blendSpace: BlendSpace;
162
+ }
163
+
164
+ export const DEFAULT_ACCESSIBILITY: Accessibility = {
165
+ enabled: true,
166
+ metric: "apca",
167
+ wcagLevel: 7,
168
+ apcaTarget: "auto",
169
+ mode: "auto-color",
170
+ warn: true,
171
+ equalize: true,
172
+ themeBias: 0.5,
173
+ blendSpace: "oklch",
174
+ };
175
+
176
+ /**
177
+ * Stub for SDF text effects. Setting these on the theme today has no visual
178
+ * effect — the renderer will pick them up once outline/shadow are wired into
179
+ * the text pipeline. Kept on the public API so call sites and themes can
180
+ * declare intent now without a follow-up breaking change.
181
+ */
182
+ export interface TextEffects {
183
+ outline?: { color: Color; width: number };
184
+ shadow?: { color: Color; blur?: number; dx?: number; dy?: number };
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Once-per-session warnings — keyed by (site, fg, bg, metric value bucket)
189
+ // so a single repeating draw call doesn't spam the console every frame.
190
+
191
+ const _warnedKeys = new Set<string>();
192
+ const _warnedEffectModes = new Set<AccessibilityMode>();
193
+
194
+ function colorKey(c: Color): string {
195
+ const q = (v: number) => Math.round(v * 255);
196
+ return `${q(c.r)},${q(c.g)},${q(c.b)},${c.a.toFixed(2)}`;
197
+ }
198
+
199
+ function warnOnce(
200
+ site: string,
201
+ fg: Color,
202
+ bg: Color,
203
+ formattedMessage: string,
204
+ bucketKey: string,
205
+ enabledHint: boolean,
206
+ ): void {
207
+ const key = `${site}|${colorKey(fg)}|${colorKey(bg)}|${bucketKey}`;
208
+ if (_warnedKeys.has(key)) return;
209
+ _warnedKeys.add(key);
210
+ const suffix = enabledHint
211
+ ? ""
212
+ : " (theme.accessibility.enabled is false; set mode to 'auto-color' to auto-fix)";
213
+ console.warn(`[plot] Low text contrast at ${site}: ${formattedMessage}.${suffix}`);
214
+ }
215
+
216
+ function warnEffectModeOnce(mode: AccessibilityMode): void {
217
+ if (_warnedEffectModes.has(mode)) return;
218
+ _warnedEffectModes.add(mode);
219
+ console.warn(
220
+ `[plot] accessibility.mode='${mode}' is not yet implemented (SDF text outline/shadow ` +
221
+ `pending). Falling back to 'auto-color' for now.`,
222
+ );
223
+ }
224
+
225
+ /** Test helper — clears the dedupe cache so repeat warnings fire again. */
226
+ export function _resetAccessibilityWarnings(): void {
227
+ _warnedKeys.clear();
228
+ _warnedEffectModes.clear();
229
+ }
230
+
231
+ /**
232
+ * Compute a layer-wide equalize target — the largest |contrast| the worst
233
+ * cell in `backgrounds` can achieve from a black or white label. Used by
234
+ * geoms that want every label normalized to the same readable level even
235
+ * if that means muting high-contrast cells.
236
+ *
237
+ * Returns `null` when the metric/policy doesn't equalize (so callers can
238
+ * skip the pre-pass cheaply).
239
+ */
240
+ export function layerEqualizeTarget(theme: Theme, backgrounds: readonly Color[]): number | null {
241
+ const a = theme.accessibility ?? DEFAULT_ACCESSIBILITY;
242
+ if (!a.enabled || !a.equalize || a.mode === "none") return null;
243
+ if (backgrounds.length === 0) return null;
244
+ const engine = a.metric === "apca" ? apcaEngine : wcagEngine;
245
+ let floor = Infinity;
246
+ for (const bg of backgrounds) {
247
+ // Each cell's reachable max is bounded by the more contrasting of
248
+ // black / white. (Hue/sat-preserving search can't beat the extremes.)
249
+ const reach = Math.max(engine.measure(BLACK, bg), engine.measure(WHITE, bg));
250
+ if (reach < floor) floor = reach;
251
+ }
252
+ return Number.isFinite(floor) ? floor : null;
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Contrast engine — abstracts WCAG vs APCA so the resolver logic stays
257
+ // metric-agnostic. `value` is the metric reading: WCAG ratio or |APCA Lc|.
258
+ // `target` is the threshold. Both are non-negative scalars; the engine
259
+ // internally tracks polarity for APCA.
260
+
261
+ interface ContrastEngine {
262
+ /** Magnitude of the metric (WCAG ratio, or |Lc| for APCA). */
263
+ measure(fg: Color, bg: Color): number;
264
+ /** Target magnitude for this draw site (font-size lookup or fixed). */
265
+ target(opts: ResolveTextColorOptions, a: Accessibility): number;
266
+ /** Find the nearest passing color (preserves hue/sat). */
267
+ findFix(fg: Color, bg: Color, target: number): Color;
268
+ /** Find the color whose magnitude lands *at* `target` (preserves hue/sat). */
269
+ findExact(fg: Color, bg: Color, target: number): Color;
270
+ /** Format the failure for warn-once messages. */
271
+ formatFailure(value: number, target: number): { message: string; bucket: string };
272
+ }
273
+
274
+ const wcagEngine: ContrastEngine = {
275
+ measure: (fg, bg) => contrastRatio(fg, bg),
276
+ target: (opts, a) =>
277
+ wcagMinContrast({
278
+ fontSizePx: opts.fontSizePx,
279
+ bold: opts.bold,
280
+ level: a.wcagLevel,
281
+ }),
282
+ findFix: (fg, bg, t) => findAccessibleColor(fg, bg, t),
283
+ findExact: (fg, bg, t) => colorAtContrast(fg, bg, t),
284
+ formatFailure: (value, target) => ({
285
+ message: `${value.toFixed(2)}:1 (need ${target.toFixed(1)}:1)`,
286
+ bucket: value.toFixed(1),
287
+ }),
288
+ };
289
+
290
+ // APCA polarity asymmetry: light-on-dark needs slightly more |Lc| than
291
+ // dark-on-light to feel equally readable. Per Somers' guidance, the reverse
292
+ // polarity recommendation runs ~10% higher.
293
+ const APCA_REVERSE_BIAS = 1.1;
294
+
295
+ const apcaEngine: ContrastEngine = {
296
+ measure: (fg, bg) => Math.abs(apcaContrast(fg, bg)),
297
+ target: (opts, a) => {
298
+ const base =
299
+ a.apcaTarget === "auto"
300
+ ? apcaFontLookup(opts.fontSizePx, opts.bold ? 700 : 400)
301
+ : a.apcaTarget;
302
+ // Polarity-aware bump — when fg ends up lighter than bg (dark mode
303
+ // direction), require ~10% more |Lc| for equal perceived weight.
304
+ const yFg = relativeApcaLuminance(opts);
305
+ return yFg.reverse ? base * APCA_REVERSE_BIAS : base;
306
+ },
307
+ findFix: (fg, bg, t) => findApcaColor(fg, bg, t),
308
+ findExact: (fg, bg, t) => colorAtApca(fg, bg, t),
309
+ formatFailure: (value, target) => ({
310
+ message: `Lc ${value.toFixed(0)} (need ${target.toFixed(0)})`,
311
+ bucket: value.toFixed(0),
312
+ }),
313
+ };
314
+
315
+ // Polarity hint passthrough — the APCA engine's `target` doesn't have direct
316
+ // access to (fg, bg) at lookup time, so we stash the inferred polarity on
317
+ // the options object. Filled in by `resolveTextColor` before dispatch.
318
+ function relativeApcaLuminance(opts: ResolveTextColorOptions): { reverse: boolean } {
319
+ return { reverse: opts._apcaReverse === true };
320
+ }
321
+
322
+ // ---------------------------------------------------------------------------
323
+
324
+ export interface ResolveTextColorOptions {
325
+ /** Font size in CSS pixels. */
326
+ fontSizePx: number;
327
+ /** Treat the text as bold (affects WCAG large-text and APCA weight lookup). */
328
+ bold?: boolean;
329
+ /**
330
+ * Short label identifying the draw site (e.g. `"axis-label"`,
331
+ * `"tile-label"`). Used to namespace dedupe keys for warnings.
332
+ */
333
+ site: string;
334
+ /**
335
+ * Whether this site sits on a *data-driven* background (mark fills,
336
+ * heatmap cells) versus chrome on the panel background. `equalize`
337
+ * normalization applies only to mark sites — chrome text always sits on
338
+ * the same panel background, so equalizing it would just lower legible
339
+ * titles/axis/legend text down toward the contrast floor for no gain.
340
+ * Default `false`.
341
+ */
342
+ markLabel?: boolean;
343
+ /**
344
+ * Override the engine-computed target. When set, the resolver uses this
345
+ * value instead of the font/level lookup. Used by geoms that need to
346
+ * coordinate a layer-wide equalize floor (e.g. tile pulls every label
347
+ * down to the worst cell's max-achievable contrast so the layer reads
348
+ * uniformly, instead of saturating each label at its own per-cell max).
349
+ */
350
+ targetOverride?: number;
351
+ /** @internal — APCA polarity, computed inside the resolver. */
352
+ _apcaReverse?: boolean;
353
+ }
354
+
355
+ /**
356
+ * Resolve the actual color that should be drawn for a piece of text given
357
+ * the requested foreground, the known background, and the chart's
358
+ * accessibility policy. Pure — does not draw anything.
359
+ */
360
+ export function resolveTextColor(
361
+ fg: Color,
362
+ bg: Color,
363
+ theme: Theme,
364
+ opts: ResolveTextColorOptions,
365
+ ): Color {
366
+ const a = theme.accessibility ?? DEFAULT_ACCESSIBILITY;
367
+ const engine = a.metric === "apca" ? apcaEngine : wcagEngine;
368
+
369
+ const fgEffective = compositeOver(fg, bg);
370
+
371
+ // For APCA: infer polarity (sign of Lc) so the target lookup can apply
372
+ // the asymmetric reverse-polarity bump.
373
+ const optsResolved: ResolveTextColorOptions =
374
+ a.metric === "apca" ? { ...opts, _apcaReverse: apcaContrast(fgEffective, bg) < 0 } : opts;
375
+
376
+ const target = optsResolved.targetOverride ?? engine.target(optsResolved, a);
377
+ const value = engine.measure(fgEffective, bg);
378
+ const passes = value >= target;
379
+
380
+ const computeFix = (): Color =>
381
+ a.equalize
382
+ ? engine.findExact(fgEffective, bg, target)
383
+ : engine.findFix(fgEffective, bg, target);
384
+
385
+ // Equalize: every mark label normalizes (even ones already above the
386
+ // threshold). Chrome sites skip this branch.
387
+ if (a.enabled && a.equalize && a.mode !== "none" && opts.markLabel) {
388
+ if (a.mode === "outline" || a.mode === "shadow") warnEffectModeOnce(a.mode);
389
+ return applyThemeBias(computeFix(), bg, target, a, theme, engine);
390
+ }
391
+
392
+ if (passes) return fg;
393
+
394
+ if (!a.enabled || a.mode === "none") {
395
+ if (a.warn) {
396
+ const { message, bucket } = engine.formatFailure(value, target);
397
+ warnOnce(opts.site, fg, bg, message, bucket, a.enabled);
398
+ }
399
+ return fg;
400
+ }
401
+
402
+ if (a.mode === "outline" || a.mode === "shadow") {
403
+ warnEffectModeOnce(a.mode);
404
+ }
405
+ return applyThemeBias(computeFix(), bg, target, a, theme, engine);
406
+ }
407
+
408
+ // Pull the candidate toward the nearest theme accent that *also* meets the
409
+ // contrast target. When `equalize` is on, re-fit lightness after the blend
410
+ // so the final contrast lands at the target.
411
+ function applyThemeBias(
412
+ candidate: Color,
413
+ bg: Color,
414
+ target: number,
415
+ a: Accessibility,
416
+ theme: Theme,
417
+ engine: ContrastEngine,
418
+ ): Color {
419
+ if (a.themeBias <= 0) return candidate;
420
+ const accents = a.accents ?? defaultAccents(theme);
421
+ if (accents.length === 0) return candidate;
422
+
423
+ // Only consider accents that independently pass the target.
424
+ let best: Color | null = null;
425
+ let bestDist = Infinity;
426
+ for (const accent of accents) {
427
+ if (engine.measure(accent, bg) < target) continue;
428
+ const d = oklabDist2(candidate, accent);
429
+ if (d < bestDist) {
430
+ bestDist = d;
431
+ best = accent;
432
+ }
433
+ }
434
+ if (!best) return candidate;
435
+
436
+ const blended = lerpInSpace(candidate, best, a.themeBias, a.blendSpace);
437
+ if (a.equalize) return engine.findExact(blended, bg, target);
438
+ // Non-equalize path: the blend can usually only move the metric
439
+ // monotonically between two passing colors, but float rounding occasionally
440
+ // nudges it a hair below — clamp back up if so.
441
+ if (engine.measure(blended, bg) >= target) return blended;
442
+ return engine.findFix(blended, bg, target);
443
+ }
@@ -0,0 +1,35 @@
1
+ export type ColumnKeys<T, V> = {
2
+ [K in keyof T]-?: T[K] extends V ? K : never;
3
+ }[keyof T];
4
+ export type Accessor<T, V> = (datum: T, index: number) => V;
5
+ export type Aes<T, V> = ColumnKeys<T, V> | Accessor<T, V> | V;
6
+ export type Row = Record<string, unknown>;
7
+ export interface ResolvedAes<T, V> {
8
+ readonly kind: "column" | "accessor" | "constant";
9
+ readonly column?: keyof T & string;
10
+ readonly fn: Accessor<T, V>;
11
+ }
12
+ /**
13
+ * Normalize an `Aes<T, V>` to a `(d, i) => V` accessor plus metadata. The
14
+ * `kind` and `column` fields let consumers (auto-scale inference, legends,
15
+ * tooltip labels) introspect the original mapping.
16
+ */
17
+ export declare function resolveAes<T, V>(aes: Aes<T, V> | undefined, fallback?: V): ResolvedAes<T, V>;
18
+ /**
19
+ * Materialize an aesthetic to a flat array. Used when the chart needs the
20
+ * full column for domain inference.
21
+ */
22
+ export declare function materialize<T, V>(aes: ResolvedAes<T, V>, data: readonly T[]): V[];
23
+ /**
24
+ * Return the subset of `indices` whose row produces a non-null categorical
25
+ * value under `aes`. Used by geoms that group by a categorical channel; keeps
26
+ * the drop-row policy in one place.
27
+ */
28
+ export declare function dropNullCategoricalIndices<T>(indices: readonly number[], aes: ResolvedAes<T, unknown>, data: readonly T[]): number[];
29
+ /**
30
+ * Best-effort guess of a channel's data type. Used to pick a default scale
31
+ * type when no override is supplied. Null / undefined values are skipped
32
+ * (see "Null / undefined policy" above).
33
+ */
34
+ export type ChannelDataType = "number" | "date" | "string" | "boolean" | "unknown";
35
+ export declare function inferDataType(values: readonly unknown[]): ChannelDataType;