@kgdiff/scene 0.0.0-stage → 0.2.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 +21 -0
- package/README.md +50 -2
- package/dist/build.d.ts +23 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +407 -0
- package/dist/build.js.map +1 -0
- package/dist/choreography.d.ts +98 -0
- package/dist/choreography.d.ts.map +1 -0
- package/dist/choreography.js +271 -0
- package/dist/choreography.js.map +1 -0
- package/dist/color.d.ts +42 -0
- package/dist/color.d.ts.map +1 -0
- package/dist/color.js +169 -0
- package/dist/color.js.map +1 -0
- package/dist/constants.d.ts +89 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +89 -0
- package/dist/constants.js.map +1 -0
- package/dist/easing.d.ts +28 -0
- package/dist/easing.d.ts.map +1 -0
- package/dist/easing.js +103 -0
- package/dist/easing.js.map +1 -0
- package/dist/focus.d.ts +45 -0
- package/dist/focus.d.ts.map +1 -0
- package/dist/focus.js +317 -0
- package/dist/focus.js.map +1 -0
- package/dist/frame.d.ts +13 -0
- package/dist/frame.d.ts.map +1 -0
- package/dist/frame.js +32 -0
- package/dist/frame.js.map +1 -0
- package/dist/geometry.d.ts +61 -0
- package/dist/geometry.d.ts.map +1 -0
- package/dist/geometry.js +122 -0
- package/dist/geometry.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/resolver.d.ts +9 -0
- package/dist/resolver.d.ts.map +1 -0
- package/dist/resolver.js +1621 -0
- package/dist/resolver.js.map +1 -0
- package/dist/roles.d.ts +46 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/roles.js +197 -0
- package/dist/roles.js.map +1 -0
- package/dist/styles.d.ts +116 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +391 -0
- package/dist/styles.js.map +1 -0
- package/dist/theme.d.ts +14 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +154 -0
- package/dist/theme.js.map +1 -0
- package/dist/timeline.d.ts +69 -0
- package/dist/timeline.d.ts.map +1 -0
- package/dist/timeline.js +362 -0
- package/dist/timeline.js.map +1 -0
- package/dist/types.d.ts +493 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/view.d.ts +11 -0
- package/dist/view.d.ts.map +1 -0
- package/dist/view.js +22 -0
- package/dist/view.js.map +1 -0
- package/package.json +46 -3
- package/src/build.ts +450 -0
- package/src/choreography.ts +342 -0
- package/src/color.ts +194 -0
- package/src/constants.ts +97 -0
- package/src/easing.ts +104 -0
- package/src/focus.ts +335 -0
- package/src/frame.ts +37 -0
- package/src/geometry.ts +162 -0
- package/src/index.ts +15 -0
- package/src/resolver.ts +1710 -0
- package/src/roles.ts +203 -0
- package/src/styles.ts +513 -0
- package/src/theme.ts +185 -0
- package/src/timeline.ts +391 -0
- package/src/types.ts +544 -0
- package/src/view.ts +27 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay choreography plan (docs/visual-language.md §Replay choreography): for every element
|
|
3
|
+
* of every hunk, which animation it plays and when, as fractions `u` ∈ [0, 1] of the hunk
|
|
4
|
+
* window's transform phase (18–82% of the window). Computed once per resolver.
|
|
5
|
+
*/
|
|
6
|
+
import type { HunkKind } from '@kgdiff/core';
|
|
7
|
+
import { assertNever, mergeBadge } from './roles.js';
|
|
8
|
+
import type { EdgeStyle, NodeStyle } from './styles.js';
|
|
9
|
+
import type { Scene } from './types.js';
|
|
10
|
+
|
|
11
|
+
/** Node animation kinds. */
|
|
12
|
+
export const NA_NONE = 0;
|
|
13
|
+
/** Both states visible: colors / rings / halos crossfade, label slides, ring flashes, badge ticks in. */
|
|
14
|
+
export const NA_FADE = 1;
|
|
15
|
+
/** Shrinks to 0.6·r while its fill drains into a dashed ghost, which lingers, then fades. */
|
|
16
|
+
export const NA_REMOVE = 2;
|
|
17
|
+
/** Pops in (scale 0 → 1.08 → 1, back-out), then its label fades in. */
|
|
18
|
+
export const NA_ADD = 3;
|
|
19
|
+
/** Merge source: travels on a gentle arc into its target, fades on arrival. */
|
|
20
|
+
export const NA_TRAVEL = 4;
|
|
21
|
+
/** Split target: emerges from its source and travels out to its position. */
|
|
22
|
+
export const NA_EMERGE = 5;
|
|
23
|
+
|
|
24
|
+
/** Edge animation kinds. */
|
|
25
|
+
export const EA_NONE = 0;
|
|
26
|
+
/** Grows from the existing endpoint (`progress` 0 → 1). */
|
|
27
|
+
export const EA_GROW = 1;
|
|
28
|
+
/** Retracts toward the surviving endpoint (`progress` 1 → 0). */
|
|
29
|
+
export const EA_RETRACT = 2;
|
|
30
|
+
/** The changed end swings around the shared endpoint from the old to the new node. */
|
|
31
|
+
export const EA_SWING = 3;
|
|
32
|
+
/** Carried by a merge: rides the travelling source (before form), then is the after form. */
|
|
33
|
+
export const EA_FOLLOW_MERGE = 4;
|
|
34
|
+
/** Carried by a split: rides the emerging target (after form). */
|
|
35
|
+
export const EA_FOLLOW_SPLIT = 5;
|
|
36
|
+
/** Same geometry, new label / predicate: label swaps, stroke flashes. */
|
|
37
|
+
export const EA_FADE = 6;
|
|
38
|
+
/** Geometry changes with no shared endpoint or no mover: crossfade old form → new form. */
|
|
39
|
+
export const EA_SWAP = 7;
|
|
40
|
+
|
|
41
|
+
/** Timings in transform-phase fractions. */
|
|
42
|
+
export const CHOREO = {
|
|
43
|
+
retract: [0, 0.4],
|
|
44
|
+
removeShrink: [0.25, 0.55],
|
|
45
|
+
/** The ghost lingers until 60% of the window (u = (0.6 − 0.18) / 0.64), then fades. */
|
|
46
|
+
removeGhostFade: [0.65625, 1],
|
|
47
|
+
splitSourceShrink: [0.6, 0.8],
|
|
48
|
+
splitSourceFade: [0.8, 1],
|
|
49
|
+
growFromExisting: [0, 0.45],
|
|
50
|
+
pop: [0.35, 0.75],
|
|
51
|
+
popAfterArrival: [0.55, 0.85],
|
|
52
|
+
growBetweenNew: [0.6, 0.9],
|
|
53
|
+
growAfterArrival: [0.7, 0.95],
|
|
54
|
+
growOther: [0.35, 0.85],
|
|
55
|
+
labelFadeIn: 0.15,
|
|
56
|
+
travelStagger: 0.15,
|
|
57
|
+
travel: 0.55,
|
|
58
|
+
arrivalFade: 0.12,
|
|
59
|
+
mergeTargetFade: [0.55, 0.85],
|
|
60
|
+
mergeFlash: [0.7, 1],
|
|
61
|
+
splitStart: 0.05,
|
|
62
|
+
splitSourceStay: [0.6, 0.9],
|
|
63
|
+
modifyFade: [0.3, 0.7],
|
|
64
|
+
modifyFlash: [0.4, 0.8],
|
|
65
|
+
labelOut: [0.1, 0.5],
|
|
66
|
+
labelIn: [0.3, 0.7],
|
|
67
|
+
/** px the old label rises while fading / the new label rises from. */
|
|
68
|
+
labelSlide: 6,
|
|
69
|
+
badgeTick: 0.6,
|
|
70
|
+
swing: [0.1, 0.8],
|
|
71
|
+
ghostFade: [0.3, 1],
|
|
72
|
+
pivotFade: [0.8, 1],
|
|
73
|
+
edgeFade: [0.2, 0.7],
|
|
74
|
+
swap: [0.2, 0.8],
|
|
75
|
+
/** Bend of travel arcs and merge / split arrows. */
|
|
76
|
+
arc: 0.25,
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
export interface Choreography {
|
|
80
|
+
readonly nKind: Uint8Array;
|
|
81
|
+
/** Main interval [nA, nB]; nC: extra time (REMOVE fade start, ADD label start). */
|
|
82
|
+
readonly nA: Float64Array;
|
|
83
|
+
readonly nB: Float64Array;
|
|
84
|
+
readonly nC: Float64Array;
|
|
85
|
+
/** Ring flash interval; nFlashA < 0 for none. */
|
|
86
|
+
readonly nFlashA: Float64Array;
|
|
87
|
+
readonly nFlashB: Float64Array;
|
|
88
|
+
/** 1 when the primary label text changes (slide / crossfade). */
|
|
89
|
+
readonly nLabelSwap: Uint8Array;
|
|
90
|
+
/** Badge tick-in time (u), or 2 for never during the transform. */
|
|
91
|
+
readonly nBadgeAt: Float64Array;
|
|
92
|
+
/** Merge targets: sorted arrival times and the badge text after k arrivals (`⊕ k`). */
|
|
93
|
+
readonly nArrivals: readonly (Float64Array | null)[];
|
|
94
|
+
readonly nBadgeSteps: readonly (readonly string[] | null)[];
|
|
95
|
+
/** 1 when the node pulses at its hunk's spotlight phase (visible in its before state). */
|
|
96
|
+
readonly nPulse: Uint8Array;
|
|
97
|
+
/** Per hunk: no hunk node is visible before, so its context pulses instead (additions). */
|
|
98
|
+
readonly hPulseContext: Uint8Array;
|
|
99
|
+
readonly eKind: Uint8Array;
|
|
100
|
+
readonly eA: Float64Array;
|
|
101
|
+
readonly eB: Float64Array;
|
|
102
|
+
/** GROW / RETRACT: draw from the target end. */
|
|
103
|
+
readonly eFromTarget: Uint8Array;
|
|
104
|
+
/** SWING: fixed endpoint node; FOLLOW: moving node. -1 when none. */
|
|
105
|
+
readonly ePivot: Int32Array;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function staggered(j: number, n: number, start: number): [number, number] {
|
|
109
|
+
const s = start + (n > 1 ? (CHOREO.travelStagger * j) / (n - 1) : CHOREO.travelStagger / 2);
|
|
110
|
+
return [s, s + CHOREO.travel];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Plan every element's replay animation. O(N + E). */
|
|
114
|
+
export function choreograph(
|
|
115
|
+
scene: Scene,
|
|
116
|
+
nodeStyles: readonly (readonly [NodeStyle, NodeStyle, NodeStyle])[],
|
|
117
|
+
edgeStyles: readonly (readonly [EdgeStyle, EdgeStyle, EdgeStyle])[],
|
|
118
|
+
): Choreography {
|
|
119
|
+
const N = scene.nodes.length;
|
|
120
|
+
const M = scene.edges.length;
|
|
121
|
+
const H = scene.hunks.length;
|
|
122
|
+
const c: Choreography = {
|
|
123
|
+
nKind: new Uint8Array(N),
|
|
124
|
+
nA: new Float64Array(N),
|
|
125
|
+
nB: new Float64Array(N),
|
|
126
|
+
nC: new Float64Array(N),
|
|
127
|
+
nFlashA: new Float64Array(N).fill(-1),
|
|
128
|
+
nFlashB: new Float64Array(N).fill(-1),
|
|
129
|
+
nLabelSwap: new Uint8Array(N),
|
|
130
|
+
nBadgeAt: new Float64Array(N).fill(2),
|
|
131
|
+
nArrivals: new Array<Float64Array | null>(N).fill(null),
|
|
132
|
+
nBadgeSteps: new Array<readonly string[] | null>(N).fill(null),
|
|
133
|
+
nPulse: new Uint8Array(N),
|
|
134
|
+
hPulseContext: new Uint8Array(H),
|
|
135
|
+
eKind: new Uint8Array(M),
|
|
136
|
+
eA: new Float64Array(M),
|
|
137
|
+
eB: new Float64Array(M),
|
|
138
|
+
eFromTarget: new Uint8Array(M),
|
|
139
|
+
ePivot: new Int32Array(M).fill(-1),
|
|
140
|
+
};
|
|
141
|
+
const nArrivals = c.nArrivals as (Float64Array | null)[];
|
|
142
|
+
const nBadgeSteps = c.nBadgeSteps as (readonly string[] | null)[];
|
|
143
|
+
const set = (i: number, kind: number, a: number, b: number, extra = 0): void => {
|
|
144
|
+
c.nKind[i] = kind;
|
|
145
|
+
c.nA[i] = a;
|
|
146
|
+
c.nB[i] = b;
|
|
147
|
+
c.nC[i] = extra;
|
|
148
|
+
};
|
|
149
|
+
const flash = (i: number, ab: readonly [number, number]): void => {
|
|
150
|
+
c.nFlashA[i] = ab[0];
|
|
151
|
+
c.nFlashB[i] = ab[1];
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
for (const hunk of scene.hunks) {
|
|
155
|
+
const kind: HunkKind = hunk.kind;
|
|
156
|
+
const before = (i: number): NodeStyle =>
|
|
157
|
+
(nodeStyles[i] as readonly NodeStyle[])[1] as NodeStyle;
|
|
158
|
+
const after = (i: number): NodeStyle => (nodeStyles[i] as readonly NodeStyle[])[2] as NodeStyle;
|
|
159
|
+
|
|
160
|
+
// Travelling participants first (they define arrival times).
|
|
161
|
+
const sources = hunk.nodes.filter((i) => scene.nodes[i]?.role === 'merge-source');
|
|
162
|
+
sources.forEach((i, j) => {
|
|
163
|
+
const node = scene.nodes[i];
|
|
164
|
+
const [a, b] = staggered(j, sources.length, 0);
|
|
165
|
+
if (node?.flow !== undefined && !node.presentAfter) set(i, NA_TRAVEL, a, b);
|
|
166
|
+
else if (!after(i).hidden) set(i, NA_FADE, a, b);
|
|
167
|
+
else
|
|
168
|
+
set(
|
|
169
|
+
i,
|
|
170
|
+
NA_REMOVE,
|
|
171
|
+
CHOREO.removeShrink[0],
|
|
172
|
+
CHOREO.removeShrink[1],
|
|
173
|
+
CHOREO.removeGhostFade[0],
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
const targets = hunk.nodes.filter(
|
|
177
|
+
(i) =>
|
|
178
|
+
scene.nodes[i]?.role === 'split-target' &&
|
|
179
|
+
scene.nodes[i]?.flow !== undefined &&
|
|
180
|
+
before(i).hidden,
|
|
181
|
+
);
|
|
182
|
+
targets.forEach((i, j) => {
|
|
183
|
+
const [a, b] = staggered(j, targets.length, CHOREO.splitStart);
|
|
184
|
+
set(i, NA_EMERGE, a, b);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
let anyBefore = false;
|
|
188
|
+
for (const i of hunk.nodes) {
|
|
189
|
+
const node = scene.nodes[i];
|
|
190
|
+
if (node === undefined) continue;
|
|
191
|
+
const B = before(i);
|
|
192
|
+
const A = after(i);
|
|
193
|
+
if (!B.hidden) {
|
|
194
|
+
anyBefore = true;
|
|
195
|
+
c.nPulse[i] = 1;
|
|
196
|
+
}
|
|
197
|
+
if (c.nKind[i] !== NA_NONE) continue;
|
|
198
|
+
if (B.hidden && A.hidden) continue;
|
|
199
|
+
if (B.hidden) {
|
|
200
|
+
const [a, b] = kind === 'merge' ? CHOREO.popAfterArrival : CHOREO.pop;
|
|
201
|
+
set(i, NA_ADD, a, b, Math.min(1, b - 0.05));
|
|
202
|
+
} else if (A.hidden) {
|
|
203
|
+
if (kind === 'split') {
|
|
204
|
+
set(
|
|
205
|
+
i,
|
|
206
|
+
NA_REMOVE,
|
|
207
|
+
CHOREO.splitSourceShrink[0],
|
|
208
|
+
CHOREO.splitSourceShrink[1],
|
|
209
|
+
CHOREO.splitSourceFade[0],
|
|
210
|
+
);
|
|
211
|
+
} else {
|
|
212
|
+
set(
|
|
213
|
+
i,
|
|
214
|
+
NA_REMOVE,
|
|
215
|
+
CHOREO.removeShrink[0],
|
|
216
|
+
CHOREO.removeShrink[1],
|
|
217
|
+
CHOREO.removeGhostFade[0],
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
} else if (node.role === 'merge-target') {
|
|
221
|
+
set(i, NA_FADE, CHOREO.mergeTargetFade[0], CHOREO.mergeTargetFade[1]);
|
|
222
|
+
flash(i, CHOREO.mergeFlash);
|
|
223
|
+
} else if (node.role === 'split-source') {
|
|
224
|
+
set(i, NA_FADE, CHOREO.splitSourceStay[0], CHOREO.splitSourceStay[1]);
|
|
225
|
+
flash(i, CHOREO.mergeFlash);
|
|
226
|
+
} else {
|
|
227
|
+
set(i, NA_FADE, CHOREO.modifyFade[0], CHOREO.modifyFade[1]);
|
|
228
|
+
if (A.ring !== null) flash(i, CHOREO.modifyFlash);
|
|
229
|
+
}
|
|
230
|
+
if ((c.nKind[i] as number) === NA_FADE) {
|
|
231
|
+
c.nLabelSwap[i] = B.labelBefore && !A.labelBefore && node.labelBefore !== undefined ? 1 : 0;
|
|
232
|
+
if (A.badge && !B.badge && node.badge !== undefined) c.nBadgeAt[i] = CHOREO.badgeTick;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!anyBefore) c.hPulseContext[hunk.index] = 1;
|
|
236
|
+
|
|
237
|
+
// Merge targets count their arrivals up: ⊕ 1, ⊕ 2, …, then the final badge.
|
|
238
|
+
if (kind === 'merge') {
|
|
239
|
+
for (const i of hunk.nodes) {
|
|
240
|
+
const node = scene.nodes[i];
|
|
241
|
+
if (node?.role !== 'merge-target' || node.badge === undefined) continue;
|
|
242
|
+
const times = sources
|
|
243
|
+
.filter((s) => scene.nodes[s]?.flow === i)
|
|
244
|
+
.map((s) => c.nB[s] as number)
|
|
245
|
+
.sort((a, b) => a - b);
|
|
246
|
+
if (times.length === 0) times.push(CHOREO.mergeFlash[0]);
|
|
247
|
+
nArrivals[i] = Float64Array.from(times);
|
|
248
|
+
nBadgeSteps[i] = times.map((_, k) =>
|
|
249
|
+
k === times.length - 1 ? (node.badge as string) : mergeBadge(k + 1),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const appears = (i: number): boolean =>
|
|
255
|
+
scene.nodes[i]?.hunk === hunk.index && (c.nKind[i] === NA_ADD || c.nKind[i] === NA_EMERGE);
|
|
256
|
+
const leaves = (i: number): boolean =>
|
|
257
|
+
scene.nodes[i]?.hunk === hunk.index && (c.nKind[i] === NA_REMOVE || c.nKind[i] === NA_TRAVEL);
|
|
258
|
+
const edge = (
|
|
259
|
+
j: number,
|
|
260
|
+
kind2: number,
|
|
261
|
+
ab: readonly [number, number],
|
|
262
|
+
fromTarget = false,
|
|
263
|
+
pivot = -1,
|
|
264
|
+
): void => {
|
|
265
|
+
c.eKind[j] = kind2;
|
|
266
|
+
c.eA[j] = ab[0];
|
|
267
|
+
c.eB[j] = ab[1];
|
|
268
|
+
c.eFromTarget[j] = fromTarget ? 1 : 0;
|
|
269
|
+
c.ePivot[j] = pivot;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
for (const j of hunk.edges) {
|
|
273
|
+
const e = scene.edges[j];
|
|
274
|
+
if (e === undefined) continue;
|
|
275
|
+
const styles = edgeStyles[j] as readonly EdgeStyle[];
|
|
276
|
+
const B = styles[1] as EdgeStyle;
|
|
277
|
+
const A = styles[2] as EdgeStyle;
|
|
278
|
+
switch (e.role) {
|
|
279
|
+
case 'added': {
|
|
280
|
+
// Grow from the endpoint that already exists; between two new nodes, after they pop;
|
|
281
|
+
// in merges / splits, once the travellers have arrived.
|
|
282
|
+
const sNew = appears(e.s);
|
|
283
|
+
const tNew = appears(e.t);
|
|
284
|
+
const timing =
|
|
285
|
+
sNew && tNew
|
|
286
|
+
? CHOREO.growBetweenNew
|
|
287
|
+
: kind === 'add' && (sNew || tNew)
|
|
288
|
+
? CHOREO.growFromExisting
|
|
289
|
+
: kind === 'merge' || kind === 'split'
|
|
290
|
+
? CHOREO.growAfterArrival
|
|
291
|
+
: CHOREO.growOther;
|
|
292
|
+
edge(j, EA_GROW, timing, sNew && !tNew);
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
case 'removed':
|
|
296
|
+
edge(j, EA_RETRACT, CHOREO.retract, leaves(e.s) && !leaves(e.t));
|
|
297
|
+
break;
|
|
298
|
+
case 'collapsed':
|
|
299
|
+
case 'carried': {
|
|
300
|
+
if (kind === 'split' && e.role === 'carried') {
|
|
301
|
+
const mover = c.nKind[e.s] === NA_EMERGE ? e.s : c.nKind[e.t] === NA_EMERGE ? e.t : -1;
|
|
302
|
+
if (mover >= 0)
|
|
303
|
+
edge(
|
|
304
|
+
j,
|
|
305
|
+
EA_FOLLOW_SPLIT,
|
|
306
|
+
[c.nA[mover] as number, c.nB[mover] as number],
|
|
307
|
+
false,
|
|
308
|
+
mover,
|
|
309
|
+
);
|
|
310
|
+
else edge(j, EA_SWAP, CHOREO.swap);
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
const mover =
|
|
314
|
+
c.nKind[e.sb] === NA_TRAVEL ? e.sb : c.nKind[e.tb] === NA_TRAVEL ? e.tb : -1;
|
|
315
|
+
if (mover >= 0)
|
|
316
|
+
edge(j, EA_FOLLOW_MERGE, [c.nA[mover] as number, c.nB[mover] as number], false, mover);
|
|
317
|
+
else if (e.role === 'collapsed')
|
|
318
|
+
edge(j, EA_RETRACT, CHOREO.retract, leaves(e.s) && !leaves(e.t));
|
|
319
|
+
else edge(j, EA_SWAP, CHOREO.swap);
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
case 'retarget': {
|
|
323
|
+
const pivot =
|
|
324
|
+
e.s === e.sb && e.t !== e.tb ? e.s : e.t === e.tb && e.s !== e.sb ? e.t : -1;
|
|
325
|
+
if (pivot >= 0) edge(j, EA_SWING, CHOREO.swing, false, pivot);
|
|
326
|
+
else edge(j, EA_SWAP, CHOREO.swap);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
case 'modified':
|
|
330
|
+
edge(j, EA_FADE, CHOREO.edgeFade);
|
|
331
|
+
break;
|
|
332
|
+
case 'unchanged':
|
|
333
|
+
break;
|
|
334
|
+
default:
|
|
335
|
+
assertNever(e.role);
|
|
336
|
+
}
|
|
337
|
+
// Safety net for unusual style combinations (e.g. an edge hidden in both versions).
|
|
338
|
+
if (B.hidden && A.hidden) c.eKind[j] = EA_NONE;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return c;
|
|
342
|
+
}
|
package/src/color.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Color math for the design tokens: parsing, alpha compositing, WCAG contrast and
|
|
3
|
+
* derived colors (mixes, desaturation, precomputed ramps for animation).
|
|
4
|
+
*
|
|
5
|
+
* Everything here allocates; it runs when tokens or a resolver are built. Hot paths
|
|
6
|
+
* (the resolver at 60 fps) only index into precomputed `ColorRamp`s.
|
|
7
|
+
*/
|
|
8
|
+
import { KgdiffError } from '@kgdiff/core';
|
|
9
|
+
|
|
10
|
+
/** sRGB color with straight (non-premultiplied) alpha. Channels 0–255, alpha 0–1. */
|
|
11
|
+
export interface Rgba {
|
|
12
|
+
readonly r: number;
|
|
13
|
+
readonly g: number;
|
|
14
|
+
readonly b: number;
|
|
15
|
+
readonly a: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const WHITE: Rgba = { r: 255, g: 255, b: 255, a: 1 };
|
|
19
|
+
|
|
20
|
+
const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));
|
|
21
|
+
|
|
22
|
+
function invalid(css: string, why: string): never {
|
|
23
|
+
throw new KgdiffError(
|
|
24
|
+
'invalid-input',
|
|
25
|
+
`Unsupported color ${JSON.stringify(css)}: ${why}. Use #rgb, #rgba, #rrggbb, #rrggbbaa, rgb() or rgba().`,
|
|
26
|
+
{ color: css },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseHex(css: string, hex: string): Rgba {
|
|
31
|
+
if (!/^[0-9a-f]+$/i.test(hex)) invalid(css, 'not a hex color');
|
|
32
|
+
const short = hex.length === 3 || hex.length === 4;
|
|
33
|
+
if (!short && hex.length !== 6 && hex.length !== 8)
|
|
34
|
+
invalid(css, 'hex colors have 3, 4, 6 or 8 digits');
|
|
35
|
+
const channel = (i: number): number =>
|
|
36
|
+
short
|
|
37
|
+
? Number.parseInt(hex.charAt(i) + hex.charAt(i), 16)
|
|
38
|
+
: Number.parseInt(hex.slice(2 * i, 2 * i + 2), 16);
|
|
39
|
+
const hasAlpha = hex.length === 4 || hex.length === 8;
|
|
40
|
+
return { r: channel(0), g: channel(1), b: channel(2), a: hasAlpha ? channel(3) / 255 : 1 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseChannel(css: string, token: string, scale: number): number {
|
|
44
|
+
const percent = token.endsWith('%');
|
|
45
|
+
const value = Number(percent ? token.slice(0, -1) : token);
|
|
46
|
+
if (token === '' || !Number.isFinite(value)) invalid(css, `bad component "${token}"`);
|
|
47
|
+
return clamp(percent ? (value / 100) * scale : value, 0, scale);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseFunctional(css: string, body: string): Rgba {
|
|
51
|
+
// Both `rgb(1, 2, 3, 0.5)` and `rgb(1 2 3 / 50%)`.
|
|
52
|
+
const [channels = '', alphaPart, extra] = body.split('/');
|
|
53
|
+
if (extra !== undefined) invalid(css, 'more than one "/"');
|
|
54
|
+
const parts = channels
|
|
55
|
+
.split(/[\s,]+/)
|
|
56
|
+
.map((s) => s.trim())
|
|
57
|
+
.filter((s) => s !== '');
|
|
58
|
+
if (alphaPart !== undefined) parts.push(alphaPart.trim());
|
|
59
|
+
if (parts.length !== 3 && parts.length !== 4) invalid(css, 'expected 3 or 4 components');
|
|
60
|
+
const [r = '', g = '', b = '', a] = parts;
|
|
61
|
+
return {
|
|
62
|
+
r: parseChannel(css, r, 255),
|
|
63
|
+
g: parseChannel(css, g, 255),
|
|
64
|
+
b: parseChannel(css, b, 255),
|
|
65
|
+
a: a === undefined ? 1 : parseChannel(css, a, 1),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Parse `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()` and `transparent`. */
|
|
70
|
+
export function parseColor(css: string): Rgba {
|
|
71
|
+
const s = css.trim().toLowerCase();
|
|
72
|
+
if (s.startsWith('#')) return parseHex(css, s.slice(1));
|
|
73
|
+
if (s === 'transparent') return { r: 0, g: 0, b: 0, a: 0 };
|
|
74
|
+
const m = /^rgba?\((.*)\)$/.exec(s);
|
|
75
|
+
if (m) return parseFunctional(css, m[1] ?? '');
|
|
76
|
+
return invalid(css, 'unknown syntax');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Source-over compositing of `fg` onto `bg` (both may be translucent). */
|
|
80
|
+
export function composite(fg: Rgba, bg: Rgba): Rgba {
|
|
81
|
+
const a = fg.a + bg.a * (1 - fg.a);
|
|
82
|
+
if (a === 0) return { r: 0, g: 0, b: 0, a: 0 };
|
|
83
|
+
const ch = (f: number, b: number): number => (f * fg.a + b * bg.a * (1 - fg.a)) / a;
|
|
84
|
+
return { r: ch(fg.r, bg.r), g: ch(fg.g, bg.g), b: ch(fg.b, bg.b), a };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const linear = (c: number): number => {
|
|
88
|
+
const s = c / 255;
|
|
89
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** WCAG 2.x relative luminance of an opaque color. */
|
|
93
|
+
export function relativeLuminance(c: Rgba): number {
|
|
94
|
+
return 0.2126 * linear(c.r) + 0.7152 * linear(c.g) + 0.0722 * linear(c.b);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* WCAG 2.x contrast ratio in [1, 21]. A translucent foreground is composited over the
|
|
99
|
+
* background first; a translucent background is composited over white (the initial CSS
|
|
100
|
+
* canvas), since what lies beneath it is unknown.
|
|
101
|
+
*/
|
|
102
|
+
export function contrastRatio(foreground: string, background: string): number {
|
|
103
|
+
let bg = parseColor(background);
|
|
104
|
+
if (bg.a < 1) bg = composite(bg, WHITE);
|
|
105
|
+
const fg = composite(parseColor(foreground), bg);
|
|
106
|
+
const l1 = relativeLuminance(fg);
|
|
107
|
+
const l2 = relativeLuminance(bg);
|
|
108
|
+
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const hex2 = (v: number): string =>
|
|
112
|
+
Math.round(clamp(v, 0, 255))
|
|
113
|
+
.toString(16)
|
|
114
|
+
.padStart(2, '0');
|
|
115
|
+
|
|
116
|
+
/** `#rrggbb` when opaque, else `rgba(r, g, b, a)` (alpha rounded to 3 decimals). */
|
|
117
|
+
export function formatColor(c: Rgba): string {
|
|
118
|
+
if (c.a >= 1) return `#${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}`.toUpperCase();
|
|
119
|
+
const r = Math.round(clamp(c.r, 0, 255));
|
|
120
|
+
const g = Math.round(clamp(c.g, 0, 255));
|
|
121
|
+
const b = Math.round(clamp(c.b, 0, 255));
|
|
122
|
+
return `rgba(${r}, ${g}, ${b}, ${Math.round(clamp(c.a, 0, 1) * 1000) / 1000})`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Interpolate in sRGB with premultiplied alpha (no dark fringes when fading to transparent). */
|
|
126
|
+
export function mixRgba(a: Rgba, b: Rgba, t: number): Rgba {
|
|
127
|
+
const u = clamp(t, 0, 1);
|
|
128
|
+
const alpha = a.a + (b.a - a.a) * u;
|
|
129
|
+
if (alpha === 0) return { r: 0, g: 0, b: 0, a: 0 };
|
|
130
|
+
const ch = (x: number, y: number): number => (x * a.a * (1 - u) + y * b.a * u) / alpha;
|
|
131
|
+
return { r: ch(a.r, b.r), g: ch(a.g, b.g), b: ch(a.b, b.b), a: alpha };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `a` → `b` at `t` ∈ [0, 1]. */
|
|
135
|
+
export function mix(a: string, b: string, t: number): string {
|
|
136
|
+
return formatColor(mixRgba(parseColor(a), parseColor(b), t));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The same color with its alpha replaced. */
|
|
140
|
+
export function withAlpha(color: string, alpha: number): string {
|
|
141
|
+
return formatColor({ ...parseColor(color), a: clamp(alpha, 0, 1) });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Move a color toward the gray of equal luma by `amount` ∈ [0, 1] (alpha kept). */
|
|
145
|
+
export function desaturate(color: string, amount: number): string {
|
|
146
|
+
const c = parseColor(color);
|
|
147
|
+
const gray = 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
|
|
148
|
+
const u = clamp(amount, 0, 1);
|
|
149
|
+
return formatColor({
|
|
150
|
+
r: c.r + (gray - c.r) * u,
|
|
151
|
+
g: c.g + (gray - c.g) * u,
|
|
152
|
+
b: c.b + (gray - c.b) * u,
|
|
153
|
+
a: c.a,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Precomputed interpolation `from` → `to` for allocation-free animation. */
|
|
158
|
+
export type ColorRamp = readonly string[];
|
|
159
|
+
|
|
160
|
+
/** Number of steps in a ramp: smooth to the eye over the ≤ 1 s transitions it is used for. */
|
|
161
|
+
export const RAMP_STEPS = 33;
|
|
162
|
+
|
|
163
|
+
export function colorRamp(from: string, to: string, steps: number = RAMP_STEPS): ColorRamp {
|
|
164
|
+
const a = parseColor(from);
|
|
165
|
+
const b = parseColor(to);
|
|
166
|
+
const out: string[] = [];
|
|
167
|
+
for (let i = 0; i < steps; i++) out.push(formatColor(mixRgba(a, b, i / (steps - 1))));
|
|
168
|
+
// Exact endpoints: animations must settle on the very token strings.
|
|
169
|
+
out[0] = from;
|
|
170
|
+
out[steps - 1] = to;
|
|
171
|
+
return Object.freeze(out);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Sample a ramp at `t` ∈ [0, 1]. Allocation-free. */
|
|
175
|
+
export function rampAt(ramp: ColorRamp, t: number): string {
|
|
176
|
+
const n = ramp.length - 1;
|
|
177
|
+
const i = t <= 0 ? 0 : t >= 1 ? n : Math.round(t * n);
|
|
178
|
+
return ramp[i] as string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Memoizes ramps by endpoint pair so equal transitions share one array. */
|
|
182
|
+
export class RampCache {
|
|
183
|
+
private readonly cache = new Map<string, ColorRamp>();
|
|
184
|
+
|
|
185
|
+
get(from: string, to: string): ColorRamp {
|
|
186
|
+
const key = `${from}\u0000${to}`;
|
|
187
|
+
let ramp = this.cache.get(key);
|
|
188
|
+
if (ramp === undefined) {
|
|
189
|
+
ramp = colorRamp(from, to);
|
|
190
|
+
this.cache.set(key, ramp);
|
|
191
|
+
}
|
|
192
|
+
return ramp;
|
|
193
|
+
}
|
|
194
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Numeric constants of the visual language (docs/visual-language.md §Geometry, §Typography,
|
|
3
|
+
* §Level of detail, §Replay choreography). One place so every module agrees.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Stroke widths in screen px (constant under zoom). */
|
|
7
|
+
export const STROKE = {
|
|
8
|
+
neutralEdge: 1,
|
|
9
|
+
changedEdge: 1.75,
|
|
10
|
+
selectedEdge: 2.5,
|
|
11
|
+
node: 1.25,
|
|
12
|
+
hoveredNode: 2,
|
|
13
|
+
ring: 1.5,
|
|
14
|
+
/** Gap between the node boundary and its ring, px. */
|
|
15
|
+
ringGap: 3.5,
|
|
16
|
+
glyph: 1.5,
|
|
17
|
+
suggestion: 1.25,
|
|
18
|
+
/** Diameter of a pivot dot, px. */
|
|
19
|
+
pivot: 5,
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
/** Dash patterns, px. Frozen so they can be shared by every visual without copying. */
|
|
23
|
+
export const DASH_GHOST: readonly number[] = Object.freeze([3, 2.5]);
|
|
24
|
+
export const DASH_EDGE: readonly number[] = Object.freeze([4, 3]);
|
|
25
|
+
export const DASH_DOTTED: readonly number[] = Object.freeze([1, 3]);
|
|
26
|
+
|
|
27
|
+
export const HALO = {
|
|
28
|
+
/** Dark themes: radial glow radius as a multiple of r. */
|
|
29
|
+
glowScale: 2.6,
|
|
30
|
+
glowAlpha: 0.32,
|
|
31
|
+
/** Light themes: offset ring; `radius` is r and renderers draw the ring 5 px outside it, 2 px wide. */
|
|
32
|
+
ringAlpha: 0.35,
|
|
33
|
+
ringOffset: 5,
|
|
34
|
+
ringWidth: 2,
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/** Label typography in px. */
|
|
38
|
+
export const LABEL = {
|
|
39
|
+
size: 12,
|
|
40
|
+
oldSize: 11,
|
|
41
|
+
edgeSize: 10.5,
|
|
42
|
+
/** Gap between the node's bottom and its label, px (`offset = r·k + 5 px`). */
|
|
43
|
+
offset: 5,
|
|
44
|
+
/** Secondary label sits this much lower than the primary one. */
|
|
45
|
+
secondaryGap: 14,
|
|
46
|
+
/** Line box used for bounds estimation (font size × line height). */
|
|
47
|
+
lineHeight: 1.3,
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Level-of-detail thresholds (camera zoom). Label thresholds are permissive on purpose: the
|
|
52
|
+
* renderer's greedy collision culling decides what fits, so the overview of a small graph is
|
|
53
|
+
* not a field of anonymous dots.
|
|
54
|
+
*/
|
|
55
|
+
export const LOD = {
|
|
56
|
+
changedLabel: 0.35,
|
|
57
|
+
neutralLabel: 0.5,
|
|
58
|
+
importantNeutralLabel: 0.35,
|
|
59
|
+
importantThreshold: 0.7,
|
|
60
|
+
/** Scenes with at most this many visible nodes (after focus) use the small-scene thresholds. */
|
|
61
|
+
smallScene: 300,
|
|
62
|
+
smallChangedLabel: 0.2,
|
|
63
|
+
smallNeutralLabel: 0.25,
|
|
64
|
+
edgeLabel: 1.1,
|
|
65
|
+
badge: 1.2,
|
|
66
|
+
/** Renderers draw arrowheads on edges with `arrow: false` from this zoom on. */
|
|
67
|
+
arrow: 1.2,
|
|
68
|
+
} as const;
|
|
69
|
+
|
|
70
|
+
/** Parallel edges between one pair curve with ±k·this (k = 1, 2, …). */
|
|
71
|
+
export const PARALLEL_CURVATURE = 0.18;
|
|
72
|
+
|
|
73
|
+
/** Alpha levels of the selection spotlight, the replay spotlight, filters and focus. */
|
|
74
|
+
export const EMPHASIS = {
|
|
75
|
+
selectedContext: 0.7,
|
|
76
|
+
selectedRest: 0.18,
|
|
77
|
+
replayContext: 0.7,
|
|
78
|
+
replayRest: 0.3,
|
|
79
|
+
filtered: 0.4,
|
|
80
|
+
focusContext: 0.55,
|
|
81
|
+
ghostEdge: 0.45,
|
|
82
|
+
collapsedEdge: 0.5,
|
|
83
|
+
} as const;
|
|
84
|
+
|
|
85
|
+
/** Replay window phases as fractions of the window (spotlight / transform / settle). */
|
|
86
|
+
export const PHASE = {
|
|
87
|
+
spotlightEnd: 0.18,
|
|
88
|
+
settleStart: 0.82,
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
/** Replay timeline (docs/algorithms.md §10). */
|
|
92
|
+
export const TIMELINE = {
|
|
93
|
+
pace: 1100,
|
|
94
|
+
chapterThreshold: 40,
|
|
95
|
+
gap: 160,
|
|
96
|
+
maxStagger: 0.35,
|
|
97
|
+
} as const;
|