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,621 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// tile geom — categorical / matrix heatmap (one cell per row)
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Produces one rectangular cell per datum. The `fill` channel feeds the
|
|
5
|
+
// chart-wide color scale, so a continuous palette + automatic color-bar
|
|
6
|
+
// legend just work via the existing pipeline.
|
|
7
|
+
//
|
|
8
|
+
// Two layout modes, picked at compile-time from the active scales:
|
|
9
|
+
// - band × band → cell size taken from `bandwidth()` on each axis
|
|
10
|
+
// - continuous → cell size inferred from sorted unique values (assumes
|
|
11
|
+
// a regular grid, like long-format `mtcars` rows or `geom_tile()` data).
|
|
12
|
+
|
|
13
|
+
import type { Color, Layer } from "insomni";
|
|
14
|
+
import { layerEqualizeTarget, resolveTextColor } from "../accessibility.ts";
|
|
15
|
+
import { valueLabelMark } from "../../annotations.ts";
|
|
16
|
+
import {
|
|
17
|
+
type BandScale,
|
|
18
|
+
type ContinuousScale,
|
|
19
|
+
type AxisScale,
|
|
20
|
+
type TimeScale,
|
|
21
|
+
} from "../../scales.ts";
|
|
22
|
+
import type { Aes } from "../aes.ts";
|
|
23
|
+
import { resolveAes } from "../aes.ts";
|
|
24
|
+
import { withAlpha } from "../color-utils.ts";
|
|
25
|
+
import { barSwatch } from "../../legend.ts";
|
|
26
|
+
import type {
|
|
27
|
+
CompileContext,
|
|
28
|
+
CompiledHitTest,
|
|
29
|
+
Geom,
|
|
30
|
+
GeomHoverDecorator,
|
|
31
|
+
HoveredHit,
|
|
32
|
+
ResolvedChannelMap,
|
|
33
|
+
} from "./types.ts";
|
|
34
|
+
import { defaultMarkFill, resolveCoord, wrapMark } from "./_mark.ts";
|
|
35
|
+
import { emphasisContext } from "./emphasis.ts";
|
|
36
|
+
|
|
37
|
+
export interface TileChannels<T> {
|
|
38
|
+
x: Aes<T, string | number | Date>;
|
|
39
|
+
y: Aes<T, string | number | Date>;
|
|
40
|
+
/**
|
|
41
|
+
* Continuous fill — typically a numeric column. Drives the chart's color
|
|
42
|
+
* scale (legend renders as a continuous color bar). For a per-cell constant
|
|
43
|
+
* color, pass `options.fill` instead and omit this channel.
|
|
44
|
+
*/
|
|
45
|
+
fill?: Aes<T, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TileNAOptions {
|
|
49
|
+
/** Fill for missing/non-finite values. When unset, NA cells are skipped. */
|
|
50
|
+
fill?: Color;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TileOptions<T = unknown> {
|
|
54
|
+
/**
|
|
55
|
+
* Constant cell color when no `fill` channel is mapped. Ignored when
|
|
56
|
+
* `channels.fill` is present (in which case the color scale supplies the
|
|
57
|
+
* fill).
|
|
58
|
+
*/
|
|
59
|
+
fill?: Color;
|
|
60
|
+
/** Horizontal/vertical inset between cells. Default `0`. */
|
|
61
|
+
padding?: number;
|
|
62
|
+
/** Per-axis padding override (multi-key form). Wins over `padding`. */
|
|
63
|
+
paddingX?: number;
|
|
64
|
+
paddingY?: number;
|
|
65
|
+
/** Optional border outline per cell. */
|
|
66
|
+
stroke?: Color;
|
|
67
|
+
strokeWidth?: number;
|
|
68
|
+
cornerRadius?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Per-cell text label. Receives the (numeric) fill value when the `fill`
|
|
71
|
+
* channel is mapped; receives `NaN` otherwise (in which case you'll
|
|
72
|
+
* typically derive the label from the datum directly).
|
|
73
|
+
*/
|
|
74
|
+
showValues?: (value: number, datum: T, index: number) => string;
|
|
75
|
+
labelColor?: Color;
|
|
76
|
+
labelFontSize?: number;
|
|
77
|
+
/**
|
|
78
|
+
* Skip per-cell labels when the projected cell is below this pixel size on
|
|
79
|
+
* either axis. Labels at small cell sizes are unreadable smudges and the
|
|
80
|
+
* shaping cost dominates frame time at high cell counts. Default: unset
|
|
81
|
+
* (every cell labeled). A value around `1.5 * labelFontSize` is a sensible
|
|
82
|
+
* floor for legibility.
|
|
83
|
+
*/
|
|
84
|
+
minLabelCellPx?: number;
|
|
85
|
+
/**
|
|
86
|
+
* For numeric/time x or y axes only — the implicit cell width/height in
|
|
87
|
+
* domain units. When omitted, the cell extent is inferred from the
|
|
88
|
+
* smallest gap between sorted unique values in the data.
|
|
89
|
+
*/
|
|
90
|
+
cellWidth?: number;
|
|
91
|
+
cellHeight?: number;
|
|
92
|
+
/** Missing-value handling. */
|
|
93
|
+
na?: TileNAOptions;
|
|
94
|
+
label?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function tile<T>(channels: TileChannels<T>, options: TileOptions<T> = {}): Geom<T> {
|
|
98
|
+
return {
|
|
99
|
+
kind: "tile",
|
|
100
|
+
channels: { x: channels.x, y: channels.y, color: channels.fill },
|
|
101
|
+
label: options.label,
|
|
102
|
+
legendSwatch: (color) => barSwatch({ fill: color, size: 12 }),
|
|
103
|
+
compile(ctx: CompileContext<T>) {
|
|
104
|
+
const { data, scales, plot, theme, atlas } = ctx;
|
|
105
|
+
const coord = resolveCoord(ctx);
|
|
106
|
+
if (coord.kind !== "cartesian") {
|
|
107
|
+
// Per Phase 3b spec: polar tile would emit annular sectors (polygon
|
|
108
|
+
// walking inner-arc + outer-arc at ~1° step). Deferred to v2; throw
|
|
109
|
+
// a friendly error so users get a clear signal instead of a silently
|
|
110
|
+
// mis-projected rectangle quad.
|
|
111
|
+
throw new Error(
|
|
112
|
+
`coordPolar() does not support geomTile in v1. Use point/line/text/rule under polar; tile remains Cartesian-only.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
|
|
116
|
+
const yAes = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
|
|
117
|
+
const fillAes = channels.fill ? resolveAes<T, unknown>(channels.fill) : undefined;
|
|
118
|
+
|
|
119
|
+
const padX = options.paddingX ?? options.padding ?? 0;
|
|
120
|
+
const padY = options.paddingY ?? options.padding ?? 0;
|
|
121
|
+
|
|
122
|
+
const xLayout = makeAxisLayout<T>(
|
|
123
|
+
scales.x.axisScale as AxisScale<unknown>,
|
|
124
|
+
xAes,
|
|
125
|
+
data,
|
|
126
|
+
options.cellWidth,
|
|
127
|
+
);
|
|
128
|
+
const yLayout = makeAxisLayout<T>(
|
|
129
|
+
scales.y.axisScale as AxisScale<unknown>,
|
|
130
|
+
yAes,
|
|
131
|
+
data,
|
|
132
|
+
options.cellHeight,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const baseFill: Color = options.fill ?? defaultMarkFill(theme);
|
|
136
|
+
const colorScaleFn = scales.color?.fn;
|
|
137
|
+
const naFill = options.na?.fill;
|
|
138
|
+
|
|
139
|
+
// Resolve per-cell fill once — the inner loop just reads it.
|
|
140
|
+
const fillFor =
|
|
141
|
+
fillAes && colorScaleFn
|
|
142
|
+
? (d: T, i: number): Color | null => {
|
|
143
|
+
const raw = fillAes.fn(d, i);
|
|
144
|
+
if (!isFiniteValue(raw)) return naFill ?? null;
|
|
145
|
+
return withAlpha(colorScaleFn(raw), theme.marks.fillAlpha);
|
|
146
|
+
}
|
|
147
|
+
: (_d: T, _i: number): Color | null => withAlpha(baseFill, theme.marks.fillAlpha);
|
|
148
|
+
|
|
149
|
+
// Hover emphasis — the dim-others treatment now rides the core's GPU
|
|
150
|
+
// emphasis uniform (P5-T3): tag each cell with a stable per-row key; the
|
|
151
|
+
// mount fades non-focused cells without a marks recompile. No compile-time
|
|
152
|
+
// color dim / halo. `dataIndex` in compileHitTest equals the datum index `i`.
|
|
153
|
+
const emph = emphasisContext(ctx, "tile");
|
|
154
|
+
|
|
155
|
+
const builders: ReturnType<typeof wrapMark>[] = [];
|
|
156
|
+
|
|
157
|
+
builders.push({
|
|
158
|
+
length: data.length,
|
|
159
|
+
addTo: (layer) => {
|
|
160
|
+
const ox = plot.topLeft.x;
|
|
161
|
+
const oy = plot.topLeft.y;
|
|
162
|
+
const stroke = options.stroke;
|
|
163
|
+
const strokeWidth = options.strokeWidth;
|
|
164
|
+
const cornerRadius = options.cornerRadius;
|
|
165
|
+
|
|
166
|
+
for (let i = 0; i < data.length; i++) {
|
|
167
|
+
const datum = data[i]!;
|
|
168
|
+
const xv = xAes.fn(datum, i);
|
|
169
|
+
const yv = yAes.fn(datum, i);
|
|
170
|
+
const xRect = xLayout.rect(xv);
|
|
171
|
+
const yRect = yLayout.rect(yv);
|
|
172
|
+
if (!xRect || !yRect) continue;
|
|
173
|
+
const fill = fillFor(datum, i);
|
|
174
|
+
if (!fill) continue;
|
|
175
|
+
// Project the cell's top-left corner through the active coord.
|
|
176
|
+
// Under Cartesian this is the identity; polar (Phase 3) routes
|
|
177
|
+
// tiles to an annular-sector mark instead and bypasses this path.
|
|
178
|
+
const projectedCorner = coord.project({
|
|
179
|
+
x: xRect.start + padX / 2,
|
|
180
|
+
y: yRect.start + padY / 2,
|
|
181
|
+
});
|
|
182
|
+
const rx = ox + projectedCorner.x;
|
|
183
|
+
const ry = oy + projectedCorner.y;
|
|
184
|
+
const rw = Math.max(0, xRect.length - padX);
|
|
185
|
+
const rh = Math.max(0, yRect.length - padY);
|
|
186
|
+
layer.pushRect({
|
|
187
|
+
x: rx,
|
|
188
|
+
y: ry,
|
|
189
|
+
width: rw,
|
|
190
|
+
height: rh,
|
|
191
|
+
fill,
|
|
192
|
+
stroke,
|
|
193
|
+
strokeWidth,
|
|
194
|
+
cornerRadius,
|
|
195
|
+
emphasisKey: emph?.keyFor(i),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return layer;
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Optional per-cell text labels.
|
|
204
|
+
if (atlas && options.showValues) {
|
|
205
|
+
const fmt = options.showValues;
|
|
206
|
+
const baseLabelColor: Color = options.labelColor ?? theme.text.color;
|
|
207
|
+
const labelFontSize = options.labelFontSize ?? theme.marks.labelFontSize;
|
|
208
|
+
// Single pre-pass over data: gather per-cell fills (null where the
|
|
209
|
+
// cell is empty) so we can derive the equalize target AND skip
|
|
210
|
+
// re-calling `fillFor` from `labelColorFor` below.
|
|
211
|
+
//
|
|
212
|
+
// Equalize: pull every label down to the lowest contrast *any* cell
|
|
213
|
+
// can support. Without this, equalize saturates at each cell's
|
|
214
|
+
// individual max — loud cells stay loud, indistinguishable from
|
|
215
|
+
// "fix failing only".
|
|
216
|
+
const perCellFill: (Color | null)[] = Array.from({ length: data.length });
|
|
217
|
+
const cellFills: Color[] = [];
|
|
218
|
+
for (let i = 0; i < data.length; i++) {
|
|
219
|
+
const f = fillFor(data[i]!, i);
|
|
220
|
+
perCellFill[i] = f;
|
|
221
|
+
if (f) cellFills.push(f);
|
|
222
|
+
}
|
|
223
|
+
const equalizeTarget = layerEqualizeTarget(theme, cellFills);
|
|
224
|
+
// `resolveTextColor` runs APCA contrast math + iterative color search
|
|
225
|
+
// per call — expensive enough that running it for thousands of cells
|
|
226
|
+
// with a continuous palette dominates frame time. Cache by quantized
|
|
227
|
+
// RGB string so visually-equivalent fills reuse the result; with a
|
|
228
|
+
// continuous palette over thousands of cells the cache typically
|
|
229
|
+
// collapses to a few hundred unique fills.
|
|
230
|
+
const labelColorCache = new Map<string, Color>();
|
|
231
|
+
const labelColorFor = (_d: T, i: number): Color => {
|
|
232
|
+
const cellFill = perCellFill[i];
|
|
233
|
+
if (!cellFill) return baseLabelColor;
|
|
234
|
+
const key = quantizeColorKey(cellFill);
|
|
235
|
+
const hit = labelColorCache.get(key);
|
|
236
|
+
if (hit !== undefined) return hit;
|
|
237
|
+
const resolved = resolveTextColor(baseLabelColor, cellFill, theme, {
|
|
238
|
+
fontSizePx: labelFontSize,
|
|
239
|
+
site: "tile-label",
|
|
240
|
+
markLabel: true,
|
|
241
|
+
targetOverride: equalizeTarget ?? undefined,
|
|
242
|
+
});
|
|
243
|
+
labelColorCache.set(key, resolved);
|
|
244
|
+
return resolved;
|
|
245
|
+
};
|
|
246
|
+
const labelMark = valueLabelMark(data, {
|
|
247
|
+
x: (d, i) => {
|
|
248
|
+
const xr = xLayout.rect(xAes.fn(d, i));
|
|
249
|
+
const yr = yLayout.rect(yAes.fn(d, i));
|
|
250
|
+
if (!xr || !yr) return 0;
|
|
251
|
+
return coord.project({
|
|
252
|
+
x: xr.start + xr.length / 2,
|
|
253
|
+
y: yr.start + yr.length / 2,
|
|
254
|
+
}).x;
|
|
255
|
+
},
|
|
256
|
+
y: (d, i) => {
|
|
257
|
+
const xr = xLayout.rect(xAes.fn(d, i));
|
|
258
|
+
const yr = yLayout.rect(yAes.fn(d, i));
|
|
259
|
+
if (!xr || !yr) return 0;
|
|
260
|
+
// Center vertically inside the cell — `valueLabelMark` uses the
|
|
261
|
+
// y as the text baseline, so subtract half the font for visual
|
|
262
|
+
// centering.
|
|
263
|
+
return (
|
|
264
|
+
coord.project({
|
|
265
|
+
x: xr.start + xr.length / 2,
|
|
266
|
+
y: yr.start + yr.length / 2,
|
|
267
|
+
}).y -
|
|
268
|
+
labelFontSize / 2
|
|
269
|
+
);
|
|
270
|
+
},
|
|
271
|
+
text: (d, i) => {
|
|
272
|
+
const raw = fillAes ? fillAes.fn(d, i) : NaN;
|
|
273
|
+
const numeric = typeof raw === "number" ? raw : NaN;
|
|
274
|
+
return fmt(numeric, d, i);
|
|
275
|
+
},
|
|
276
|
+
color: labelColorFor,
|
|
277
|
+
fontSize: labelFontSize,
|
|
278
|
+
align: "center",
|
|
279
|
+
offset: { x: 0, y: 0 },
|
|
280
|
+
filter: (d, i) => {
|
|
281
|
+
const xr = xLayout.rect(xAes.fn(d, i));
|
|
282
|
+
const yr = yLayout.rect(yAes.fn(d, i));
|
|
283
|
+
if (xr === null || yr === null) return false;
|
|
284
|
+
const minPx = options.minLabelCellPx;
|
|
285
|
+
if (minPx !== undefined && (xr.length < minPx || yr.length < minPx)) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
builders.push(wrapMark(labelMark, plot.topLeft, data.length));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return builders;
|
|
295
|
+
},
|
|
296
|
+
emphasisResolution(ctx) {
|
|
297
|
+
return emphasisContext(ctx, "tile")?.resolver() ?? null;
|
|
298
|
+
},
|
|
299
|
+
hoverDecoration(ctx: CompileContext<T>): GeomHoverDecorator | null {
|
|
300
|
+
// Focus halo on the hovered cell — recovered from the old snap path's
|
|
301
|
+
// `paintHalos` outline (deleted in e6d5643). Now an OVERLAY decorator
|
|
302
|
+
// replayed into the live overlay layer (NO marks recompile). Overlay shapes
|
|
303
|
+
// leave emphasisKey 0 ⇒ EXEMPT ⇒ the halo stays full-strength while the
|
|
304
|
+
// other cells dim via the GPU uniform. The hit's `dataIndex` is the datum
|
|
305
|
+
// index `i`, so we re-project just that cell's rect.
|
|
306
|
+
const { data, plot } = ctx;
|
|
307
|
+
if (data.length === 0) return null;
|
|
308
|
+
const coord = resolveCoord(ctx);
|
|
309
|
+
if (coord.kind !== "cartesian") return null;
|
|
310
|
+
const hoverCfg = ctx.theme.interactions.hover;
|
|
311
|
+
const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
|
|
312
|
+
const yAes = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
|
|
313
|
+
const xLayout = makeAxisLayout<T>(
|
|
314
|
+
ctx.scales.x.axisScale as AxisScale<unknown>,
|
|
315
|
+
xAes,
|
|
316
|
+
data,
|
|
317
|
+
options.cellWidth,
|
|
318
|
+
);
|
|
319
|
+
const yLayout = makeAxisLayout<T>(
|
|
320
|
+
ctx.scales.y.axisScale as AxisScale<unknown>,
|
|
321
|
+
yAes,
|
|
322
|
+
data,
|
|
323
|
+
options.cellHeight,
|
|
324
|
+
);
|
|
325
|
+
const padX = options.paddingX ?? options.padding ?? 0;
|
|
326
|
+
const padY = options.paddingY ?? options.padding ?? 0;
|
|
327
|
+
const ringColor: Color = hoverCfg.haloColor ?? ctx.theme.text.color;
|
|
328
|
+
const ox = plot.topLeft.x;
|
|
329
|
+
const oy = plot.topLeft.y;
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
geomKind: "tile",
|
|
333
|
+
data,
|
|
334
|
+
decorate(hit: HoveredHit, layer: Layer): void {
|
|
335
|
+
if (!hoverCfg.enabled || hit.data !== data) return;
|
|
336
|
+
const i = hit.dataIndex;
|
|
337
|
+
const d = data[i];
|
|
338
|
+
if (d === undefined) return;
|
|
339
|
+
const xRect = xLayout.rect(xAes.fn(d, i));
|
|
340
|
+
const yRect = yLayout.rect(yAes.fn(d, i));
|
|
341
|
+
if (!xRect || !yRect) return;
|
|
342
|
+
const corner = coord.project({ x: xRect.start + padX / 2, y: yRect.start + padY / 2 });
|
|
343
|
+
layer.pushRect({
|
|
344
|
+
x: ox + corner.x,
|
|
345
|
+
y: oy + corner.y,
|
|
346
|
+
width: Math.max(0, xRect.length - padX),
|
|
347
|
+
height: Math.max(0, yRect.length - padY),
|
|
348
|
+
cornerRadius: options.cornerRadius,
|
|
349
|
+
stroke: ringColor,
|
|
350
|
+
strokeWidth: hoverCfg.haloStrokeWidth,
|
|
351
|
+
});
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
},
|
|
355
|
+
compileHitTest(ctx: CompileContext<T>): CompiledHitTest<T> | null {
|
|
356
|
+
const { data, scales, plot } = ctx;
|
|
357
|
+
if (data.length === 0) return null;
|
|
358
|
+
const coord = resolveCoord(ctx);
|
|
359
|
+
const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
|
|
360
|
+
const yAes = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
|
|
361
|
+
const fillAes = channels.fill ? resolveAes<T, unknown>(channels.fill) : undefined;
|
|
362
|
+
const xLayout = makeAxisLayout<T>(
|
|
363
|
+
scales.x.axisScale as AxisScale<unknown>,
|
|
364
|
+
xAes,
|
|
365
|
+
data,
|
|
366
|
+
options.cellWidth,
|
|
367
|
+
);
|
|
368
|
+
const yLayout = makeAxisLayout<T>(
|
|
369
|
+
scales.y.axisScale as AxisScale<unknown>,
|
|
370
|
+
yAes,
|
|
371
|
+
data,
|
|
372
|
+
options.cellHeight,
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const ox = plot.topLeft.x;
|
|
376
|
+
const oy = plot.topLeft.y;
|
|
377
|
+
const positions = new Float32Array(data.length * 2);
|
|
378
|
+
const rects = new Float32Array(data.length * 4);
|
|
379
|
+
const dataIndex = new Int32Array(data.length);
|
|
380
|
+
let n = 0;
|
|
381
|
+
// Track the smallest cell extent to size pickRadius to one cell.
|
|
382
|
+
let halfMin = Infinity;
|
|
383
|
+
for (let i = 0; i < data.length; i++) {
|
|
384
|
+
const datum = data[i]!;
|
|
385
|
+
const xRect = xLayout.rect(xAes.fn(datum, i));
|
|
386
|
+
const yRect = yLayout.rect(yAes.fn(datum, i));
|
|
387
|
+
if (!xRect || !yRect) continue;
|
|
388
|
+
const center = coord.project({
|
|
389
|
+
x: xRect.start + xRect.length / 2,
|
|
390
|
+
y: yRect.start + yRect.length / 2,
|
|
391
|
+
});
|
|
392
|
+
const corner = coord.project({ x: xRect.start, y: yRect.start });
|
|
393
|
+
positions[n * 2] = ox + center.x;
|
|
394
|
+
positions[n * 2 + 1] = oy + center.y;
|
|
395
|
+
// Whole-cell footprint for region hit testing.
|
|
396
|
+
rects[n * 4] = ox + corner.x;
|
|
397
|
+
rects[n * 4 + 1] = oy + corner.y;
|
|
398
|
+
rects[n * 4 + 2] = xRect.length;
|
|
399
|
+
rects[n * 4 + 3] = yRect.length;
|
|
400
|
+
dataIndex[n] = i;
|
|
401
|
+
n++;
|
|
402
|
+
const half = Math.min(xRect.length, yRect.length) / 2;
|
|
403
|
+
if (half < halfMin) halfMin = half;
|
|
404
|
+
}
|
|
405
|
+
if (n === 0) return null;
|
|
406
|
+
const channelsMap: ResolvedChannelMap<T> = {
|
|
407
|
+
x: xAes,
|
|
408
|
+
y: yAes,
|
|
409
|
+
color: fillAes,
|
|
410
|
+
};
|
|
411
|
+
return {
|
|
412
|
+
geomKind: "tile",
|
|
413
|
+
label: options.label,
|
|
414
|
+
positions: positions.subarray(0, n * 2),
|
|
415
|
+
rects: rects.subarray(0, n * 4),
|
|
416
|
+
dataIndex: dataIndex.subarray(0, n),
|
|
417
|
+
// Half the smallest cell — biased toward not over-claiming neighbors
|
|
418
|
+
// when cell sizes vary. Euclidean within a cell still always picks.
|
|
419
|
+
pickRadius: Number.isFinite(halfMin) ? halfMin : 0,
|
|
420
|
+
channels: channelsMap,
|
|
421
|
+
data,
|
|
422
|
+
};
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Per-axis layout — resolves a value to its cell's pixel `[start, length]`.
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
interface AxisLayout {
|
|
432
|
+
rect(value: unknown): { start: number; length: number } | null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function makeAxisLayout<T>(
|
|
436
|
+
scale: AxisScale<unknown>,
|
|
437
|
+
aes: { fn: (d: T, i: number) => unknown },
|
|
438
|
+
data: readonly T[],
|
|
439
|
+
cellExtent: number | undefined,
|
|
440
|
+
): AxisLayout {
|
|
441
|
+
if (isBandScale(scale)) {
|
|
442
|
+
const bw = scale.bandwidth();
|
|
443
|
+
return {
|
|
444
|
+
rect: (value) => {
|
|
445
|
+
const px = scale(String(value) as never);
|
|
446
|
+
if (!Number.isFinite(px)) return null;
|
|
447
|
+
return { start: px, length: bw };
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Continuous / time scale: cell extent in domain units.
|
|
453
|
+
// Either a user-supplied `cellExtent` or one inferred from the data.
|
|
454
|
+
const domainExtent = cellExtent ?? inferCellExtent(scale, aes, data);
|
|
455
|
+
// Pre-resolve to avoid repeated property reads in the hot loop.
|
|
456
|
+
const continuous = scale as ContinuousScale | TimeScale;
|
|
457
|
+
|
|
458
|
+
// Detect regular grids: when the sorted unique values are equally spaced
|
|
459
|
+
// (e.g. hours 0–23), the data is effectively ordinal and cells should
|
|
460
|
+
// abut from the domain edge rather than centering on each value. This
|
|
461
|
+
// gives edge cells their full height flush with the domain boundary.
|
|
462
|
+
// Irregularly-spaced continuous data falls through to centered layout.
|
|
463
|
+
const [sortedValues, isRegular] = detectGrid(valuesFromAes(aes, data), domainExtent);
|
|
464
|
+
|
|
465
|
+
if (isRegular) {
|
|
466
|
+
// Abutting-bin layout: cell i occupies [d0 + i*step, d0 + (i+1)*step]
|
|
467
|
+
// in domain space. The step is computed from the domain span divided
|
|
468
|
+
// by the number of unique values so all cells fit exactly within the
|
|
469
|
+
// visible range — no overflow at the edges.
|
|
470
|
+
const [d0, d1] = (scale as ContinuousScale).domain as [number, number];
|
|
471
|
+
const count = sortedValues.length;
|
|
472
|
+
const step = count > 1 ? (d1 - d0) / count : 1;
|
|
473
|
+
const valueToIndex = new Map(sortedValues.map((v, i) => [v, i]));
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
rect: (value) => {
|
|
477
|
+
const nv = isDate(value) ? (value as Date).getTime() : (value as number);
|
|
478
|
+
if (!Number.isFinite(nv)) return null;
|
|
479
|
+
const idx = valueToIndex.get(nv);
|
|
480
|
+
if (idx === undefined) return null;
|
|
481
|
+
const dataStart = d0 + idx * step;
|
|
482
|
+
const dataEnd = dataStart + step;
|
|
483
|
+
let p0: number;
|
|
484
|
+
let p1: number;
|
|
485
|
+
if (isDate(value)) {
|
|
486
|
+
p0 = (continuous as TimeScale)(new Date(dataStart));
|
|
487
|
+
p1 = (continuous as TimeScale)(new Date(dataEnd));
|
|
488
|
+
} else {
|
|
489
|
+
p0 = (continuous as ContinuousScale)(dataStart);
|
|
490
|
+
p1 = (continuous as ContinuousScale)(dataEnd);
|
|
491
|
+
}
|
|
492
|
+
return { start: Math.min(p0, p1), length: Math.abs(p1 - p0) };
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Centered-cell layout for irregularly-spaced continuous data. Each cell
|
|
498
|
+
// is centred on its data value and extends half a cell extent in each
|
|
499
|
+
// direction. Edge cells may overflow the scale range — the layer clip
|
|
500
|
+
// rect clips to the plot frame, which gives geometrically-honest results
|
|
501
|
+
// (the cell's domain footprint legitimately extends past the visible
|
|
502
|
+
// domain). Truncating or shifting edge cells would cause half-sized or
|
|
503
|
+
// overlapping cells, both worse outcomes.
|
|
504
|
+
return {
|
|
505
|
+
rect: (value) => {
|
|
506
|
+
const half = domainExtent / 2;
|
|
507
|
+
let p0: number;
|
|
508
|
+
let p1: number;
|
|
509
|
+
if (isDate(value)) {
|
|
510
|
+
p0 = (continuous as TimeScale)(addDate(value as Date, -half));
|
|
511
|
+
p1 = (continuous as TimeScale)(addDate(value as Date, half));
|
|
512
|
+
} else {
|
|
513
|
+
const v = value as number;
|
|
514
|
+
if (!Number.isFinite(v)) return null;
|
|
515
|
+
p0 = (continuous as ContinuousScale)(v - half);
|
|
516
|
+
p1 = (continuous as ContinuousScale)(v + half);
|
|
517
|
+
}
|
|
518
|
+
return { start: Math.min(p0, p1), length: Math.abs(p1 - p0) };
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Collect unique numeric (or epoch-ms Date) values from a channel accessor,
|
|
525
|
+
* returned as a sorted array. Shared by `detectGrid` and `inferCellExtent`.
|
|
526
|
+
*/
|
|
527
|
+
function valuesFromAes<T>(aes: { fn: (d: T, i: number) => unknown }, data: readonly T[]): number[] {
|
|
528
|
+
const seen = new Set<number>();
|
|
529
|
+
for (let i = 0; i < data.length; i++) {
|
|
530
|
+
const raw = aes.fn(data[i]!, i);
|
|
531
|
+
const n = isDate(raw) ? raw.getTime() : (raw as number);
|
|
532
|
+
if (typeof n === "number" && Number.isFinite(n) && !seen.has(n)) {
|
|
533
|
+
seen.add(n);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return [...seen].sort((a, b) => a - b);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Check whether a sorted array of values forms a regular arithmetic
|
|
541
|
+
* progression (equal gaps between every adjacent pair). When true, the
|
|
542
|
+
* tile data is effectively ordinal (e.g. hours 0–23, months 1–12) and
|
|
543
|
+
* an abutting-bin layout is safe and correct.
|
|
544
|
+
*
|
|
545
|
+
* Returns `[sortedValues, true]` for a regular grid, `[sortedValues, false]`
|
|
546
|
+
* otherwise. The sorted values are returned to avoid re-scanning.
|
|
547
|
+
*/
|
|
548
|
+
function detectGrid(sorted: number[], step: number): [sorted: number[], isRegular: boolean] {
|
|
549
|
+
if (sorted.length < 2) return [sorted, false];
|
|
550
|
+
const eps = Math.abs(step) * 1e-9;
|
|
551
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
552
|
+
if (Math.abs(sorted[i]! - sorted[i - 1]! - step) > eps) {
|
|
553
|
+
return [sorted, false];
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return [sorted, true];
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Collect unique numeric (or epoch-ms Date) values from a channel accessor. */
|
|
560
|
+
function numericValuesFromAes<T>(
|
|
561
|
+
aes: { fn: (d: T, i: number) => unknown },
|
|
562
|
+
data: readonly T[],
|
|
563
|
+
): Set<number> {
|
|
564
|
+
const vals = new Set<number>();
|
|
565
|
+
for (let i = 0; i < data.length; i++) {
|
|
566
|
+
const raw = aes.fn(data[i]!, i);
|
|
567
|
+
const n = isDate(raw) ? raw.getTime() : (raw as number);
|
|
568
|
+
if (typeof n === "number" && Number.isFinite(n)) vals.add(n);
|
|
569
|
+
}
|
|
570
|
+
return vals;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function inferCellExtent<T>(
|
|
574
|
+
scale: AxisScale<unknown>,
|
|
575
|
+
aes: { fn: (d: T, i: number) => unknown },
|
|
576
|
+
data: readonly T[],
|
|
577
|
+
): number {
|
|
578
|
+
const vals = numericValuesFromAes(aes, data);
|
|
579
|
+
if (vals.size < 2) {
|
|
580
|
+
// Fallback: 1% of the scale's domain extent — single-row datasets.
|
|
581
|
+
const dom = (scale as ContinuousScale).domain;
|
|
582
|
+
const span = Math.abs((dom[1] as number) - (dom[0] as number));
|
|
583
|
+
return span > 0 ? span * 0.01 : 1;
|
|
584
|
+
}
|
|
585
|
+
const sorted = [...vals].sort((a, b) => a - b);
|
|
586
|
+
let minGap = Infinity;
|
|
587
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
588
|
+
const g = sorted[i]! - sorted[i - 1]!;
|
|
589
|
+
if (g > 0 && g < minGap) minGap = g;
|
|
590
|
+
}
|
|
591
|
+
return Number.isFinite(minGap) ? minGap : 1;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function addDate(d: Date, deltaMs: number): Date {
|
|
595
|
+
return new Date(d.getTime() + deltaMs);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function isFiniteValue(v: unknown): boolean {
|
|
599
|
+
if (typeof v === "number") return Number.isFinite(v);
|
|
600
|
+
if (isDate(v)) return Number.isFinite(v.getTime());
|
|
601
|
+
return v !== null && v !== undefined;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function isDate(v: unknown): v is Date {
|
|
605
|
+
return typeof v === "object" && v !== null && v instanceof Date;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function isBandScale(scale: AxisScale<unknown>): scale is BandScale<unknown> {
|
|
609
|
+
return "bandwidth" in scale;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// 8-bit-per-channel quantized RGBA — perceptually indistinguishable from full
|
|
613
|
+
// precision for contrast resolution; collapses near-identical palette samples
|
|
614
|
+
// onto a single cache slot.
|
|
615
|
+
function quantizeColorKey(c: Color): string {
|
|
616
|
+
const r = (c.r * 255 + 0.5) | 0;
|
|
617
|
+
const g = (c.g * 255 + 0.5) | 0;
|
|
618
|
+
const b = (c.b * 255 + 0.5) | 0;
|
|
619
|
+
const a = (c.a * 255 + 0.5) | 0;
|
|
620
|
+
return `${r},${g},${b},${a}`;
|
|
621
|
+
}
|