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,107 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Wide → long reshape helpers
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Most of the heatmap references in the gallery start from a 2D matrix or a
|
|
5
|
+
// wide table — `tile()` consumes long rows of `{x, y, value}`. These two
|
|
6
|
+
// helpers cover both shapes without pulling in a dataframe dependency.
|
|
7
|
+
|
|
8
|
+
export interface FromMatrixOptions {
|
|
9
|
+
/** Y labels (rows). Top to bottom. Length must equal `values.length`. */
|
|
10
|
+
rows: readonly string[];
|
|
11
|
+
/** X labels (cols). Left to right. Length must equal `values[0].length`. */
|
|
12
|
+
cols: readonly string[];
|
|
13
|
+
/** Output key for the row label. Default `"row"`. */
|
|
14
|
+
rowKey?: string;
|
|
15
|
+
/** Output key for the col label. Default `"col"`. */
|
|
16
|
+
colKey?: string;
|
|
17
|
+
/** Output key for the cell value. Default `"value"`. */
|
|
18
|
+
valueKey?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type LongRow<R extends string, C extends string, V extends string> = {
|
|
22
|
+
[K in R | C | V]: K extends V ? number : string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Convert a 2D `values[row][col]` matrix into long-format rows that the
|
|
27
|
+
* `tile()` geom consumes directly. Cells with `NaN` / `null` / `undefined`
|
|
28
|
+
* are kept (so consumers can opt into NA cell rendering); cells whose
|
|
29
|
+
* coordinates are out of range are skipped.
|
|
30
|
+
*
|
|
31
|
+
* Example:
|
|
32
|
+
* ```ts
|
|
33
|
+
* const long = fromMatrix(temperatures, {
|
|
34
|
+
* rows: ["00:00", "06:00", "12:00", "18:00"],
|
|
35
|
+
* cols: ["Mon", "Tue", "Wed", "Thu", "Fri"],
|
|
36
|
+
* });
|
|
37
|
+
* tile({ x: "col", y: "row", fill: "value" })
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function fromMatrix(
|
|
41
|
+
values: readonly (readonly number[])[],
|
|
42
|
+
opts: FromMatrixOptions,
|
|
43
|
+
): { row: string; col: string; value: number }[] {
|
|
44
|
+
const rowKey = opts.rowKey ?? "row";
|
|
45
|
+
const colKey = opts.colKey ?? "col";
|
|
46
|
+
const valueKey = opts.valueKey ?? "value";
|
|
47
|
+
const out: { row: string; col: string; value: number }[] = [];
|
|
48
|
+
for (let r = 0; r < opts.rows.length; r++) {
|
|
49
|
+
const row = values[r];
|
|
50
|
+
if (!row) continue;
|
|
51
|
+
const rowLabel = opts.rows[r]!;
|
|
52
|
+
for (let c = 0; c < opts.cols.length; c++) {
|
|
53
|
+
const v = row[c];
|
|
54
|
+
if (v === undefined) continue;
|
|
55
|
+
out.push({
|
|
56
|
+
[rowKey]: rowLabel,
|
|
57
|
+
[colKey]: opts.cols[c]!,
|
|
58
|
+
[valueKey]: v,
|
|
59
|
+
} as { row: string; col: string; value: number });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface PivotLongerOptions<T> {
|
|
66
|
+
/** Output column name for the original key. Default `"name"`. */
|
|
67
|
+
nameKey?: string;
|
|
68
|
+
/** Output column name for the value. Default `"value"`. */
|
|
69
|
+
valueKey?: string;
|
|
70
|
+
/** Optional id columns kept verbatim alongside the long pair. */
|
|
71
|
+
idColumns?: readonly (keyof T & string)[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Pivot a wide row to a sequence of long rows — one per `keys` entry.
|
|
76
|
+
* `idColumns` are copied through to every output row so a per-row identifier
|
|
77
|
+
* (e.g. car name in the mtcars example) is preserved.
|
|
78
|
+
*
|
|
79
|
+
* Example:
|
|
80
|
+
* ```ts
|
|
81
|
+
* const long = pivotLonger(mtcars, ["mpg", "cyl", "disp", "hp"], {
|
|
82
|
+
* idColumns: ["model"],
|
|
83
|
+
* });
|
|
84
|
+
* // → [{ model, name: "mpg", value }, { model, name: "cyl", value }, ...]
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export function pivotLonger<T extends Record<string, unknown>>(
|
|
88
|
+
rows: readonly T[],
|
|
89
|
+
keys: readonly (keyof T & string)[],
|
|
90
|
+
options: PivotLongerOptions<T> = {},
|
|
91
|
+
): Record<string, unknown>[] {
|
|
92
|
+
const nameKey = options.nameKey ?? "name";
|
|
93
|
+
const valueKey = options.valueKey ?? "value";
|
|
94
|
+
const idColumns = options.idColumns ?? [];
|
|
95
|
+
const out: Record<string, unknown>[] = [];
|
|
96
|
+
for (const row of rows) {
|
|
97
|
+
for (const key of keys) {
|
|
98
|
+
const entry: Record<string, unknown> = {
|
|
99
|
+
[nameKey]: key,
|
|
100
|
+
[valueKey]: row[key],
|
|
101
|
+
};
|
|
102
|
+
for (const id of idColumns) entry[id] = row[id];
|
|
103
|
+
out.push(entry);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** The emphasis uniform write the driver hands back to the renderer. */
|
|
2
|
+
export interface EmphasisState {
|
|
3
|
+
focusedKey: number;
|
|
4
|
+
dimAlpha: number;
|
|
5
|
+
t: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* What a driver method asks of the host this turn.
|
|
9
|
+
* • `needsFrame` — a frame must be drawn at all.
|
|
10
|
+
* • `full` — that frame MUST be a FULL render (never a regions/overlay-only
|
|
11
|
+
* partial), because the emphasis uniform is in a visible/transitioning state
|
|
12
|
+
* (see SOUNDNESS RULE in the module header). When `needsFrame` is true and
|
|
13
|
+
* `full` is false, an overlay-only repaint is sound.
|
|
14
|
+
*/
|
|
15
|
+
export interface FrameRequest {
|
|
16
|
+
needsFrame: boolean;
|
|
17
|
+
full: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface EmphasisDriverOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Animation duration in seconds. `<= 0` means reduced-motion: the dim snaps
|
|
22
|
+
* instantly (no ramp). Re-read on every `onHover` via `durationS` being a
|
|
23
|
+
* thunk so a live theme/reduced-motion change takes effect immediately.
|
|
24
|
+
*/
|
|
25
|
+
durationS: () => number;
|
|
26
|
+
/** Dim alpha (theme.interactions.hover.dim) for the non-focused instances. */
|
|
27
|
+
dim: () => number;
|
|
28
|
+
/** Push an emphasis uniform write to the renderer. */
|
|
29
|
+
setEmphasis: (state: EmphasisState) => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The animated-emphasis state machine. Pure aside from the injected
|
|
33
|
+
* `setEmphasis` sink; time is supplied by the caller (deterministic in tests).
|
|
34
|
+
*/
|
|
35
|
+
export interface EmphasisDriver {
|
|
36
|
+
/**
|
|
37
|
+
* A hover hit resolved to its focused emphasis key (or `null`/`0` for no dim
|
|
38
|
+
* geom / exit). Updates the target and, under reduced-motion, snaps `t`.
|
|
39
|
+
* Returns the frame the host must draw. The full-frame decision encodes Fix A:
|
|
40
|
+
* ANY snap (enter OR exit) from/to a visibly-dimmed state is a global pixel
|
|
41
|
+
* change → FULL; an exit that was never showing is an ordinary overlay move.
|
|
42
|
+
*/
|
|
43
|
+
onHover(focusedKey: number | null): FrameRequest;
|
|
44
|
+
/**
|
|
45
|
+
* Advance the ramp by the time elapsed since the last `step`/`onHover` call.
|
|
46
|
+
* Writes the eased uniform and returns the frame to draw. While transitioning
|
|
47
|
+
* this is always a FULL frame. Returns {@link NO_FRAME} when already settled
|
|
48
|
+
* (so the RAF loop can stop re-invalidating — no perpetual frames).
|
|
49
|
+
*/
|
|
50
|
+
step(now: number): FrameRequest;
|
|
51
|
+
/** True while `t` has not reached its target (mid-transition). */
|
|
52
|
+
animating(): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Snap the uniform to the settled-off state (t = 0, focusedKey = 0) and clear
|
|
55
|
+
* all internal animation state. Used on the DATA path (setData/update): a held
|
|
56
|
+
* hover's stale focused key — index-derived, so it points at re-indexed
|
|
57
|
+
* instances after a data swap — is dropped before the full redraw; the
|
|
58
|
+
* pointer's next move re-establishes emphasis. Idempotent. Does NOT itself
|
|
59
|
+
* request a frame (the data path already forces a full redraw).
|
|
60
|
+
*/
|
|
61
|
+
reset(): void;
|
|
62
|
+
/** Internal state, exposed for tests/diagnostics. */
|
|
63
|
+
state(): {
|
|
64
|
+
focusedKey: number;
|
|
65
|
+
t: number;
|
|
66
|
+
target: number;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export declare function createEmphasisDriver(opts: EmphasisDriverOptions): EmphasisDriver;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the REAL animated-emphasis driver (`emphasis-driver.ts`).
|
|
3
|
+
*
|
|
4
|
+
* These drive the production state machine directly with a deterministic clock —
|
|
5
|
+
* no hand-copied mirror. The driver is the single source of truth for: the t
|
|
6
|
+
* ramp, the ease-out-cubic write, reduced-motion snap, hover swap, the exit-snap
|
|
7
|
+
* full-frame rule (Fix A), and reset() (Fix C). The mount is a thin consumer that
|
|
8
|
+
* wires `setEmphasis` → renderer and routes `{ needsFrame, full }` into
|
|
9
|
+
* inv/requestOverlay/drawRepaint.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from "vite-plus/test";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
createEmphasisDriver,
|
|
16
|
+
type EmphasisDriver,
|
|
17
|
+
type EmphasisState,
|
|
18
|
+
} from "./emphasis-driver.ts";
|
|
19
|
+
|
|
20
|
+
interface Harness {
|
|
21
|
+
driver: EmphasisDriver;
|
|
22
|
+
writes: EmphasisState[];
|
|
23
|
+
setDuration: (s: number) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeDriver(opts: { durationS?: number; dim?: number } = {}): Harness {
|
|
27
|
+
let durationS = opts.durationS ?? 0.1; // 100ms default
|
|
28
|
+
const dim = opts.dim ?? 0.45;
|
|
29
|
+
const writes: EmphasisState[] = [];
|
|
30
|
+
const driver = createEmphasisDriver({
|
|
31
|
+
durationS: () => durationS,
|
|
32
|
+
dim: () => dim,
|
|
33
|
+
setEmphasis: (s) => writes.push({ ...s }),
|
|
34
|
+
});
|
|
35
|
+
return { driver, writes, setDuration: (s) => (durationS = s) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const FOCUS_A = 1234;
|
|
39
|
+
const FOCUS_B = 5678;
|
|
40
|
+
|
|
41
|
+
describe("emphasis driver — enter / ramp / settle", () => {
|
|
42
|
+
test("enter resolves the key, requests a FULL frame, and starts t at 0", () => {
|
|
43
|
+
const { driver, writes } = makeDriver({ durationS: 0.1, dim: 0.45 });
|
|
44
|
+
const req = driver.onHover(FOCUS_A);
|
|
45
|
+
expect(req).toEqual({ needsFrame: true, full: true });
|
|
46
|
+
expect(driver.state()).toEqual({ focusedKey: FOCUS_A, t: 0, target: 1 });
|
|
47
|
+
expect(writes.at(-1)).toEqual({ focusedKey: FOCUS_A, dimAlpha: 0.45, t: 0 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("t ramps over durationMs; each step is a FULL frame; settles at 1", () => {
|
|
51
|
+
const { driver, writes } = makeDriver({ durationS: 0.1 });
|
|
52
|
+
driver.onHover(FOCUS_A);
|
|
53
|
+
// First step seeds the clock (advances nothing).
|
|
54
|
+
expect(driver.step(0).needsFrame).toBe(true);
|
|
55
|
+
expect(driver.state().t).toBe(0);
|
|
56
|
+
// 50ms over a 100ms ramp → t 0.5.
|
|
57
|
+
let r = driver.step(50);
|
|
58
|
+
expect(r).toEqual({ needsFrame: true, full: true });
|
|
59
|
+
expect(driver.state().t).toBeCloseTo(0.5, 5);
|
|
60
|
+
expect(driver.animating()).toBe(true);
|
|
61
|
+
// Another 50ms → settled at 1.
|
|
62
|
+
r = driver.step(100);
|
|
63
|
+
expect(driver.state().t).toBeCloseTo(1, 5);
|
|
64
|
+
expect(driver.animating()).toBe(false);
|
|
65
|
+
// The eased write reflects ease-out-cubic of t.
|
|
66
|
+
const last = writes.at(-1)!;
|
|
67
|
+
expect(last.t).toBeCloseTo(1, 5);
|
|
68
|
+
// A step once settled requests no frame (loop stops — no perpetual RAF).
|
|
69
|
+
expect(driver.step(116)).toEqual({ needsFrame: false, full: false });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("the written t is ease-out-cubic of the raw ramp t", () => {
|
|
73
|
+
const { driver, writes } = makeDriver({ durationS: 0.1 });
|
|
74
|
+
driver.onHover(FOCUS_A);
|
|
75
|
+
driver.step(0); // seed
|
|
76
|
+
driver.step(50); // raw t = 0.5
|
|
77
|
+
const eased = 1 - Math.pow(1 - 0.5, 3); // 0.875
|
|
78
|
+
expect(writes.at(-1)!.t).toBeCloseTo(eased, 5);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("emphasis driver — exit", () => {
|
|
83
|
+
test("exit from a shown dim ramps t→0 (FULL frames) then clears the key", () => {
|
|
84
|
+
const { driver } = makeDriver({ durationS: 0.1 });
|
|
85
|
+
driver.onHover(FOCUS_A);
|
|
86
|
+
driver.step(0);
|
|
87
|
+
driver.step(100); // settle on
|
|
88
|
+
const req = driver.onHover(null);
|
|
89
|
+
expect(req).toEqual({ needsFrame: true, full: true });
|
|
90
|
+
expect(driver.state().target).toBe(0);
|
|
91
|
+
driver.step(100); // seed clock at exit's first step
|
|
92
|
+
driver.step(150); // 50ms → t 0.5
|
|
93
|
+
expect(driver.state().t).toBeCloseTo(0.5, 5);
|
|
94
|
+
expect(driver.state().focusedKey).toBe(FOCUS_A); // held until settled
|
|
95
|
+
driver.step(200); // → t 0
|
|
96
|
+
expect(driver.state().t).toBeCloseTo(0, 5);
|
|
97
|
+
expect(driver.state().focusedKey).toBe(0); // cleared once t reaches 0
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Fix A core: an exit that was never showing a dim is an ordinary overlay move
|
|
101
|
+
// (point halo / tooltip) — NOT a full frame.
|
|
102
|
+
test("exit with no dim shown → no full frame (overlay-only territory)", () => {
|
|
103
|
+
const { driver } = makeDriver({ durationS: 0.1 });
|
|
104
|
+
const req = driver.onHover(null);
|
|
105
|
+
expect(req).toEqual({ needsFrame: false, full: false });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("emphasis driver — hover swap (single uniform)", () => {
|
|
110
|
+
test("A→B mid-ramp snaps focusedKey to B and continues toward 1 (no reset)", () => {
|
|
111
|
+
const { driver } = makeDriver({ durationS: 0.1 });
|
|
112
|
+
driver.onHover(FOCUS_A);
|
|
113
|
+
driver.step(0);
|
|
114
|
+
driver.step(50); // t 0.5 toward A
|
|
115
|
+
const req = driver.onHover(FOCUS_B);
|
|
116
|
+
expect(req).toEqual({ needsFrame: true, full: true });
|
|
117
|
+
expect(driver.state().focusedKey).toBe(FOCUS_B);
|
|
118
|
+
expect(driver.state().target).toBe(1);
|
|
119
|
+
expect(driver.state().t).toBeCloseTo(0.5, 5); // continues, not reset
|
|
120
|
+
// Resumes ramping (clock re-seeds on the swap).
|
|
121
|
+
driver.step(50);
|
|
122
|
+
driver.step(100); // 50ms more → t 1
|
|
123
|
+
expect(driver.state().t).toBeCloseTo(1, 5);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("emphasis driver — Fix A: reduced-motion snaps are FULL frames", () => {
|
|
128
|
+
test("reduced-motion ENTER snaps t→1 in one full frame (no ramp)", () => {
|
|
129
|
+
const { driver, writes } = makeDriver({ durationS: 0 });
|
|
130
|
+
const req = driver.onHover(FOCUS_A);
|
|
131
|
+
expect(req).toEqual({ needsFrame: true, full: true });
|
|
132
|
+
expect(driver.state().t).toBe(1);
|
|
133
|
+
expect(driver.animating()).toBe(false);
|
|
134
|
+
expect(writes.at(-1)!.t).toBe(1);
|
|
135
|
+
// No ramp frames follow.
|
|
136
|
+
expect(driver.step(16)).toEqual({ needsFrame: false, full: false });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// The bug Fix A repairs: reduced-motion EXIT snapped the uniform off (a global
|
|
140
|
+
// pixel change) but the OLD code routed it to an overlay-only repaint, leaving
|
|
141
|
+
// the rest of the backbuffer stuck dimmed. The driver now reports `full` for
|
|
142
|
+
// any snap-off from a shown dim.
|
|
143
|
+
test("reduced-motion EXIT from a shown dim snaps off as a FULL frame (not overlay)", () => {
|
|
144
|
+
const { driver, writes } = makeDriver({ durationS: 0 });
|
|
145
|
+
driver.onHover(FOCUS_A); // snap on
|
|
146
|
+
expect(driver.state().t).toBe(1);
|
|
147
|
+
const req = driver.onHover(null); // snap off
|
|
148
|
+
expect(req).toEqual({ needsFrame: true, full: true }); // <-- the fix
|
|
149
|
+
expect(driver.state()).toEqual({ focusedKey: 0, t: 0, target: 0 });
|
|
150
|
+
expect(writes.at(-1)!.t).toBe(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("live duration change to 0 mid-hover takes effect on the next onHover", () => {
|
|
154
|
+
const h = makeDriver({ durationS: 0.1 });
|
|
155
|
+
h.driver.onHover(FOCUS_A);
|
|
156
|
+
h.driver.step(0);
|
|
157
|
+
h.driver.step(100); // settle on (animated)
|
|
158
|
+
h.setDuration(0); // user flips on reduced-motion
|
|
159
|
+
const req = h.driver.onHover(null);
|
|
160
|
+
expect(req.full).toBe(true);
|
|
161
|
+
expect(h.driver.state().t).toBe(0); // snapped, not ramped
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("emphasis driver — Fix C: reset() on the data path", () => {
|
|
166
|
+
test("reset snaps the uniform off and clears all state", () => {
|
|
167
|
+
const { driver, writes } = makeDriver({ durationS: 0.1 });
|
|
168
|
+
driver.onHover(FOCUS_A);
|
|
169
|
+
driver.step(0);
|
|
170
|
+
driver.step(50); // mid-ramp, dim shown
|
|
171
|
+
expect(driver.state().focusedKey).toBe(FOCUS_A);
|
|
172
|
+
driver.reset();
|
|
173
|
+
expect(driver.state()).toEqual({ focusedKey: 0, t: 0, target: 0 });
|
|
174
|
+
expect(driver.animating()).toBe(false);
|
|
175
|
+
// reset writes a settled-off uniform so the next full redraw shows no dim.
|
|
176
|
+
expect(writes.at(-1)).toEqual({ focusedKey: 0, dimAlpha: 0.45, t: 0 });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("after reset, the next enter re-establishes emphasis from scratch", () => {
|
|
180
|
+
const { driver } = makeDriver({ durationS: 0.1 });
|
|
181
|
+
driver.onHover(FOCUS_A);
|
|
182
|
+
driver.step(0);
|
|
183
|
+
driver.step(100);
|
|
184
|
+
driver.reset();
|
|
185
|
+
const req = driver.onHover(FOCUS_B);
|
|
186
|
+
expect(req).toEqual({ needsFrame: true, full: true });
|
|
187
|
+
expect(driver.state()).toEqual({ focusedKey: FOCUS_B, t: 0, target: 1 });
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("emphasis driver — no-dim hits never engage", () => {
|
|
192
|
+
test("onHover(null) / onHover(0) with nothing shown is inert", () => {
|
|
193
|
+
const { driver, writes } = makeDriver({ durationS: 0.1 });
|
|
194
|
+
expect(driver.onHover(null)).toEqual({ needsFrame: false, full: false });
|
|
195
|
+
expect(driver.onHover(0)).toEqual({ needsFrame: false, full: false });
|
|
196
|
+
expect(driver.state().focusedKey).toBe(0);
|
|
197
|
+
expect(writes).toHaveLength(0);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Animated GPU-emphasis driver (P5-T3) — extracted from mount.ts
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The dim-others hover treatment is driven entirely by the core renderer's
|
|
5
|
+
// emphasis uniform: on a hover hit-change over a GPU-dim geom the mount resolves
|
|
6
|
+
// the hit to a namespaced focused key and animates `t` 0→1 (ease-out cubic) over
|
|
7
|
+
// `theme.interactions.hover.durationMs`; on exit `t` ramps back to 0 and the
|
|
8
|
+
// focused key is held until `t` settles, then cleared. Zero marks recompile per
|
|
9
|
+
// hover frame.
|
|
10
|
+
//
|
|
11
|
+
// This module is the PURE state machine for that animation, factored out of the
|
|
12
|
+
// mount so it can be unit-tested directly (deterministic `now()`) instead of
|
|
13
|
+
// against a hand-copied mirror. The mount is a thin consumer: it wires the
|
|
14
|
+
// driver's `setEmphasis(state)` callback to `renderer.setEmphasis(...)`, feeds
|
|
15
|
+
// hover hits in through `onHover(key)`, advances the ramp from the RAF tick via
|
|
16
|
+
// `step(now)`, and routes the returned `{ needsFrame, full }` decisions into
|
|
17
|
+
// `inv.invalidate()` / `requestOverlay()`.
|
|
18
|
+
//
|
|
19
|
+
// SOUNDNESS RULE (load-bearing): a changed emphasis uniform alters pixels
|
|
20
|
+
// EVERYWHERE (every tagged instance re-mixes its alpha), so a regions/overlay
|
|
21
|
+
// partial frame would leave the un-repainted backbuffer showing the previous
|
|
22
|
+
// dim. Therefore every frame the driver requests while the uniform is in a
|
|
23
|
+
// VISIBLE / TRANSITIONING state is a FULL frame (`full: true`). Only a settled,
|
|
24
|
+
// fully-undimmed state (t === 0, no focused key) lets the mount fall back to the
|
|
25
|
+
// cheap overlay path for a pure cursor-overlay change.
|
|
26
|
+
//
|
|
27
|
+
// The uniform holds ONE key. Swapping hover A→B mid-animation snaps the focused
|
|
28
|
+
// key to B and continues `t` toward 1 (no per-key crossfade — a single global
|
|
29
|
+
// uniform can't express two focuses; documented limitation).
|
|
30
|
+
|
|
31
|
+
/** The emphasis uniform write the driver hands back to the renderer. */
|
|
32
|
+
export interface EmphasisState {
|
|
33
|
+
focusedKey: number;
|
|
34
|
+
dimAlpha: number;
|
|
35
|
+
t: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What a driver method asks of the host this turn.
|
|
40
|
+
* • `needsFrame` — a frame must be drawn at all.
|
|
41
|
+
* • `full` — that frame MUST be a FULL render (never a regions/overlay-only
|
|
42
|
+
* partial), because the emphasis uniform is in a visible/transitioning state
|
|
43
|
+
* (see SOUNDNESS RULE in the module header). When `needsFrame` is true and
|
|
44
|
+
* `full` is false, an overlay-only repaint is sound.
|
|
45
|
+
*/
|
|
46
|
+
export interface FrameRequest {
|
|
47
|
+
needsFrame: boolean;
|
|
48
|
+
full: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const NO_FRAME: FrameRequest = { needsFrame: false, full: false };
|
|
52
|
+
|
|
53
|
+
export interface EmphasisDriverOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Animation duration in seconds. `<= 0` means reduced-motion: the dim snaps
|
|
56
|
+
* instantly (no ramp). Re-read on every `onHover` via `durationS` being a
|
|
57
|
+
* thunk so a live theme/reduced-motion change takes effect immediately.
|
|
58
|
+
*/
|
|
59
|
+
durationS: () => number;
|
|
60
|
+
/** Dim alpha (theme.interactions.hover.dim) for the non-focused instances. */
|
|
61
|
+
dim: () => number;
|
|
62
|
+
/** Push an emphasis uniform write to the renderer. */
|
|
63
|
+
setEmphasis: (state: EmphasisState) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The animated-emphasis state machine. Pure aside from the injected
|
|
68
|
+
* `setEmphasis` sink; time is supplied by the caller (deterministic in tests).
|
|
69
|
+
*/
|
|
70
|
+
export interface EmphasisDriver {
|
|
71
|
+
/**
|
|
72
|
+
* A hover hit resolved to its focused emphasis key (or `null`/`0` for no dim
|
|
73
|
+
* geom / exit). Updates the target and, under reduced-motion, snaps `t`.
|
|
74
|
+
* Returns the frame the host must draw. The full-frame decision encodes Fix A:
|
|
75
|
+
* ANY snap (enter OR exit) from/to a visibly-dimmed state is a global pixel
|
|
76
|
+
* change → FULL; an exit that was never showing is an ordinary overlay move.
|
|
77
|
+
*/
|
|
78
|
+
onHover(focusedKey: number | null): FrameRequest;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Advance the ramp by the time elapsed since the last `step`/`onHover` call.
|
|
82
|
+
* Writes the eased uniform and returns the frame to draw. While transitioning
|
|
83
|
+
* this is always a FULL frame. Returns {@link NO_FRAME} when already settled
|
|
84
|
+
* (so the RAF loop can stop re-invalidating — no perpetual frames).
|
|
85
|
+
*/
|
|
86
|
+
step(now: number): FrameRequest;
|
|
87
|
+
|
|
88
|
+
/** True while `t` has not reached its target (mid-transition). */
|
|
89
|
+
animating(): boolean;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Snap the uniform to the settled-off state (t = 0, focusedKey = 0) and clear
|
|
93
|
+
* all internal animation state. Used on the DATA path (setData/update): a held
|
|
94
|
+
* hover's stale focused key — index-derived, so it points at re-indexed
|
|
95
|
+
* instances after a data swap — is dropped before the full redraw; the
|
|
96
|
+
* pointer's next move re-establishes emphasis. Idempotent. Does NOT itself
|
|
97
|
+
* request a frame (the data path already forces a full redraw).
|
|
98
|
+
*/
|
|
99
|
+
reset(): void;
|
|
100
|
+
|
|
101
|
+
/** Internal state, exposed for tests/diagnostics. */
|
|
102
|
+
state(): { focusedKey: number; t: number; target: number };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3);
|
|
106
|
+
const clamp01 = (t: number): number => Math.max(0, Math.min(1, t));
|
|
107
|
+
|
|
108
|
+
export function createEmphasisDriver(opts: EmphasisDriverOptions): EmphasisDriver {
|
|
109
|
+
let focusedKey = 0; // 0 = nothing focused (settled / inert)
|
|
110
|
+
let t = 0; // 0 (no dim) … 1 (full dim) — raw, linear; eased at write time
|
|
111
|
+
let target = 0; // ramp t toward this (0 on exit, 1 on enter)
|
|
112
|
+
let durationS = 0; // resolved from the theme thunk on each hover change
|
|
113
|
+
let lastNow = 0; // last `step` timestamp; seeded on first step after a change
|
|
114
|
+
let lastNowValid = false;
|
|
115
|
+
|
|
116
|
+
const animating = (): boolean => t !== target;
|
|
117
|
+
|
|
118
|
+
// True when the uniform currently shows (or is ramping toward) a dim — i.e. a
|
|
119
|
+
// partial frame would be unsound. Either we have a live focused key with a
|
|
120
|
+
// non-zero/ramping-up t, or we are mid-ramp-out (t still > 0).
|
|
121
|
+
const isShowing = (): boolean => focusedKey !== 0 && (target === 1 || t > 0);
|
|
122
|
+
|
|
123
|
+
function write(): void {
|
|
124
|
+
opts.setEmphasis({ focusedKey, dimAlpha: opts.dim(), t: easeOutCubic(clamp01(t)) });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function onHover(nextFocused: number | null): FrameRequest {
|
|
128
|
+
const key = nextFocused ?? 0;
|
|
129
|
+
durationS = opts.durationS();
|
|
130
|
+
const snap = durationS <= 0;
|
|
131
|
+
const wasShowing = isShowing();
|
|
132
|
+
|
|
133
|
+
if (key !== 0) {
|
|
134
|
+
// Enter / swap. Snap the focused key (uniform holds one), ramp toward 1.
|
|
135
|
+
focusedKey = key;
|
|
136
|
+
target = 1;
|
|
137
|
+
lastNowValid = false; // re-seed the ramp clock on the next step
|
|
138
|
+
if (snap) t = 1; // reduced-motion: snap on
|
|
139
|
+
write();
|
|
140
|
+
// Enter is ALWAYS a global pixel change (a new dim appears, or the dim
|
|
141
|
+
// re-targets a different instance), so it always forces a FULL frame —
|
|
142
|
+
// both for the animated ramp AND the reduced-motion snap (Fix A symmetry).
|
|
143
|
+
return { needsFrame: true, full: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// No dim geom focused → ramp out (or, under reduced-motion, snap off).
|
|
147
|
+
target = 0;
|
|
148
|
+
lastNowValid = false;
|
|
149
|
+
if (snap) {
|
|
150
|
+
focusedKey = 0;
|
|
151
|
+
t = 0;
|
|
152
|
+
write();
|
|
153
|
+
}
|
|
154
|
+
// Fix A: any change from/to a visibly-dimmed state is a global pixel change
|
|
155
|
+
// and MUST be a full frame — INCLUDING the reduced-motion snap-off (which
|
|
156
|
+
// clears the dim everywhere in one frame). Only an exit that was never
|
|
157
|
+
// showing is an ordinary cursor-overlay move (point halo / tooltip).
|
|
158
|
+
if (wasShowing) return { needsFrame: true, full: true };
|
|
159
|
+
return { needsFrame: false, full: false };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function step(now: number): FrameRequest {
|
|
163
|
+
if (!animating()) {
|
|
164
|
+
lastNowValid = false;
|
|
165
|
+
return NO_FRAME;
|
|
166
|
+
}
|
|
167
|
+
if (!lastNowValid) {
|
|
168
|
+
// First step since the last change — seed the clock, advance nothing yet.
|
|
169
|
+
lastNow = now;
|
|
170
|
+
lastNowValid = true;
|
|
171
|
+
}
|
|
172
|
+
const dt = Math.max(0, (now - lastNow) / 1000);
|
|
173
|
+
lastNow = now;
|
|
174
|
+
if (dt > 0) {
|
|
175
|
+
if (durationS > 0) {
|
|
176
|
+
const stepT = dt / durationS;
|
|
177
|
+
t = t < target ? Math.min(target, t + stepT) : Math.max(target, t - stepT);
|
|
178
|
+
} else {
|
|
179
|
+
t = target;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Clear the held focused key once a ramp-out settles at 0.
|
|
183
|
+
if (target === 0 && t <= 0) focusedKey = 0;
|
|
184
|
+
write();
|
|
185
|
+
// Mid-transition (or just-settled this frame) the uniform changed → FULL.
|
|
186
|
+
return { needsFrame: true, full: true };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function reset(): void {
|
|
190
|
+
focusedKey = 0;
|
|
191
|
+
t = 0;
|
|
192
|
+
target = 0;
|
|
193
|
+
durationS = 0;
|
|
194
|
+
lastNowValid = false;
|
|
195
|
+
write();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
onHover,
|
|
200
|
+
step,
|
|
201
|
+
animating,
|
|
202
|
+
reset,
|
|
203
|
+
state: () => ({ focusedKey, t, target }),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Date-aware leaf comparison. Two Dates with the same instant count as equal
|
|
2
|
+
// even when they're separate instances — the realistic case when builder
|
|
3
|
+
// closures recreate option objects each frame, or when two channels resolve
|
|
4
|
+
// the same field via separate accessors.
|
|
5
|
+
export function valuesEqual(a: unknown, b: unknown): boolean {
|
|
6
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
7
|
+
return Object.is(a, b);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function arraysEqual(a: readonly unknown[], b: readonly unknown[]): boolean {
|
|
11
|
+
if (a === b) return true;
|
|
12
|
+
if (a.length !== b.length) return false;
|
|
13
|
+
for (let i = 0; i < a.length; i++) {
|
|
14
|
+
if (!valuesEqual(a[i], b[i])) return false;
|
|
15
|
+
}
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Shallow object equality with Date-aware leaves and array-typed values
|
|
20
|
+
// compared element-wise. Non-array, non-primitive values (palette objects,
|
|
21
|
+
// blendSpace strings) fall back to `Object.is` — those rarely change at
|
|
22
|
+
// runtime, and a false-negative there merely re-triggers a transition the
|
|
23
|
+
// user did intend.
|
|
24
|
+
export function shallowObjectEqual(a: unknown, b: unknown): boolean {
|
|
25
|
+
if (Object.is(a, b)) return true;
|
|
26
|
+
if (a == null || b == null) return false;
|
|
27
|
+
if (typeof a !== "object" || typeof b !== "object") return false;
|
|
28
|
+
const aKeys = Object.keys(a as object);
|
|
29
|
+
const bKeys = Object.keys(b as object);
|
|
30
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
31
|
+
for (const k of aKeys) {
|
|
32
|
+
if (!Object.hasOwn(b as object, k)) return false;
|
|
33
|
+
const av = (a as Record<string, unknown>)[k];
|
|
34
|
+
const bv = (b as Record<string, unknown>)[k];
|
|
35
|
+
if (Array.isArray(av) && Array.isArray(bv)) {
|
|
36
|
+
if (!arraysEqual(av, bv)) return false;
|
|
37
|
+
} else if (!valuesEqual(av, bv)) return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|