@voila.dev/chart 5.0.114 → 5.0.118
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/dist/canvas/paint-scene.d.ts.map +1 -1
- package/dist/canvas/paint-scene.js +3 -1
- package/dist/canvas/paint-scene.js.map +2 -2
- package/dist/contour/density.d.ts.map +1 -1
- package/dist/contour/density.js +2 -0
- package/dist/contour/density.js.map +2 -2
- package/dist/core/marks/area.d.ts.map +1 -1
- package/dist/core/marks/area.js +27 -11
- package/dist/core/marks/area.js.map +2 -2
- package/dist/core/marks/bar.d.ts.map +1 -1
- package/dist/core/marks/bar.js +2 -0
- package/dist/core/marks/bar.js.map +2 -2
- package/dist/core/marks/line.d.ts.map +1 -1
- package/dist/core/marks/line.js +13 -4
- package/dist/core/marks/line.js.map +2 -2
- package/dist/core/marks/polar.d.ts.map +1 -1
- package/dist/core/marks/polar.js +22 -5
- package/dist/core/marks/polar.js.map +2 -2
- package/dist/core/marks/shared.d.ts +8 -1
- package/dist/core/marks/shared.d.ts.map +1 -1
- package/dist/core/marks/shared.js.map +2 -2
- package/dist/core/marks/stats/difference.d.ts.map +1 -1
- package/dist/core/marks/stats/difference.js +20 -7
- package/dist/core/marks/stats/difference.js.map +2 -2
- package/dist/core/motion/arc-motion.d.ts +9 -0
- package/dist/core/motion/arc-motion.d.ts.map +1 -0
- package/dist/core/motion/arc-motion.js +67 -0
- package/dist/core/motion/arc-motion.js.map +7 -0
- package/dist/core/motion/enter-exit.d.ts +6 -0
- package/dist/core/motion/enter-exit.d.ts.map +1 -0
- package/dist/core/motion/enter-exit.js +20 -0
- package/dist/core/motion/enter-exit.js.map +7 -0
- package/dist/core/motion/leaving.d.ts +12 -0
- package/dist/core/motion/leaving.d.ts.map +1 -0
- package/dist/core/motion/leaving.js +33 -0
- package/dist/core/motion/leaving.js.map +7 -0
- package/dist/core/motion/motion-store.d.ts +4 -1
- package/dist/core/motion/motion-store.d.ts.map +1 -1
- package/dist/core/motion/motion-store.js +251 -100
- package/dist/core/motion/motion-store.js.map +3 -3
- package/dist/core/motion/points-motion.d.ts +6 -0
- package/dist/core/motion/points-motion.d.ts.map +1 -0
- package/dist/core/motion/points-motion.js +140 -0
- package/dist/core/motion/points-motion.js.map +7 -0
- package/dist/core/motion/strings.d.ts +18 -0
- package/dist/core/motion/strings.d.ts.map +1 -0
- package/dist/core/motion/strings.js +34 -0
- package/dist/core/motion/strings.js.map +7 -0
- package/dist/core/types.d.ts +85 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/geo/geo.d.ts.map +1 -1
- package/dist/geo/geo.js +2 -0
- package/dist/geo/geo.js.map +2 -2
- package/dist/react/chart.d.ts.map +1 -1
- package/dist/react/chart.js +6 -2
- package/dist/react/chart.js.map +2 -2
- package/dist/react/svg-scene.d.ts.map +1 -1
- package/dist/react/svg-scene.js +3 -1
- package/dist/react/svg-scene.js.map +2 -2
- package/dist/react/use-animated-scene.d.ts.map +1 -1
- package/dist/react/use-animated-scene.js +14 -2
- package/dist/react/use-animated-scene.js.map +2 -2
- package/dist/voronoi/voronoi.d.ts.map +1 -1
- package/dist/voronoi/voronoi.js +2 -0
- package/dist/voronoi/voronoi.js.map +2 -2
- package/package.json +2 -2
- package/src/canvas/paint-scene.ts +5 -1
- package/src/contour/density.ts +2 -0
- package/src/core/marks/area.ts +28 -13
- package/src/core/marks/bar.ts +2 -0
- package/src/core/marks/line.ts +15 -8
- package/src/core/marks/polar.ts +22 -5
- package/src/core/marks/shared.ts +8 -0
- package/src/core/marks/stats/difference.ts +28 -8
- package/src/core/motion/arc-motion.ts +113 -0
- package/src/core/motion/enter-exit.ts +44 -0
- package/src/core/motion/leaving.ts +55 -0
- package/src/core/motion/motion-store.ts +332 -109
- package/src/core/motion/points-motion.ts +190 -0
- package/src/core/motion/strings.ts +68 -0
- package/src/core/types.ts +98 -0
- package/src/geo/geo.ts +2 -0
- package/src/react/chart.tsx +8 -2
- package/src/react/svg-scene.tsx +5 -1
- package/src/react/use-animated-scene.ts +18 -2
- package/src/voronoi/voronoi.ts +2 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { areaPath, linePath, polygonPath } from "#/core/paths.ts";
|
|
2
|
+
import type {
|
|
3
|
+
GeometryMotion,
|
|
4
|
+
GeometryPlan,
|
|
5
|
+
GeometryPoint,
|
|
6
|
+
SceneGeometry,
|
|
7
|
+
ScenePath,
|
|
8
|
+
} from "#/core/types.ts";
|
|
9
|
+
|
|
10
|
+
type Points = Extract<SceneGeometry, { kind: "points" }>;
|
|
11
|
+
|
|
12
|
+
const POINT_FIELDS = ["x", "y", "x0", "y0"] as const;
|
|
13
|
+
|
|
14
|
+
/** A line, an area or a radar outline through its points. */
|
|
15
|
+
export function pointsPath(geometry: SceneGeometry): string {
|
|
16
|
+
if (geometry.kind !== "points") return "";
|
|
17
|
+
const { runs, shape, curve } = geometry;
|
|
18
|
+
if (shape === "polygon") return polygonPath(runs[0] ?? []);
|
|
19
|
+
return runs
|
|
20
|
+
.map((run) =>
|
|
21
|
+
shape === "area"
|
|
22
|
+
? areaPath(
|
|
23
|
+
run,
|
|
24
|
+
run.map((point) => ({
|
|
25
|
+
x: point.x0 ?? point.x,
|
|
26
|
+
y: point.y0 ?? point.y,
|
|
27
|
+
})),
|
|
28
|
+
curve,
|
|
29
|
+
)
|
|
30
|
+
: linePath(run, curve),
|
|
31
|
+
)
|
|
32
|
+
.join("");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function channel(key: string, field: string): string {
|
|
36
|
+
return `${key}\u0001${field}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fields(point: GeometryPoint, into: Map<string, number>) {
|
|
40
|
+
for (const field of POINT_FIELDS) {
|
|
41
|
+
const value = point[field];
|
|
42
|
+
if (value !== undefined) into.set(channel(point.key, field), value);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `point`'s numbers under `key`'s channel names: a point entering from, or folding into, a neighbour. */
|
|
47
|
+
function as(key: string, point: GeometryPoint): GeometryPoint {
|
|
48
|
+
return { ...point, key };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The nearest of `order` around `index` that `has` knows: looking back first, then ahead. */
|
|
52
|
+
function neighbour(
|
|
53
|
+
order: readonly string[],
|
|
54
|
+
index: number,
|
|
55
|
+
has: ReadonlyMap<string, GeometryPoint>,
|
|
56
|
+
): GeometryPoint | undefined {
|
|
57
|
+
for (let at = index - 1; at >= 0; at -= 1) {
|
|
58
|
+
const found = has.get(order[at] as string);
|
|
59
|
+
if (found) return found;
|
|
60
|
+
}
|
|
61
|
+
for (let at = index + 1; at < order.length; at += 1) {
|
|
62
|
+
const found = has.get(order[at] as string);
|
|
63
|
+
if (found) return found;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The new keys, with the ones that left kept after the old point before them. */
|
|
69
|
+
function mergeOrder(
|
|
70
|
+
before: readonly string[],
|
|
71
|
+
after: readonly string[],
|
|
72
|
+
): string[] {
|
|
73
|
+
const merged = [...after];
|
|
74
|
+
const known = new Set(after);
|
|
75
|
+
for (const [index, key] of before.entries()) {
|
|
76
|
+
if (known.has(key)) continue;
|
|
77
|
+
const previous = before[index - 1];
|
|
78
|
+
merged.splice(
|
|
79
|
+
previous === undefined ? 0 : merged.indexOf(previous) + 1,
|
|
80
|
+
0,
|
|
81
|
+
key,
|
|
82
|
+
);
|
|
83
|
+
known.add(key);
|
|
84
|
+
}
|
|
85
|
+
return merged;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Points keyed by x. A point that appears enters from its nearest old
|
|
90
|
+
* neighbour (a month added on the right slides out of the last one); a
|
|
91
|
+
* point that leaves folds into its nearest new neighbour. Undefined when the
|
|
92
|
+
* runs don't line up: the caller falls back to tweening the path.
|
|
93
|
+
*/
|
|
94
|
+
function pointsPlan(from: Points, to: Points): GeometryPlan | undefined {
|
|
95
|
+
if (from.runs.length !== to.runs.length || from.shape !== to.shape)
|
|
96
|
+
return undefined;
|
|
97
|
+
const starts = new Map<string, number>();
|
|
98
|
+
const targets = new Map<string, number>();
|
|
99
|
+
const orders = to.runs.map((run, index): string[] | undefined => {
|
|
100
|
+
const old = new Map(
|
|
101
|
+
(from.runs[index] ?? []).map((point) => [point.key, point]),
|
|
102
|
+
);
|
|
103
|
+
const next = new Map(run.map((point) => [point.key, point]));
|
|
104
|
+
// Two points under one key can't be told apart: tween the path instead.
|
|
105
|
+
if (
|
|
106
|
+
old.size !== (from.runs[index]?.length ?? 0) ||
|
|
107
|
+
next.size !== run.length
|
|
108
|
+
) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const order = mergeOrder([...old.keys()], [...next.keys()]);
|
|
112
|
+
for (const [at, key] of order.entries()) {
|
|
113
|
+
const target = next.get(key) ?? neighbour(order, at, next);
|
|
114
|
+
const start = old.get(key) ?? neighbour(order, at, old) ?? target;
|
|
115
|
+
if (target === undefined || start === undefined) continue;
|
|
116
|
+
fields(as(key, start), starts);
|
|
117
|
+
fields(as(key, target), targets);
|
|
118
|
+
}
|
|
119
|
+
return order;
|
|
120
|
+
});
|
|
121
|
+
if (orders.some((order) => order === undefined)) return undefined;
|
|
122
|
+
return {
|
|
123
|
+
starts,
|
|
124
|
+
targets,
|
|
125
|
+
build(value) {
|
|
126
|
+
const area = to.runs.some((run) => run[0]?.y0 !== undefined);
|
|
127
|
+
return {
|
|
128
|
+
...to,
|
|
129
|
+
runs: (orders as string[][]).map((order) =>
|
|
130
|
+
order.map((key) => ({
|
|
131
|
+
key,
|
|
132
|
+
x: value(channel(key, "x")),
|
|
133
|
+
y: value(channel(key, "y")),
|
|
134
|
+
...(area
|
|
135
|
+
? { x0: value(channel(key, "x0")), y0: value(channel(key, "y0")) }
|
|
136
|
+
: {}),
|
|
137
|
+
})),
|
|
138
|
+
),
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A line's length along its points: what Canvas dashes against to trace it in. */
|
|
145
|
+
function pointsLength(geometry: SceneGeometry): number {
|
|
146
|
+
if (geometry.kind !== "points") return 0;
|
|
147
|
+
let length = 0;
|
|
148
|
+
for (const run of geometry.runs) {
|
|
149
|
+
for (let index = 1; index < run.length; index += 1) {
|
|
150
|
+
const a = run[index - 1] as GeometryPoint;
|
|
151
|
+
const b = run[index] as GeometryPoint;
|
|
152
|
+
length += Math.hypot(b.x - a.x, b.y - a.y);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// A curve runs a little longer than its chords; the dash must cover it all.
|
|
156
|
+
return length * 1.1;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** An area, or the line over it, grows from its lower edge. */
|
|
160
|
+
function collapsed(node: ScenePath): ScenePath | undefined {
|
|
161
|
+
const geometry = node.geometry;
|
|
162
|
+
// A line over an area carries the area's lower edge too, so the two grow as one.
|
|
163
|
+
if (geometry?.kind !== "points" || geometry.runs[0]?.[0]?.y0 === undefined) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
...node,
|
|
168
|
+
geometry: {
|
|
169
|
+
...geometry,
|
|
170
|
+
runs: geometry.runs.map((run) =>
|
|
171
|
+
run.map((point) => ({
|
|
172
|
+
...point,
|
|
173
|
+
x: point.x0 ?? point.x,
|
|
174
|
+
y: point.y0 ?? point.y,
|
|
175
|
+
})),
|
|
176
|
+
),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Lines, areas and radar outlines: points keyed by x (or by axis). */
|
|
182
|
+
export const POINTS_MOTION: GeometryMotion = {
|
|
183
|
+
plan: (from, to) =>
|
|
184
|
+
from.kind === "points" && to.kind === "points"
|
|
185
|
+
? pointsPlan(from, to)
|
|
186
|
+
: undefined,
|
|
187
|
+
draw: pointsPath,
|
|
188
|
+
length: pointsLength,
|
|
189
|
+
enter: (node) => (node.enter === "grow" ? collapsed(node) : undefined),
|
|
190
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { tweenPath } from "#/core/motion/tween.ts";
|
|
2
|
+
import type { SceneNode } from "#/core/types.ts";
|
|
3
|
+
|
|
4
|
+
/** The channels that move as a string mixed by progress, not as a number. */
|
|
5
|
+
export const STRING_CHANNELS = ["d", "fill", "stroke"] as const;
|
|
6
|
+
|
|
7
|
+
export type StringChannel = (typeof STRING_CHANNELS)[number];
|
|
8
|
+
|
|
9
|
+
/** 2 % steps: at most 51 colours per pair, so the Canvas resolver's cache stays small. */
|
|
10
|
+
const COLOUR_STEPS = 50;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A colour part of the way to another, as CSS: `color-mix()` reads the
|
|
14
|
+
* chart's `var(--chart-1)` tokens as they are, so nothing parses colours,
|
|
15
|
+
* and the Canvas resolver computes the mix against the page like any
|
|
16
|
+
* other colour.
|
|
17
|
+
*/
|
|
18
|
+
export function colourBetween(
|
|
19
|
+
from: string,
|
|
20
|
+
to: string,
|
|
21
|
+
progress: number,
|
|
22
|
+
): string {
|
|
23
|
+
const step = Math.round(Math.min(1, Math.max(0, progress)) * COLOUR_STEPS);
|
|
24
|
+
if (step === 0) return from;
|
|
25
|
+
if (step === COLOUR_STEPS) return to;
|
|
26
|
+
return `color-mix(in oklab, ${to} ${(step * 100) / COLOUR_STEPS}%, ${from})`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What a string channel of `node` reads now. */
|
|
30
|
+
export function stringOf(
|
|
31
|
+
node: SceneNode | undefined,
|
|
32
|
+
channel: StringChannel,
|
|
33
|
+
): string | undefined {
|
|
34
|
+
if (node === undefined || node.kind === "group") return undefined;
|
|
35
|
+
return channel === "d"
|
|
36
|
+
? node.kind === "path"
|
|
37
|
+
? node.d
|
|
38
|
+
: undefined
|
|
39
|
+
: node.paint[channel];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The mix from what was painted to what `node` asks for. */
|
|
43
|
+
export function stringMixer(
|
|
44
|
+
node: SceneNode,
|
|
45
|
+
channel: StringChannel,
|
|
46
|
+
from: string,
|
|
47
|
+
to: string,
|
|
48
|
+
): (progress: number) => string {
|
|
49
|
+
if (channel !== "d") {
|
|
50
|
+
return (progress) => colourBetween(from, to, progress);
|
|
51
|
+
}
|
|
52
|
+
const morph = node.kind === "path" ? node.morph : undefined;
|
|
53
|
+
return morph ? morph(from, to) : (progress) => tweenPath(from, to, progress);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `node` with each string channel set to its moving value. */
|
|
57
|
+
export function withStrings(
|
|
58
|
+
node: SceneNode,
|
|
59
|
+
values: Partial<Record<StringChannel, string>>,
|
|
60
|
+
): SceneNode {
|
|
61
|
+
if (node.kind === "group") return node;
|
|
62
|
+
const { d, ...colours } = values;
|
|
63
|
+
const shaped =
|
|
64
|
+
d === undefined || node.kind !== "path" ? node : { ...node, d };
|
|
65
|
+
return Object.keys(colours).length === 0
|
|
66
|
+
? shaped
|
|
67
|
+
: ({ ...shaped, paint: { ...shaped.paint, ...colours } } as SceneNode);
|
|
68
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -92,6 +92,12 @@ export interface ChartPaint {
|
|
|
92
92
|
readonly strokeOpacity?: number;
|
|
93
93
|
/** Fill with diagonal hatching in the fill colour: a value pencilled in, not yet true. */
|
|
94
94
|
readonly hatch?: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Only the first `fraction` of the stroke is drawn: a line tracing itself
|
|
97
|
+
* in. `length` is the path's length in pixels, which Canvas needs for its
|
|
98
|
+
* dash and SVG doesn't (it measures with `pathLength`).
|
|
99
|
+
*/
|
|
100
|
+
readonly drawn?: { readonly fraction: number; readonly length: number };
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
export interface ChartTextPaint extends ChartPaint {
|
|
@@ -108,8 +114,17 @@ interface SceneNodeBase {
|
|
|
108
114
|
readonly series?: string;
|
|
109
115
|
/** A role for styling and tests: "mark", "axis", "grid", "label"… */
|
|
110
116
|
readonly role?: string;
|
|
117
|
+
/** How the node appears when an update adds it: see `ChartEnter`. Fades by default. */
|
|
118
|
+
readonly enter?: ChartEnter;
|
|
111
119
|
}
|
|
112
120
|
|
|
121
|
+
/**
|
|
122
|
+
* How a data mark appears. "grow" rises from its baseline (bars, areas) or
|
|
123
|
+
* opens from its neighbour (slices); "draw" traces a line in on the first
|
|
124
|
+
* render; "fade" fades in; "none" is there at once.
|
|
125
|
+
*/
|
|
126
|
+
export type ChartEnter = "grow" | "draw" | "fade" | "none";
|
|
127
|
+
|
|
113
128
|
export interface SceneGroup extends SceneNodeBase {
|
|
114
129
|
readonly kind: "group";
|
|
115
130
|
/** Shifts the children: a facet's cell drawn at its place in the grid. Clips are in the shifted space. */
|
|
@@ -125,6 +140,8 @@ export interface SceneRect extends SceneNodeBase {
|
|
|
125
140
|
readonly width: number;
|
|
126
141
|
readonly height: number;
|
|
127
142
|
readonly corners?: ChartCorners;
|
|
143
|
+
/** The edge a bar grows from and shrinks back to: `y` for a column, `x` for a bar, in pixels. */
|
|
144
|
+
readonly baseline?: { readonly axis: "x" | "y"; readonly at: number };
|
|
128
145
|
readonly paint: ChartPaint;
|
|
129
146
|
}
|
|
130
147
|
|
|
@@ -132,9 +149,90 @@ export interface SceneRect extends SceneNodeBase {
|
|
|
132
149
|
export interface ScenePath extends SceneNodeBase {
|
|
133
150
|
readonly kind: "path";
|
|
134
151
|
readonly d: string;
|
|
152
|
+
/** What the shape means, when it has a simple meaning: motion moves that, then redraws `d`. */
|
|
153
|
+
readonly geometry?: SceneGeometry;
|
|
154
|
+
/**
|
|
155
|
+
* How that geometry moves, brought by the mark that drew it (like
|
|
156
|
+
* `morph`), so a chart only loads the motion of the shapes it has.
|
|
157
|
+
*/
|
|
158
|
+
readonly motion?: GeometryMotion;
|
|
135
159
|
readonly paint: ChartPaint;
|
|
160
|
+
/**
|
|
161
|
+
* How an update moves this outline into a new one whose commands differ:
|
|
162
|
+
* a country, a contour, a cell. Set by the marks whose shapes have no
|
|
163
|
+
* simpler meaning, so the morph only loads with them; without it, paths
|
|
164
|
+
* whose commands differ swap at the end.
|
|
165
|
+
*/
|
|
166
|
+
readonly morph?: PathMorpher;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A point of a line, an area or a radar, keyed by its position so an update can slide it. */
|
|
170
|
+
export interface GeometryPoint {
|
|
171
|
+
readonly key: string;
|
|
172
|
+
readonly x: number;
|
|
173
|
+
readonly y: number;
|
|
174
|
+
/** The area's lower edge under this point. */
|
|
175
|
+
readonly x0?: number;
|
|
176
|
+
readonly y0?: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export type SceneGeometry =
|
|
180
|
+
| {
|
|
181
|
+
readonly kind: "points";
|
|
182
|
+
/** One run per unbroken stretch: a gap in the data splits the line. */
|
|
183
|
+
readonly runs: ReadonlyArray<ReadonlyArray<GeometryPoint>>;
|
|
184
|
+
readonly shape: "line" | "area" | "polygon";
|
|
185
|
+
readonly curve?: ChartCurve;
|
|
186
|
+
}
|
|
187
|
+
| {
|
|
188
|
+
readonly kind: "arc";
|
|
189
|
+
readonly cx: number;
|
|
190
|
+
readonly cy: number;
|
|
191
|
+
readonly innerRadius: number;
|
|
192
|
+
readonly outerRadius: number;
|
|
193
|
+
readonly startAngle: number;
|
|
194
|
+
readonly endAngle: number;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A geometry in motion: every number it has, by channel name, where each
|
|
199
|
+
* starts and where it goes, and how to rebuild the shape from the numbers of
|
|
200
|
+
* one frame. The store springs the numbers; the plan only knows shapes.
|
|
201
|
+
*/
|
|
202
|
+
export interface GeometryPlan {
|
|
203
|
+
readonly starts: ReadonlyMap<string, number>;
|
|
204
|
+
readonly targets: ReadonlyMap<string, number>;
|
|
205
|
+
build(value: (channel: string) => number): SceneGeometry;
|
|
136
206
|
}
|
|
137
207
|
|
|
208
|
+
/** What the motion store asks of a kind of geometry. */
|
|
209
|
+
export interface GeometryMotion {
|
|
210
|
+
/** How `from` (what is painted) becomes `to`; undefined to tween the path instead. */
|
|
211
|
+
plan(from: SceneGeometry, to: SceneGeometry): GeometryPlan | undefined;
|
|
212
|
+
/** The path data of one frame, through the mark's own builder, so every frame is a valid shape. */
|
|
213
|
+
draw(geometry: SceneGeometry): string;
|
|
214
|
+
/** Its length in pixels: what Canvas dashes against to trace a line in. */
|
|
215
|
+
length?(geometry: SceneGeometry): number;
|
|
216
|
+
/** The shape a node an update adds grows from; `siblings` is its new list, `known` the old scene. */
|
|
217
|
+
enter?(
|
|
218
|
+
node: ScenePath,
|
|
219
|
+
siblings: readonly SceneNode[],
|
|
220
|
+
known: ReadonlyMap<string, SceneNode>,
|
|
221
|
+
): ScenePath | undefined;
|
|
222
|
+
/** The shape a node an update removes collapses to; `siblings` is its old list, `known` the new scene. */
|
|
223
|
+
exit?(
|
|
224
|
+
node: ScenePath,
|
|
225
|
+
siblings: readonly SceneNode[],
|
|
226
|
+
known: ReadonlyMap<string, SceneNode>,
|
|
227
|
+
): ScenePath | undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The outlines between two paths, 0 at `from` and 1 at `to`. */
|
|
231
|
+
export type PathMorpher = (
|
|
232
|
+
from: string,
|
|
233
|
+
to: string,
|
|
234
|
+
) => (progress: number) => string;
|
|
235
|
+
|
|
138
236
|
export interface SceneCircle extends SceneNodeBase {
|
|
139
237
|
readonly kind: "circle";
|
|
140
238
|
readonly cx: number;
|
package/src/geo/geo.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mixPath } from "@voila.dev/motion";
|
|
1
2
|
import {
|
|
2
3
|
type GeoPermissibleObjects,
|
|
3
4
|
type GeoProjection,
|
|
@@ -202,6 +203,7 @@ export function geoShape<TFeature extends Feature>(
|
|
|
202
203
|
key: `${id}:${name}`,
|
|
203
204
|
role: "mark",
|
|
204
205
|
d,
|
|
206
|
+
morph: mixPath,
|
|
205
207
|
paint: paint({
|
|
206
208
|
fill,
|
|
207
209
|
stroke: options.stroke ?? context.theme.background,
|
package/src/react/chart.tsx
CHANGED
|
@@ -172,9 +172,12 @@ export function Chart({
|
|
|
172
172
|
moved: boolean;
|
|
173
173
|
} | null>(null);
|
|
174
174
|
const stops = React.useMemo(() => focusStops(scene), [scene]);
|
|
175
|
+
// A step zoom from the keyboard moves like a data update; the wheel and a
|
|
176
|
+
// drag are direct manipulation, so they snap.
|
|
177
|
+
const [keyboardZooms, setKeyboardZooms] = React.useState(0);
|
|
175
178
|
const animationTrigger = React.useMemo(
|
|
176
|
-
() => [definition, hidden],
|
|
177
|
-
[definition, hidden],
|
|
179
|
+
() => [definition, hidden, keyboardZooms],
|
|
180
|
+
[definition, hidden, keyboardZooms],
|
|
178
181
|
);
|
|
179
182
|
// An inline `animate={{ … }}` object is read by value, not identity.
|
|
180
183
|
const animateKey = JSON.stringify(animate);
|
|
@@ -283,16 +286,19 @@ export function Chart({
|
|
|
283
286
|
if (event.key === "+" || event.key === "=") {
|
|
284
287
|
event.preventDefault();
|
|
285
288
|
viewport.zoomAround(center, ZOOM_STEP);
|
|
289
|
+
setKeyboardZooms((count) => count + 1);
|
|
286
290
|
return;
|
|
287
291
|
}
|
|
288
292
|
if (event.key === "-" || event.key === "_") {
|
|
289
293
|
event.preventDefault();
|
|
290
294
|
viewport.zoomAround(center, 1 / ZOOM_STEP);
|
|
295
|
+
setKeyboardZooms((count) => count + 1);
|
|
291
296
|
return;
|
|
292
297
|
}
|
|
293
298
|
if (event.key === "0") {
|
|
294
299
|
event.preventDefault();
|
|
295
300
|
viewport.reset();
|
|
301
|
+
setKeyboardZooms((count) => count + 1);
|
|
296
302
|
return;
|
|
297
303
|
}
|
|
298
304
|
if (
|
package/src/react/svg-scene.tsx
CHANGED
|
@@ -53,7 +53,11 @@ function paintProps(
|
|
|
53
53
|
: (paint.fill ?? "none"),
|
|
54
54
|
stroke: paint.stroke,
|
|
55
55
|
strokeWidth: paint.strokeWidth,
|
|
56
|
-
strokeDasharray: paint.strokeDasharray,
|
|
56
|
+
strokeDasharray: paint.drawn ? "1 1" : paint.strokeDasharray,
|
|
57
|
+
// Measured in path lengths: the dash shows the first `fraction` of the line.
|
|
58
|
+
...(paint.drawn
|
|
59
|
+
? { pathLength: 1, strokeDashoffset: 1 - paint.drawn.fraction }
|
|
60
|
+
: {}),
|
|
57
61
|
strokeLinecap: paint.strokeLinecap,
|
|
58
62
|
strokeLinejoin: paint.strokeLinejoin,
|
|
59
63
|
opacity: paint.opacity,
|
|
@@ -21,6 +21,9 @@ export function useAnimatedScene(
|
|
|
21
21
|
): ChartScene {
|
|
22
22
|
const [shown, setShown] = React.useState(scene);
|
|
23
23
|
const triggerRef = React.useRef(trigger);
|
|
24
|
+
// The first render may play once: lines that `enter: "draw"` trace themselves in.
|
|
25
|
+
const introduced = React.useRef(false);
|
|
26
|
+
const introStart = React.useRef<number | null>(null);
|
|
24
27
|
const storeRef = React.useRef<{
|
|
25
28
|
store: MotionStore;
|
|
26
29
|
timing: ChartTiming;
|
|
@@ -33,15 +36,28 @@ export function useAnimatedScene(
|
|
|
33
36
|
storeRef.current = { store: createMotionStore(scene, timing), timing };
|
|
34
37
|
}
|
|
35
38
|
const store = storeRef.current?.store;
|
|
36
|
-
|
|
39
|
+
const still = !timing || !store || shouldReduceMotion();
|
|
40
|
+
let intro = false;
|
|
41
|
+
if (!still && !changed && !introduced.current) {
|
|
42
|
+
const start = introStart.current ?? performance.now();
|
|
43
|
+
// A re-render mid-intro (the width measured after mount, fonts loaded): the
|
|
44
|
+
// new geometry, drawn from where the intro already is, never from scratch.
|
|
45
|
+
if (introStart.current !== null) store.snap(scene);
|
|
46
|
+
intro = store.introduce(start);
|
|
47
|
+
if (intro) introStart.current = start;
|
|
48
|
+
}
|
|
49
|
+
if (still || (!changed && !intro)) {
|
|
50
|
+
introduced.current = true;
|
|
37
51
|
store?.snap(scene);
|
|
38
52
|
setShown(scene);
|
|
39
53
|
return;
|
|
40
54
|
}
|
|
41
|
-
store.retarget(scene, performance.now());
|
|
55
|
+
if (changed) store.retarget(scene, performance.now());
|
|
42
56
|
function tick() {
|
|
43
57
|
const now = performance.now();
|
|
44
58
|
if (store?.settled(now)) {
|
|
59
|
+
// Only once it has played: StrictMode's rerun of the effect starts it again.
|
|
60
|
+
introduced.current = true;
|
|
45
61
|
cancelFrame(tick);
|
|
46
62
|
setShown(scene);
|
|
47
63
|
return;
|
package/src/voronoi/voronoi.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mixPath } from "@voila.dev/motion";
|
|
1
2
|
import { Delaunay } from "d3-delaunay";
|
|
2
3
|
import { isPlaceable, readChannel } from "#/core/channel.ts";
|
|
3
4
|
import { markId, valueOn } from "#/core/marks/shared.ts";
|
|
@@ -66,6 +67,7 @@ export function voronoi<TDatum>(
|
|
|
66
67
|
key: `${id}:${dataIndex}`,
|
|
67
68
|
role: "mark",
|
|
68
69
|
d: diagram.renderCell(cell) ?? "",
|
|
70
|
+
morph: mixPath,
|
|
69
71
|
paint: options.fill
|
|
70
72
|
? {
|
|
71
73
|
fill: options.fill(data[dataIndex], dataIndex),
|