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.
- package/LICENSE.md +674 -0
- package/README.md +81 -0
- package/dist/core.d.mts +340 -0
- package/dist/core.mjs +1047 -0
- package/dist/index.d.mts +3426 -0
- package/dist/index.mjs +12762 -0
- package/dist/interactions-DEFL_F4E.mjs +5395 -0
- package/dist/range-presets-CzECsu3V.d.mts +1523 -0
- package/package.json +34 -0
- package/src/annotations.d.ts +121 -0
- package/src/annotations.ts +438 -0
- package/src/axis.d.ts +184 -0
- package/src/axis.test.ts +131 -0
- package/src/axis.ts +765 -0
- package/src/colorbar.d.ts +69 -0
- package/src/colorbar.ts +294 -0
- package/src/colors.d.ts +57 -0
- package/src/colors.test.ts +28 -0
- package/src/colors.ts +486 -0
- package/src/core.ts +299 -0
- package/src/format.d.ts +54 -0
- package/src/format.ts +138 -0
- package/src/grammar/accessibility.d.ts +147 -0
- package/src/grammar/accessibility.test.ts +199 -0
- package/src/grammar/accessibility.ts +443 -0
- package/src/grammar/aes.d.ts +35 -0
- package/src/grammar/aes.test.ts +75 -0
- package/src/grammar/aes.ts +120 -0
- package/src/grammar/annotations.d.ts +86 -0
- package/src/grammar/annotations.test.ts +68 -0
- package/src/grammar/annotations.ts +336 -0
- package/src/grammar/attach-brush.d.ts +44 -0
- package/src/grammar/attach-brush.test.ts +214 -0
- package/src/grammar/attach-brush.ts +111 -0
- package/src/grammar/attach-presets.d.ts +33 -0
- package/src/grammar/attach-presets.test.ts +106 -0
- package/src/grammar/attach-presets.ts +215 -0
- package/src/grammar/chart.d.ts +952 -0
- package/src/grammar/chart.test.ts +118 -0
- package/src/grammar/chart.ts +1172 -0
- package/src/grammar/color-utils.d.ts +29 -0
- package/src/grammar/color-utils.test.ts +53 -0
- package/src/grammar/color-utils.ts +66 -0
- package/src/grammar/constants.d.ts +45 -0
- package/src/grammar/constants.ts +61 -0
- package/src/grammar/coord.d.ts +183 -0
- package/src/grammar/coord.test.ts +355 -0
- package/src/grammar/coord.ts +619 -0
- package/src/grammar/data/pivot.d.ts +57 -0
- package/src/grammar/data/pivot.ts +107 -0
- package/src/grammar/emphasis-driver.d.ts +69 -0
- package/src/grammar/emphasis-driver.test.ts +199 -0
- package/src/grammar/emphasis-driver.ts +205 -0
- package/src/grammar/equality.d.ts +3 -0
- package/src/grammar/equality.ts +40 -0
- package/src/grammar/facet.d.ts +63 -0
- package/src/grammar/facet.test.ts +60 -0
- package/src/grammar/facet.ts +175 -0
- package/src/grammar/geoms/_categorical.d.ts +94 -0
- package/src/grammar/geoms/_categorical.ts +0 -0
- package/src/grammar/geoms/_distribution.d.ts +52 -0
- package/src/grammar/geoms/_distribution.ts +125 -0
- package/src/grammar/geoms/_mark.d.ts +69 -0
- package/src/grammar/geoms/_mark.ts +136 -0
- package/src/grammar/geoms/_shape.d.ts +41 -0
- package/src/grammar/geoms/_shape.ts +74 -0
- package/src/grammar/geoms/aggregate.d.ts +95 -0
- package/src/grammar/geoms/aggregate.test.ts +554 -0
- package/src/grammar/geoms/aggregate.ts +840 -0
- package/src/grammar/geoms/area.d.ts +32 -0
- package/src/grammar/geoms/area.test.ts +165 -0
- package/src/grammar/geoms/area.ts +578 -0
- package/src/grammar/geoms/band.d.ts +27 -0
- package/src/grammar/geoms/band.test.ts +57 -0
- package/src/grammar/geoms/band.ts +126 -0
- package/src/grammar/geoms/bar.d.ts +56 -0
- package/src/grammar/geoms/bar.test.ts +367 -0
- package/src/grammar/geoms/bar.ts +1054 -0
- package/src/grammar/geoms/boxplot.d.ts +129 -0
- package/src/grammar/geoms/boxplot.test.ts +299 -0
- package/src/grammar/geoms/boxplot.ts +834 -0
- package/src/grammar/geoms/connected-scatter.d.ts +27 -0
- package/src/grammar/geoms/connected-scatter.test.ts +157 -0
- package/src/grammar/geoms/connected-scatter.ts +63 -0
- package/src/grammar/geoms/emphasis.d.ts +76 -0
- package/src/grammar/geoms/emphasis.test.ts +135 -0
- package/src/grammar/geoms/emphasis.ts +162 -0
- package/src/grammar/geoms/histogram.d.ts +75 -0
- package/src/grammar/geoms/histogram.test.ts +262 -0
- package/src/grammar/geoms/histogram.ts +740 -0
- package/src/grammar/geoms/index.d.ts +20 -0
- package/src/grammar/geoms/index.ts +77 -0
- package/src/grammar/geoms/interval.d.ts +31 -0
- package/src/grammar/geoms/interval.test.ts +154 -0
- package/src/grammar/geoms/interval.ts +342 -0
- package/src/grammar/geoms/line.d.ts +38 -0
- package/src/grammar/geoms/line.test.ts +247 -0
- package/src/grammar/geoms/line.ts +659 -0
- package/src/grammar/geoms/point.d.ts +57 -0
- package/src/grammar/geoms/point.test.ts +163 -0
- package/src/grammar/geoms/point.ts +545 -0
- package/src/grammar/geoms/polar.test.ts +216 -0
- package/src/grammar/geoms/ribbon.d.ts +21 -0
- package/src/grammar/geoms/ribbon.test.ts +170 -0
- package/src/grammar/geoms/ribbon.ts +87 -0
- package/src/grammar/geoms/ridgeline.d.ts +89 -0
- package/src/grammar/geoms/ridgeline.test.ts +247 -0
- package/src/grammar/geoms/ridgeline.ts +1164 -0
- package/src/grammar/geoms/rolling.d.ts +43 -0
- package/src/grammar/geoms/rolling.test.ts +217 -0
- package/src/grammar/geoms/rolling.ts +387 -0
- package/src/grammar/geoms/rug.d.ts +28 -0
- package/src/grammar/geoms/rug.test.ts +126 -0
- package/src/grammar/geoms/rug.ts +214 -0
- package/src/grammar/geoms/rule.d.ts +23 -0
- package/src/grammar/geoms/rule.test.ts +69 -0
- package/src/grammar/geoms/rule.ts +212 -0
- package/src/grammar/geoms/smooth.d.ts +54 -0
- package/src/grammar/geoms/smooth.test.ts +78 -0
- package/src/grammar/geoms/smooth.ts +337 -0
- package/src/grammar/geoms/text.d.ts +29 -0
- package/src/grammar/geoms/text.test.ts +64 -0
- package/src/grammar/geoms/text.ts +234 -0
- package/src/grammar/geoms/tile.d.ts +61 -0
- package/src/grammar/geoms/tile.test.ts +157 -0
- package/src/grammar/geoms/tile.ts +621 -0
- package/src/grammar/geoms/types.d.ts +319 -0
- package/src/grammar/geoms/types.ts +362 -0
- package/src/grammar/geoms/violin.d.ts +85 -0
- package/src/grammar/geoms/violin.test.ts +187 -0
- package/src/grammar/geoms/violin.ts +672 -0
- package/src/grammar/index.d.ts +22 -0
- package/src/grammar/index.ts +269 -0
- package/src/grammar/interactions/_disposable.d.ts +5 -0
- package/src/grammar/interactions/_disposable.ts +23 -0
- package/src/grammar/interactions/_z.d.ts +4 -0
- package/src/grammar/interactions/_z.ts +16 -0
- package/src/grammar/interactions/brush-selection.test.ts +262 -0
- package/src/grammar/interactions/brush.d.ts +63 -0
- package/src/grammar/interactions/brush.test.ts +483 -0
- package/src/grammar/interactions/brush.ts +452 -0
- package/src/grammar/interactions/crosshair.d.ts +19 -0
- package/src/grammar/interactions/crosshair.test.ts +127 -0
- package/src/grammar/interactions/crosshair.ts +76 -0
- package/src/grammar/interactions/hit-layer.d.ts +64 -0
- package/src/grammar/interactions/hit-layer.ts +246 -0
- package/src/grammar/interactions/legend.d.ts +19 -0
- package/src/grammar/interactions/legend.ts +101 -0
- package/src/grammar/interactions/menu.d.ts +93 -0
- package/src/grammar/interactions/menu.test.ts +373 -0
- package/src/grammar/interactions/menu.ts +342 -0
- package/src/grammar/interactions/selection.d.ts +25 -0
- package/src/grammar/interactions/selection.test.ts +289 -0
- package/src/grammar/interactions/selection.ts +142 -0
- package/src/grammar/interactions/series-readout.d.ts +91 -0
- package/src/grammar/interactions/series-readout.test.ts +668 -0
- package/src/grammar/interactions/series-readout.ts +422 -0
- package/src/grammar/interactions/series-snap.d.ts +70 -0
- package/src/grammar/interactions/series-snap.test.ts +214 -0
- package/src/grammar/interactions/series-snap.ts +218 -0
- package/src/grammar/interactions/tooltip-axis.test.ts +176 -0
- package/src/grammar/interactions/tooltip-touch.browser.test.ts +49 -0
- package/src/grammar/interactions/tooltip-touch.test.ts +161 -0
- package/src/grammar/interactions/tooltip.d.ts +140 -0
- package/src/grammar/interactions/tooltip.test.ts +406 -0
- package/src/grammar/interactions/tooltip.ts +622 -0
- package/src/grammar/interactions/transitions.d.ts +34 -0
- package/src/grammar/interactions/transitions.test.ts +172 -0
- package/src/grammar/interactions/transitions.ts +160 -0
- package/src/grammar/layout.d.ts +68 -0
- package/src/grammar/layout.ts +186 -0
- package/src/grammar/legend-merge.test.ts +332 -0
- package/src/grammar/mount.d.ts +78 -0
- package/src/grammar/mount.test.ts +479 -0
- package/src/grammar/mount.ts +2112 -0
- package/src/grammar/palettes.d.ts +54 -0
- package/src/grammar/palettes.test.ts +80 -0
- package/src/grammar/palettes.ts +167 -0
- package/src/grammar/pan-zoom.test.ts +398 -0
- package/src/grammar/phylo.d.ts +65 -0
- package/src/grammar/phylo.test.ts +59 -0
- package/src/grammar/phylo.ts +112 -0
- package/src/grammar/pipeline.auto-ticks.test.ts +40 -0
- package/src/grammar/pipeline.d.ts +158 -0
- package/src/grammar/pipeline.test.ts +463 -0
- package/src/grammar/pipeline.ts +1233 -0
- package/src/grammar/profiling.d.ts +8 -0
- package/src/grammar/profiling.ts +24 -0
- package/src/grammar/scales.d.ts +188 -0
- package/src/grammar/scales.test.ts +181 -0
- package/src/grammar/scales.ts +800 -0
- package/src/grammar/svg.d.ts +3 -0
- package/src/grammar/svg.ts +39 -0
- package/src/grammar/theme.d.ts +261 -0
- package/src/grammar/theme.test.ts +105 -0
- package/src/grammar/theme.ts +490 -0
- package/src/heatmap/cpu.ts +109 -0
- package/src/heatmap/gpu.ts +565 -0
- package/src/heatmap/types.ts +177 -0
- package/src/heatmap.browser.test.ts +308 -0
- package/src/heatmap.test.ts +320 -0
- package/src/heatmap.ts +123 -0
- package/src/index.d.ts +1 -0
- package/src/index.ts +8 -0
- package/src/interactions.d.ts +48 -0
- package/src/interactions.test.ts +226 -0
- package/src/interactions.ts +394 -0
- package/src/layout/box.d.ts +48 -0
- package/src/layout/box.test.ts +107 -0
- package/src/layout/box.ts +143 -0
- package/src/legend.d.ts +115 -0
- package/src/legend.ts +422 -0
- package/src/marks/curve.d.ts +43 -0
- package/src/marks/curve.ts +244 -0
- package/src/marks/stack.d.ts +53 -0
- package/src/marks/stack.ts +184 -0
- package/src/marks.d.ts +273 -0
- package/src/marks.test.ts +541 -0
- package/src/marks.ts +1292 -0
- package/src/navigator.test.ts +174 -0
- package/src/navigator.ts +393 -0
- package/src/range-presets.d.ts +113 -0
- package/src/range-presets.test.ts +345 -0
- package/src/range-presets.ts +349 -0
- package/src/scales.d.ts +98 -0
- package/src/scales.test.ts +103 -0
- package/src/scales.ts +695 -0
- package/src/stats/index.d.ts +200 -0
- package/src/stats/index.test.ts +349 -0
- package/src/stats/index.ts +740 -0
- package/src/stats/regression.d.ts +38 -0
- package/src/stats/regression.test.ts +56 -0
- package/src/stats/regression.ts +396 -0
- package/src/stats/rolling-window.d.ts +55 -0
- package/src/stats/rolling-window.test.ts +237 -0
- package/src/stats/rolling-window.ts +256 -0
- package/src/test-setup.ts +19 -0
- package/src/viewport/axis-state.d.ts +72 -0
- package/src/viewport/axis-state.ts +476 -0
- package/src/viewport.d.ts +170 -0
- package/src/viewport.test.ts +363 -0
- 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;
|