@kgdiff/g6 0.0.0-stage → 0.1.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 +149 -2
- package/dist/color.d.ts +24 -0
- package/dist/color.d.ts.map +1 -0
- package/dist/color.js +95 -0
- package/dist/color.js.map +1 -0
- package/dist/constants.d.ts +81 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +81 -0
- package/dist/constants.js.map +1 -0
- package/dist/controller.d.ts +126 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +422 -0
- package/dist/controller.js.map +1 -0
- package/dist/geometry.d.ts +19 -0
- package/dist/geometry.d.ts.map +1 -0
- package/dist/geometry.js +43 -0
- package/dist/geometry.js.map +1 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +99 -0
- package/dist/index.js.map +1 -0
- package/dist/locator.d.ts +24 -0
- package/dist/locator.d.ts.map +1 -0
- package/dist/locator.js +69 -0
- package/dist/locator.js.map +1 -0
- package/dist/map-context.d.ts +70 -0
- package/dist/map-context.d.ts.map +1 -0
- package/dist/map-context.js +44 -0
- package/dist/map-context.js.map +1 -0
- package/dist/map-edge.d.ts +19 -0
- package/dist/map-edge.d.ts.map +1 -0
- package/dist/map-edge.js +399 -0
- package/dist/map-edge.js.map +1 -0
- package/dist/map-node.d.ts +10 -0
- package/dist/map-node.d.ts.map +1 -0
- package/dist/map-node.js +243 -0
- package/dist/map-node.js.map +1 -0
- package/dist/map.d.ts +47 -0
- package/dist/map.d.ts.map +1 -0
- package/dist/map.js +92 -0
- package/dist/map.js.map +1 -0
- package/dist/node.d.ts +68 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +189 -0
- package/dist/node.js.map +1 -0
- package/dist/options.d.ts +33 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +172 -0
- package/dist/options.js.map +1 -0
- package/dist/patch.d.ts +45 -0
- package/dist/patch.d.ts.map +1 -0
- package/dist/patch.js +120 -0
- package/dist/patch.js.map +1 -0
- package/dist/replay.d.ts +20 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +36 -0
- package/dist/replay.js.map +1 -0
- package/package.json +54 -3
- package/src/color.ts +101 -0
- package/src/constants.ts +96 -0
- package/src/controller.ts +517 -0
- package/src/geometry.ts +44 -0
- package/src/index.ts +140 -0
- package/src/locator.ts +72 -0
- package/src/map-context.ts +119 -0
- package/src/map-edge.ts +481 -0
- package/src/map-node.ts +272 -0
- package/src/map.ts +130 -0
- package/src/node.ts +240 -0
- package/src/options.ts +215 -0
- package/src/patch.ts +133 -0
- package/src/replay.ts +59 -0
package/src/constants.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Numbers and names shared by the mapping, the custom node and the controller.
|
|
3
|
+
*
|
|
4
|
+
* Scene sizes marked "px" (stroke widths, gaps, font sizes, label offsets) become G6 world
|
|
5
|
+
* units: G6 scales everything with the camera, so they read as specified at zoom 1.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Element type of the custom node registered by `createDiffGraph` (see node.ts). */
|
|
9
|
+
export const NODE_TYPE = 'kgdiff-node';
|
|
10
|
+
|
|
11
|
+
/** Element state set by the hover behavior (a private name, so G6 theme states never apply). */
|
|
12
|
+
export const HOVER_STATE = 'kgdiff-hover';
|
|
13
|
+
|
|
14
|
+
/** Key of the hover behavior and of the background plugin in the generated G6 options. */
|
|
15
|
+
export const HOVER_BEHAVIOR_KEY = 'kgdiff-hover';
|
|
16
|
+
export const BACKGROUND_PLUGIN_KEY = 'kgdiff-background';
|
|
17
|
+
|
|
18
|
+
/** Id prefixes of generated elements that are not scene nodes / edges. */
|
|
19
|
+
export const GLYPH_ID_PREFIX = 'kgdiff-glyph-';
|
|
20
|
+
export const COLLAPSED_ID_PREFIX = 'kgdiff-collapsed-';
|
|
21
|
+
export const ANCHOR_ID_PREFIX = 'kgdiff-anchor-';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* zIndex bands. G6 draws nodes and edges in one container ordered by zIndex, so every element
|
|
25
|
+
* gets an explicit band: collapsed pills < edges < glyph arcs < nodes < pivot dots (the native
|
|
26
|
+
* renderer's pass order). The scene's `z` (0–4) orders elements inside their band.
|
|
27
|
+
*/
|
|
28
|
+
export const Z = {
|
|
29
|
+
collapsed: 0,
|
|
30
|
+
edge: 10,
|
|
31
|
+
glyph: 20,
|
|
32
|
+
node: 30,
|
|
33
|
+
pivot: 40,
|
|
34
|
+
/** Width of a band: scene `z` values are clamped to [0, span). */
|
|
35
|
+
span: 10,
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
/** Arrowheads (docs/visual-language.md §Geometry): 7 long, 5 wide. */
|
|
39
|
+
export const ARROW_LENGTH = 7;
|
|
40
|
+
export const ARROW_WIDTH = 5;
|
|
41
|
+
/** Gap between an arrow tip and the node outline. */
|
|
42
|
+
export const ARROW_GAP = 1.5;
|
|
43
|
+
|
|
44
|
+
/** Text halo: stroked with the canvas background, 3 wide, alpha 0.9, round joins. */
|
|
45
|
+
export const TEXT_HALO_WIDTH = 3;
|
|
46
|
+
export const TEXT_HALO_ALPHA = 0.9;
|
|
47
|
+
|
|
48
|
+
/** Labels wider than this are truncated with an ellipsis (as in the native renderer). */
|
|
49
|
+
export const LABEL_MAX_WIDTH = 240;
|
|
50
|
+
|
|
51
|
+
/** Light-theme halo: an offset ring `r + 5` out, 2 wide. */
|
|
52
|
+
export const HALO_RING_OFFSET = 5;
|
|
53
|
+
export const HALO_RING_WIDTH = 2;
|
|
54
|
+
/** Dark-theme glows are never smaller than this multiple of r. */
|
|
55
|
+
export const GLOW_MIN_SCALE = 1.6;
|
|
56
|
+
|
|
57
|
+
/** Badges: 10 px mono, weight 600, 15 px pills with 5 px horizontal padding. */
|
|
58
|
+
export const BADGE_FONT_SIZE = 10;
|
|
59
|
+
export const BADGE_HEIGHT = 15;
|
|
60
|
+
export const BADGE_PAD_X = 5;
|
|
61
|
+
|
|
62
|
+
/** Collapsed-group pills (“+38 unchanged”): 20 px high, 8 px padding, 11 px / 500 text. */
|
|
63
|
+
export const PILL_HEIGHT = 20;
|
|
64
|
+
export const PILL_PAD_X = 8;
|
|
65
|
+
export const PILL_FONT_SIZE = 11;
|
|
66
|
+
|
|
67
|
+
/** Suggestion `?` mark: a 7-radius disc at the curve midpoint. */
|
|
68
|
+
export const MARK_RADIUS = 7;
|
|
69
|
+
|
|
70
|
+
/** Pivot dots get a background outline this wide so they stand out from what is under them. */
|
|
71
|
+
export const PIVOT_OUTLINE = 1.5;
|
|
72
|
+
|
|
73
|
+
/** Hovered strokes (STROKE.hoveredNode / STROKE.selectedEdge in @kgdiff/scene). */
|
|
74
|
+
export const HOVER_NODE_WIDTH = 2;
|
|
75
|
+
export const HOVER_EDGE_WIDTH = 2.5;
|
|
76
|
+
/** Edge label size used when hovering reveals a label the frame does not carry. */
|
|
77
|
+
export const EDGE_LABEL_SIZE = 10.5;
|
|
78
|
+
|
|
79
|
+
/** Extra hit width around thin edges so they can be hovered and clicked. */
|
|
80
|
+
export const EDGE_HIT_WIDTH = 6;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Replay sampling: each timeline window (chapter in chapter mode) is resolved at these fractions
|
|
84
|
+
* of its duration: end of the spotlight, middle of the transform, start of the settle, end.
|
|
85
|
+
* G6 animates linearly between consecutive samples.
|
|
86
|
+
*/
|
|
87
|
+
export const REPLAY_SAMPLES: readonly number[] = Object.freeze([0.18, 0.5, 0.82, 1]);
|
|
88
|
+
/**
|
|
89
|
+
* A draw is awaited at most its animation duration plus this long (ms): G6 settles a draw when
|
|
90
|
+
* its animation ends, and browsers pause animations in background pages.
|
|
91
|
+
*/
|
|
92
|
+
export const DRAW_GRACE_MS = 1000;
|
|
93
|
+
/** Replay steps shorter than this (ms, after speed scaling) are merged into the next one. */
|
|
94
|
+
export const MIN_STEP_MS = 16;
|
|
95
|
+
/** The camera follows a replay unit when less than this share of its bounds is in view. */
|
|
96
|
+
export const FOLLOW_VISIBLE_SHARE = 0.7;
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The controller behind `createDiffGraph`: owns the view state and the resolver, turns every
|
|
3
|
+
* view change into one resolved frame → mapped data → a patch of only what changed (`addData` /
|
|
4
|
+
* `updateData` with the changed keys / `removeData`) + `graph.draw()` (G6 animates the
|
|
5
|
+
* difference), and keeps G6's level of detail in step with the camera.
|
|
6
|
+
*
|
|
7
|
+
* All graph mutations go through one queue, so G6 never receives a new frame while it is still
|
|
8
|
+
* starting the previous draw. View requests (layer, selection, theme, LOD) that pile up while a
|
|
9
|
+
* draw runs collapse into one job, latest wins. Every queued task, draw and camera move is raced
|
|
10
|
+
* against `destroy()`, so no promise outlives the graph. The graph is reached through
|
|
11
|
+
* `GraphLike`, the subset of G6's `Graph` used here, so the logic is testable with a fake.
|
|
12
|
+
*/
|
|
13
|
+
import type {
|
|
14
|
+
AnimationEffectTiming,
|
|
15
|
+
FitViewOptions,
|
|
16
|
+
GraphData,
|
|
17
|
+
GraphOptions,
|
|
18
|
+
ID,
|
|
19
|
+
IEvent,
|
|
20
|
+
Point,
|
|
21
|
+
ViewportAnimationEffectTiming,
|
|
22
|
+
} from '@antv/g6';
|
|
23
|
+
import { KgdiffError } from '@kgdiff/core';
|
|
24
|
+
import {
|
|
25
|
+
createViewState,
|
|
26
|
+
createVisualResolver,
|
|
27
|
+
type FrameVisuals,
|
|
28
|
+
type Layer,
|
|
29
|
+
type Scene,
|
|
30
|
+
type Theme,
|
|
31
|
+
type ViewState,
|
|
32
|
+
type VisualResolver,
|
|
33
|
+
} from '@kgdiff/scene';
|
|
34
|
+
import {
|
|
35
|
+
BACKGROUND_PLUGIN_KEY,
|
|
36
|
+
DRAW_GRACE_MS,
|
|
37
|
+
FOLLOW_VISIBLE_SHARE,
|
|
38
|
+
MIN_STEP_MS,
|
|
39
|
+
} from './constants.js';
|
|
40
|
+
import { crossesLod, type KgdiffDatumData, kgdiffData, mapFrame } from './map.js';
|
|
41
|
+
import { generatedOptions, mergeG6Options } from './options.js';
|
|
42
|
+
import { type Datum, diffData } from './patch.js';
|
|
43
|
+
import { planReplay } from './replay.js';
|
|
44
|
+
|
|
45
|
+
/** The part of G6's `Graph` the controller uses. */
|
|
46
|
+
export interface GraphLike {
|
|
47
|
+
readonly destroyed: boolean;
|
|
48
|
+
render(): Promise<void>;
|
|
49
|
+
draw(): Promise<void>;
|
|
50
|
+
addData(data: GraphData): void;
|
|
51
|
+
updateData(data: GraphData): void;
|
|
52
|
+
removeData(ids: { nodes?: ID[]; edges?: ID[] }): void;
|
|
53
|
+
setOptions(options: GraphOptions): void;
|
|
54
|
+
fitView(options?: FitViewOptions, animation?: ViewportAnimationEffectTiming): Promise<void>;
|
|
55
|
+
focusElement(id: ID | ID[], animation?: ViewportAnimationEffectTiming): Promise<void>;
|
|
56
|
+
getZoom(): number;
|
|
57
|
+
getSize(): [number, number];
|
|
58
|
+
getCanvasByViewport(point: Point): Point;
|
|
59
|
+
updatePlugin(plugin: { key: string; [key: string]: unknown }): void;
|
|
60
|
+
on(eventName: string, callback: (event: IEvent) => void): unknown;
|
|
61
|
+
off(eventName: string, callback: (event: IEvent) => void): unknown;
|
|
62
|
+
destroy(): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ControllerOptions {
|
|
66
|
+
readonly view?: Partial<ViewState>;
|
|
67
|
+
readonly g6?: Readonly<Record<string, unknown>>;
|
|
68
|
+
readonly interactive?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ControllerDeps<G extends GraphLike> {
|
|
72
|
+
readonly createGraph: (options: GraphOptions) => G;
|
|
73
|
+
/** Resolves after `ms` (replay pacing when G6 animations are off). */
|
|
74
|
+
readonly wait: (ms: number) => Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Resolves after `ms`: the longest a draw is awaited. Browsers pause animation frames in
|
|
77
|
+
* background pages, and G6's draw promise only settles when its animation has run.
|
|
78
|
+
*/
|
|
79
|
+
readonly deadline: (ms: number) => Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const EDITABLE_LAYERS: ReadonlySet<string> = new Set(['union', 'before', 'after']);
|
|
83
|
+
|
|
84
|
+
function invalid(message: string): KgdiffError {
|
|
85
|
+
return new KgdiffError('invalid-input', message);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function targetId(event: IEvent): string | null {
|
|
89
|
+
const target = (event as { readonly target?: { readonly id?: unknown } }).target;
|
|
90
|
+
return typeof target?.id === 'string' ? target.id : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function destroyedError(): KgdiffError {
|
|
94
|
+
return invalid('This diff graph has been destroyed.');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A pending view update; requests made before it starts are merged into it (latest wins). */
|
|
98
|
+
interface Job {
|
|
99
|
+
/** Animation duration (ms) of the draw, or false for none. */
|
|
100
|
+
duration: number | false;
|
|
101
|
+
/** Hunks to pan to; null cancels an earlier request; undefined = leave the camera alone. */
|
|
102
|
+
focus: readonly number[] | null | undefined;
|
|
103
|
+
theme: Theme | null;
|
|
104
|
+
promise: Promise<void>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
class ReplayRun {
|
|
108
|
+
cancelled = false;
|
|
109
|
+
readonly stopped: Promise<void>;
|
|
110
|
+
private release!: () => void;
|
|
111
|
+
constructor() {
|
|
112
|
+
this.stopped = new Promise((resolve) => {
|
|
113
|
+
this.release = resolve;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
cancel(): void {
|
|
117
|
+
this.cancelled = true;
|
|
118
|
+
this.release();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class DiffGraphControllerImpl<G extends GraphLike> {
|
|
123
|
+
private graphRef: G | null = null;
|
|
124
|
+
private scene: Scene;
|
|
125
|
+
private resolver: VisualResolver;
|
|
126
|
+
private view: ViewState;
|
|
127
|
+
private frame: FrameVisuals | undefined;
|
|
128
|
+
private data = new Map<ID, Datum>();
|
|
129
|
+
private meta = new Map<ID, KgdiffDatumData>();
|
|
130
|
+
private lod: readonly number[] = [];
|
|
131
|
+
private lodZoom = 1;
|
|
132
|
+
private job: Job | null = null;
|
|
133
|
+
private queue: Promise<void> = Promise.resolve();
|
|
134
|
+
private replay: ReplayRun | null = null;
|
|
135
|
+
private destroyed = false;
|
|
136
|
+
private readonly dead: Promise<void>;
|
|
137
|
+
private kill!: () => void;
|
|
138
|
+
private readonly handlers: [string, (event: IEvent) => void][] = [];
|
|
139
|
+
|
|
140
|
+
constructor(
|
|
141
|
+
scene: Scene,
|
|
142
|
+
private readonly options: ControllerOptions,
|
|
143
|
+
private readonly deps: ControllerDeps<G>,
|
|
144
|
+
) {
|
|
145
|
+
this.scene = scene;
|
|
146
|
+
this.resolver = createVisualResolver(scene);
|
|
147
|
+
this.view = createViewState(options.view ?? {});
|
|
148
|
+
this.dead = new Promise((resolve) => {
|
|
149
|
+
this.kill = resolve;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
get graph(): G {
|
|
154
|
+
if (this.graphRef === null) throw invalid('The G6 graph is not created yet.');
|
|
155
|
+
return this.graphRef;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Create, render and fit the graph. */
|
|
159
|
+
async start(container: HTMLElement | string): Promise<void> {
|
|
160
|
+
const interactive = this.options.interactive !== false;
|
|
161
|
+
const initial = this.map(1);
|
|
162
|
+
const generated = generatedOptions({
|
|
163
|
+
container,
|
|
164
|
+
data: initial,
|
|
165
|
+
theme: () => this.scene.theme,
|
|
166
|
+
interactive,
|
|
167
|
+
hoverable: (event) => {
|
|
168
|
+
const id = targetId(event);
|
|
169
|
+
const d = id === null ? undefined : this.meta.get(id);
|
|
170
|
+
return d !== undefined && d.kgdiff !== 'collapsed' && d.kgdiff !== 'anchor';
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
const graph = this.deps.createGraph(
|
|
174
|
+
mergeG6Options({ ...generated, animation: this.timing(false) }, this.options.g6),
|
|
175
|
+
);
|
|
176
|
+
this.graphRef = graph;
|
|
177
|
+
if (interactive) {
|
|
178
|
+
this.listen('node:click', (event) => this.onElementClick(event));
|
|
179
|
+
this.listen('edge:click', (event) => this.onElementClick(event));
|
|
180
|
+
this.listen('canvas:click', () => this.onCanvasClick());
|
|
181
|
+
}
|
|
182
|
+
await graph.render();
|
|
183
|
+
// Only now: G6 emits transforms while it sets up its viewport, before `getZoom` works.
|
|
184
|
+
this.listen('aftertransform', () => this.onTransform());
|
|
185
|
+
await graph.fitView(undefined, false);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── Public API ───────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
setLayer(layer: Exclude<Layer, 'replay'>): Promise<void> {
|
|
191
|
+
if (!EDITABLE_LAYERS.has(layer)) {
|
|
192
|
+
return Promise.reject(
|
|
193
|
+
invalid(`setLayer expects 'union', 'before' or 'after', got ${JSON.stringify(layer)}.`),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (this.destroyed) return Promise.reject(destroyedError());
|
|
197
|
+
this.stop();
|
|
198
|
+
this.view = createViewState({ ...this.view, layer });
|
|
199
|
+
return this.request(this.scene.theme.motion.base);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
selectHunk(index: number): Promise<void> {
|
|
203
|
+
const H = this.scene.hunks.length;
|
|
204
|
+
if (!Number.isInteger(index) || index < -1 || index >= H) {
|
|
205
|
+
return Promise.reject(
|
|
206
|
+
invalid(`Hunk index must be an integer in [-1, ${H - 1}], got ${String(index)}.`),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return this.select(index, -1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async playReplay(options: { readonly speed?: number } = {}): Promise<void> {
|
|
213
|
+
const speed = options.speed ?? 1;
|
|
214
|
+
if (!(typeof speed === 'number' && Number.isFinite(speed) && speed > 0)) {
|
|
215
|
+
throw invalid(`Replay speed must be a positive number, got ${String(speed)}.`);
|
|
216
|
+
}
|
|
217
|
+
this.assertAlive();
|
|
218
|
+
this.stop();
|
|
219
|
+
const run = new ReplayRun();
|
|
220
|
+
this.replay = run;
|
|
221
|
+
try {
|
|
222
|
+
await Promise.race([this.runReplay(run, speed), run.stopped, this.dead]);
|
|
223
|
+
} finally {
|
|
224
|
+
if (this.replay === run) this.replay = null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Stop a running replay (its promise resolves at once); no-op otherwise. */
|
|
229
|
+
stop(): void {
|
|
230
|
+
const run = this.replay;
|
|
231
|
+
this.replay = null;
|
|
232
|
+
run?.cancel();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
setTheme(theme: Theme): Promise<void> {
|
|
236
|
+
if (
|
|
237
|
+
theme === null ||
|
|
238
|
+
typeof theme !== 'object' ||
|
|
239
|
+
typeof theme.canvas?.background !== 'string'
|
|
240
|
+
) {
|
|
241
|
+
return Promise.reject(invalid('setTheme expects a Theme (see getTheme in @kgdiff/scene).'));
|
|
242
|
+
}
|
|
243
|
+
return this.request(theme.motion.base, undefined, theme);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
destroy(): void {
|
|
247
|
+
if (this.destroyed) return;
|
|
248
|
+
this.stop();
|
|
249
|
+
this.destroyed = true;
|
|
250
|
+
this.kill();
|
|
251
|
+
const graph = this.graphRef;
|
|
252
|
+
if (graph === null) return;
|
|
253
|
+
for (const [name, handler] of this.handlers) graph.off(name, handler);
|
|
254
|
+
this.handlers.length = 0;
|
|
255
|
+
if (!graph.destroyed) graph.destroy();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── Internals ────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
private assertAlive(): void {
|
|
261
|
+
if (this.destroyed) throw invalid('This diff graph has been destroyed.');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private listen(name: string, handler: (event: IEvent) => void): void {
|
|
265
|
+
this.graph.on(name, handler);
|
|
266
|
+
this.handlers.push([name, handler]);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Run `task` after the queued ones. Rejects at once after `destroy()`; a task still queued or
|
|
271
|
+
* running when the graph is destroyed settles (resolves) instead of waiting on G6.
|
|
272
|
+
*/
|
|
273
|
+
private enqueue(task: () => Promise<void>): Promise<void> {
|
|
274
|
+
if (this.destroyed) return Promise.reject(destroyedError());
|
|
275
|
+
const run = this.queue.then(() =>
|
|
276
|
+
this.destroyed ? undefined : Promise.race([task(), this.dead]),
|
|
277
|
+
);
|
|
278
|
+
this.queue = run.catch(() => undefined);
|
|
279
|
+
return run;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Schedule one view update: resolve the current view, patch the graph, optionally switch the
|
|
284
|
+
* theme and pan. Requests made while an update is waiting in the queue join it (latest wins),
|
|
285
|
+
* so a burst of clicks costs one G6 draw.
|
|
286
|
+
*/
|
|
287
|
+
private request(
|
|
288
|
+
duration: number | false,
|
|
289
|
+
focus?: readonly number[] | null,
|
|
290
|
+
theme?: Theme,
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
if (this.destroyed) return Promise.reject(destroyedError());
|
|
293
|
+
let job = this.job;
|
|
294
|
+
if (job === null) {
|
|
295
|
+
const created: Job = { duration, focus: undefined, theme: null, promise: Promise.resolve() };
|
|
296
|
+
created.promise = this.enqueue(() => this.runJob(created));
|
|
297
|
+
this.job = created;
|
|
298
|
+
job = created;
|
|
299
|
+
}
|
|
300
|
+
job.duration = duration;
|
|
301
|
+
if (focus !== undefined) job.focus = focus;
|
|
302
|
+
if (theme !== undefined) job.theme = theme;
|
|
303
|
+
return job.promise;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private async runJob(job: Job): Promise<void> {
|
|
307
|
+
if (this.job === job) this.job = null;
|
|
308
|
+
if (job.theme !== null) {
|
|
309
|
+
this.scene = { ...this.scene, theme: job.theme };
|
|
310
|
+
this.resolver = createVisualResolver(this.scene);
|
|
311
|
+
this.frame = undefined;
|
|
312
|
+
this.graph.updatePlugin({
|
|
313
|
+
key: BACKGROUND_PLUGIN_KEY,
|
|
314
|
+
background: job.theme.canvas.background,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
const camera =
|
|
318
|
+
job.focus !== undefined && job.focus !== null
|
|
319
|
+
? this.focus(job.focus, this.scene.theme.motion.slow)
|
|
320
|
+
: null;
|
|
321
|
+
await Promise.all([this.apply(this.timing(job.duration)), camera]);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Failures of fire-and-forget work (event handlers): surfaced, unless the graph is gone. */
|
|
325
|
+
private report = (error: unknown): void => {
|
|
326
|
+
if (this.destroyed) return;
|
|
327
|
+
queueMicrotask(() => {
|
|
328
|
+
throw error;
|
|
329
|
+
});
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/** G6's global animation option for one draw: false, or the theme easing over `duration`. */
|
|
333
|
+
private timing(duration: number | false): false | AnimationEffectTiming {
|
|
334
|
+
if (duration === false || this.options.g6?.animation === false) return false;
|
|
335
|
+
const [x1, y1, x2, y2] = this.scene.theme.motion.easing;
|
|
336
|
+
return { duration: Math.max(0, duration), easing: `cubic-bezier(${x1}, ${y1}, ${x2}, ${y2})` };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private viewportTiming(duration: number): ViewportAnimationEffectTiming {
|
|
340
|
+
const t = this.timing(duration);
|
|
341
|
+
if (t === false || this.view.reducedMotion) return false;
|
|
342
|
+
return { duration: t.duration ?? duration, easing: t.easing ?? 'ease' };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Resolve the current view and map it for `zoom`; records what was mapped. */
|
|
346
|
+
private map(zoom: number): {
|
|
347
|
+
nodes: NonNullable<GraphData['nodes']>;
|
|
348
|
+
edges: NonNullable<GraphData['edges']>;
|
|
349
|
+
} {
|
|
350
|
+
this.frame = this.resolver.resolve(this.view, this.frame);
|
|
351
|
+
const mapped = mapFrame(this.scene, this.frame, { idPrefix: '', keepHidden: false, zoom });
|
|
352
|
+
const all: Datum[] = [...mapped.data.nodes, ...mapped.data.edges];
|
|
353
|
+
this.data = new Map();
|
|
354
|
+
this.meta = new Map();
|
|
355
|
+
for (const datum of all) {
|
|
356
|
+
if (datum.id === undefined) continue;
|
|
357
|
+
this.data.set(datum.id, datum);
|
|
358
|
+
const d = kgdiffData(datum.data);
|
|
359
|
+
if (d !== null) this.meta.set(datum.id, d);
|
|
360
|
+
}
|
|
361
|
+
this.lod = mapped.lod;
|
|
362
|
+
this.lodZoom = zoom;
|
|
363
|
+
return mapped.data;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Map the current view and hand G6 only the difference to what it shows. */
|
|
367
|
+
private async apply(animation: false | AnimationEffectTiming): Promise<void> {
|
|
368
|
+
if (this.destroyed) return;
|
|
369
|
+
const graph = this.graph;
|
|
370
|
+
const prev = this.data;
|
|
371
|
+
const next = this.map(graph.getZoom());
|
|
372
|
+
const patch = diffData(prev, next);
|
|
373
|
+
if (patch.size === 0) return;
|
|
374
|
+
graph.setOptions({ animation });
|
|
375
|
+
// G6's own order: add, update (edges may move to added nodes), then remove.
|
|
376
|
+
if (patch.add.nodes.length + patch.add.edges.length > 0) graph.addData(patch.add);
|
|
377
|
+
if (patch.update.nodes.length + patch.update.edges.length > 0) graph.updateData(patch.update);
|
|
378
|
+
if (patch.remove.nodes.length + patch.remove.edges.length > 0) graph.removeData(patch.remove);
|
|
379
|
+
if (patch.readd.nodes.length + patch.readd.edges.length > 0) graph.addData(patch.readd);
|
|
380
|
+
const duration = animation === false ? 0 : (animation.duration ?? 0);
|
|
381
|
+
await Promise.race([graph.draw(), this.dead, this.deps.deadline(duration + DRAW_GRACE_MS)]);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private select(hunk: number, node: number): Promise<void> {
|
|
385
|
+
if (this.destroyed) return Promise.reject(destroyedError());
|
|
386
|
+
this.stop();
|
|
387
|
+
let view: Partial<ViewState> = { ...this.view, selectedHunk: hunk, selectedNode: node };
|
|
388
|
+
if (this.view.layer === 'replay' && hunk >= 0) {
|
|
389
|
+
// In the replay layer a selection seeks to the end of the hunk's window.
|
|
390
|
+
const w = this.scene.timeline.windows.find((x) => x.hunk === hunk);
|
|
391
|
+
if (w !== undefined) view = { ...view, t: w.end };
|
|
392
|
+
}
|
|
393
|
+
this.view = createViewState(view);
|
|
394
|
+
return this.request(this.scene.theme.motion.base, hunk >= 0 ? [hunk] : null);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Ids of the hunks' nodes that are currently drawn. */
|
|
398
|
+
private hunkNodeIds(hunks: readonly number[]): string[] {
|
|
399
|
+
const ids: string[] = [];
|
|
400
|
+
for (const h of hunks) {
|
|
401
|
+
const hunk = this.scene.hunks[h];
|
|
402
|
+
if (hunk === undefined) continue;
|
|
403
|
+
for (const i of hunk.nodes) {
|
|
404
|
+
const id = this.scene.nodes[i]?.id;
|
|
405
|
+
if (id !== undefined && this.meta.get(id)?.kgdiff === 'node') ids.push(id);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return ids;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Pan to the hunks' nodes. Called before the next patch, so G6 measures elements that still
|
|
413
|
+
* exist; resolves when the camera has arrived — or when the graph is destroyed, since G6 then
|
|
414
|
+
* cancels the camera animation without ever settling its promise.
|
|
415
|
+
*/
|
|
416
|
+
private focus(hunks: readonly number[], duration: number): Promise<void> | null {
|
|
417
|
+
const ids = this.hunkNodeIds(hunks);
|
|
418
|
+
if (ids.length === 0) return null;
|
|
419
|
+
const moving = this.graph.focusElement(ids, this.viewportTiming(duration)).catch(this.report);
|
|
420
|
+
return Promise.race([moving, this.dead]);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Share of the hunks' bounds inside the viewport, in [0, 1]. */
|
|
424
|
+
private visibleShare(hunks: readonly number[]): number {
|
|
425
|
+
let x0 = Number.POSITIVE_INFINITY;
|
|
426
|
+
let y0 = Number.POSITIVE_INFINITY;
|
|
427
|
+
let x1 = Number.NEGATIVE_INFINITY;
|
|
428
|
+
let y1 = Number.NEGATIVE_INFINITY;
|
|
429
|
+
for (const h of hunks) {
|
|
430
|
+
const b = this.scene.hunks[h]?.bounds;
|
|
431
|
+
if (b === undefined) continue;
|
|
432
|
+
x0 = Math.min(x0, b.x);
|
|
433
|
+
y0 = Math.min(y0, b.y);
|
|
434
|
+
x1 = Math.max(x1, b.x + b.width);
|
|
435
|
+
y1 = Math.max(y1, b.y + b.height);
|
|
436
|
+
}
|
|
437
|
+
const area = (x1 - x0) * (y1 - y0);
|
|
438
|
+
if (!(area > 0)) return 1;
|
|
439
|
+
const graph = this.graph;
|
|
440
|
+
const [w, h] = graph.getSize();
|
|
441
|
+
const a = graph.getCanvasByViewport([0, 0]);
|
|
442
|
+
const b = graph.getCanvasByViewport([w, h]);
|
|
443
|
+
const vx0 = Math.min(a[0] as number, b[0] as number);
|
|
444
|
+
const vx1 = Math.max(a[0] as number, b[0] as number);
|
|
445
|
+
const vy0 = Math.min(a[1] as number, b[1] as number);
|
|
446
|
+
const vy1 = Math.max(a[1] as number, b[1] as number);
|
|
447
|
+
const ix = Math.max(0, Math.min(x1, vx1) - Math.max(x0, vx0));
|
|
448
|
+
const iy = Math.max(0, Math.min(y1, vy1) - Math.max(y0, vy0));
|
|
449
|
+
return (ix * iy) / area;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private async runReplay(run: ReplayRun, speed: number): Promise<void> {
|
|
453
|
+
const steps = planReplay(this.scene.timeline);
|
|
454
|
+
const base = this.scene.theme.motion.base;
|
|
455
|
+
// Morph from whatever is shown into the before state at t = 0.
|
|
456
|
+
await this.enqueue(async () => {
|
|
457
|
+
if (run.cancelled) return;
|
|
458
|
+
this.view = createViewState({
|
|
459
|
+
...this.view,
|
|
460
|
+
layer: 'replay',
|
|
461
|
+
t: 0,
|
|
462
|
+
selectedHunk: -1,
|
|
463
|
+
selectedNode: -1,
|
|
464
|
+
});
|
|
465
|
+
await this.apply(this.timing(base));
|
|
466
|
+
});
|
|
467
|
+
let pending = 0;
|
|
468
|
+
let focus: readonly number[] | null = null;
|
|
469
|
+
for (let k = 0; k < steps.length; k++) {
|
|
470
|
+
if (run.cancelled) return;
|
|
471
|
+
const step = steps[k] as (typeof steps)[number];
|
|
472
|
+
pending += step.dt / speed;
|
|
473
|
+
focus = step.focus ?? focus;
|
|
474
|
+
// Steps too short to see are folded into the next one.
|
|
475
|
+
if (pending < MIN_STEP_MS && k < steps.length - 1) continue;
|
|
476
|
+
const duration = pending;
|
|
477
|
+
const hunks = focus;
|
|
478
|
+
pending = 0;
|
|
479
|
+
focus = null;
|
|
480
|
+
await this.enqueue(async () => {
|
|
481
|
+
if (run.cancelled) return;
|
|
482
|
+
const camera =
|
|
483
|
+
hunks !== null && this.visibleShare(hunks) < FOLLOW_VISIBLE_SHARE
|
|
484
|
+
? this.focus(hunks, Math.min(Math.max(duration, 350), 1100))
|
|
485
|
+
: null;
|
|
486
|
+
this.view = createViewState({ ...this.view, t: step.t });
|
|
487
|
+
await Promise.all([
|
|
488
|
+
this.apply(this.timing(duration)),
|
|
489
|
+
Promise.race([this.deps.wait(duration), this.dead]),
|
|
490
|
+
camera,
|
|
491
|
+
]);
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private onTransform(): void {
|
|
497
|
+
// A queued update and a running replay both re-map at the current zoom anyway.
|
|
498
|
+
if (this.destroyed || this.graphRef === null || this.job !== null || this.replay !== null) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (!crossesLod(this.lod, this.lodZoom, this.graph.getZoom())) return;
|
|
502
|
+
this.request(false).catch(this.report);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private onElementClick(event: IEvent): void {
|
|
506
|
+
const id = targetId(event);
|
|
507
|
+
const d = id === null ? undefined : this.meta.get(id);
|
|
508
|
+
if (d === undefined || d.kgdiff === 'collapsed' || d.kgdiff === 'anchor') return;
|
|
509
|
+
if (d.hunk >= 0) this.select(d.hunk, -1).catch(this.report);
|
|
510
|
+
else if (d.kgdiff === 'node') this.select(-1, d.index).catch(this.report);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private onCanvasClick(): void {
|
|
514
|
+
if (this.view.selectedHunk < 0 && this.view.selectedNode < 0) return;
|
|
515
|
+
this.select(-1, -1).catch(this.report);
|
|
516
|
+
}
|
|
517
|
+
}
|
package/src/geometry.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Small numeric helpers for the mapping (pure, allocation-light). */
|
|
2
|
+
|
|
3
|
+
/** `v` clamped to [0, 1]; NaN → 0. */
|
|
4
|
+
export function unit(v: number): number {
|
|
5
|
+
return v > 0 ? Math.min(v, 1) : 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** `v` when finite, else `fallback`. */
|
|
9
|
+
export function finite(v: number, fallback: number): number {
|
|
10
|
+
return Number.isFinite(v) ? v : fallback;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** zIndex inside a band: the scene's `z` clamped to [0, span) and added to `base`. */
|
|
14
|
+
export function band(base: number, z: number, span: number): number {
|
|
15
|
+
const k = Number.isFinite(z) ? Math.round(z) : 0;
|
|
16
|
+
return base + Math.min(span - 1, Math.max(0, k));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `dash` scaled so a whole number of periods tiles a circle of radius `r` (no seam where the
|
|
21
|
+
* outline closes), exactly like the native renderer's ghost outlines.
|
|
22
|
+
*/
|
|
23
|
+
export function evenCircleDash(dash: readonly number[], r: number): number[] {
|
|
24
|
+
let period = 0;
|
|
25
|
+
for (const d of dash) period += d;
|
|
26
|
+
if (dash.length % 2 === 1) period *= 2;
|
|
27
|
+
const circumference = 2 * Math.PI * r;
|
|
28
|
+
const count = Math.max(1, Math.round(circumference / period));
|
|
29
|
+
const s = period > 0 ? circumference / (count * period) : 1;
|
|
30
|
+
return dash.map((d) => d * s);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Approximate length of the quadratic link between two points `len` apart with curvature `c`
|
|
35
|
+
* (scene curve convention: the control point sits `|c|·len` off the chord midpoint). Uses the
|
|
36
|
+
* mean of the chord and the control polygon, within a few percent for |c| ≤ 0.5.
|
|
37
|
+
*/
|
|
38
|
+
export function linkLength(len: number, c: number): number {
|
|
39
|
+
if (c === 0) return len;
|
|
40
|
+
const half = len / 2;
|
|
41
|
+
const off = Math.abs(c) * len;
|
|
42
|
+
const polygon = 2 * Math.sqrt(half * half + off * off);
|
|
43
|
+
return (len + polygon) / 2;
|
|
44
|
+
}
|