@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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/dist/color.d.ts +24 -0
  4. package/dist/color.d.ts.map +1 -0
  5. package/dist/color.js +95 -0
  6. package/dist/color.js.map +1 -0
  7. package/dist/constants.d.ts +81 -0
  8. package/dist/constants.d.ts.map +1 -0
  9. package/dist/constants.js +81 -0
  10. package/dist/constants.js.map +1 -0
  11. package/dist/controller.d.ts +126 -0
  12. package/dist/controller.d.ts.map +1 -0
  13. package/dist/controller.js +422 -0
  14. package/dist/controller.js.map +1 -0
  15. package/dist/geometry.d.ts +19 -0
  16. package/dist/geometry.d.ts.map +1 -0
  17. package/dist/geometry.js +43 -0
  18. package/dist/geometry.js.map +1 -0
  19. package/dist/index.d.ts +101 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +99 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/locator.d.ts +24 -0
  24. package/dist/locator.d.ts.map +1 -0
  25. package/dist/locator.js +69 -0
  26. package/dist/locator.js.map +1 -0
  27. package/dist/map-context.d.ts +70 -0
  28. package/dist/map-context.d.ts.map +1 -0
  29. package/dist/map-context.js +44 -0
  30. package/dist/map-context.js.map +1 -0
  31. package/dist/map-edge.d.ts +19 -0
  32. package/dist/map-edge.d.ts.map +1 -0
  33. package/dist/map-edge.js +399 -0
  34. package/dist/map-edge.js.map +1 -0
  35. package/dist/map-node.d.ts +10 -0
  36. package/dist/map-node.d.ts.map +1 -0
  37. package/dist/map-node.js +243 -0
  38. package/dist/map-node.js.map +1 -0
  39. package/dist/map.d.ts +47 -0
  40. package/dist/map.d.ts.map +1 -0
  41. package/dist/map.js +92 -0
  42. package/dist/map.js.map +1 -0
  43. package/dist/node.d.ts +68 -0
  44. package/dist/node.d.ts.map +1 -0
  45. package/dist/node.js +189 -0
  46. package/dist/node.js.map +1 -0
  47. package/dist/options.d.ts +33 -0
  48. package/dist/options.d.ts.map +1 -0
  49. package/dist/options.js +172 -0
  50. package/dist/options.js.map +1 -0
  51. package/dist/patch.d.ts +45 -0
  52. package/dist/patch.d.ts.map +1 -0
  53. package/dist/patch.js +120 -0
  54. package/dist/patch.js.map +1 -0
  55. package/dist/replay.d.ts +20 -0
  56. package/dist/replay.d.ts.map +1 -0
  57. package/dist/replay.js +36 -0
  58. package/dist/replay.js.map +1 -0
  59. package/package.json +54 -3
  60. package/src/color.ts +101 -0
  61. package/src/constants.ts +96 -0
  62. package/src/controller.ts +517 -0
  63. package/src/geometry.ts +44 -0
  64. package/src/index.ts +140 -0
  65. package/src/locator.ts +72 -0
  66. package/src/map-context.ts +119 -0
  67. package/src/map-edge.ts +481 -0
  68. package/src/map-node.ts +272 -0
  69. package/src/map.ts +130 -0
  70. package/src/node.ts +240 -0
  71. package/src/options.ts +215 -0
  72. package/src/patch.ts +133 -0
  73. package/src/replay.ts +59 -0
package/src/options.ts ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The G6 options `createDiffGraph` generates: no G6 theme (so no theme state styles, palettes or
3
+ * default animations leak into kgdiff's encodings), a background plugin carrying the canvas
4
+ * color, kgdiff-node, private hover states, and update animations for the fields kgdiff changes.
5
+ */
6
+ import type { EdgeData, GraphOptions, IEvent, NodeData } from '@antv/g6';
7
+ import type { ChangeFamily, Theme } from '@kgdiff/scene';
8
+ import {
9
+ BACKGROUND_PLUGIN_KEY,
10
+ EDGE_LABEL_SIZE,
11
+ HOVER_BEHAVIOR_KEY,
12
+ HOVER_EDGE_WIDTH,
13
+ HOVER_NODE_WIDTH,
14
+ HOVER_STATE,
15
+ NODE_TYPE,
16
+ TEXT_HALO_ALPHA,
17
+ Z,
18
+ } from './constants.js';
19
+ import { kgdiffData } from './map.js';
20
+
21
+ type NodeStyle = NonNullable<NodeData['style']>;
22
+ type EdgeStyle = NonNullable<EdgeData['style']>;
23
+
24
+ const CHANGE_FAMILIES: ReadonlySet<string> = new Set<ChangeFamily>([
25
+ 'added',
26
+ 'removed',
27
+ 'content',
28
+ 'structure',
29
+ 'identity',
30
+ ]);
31
+
32
+ function num(v: unknown, fallback: number): number {
33
+ return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
34
+ }
35
+
36
+ /**
37
+ * Hover on a node, as the resolver does it: a 2-wide stroke in the family's strong color
38
+ * (secondary text color for context), drawn on top, label revealed even below its LOD zoom.
39
+ */
40
+ export function hoverNodeStyle(datum: NodeData, theme: Theme): NodeStyle {
41
+ const d = kgdiffData(datum.data);
42
+ if (d === null || d.kgdiff !== 'node') return {};
43
+ const style = datum.style ?? {};
44
+ const fam = d.family;
45
+ const opacity = Math.max(num(style.labelOpacity, 0), num(style.opacity, 1));
46
+ return {
47
+ lineWidth: HOVER_NODE_WIDTH,
48
+ stroke:
49
+ d.changed && CHANGE_FAMILIES.has(fam)
50
+ ? theme.family[fam as ChangeFamily].strong
51
+ : theme.text.secondary,
52
+ zIndex: Z.node + Z.span - 1,
53
+ label: typeof style.labelText === 'string' && style.labelText.length > 0,
54
+ labelOpacity: opacity,
55
+ labelStrokeOpacity: TEXT_HALO_ALPHA * opacity,
56
+ };
57
+ }
58
+
59
+ /** Hover on an edge: at least 2.5 wide, arrowhead, on top, its label shown. */
60
+ export function hoverEdgeStyle(datum: EdgeData, theme: Theme): EdgeStyle {
61
+ const d = kgdiffData(datum.data);
62
+ if (d === null || (d.kgdiff !== 'edge' && d.kgdiff !== 'glyph')) return {};
63
+ const style = datum.style ?? {};
64
+ const own = typeof style.labelText === 'string' && style.labelText.length > 0;
65
+ const text = own ? (style.labelText as string) : d.label;
66
+ const opacity = Math.max(num(style.labelOpacity, 0), num(style.opacity, 1));
67
+ return {
68
+ lineWidth: Math.max(num(style.lineWidth, 1), HOVER_EDGE_WIDTH),
69
+ endArrow: datum.source !== datum.target,
70
+ zIndex: (d.kgdiff === 'glyph' ? Z.glyph : Z.edge) + Z.span - 1,
71
+ label: text.length > 0,
72
+ labelText: text,
73
+ labelFill: own ? (style.labelFill as string) : theme.neutral.label,
74
+ labelFontSize: own ? num(style.labelFontSize, EDGE_LABEL_SIZE) : EDGE_LABEL_SIZE,
75
+ labelOpacity: opacity,
76
+ labelStrokeOpacity: TEXT_HALO_ALPHA * opacity,
77
+ };
78
+ }
79
+
80
+ export interface GeneratedOptionsInput {
81
+ readonly container: HTMLElement | string;
82
+ readonly data: NonNullable<GraphOptions['data']>;
83
+ readonly theme: () => Theme;
84
+ readonly interactive: boolean;
85
+ /** Whether a pointer event may activate the hover state. */
86
+ readonly hoverable: (event: IEvent) => boolean;
87
+ }
88
+
89
+ /** Update animations: what interpolates when a frame replaces the previous one. */
90
+ const NODE_ANIMATION = {
91
+ enter: 'fade',
92
+ exit: 'fade',
93
+ show: 'fade',
94
+ hide: 'fade',
95
+ update: [
96
+ {
97
+ fields: [
98
+ 'x',
99
+ 'y',
100
+ 'size',
101
+ 'fill',
102
+ 'stroke',
103
+ 'lineWidth',
104
+ 'opacity',
105
+ 'labelOpacity',
106
+ 'ringOpacity',
107
+ 'haloStrokeOpacity',
108
+ 'sublabelOpacity',
109
+ ],
110
+ },
111
+ ],
112
+ translate: [{ fields: ['x', 'y'] }],
113
+ };
114
+
115
+ const EDGE_ANIMATION = {
116
+ enter: 'fade',
117
+ exit: 'fade',
118
+ show: 'fade',
119
+ hide: 'fade',
120
+ update: [
121
+ {
122
+ fields: [
123
+ 'sourceNode',
124
+ 'targetNode',
125
+ 'stroke',
126
+ 'lineWidth',
127
+ 'opacity',
128
+ 'labelOpacity',
129
+ 'curveOffset',
130
+ ],
131
+ },
132
+ ],
133
+ translate: [{ fields: ['sourceNode', 'targetNode'] }],
134
+ };
135
+
136
+ export function generatedOptions(input: GeneratedOptionsInput): GraphOptions {
137
+ const theme = input.theme;
138
+ return {
139
+ container: input.container,
140
+ autoResize: true,
141
+ theme: false,
142
+ padding: 32,
143
+ data: input.data,
144
+ node: {
145
+ type: NODE_TYPE,
146
+ state: { [HOVER_STATE]: (datum: NodeData) => hoverNodeStyle(datum, theme()) },
147
+ animation: NODE_ANIMATION,
148
+ },
149
+ edge: {
150
+ state: { [HOVER_STATE]: (datum: EdgeData) => hoverEdgeStyle(datum, theme()) },
151
+ animation: EDGE_ANIMATION,
152
+ },
153
+ behaviors: input.interactive
154
+ ? [
155
+ 'zoom-canvas',
156
+ 'drag-canvas',
157
+ {
158
+ type: 'hover-activate',
159
+ key: HOVER_BEHAVIOR_KEY,
160
+ state: HOVER_STATE,
161
+ degree: 0,
162
+ enable: input.hoverable,
163
+ },
164
+ ]
165
+ : [],
166
+ plugins: [
167
+ { type: 'background', key: BACKGROUND_PLUGIN_KEY, background: theme().canvas.background },
168
+ ],
169
+ };
170
+ }
171
+
172
+ const CONCAT = new Set(['behaviors', 'plugins', 'transforms']);
173
+
174
+ function isPlain(v: unknown): v is Record<string, unknown> {
175
+ return (
176
+ v !== null &&
177
+ typeof v === 'object' &&
178
+ !Array.isArray(v) &&
179
+ Object.getPrototypeOf(v) === Object.prototype
180
+ );
181
+ }
182
+
183
+ function deepMerge(
184
+ a: Record<string, unknown>,
185
+ b: Record<string, unknown>,
186
+ ): Record<string, unknown> {
187
+ const out: Record<string, unknown> = { ...a };
188
+ for (const [key, value] of Object.entries(b)) {
189
+ const base = out[key];
190
+ out[key] = isPlain(base) && isPlain(value) ? deepMerge(base, value) : value;
191
+ }
192
+ return out;
193
+ }
194
+
195
+ /**
196
+ * User options over generated ones: `behaviors`, `plugins` and `transforms` are appended to the
197
+ * generated lists; plain objects (`node`, `edge`, `node.state`…) merge recursively; everything
198
+ * else (arrays, functions, primitives) from `user` replaces the generated value.
199
+ */
200
+ export function mergeG6Options(
201
+ generated: GraphOptions,
202
+ user: Readonly<Record<string, unknown>> | undefined,
203
+ ): GraphOptions {
204
+ if (user === undefined) return generated;
205
+ const out: Record<string, unknown> = { ...generated };
206
+ for (const [key, value] of Object.entries(user)) {
207
+ const base = out[key];
208
+ if (CONCAT.has(key) && Array.isArray(base) && Array.isArray(value))
209
+ out[key] = [...base, ...value];
210
+ else if (isPlain(base) && isPlain(value)) out[key] = deepMerge(base, value);
211
+ else out[key] = value;
212
+ }
213
+ // The merged object is GraphOptions plus whatever the caller added; G6 validates it.
214
+ return out as GraphOptions;
215
+ }
package/src/patch.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Frame-to-frame patches. G6's `setData` re-reads and compares the whole graph and merges full
3
+ * styles into every changed element; sending G6 only what changed — new elements, removed ids,
4
+ * and for updated elements just the changed style keys — keeps its data layer proportional to
5
+ * the change. Pure.
6
+ */
7
+ import type { EdgeData, ID, NodeData } from '@antv/g6';
8
+
9
+ export type Datum = NodeData | EdgeData;
10
+
11
+ export interface Patch {
12
+ readonly add: { nodes: NodeData[]; edges: EdgeData[] };
13
+ /** Partial data: `id` (edges also `source` / `target`), the changed style keys, changed `data`. */
14
+ readonly update: { nodes: NodeData[]; edges: EdgeData[] };
15
+ readonly remove: { nodes: ID[]; edges: ID[] };
16
+ /**
17
+ * Elements whose id switched between node and edge (only possible through id collisions):
18
+ * added after `remove`, since G6 rejects a duplicate id.
19
+ */
20
+ readonly readd: { nodes: NodeData[]; edges: EdgeData[] };
21
+ /** Number of elements added, updated, removed or re-added. */
22
+ readonly size: number;
23
+ }
24
+
25
+ /** Structural equality for style values (primitives, arrays, plain objects such as badges). */
26
+ export function deepEqual(a: unknown, b: unknown): boolean {
27
+ if (a === b) return true;
28
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
29
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
30
+ const ka = Object.keys(a);
31
+ const kb = Object.keys(b);
32
+ if (ka.length !== kb.length) return false;
33
+ const ra = a as Record<string, unknown>;
34
+ const rb = b as Record<string, unknown>;
35
+ return ka.every((k) => deepEqual(ra[k], rb[k]));
36
+ }
37
+
38
+ /** Keys of `next` whose values differ from `prev` (structurally), or null when none do. */
39
+ function changedKeys(
40
+ prev: Record<string, unknown> | undefined,
41
+ next: Record<string, unknown> | undefined,
42
+ ): Record<string, unknown> | null {
43
+ if (next === undefined) return null;
44
+ let out: Record<string, unknown> | null = null;
45
+ for (const key of Object.keys(next)) {
46
+ const value = next[key];
47
+ const old = prev?.[key];
48
+ if (old === value || (typeof value === 'object' && deepEqual(old, value))) continue;
49
+ if (out === null) out = {};
50
+ out[key] = value;
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function shallowEqual(
56
+ a: Record<string, unknown> | undefined,
57
+ b: Record<string, unknown> | undefined,
58
+ ): boolean {
59
+ if (a === b) return true;
60
+ if (a === undefined || b === undefined) return false;
61
+ const ka = Object.keys(a);
62
+ return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
63
+ }
64
+
65
+ /**
66
+ * The patch turning `prev` (by id) into `next`. Edge updates always carry `zIndex`: without a
67
+ * numeric one, G6's `updateData` recomputes an edge's zIndex from its endpoints. O(elements ×
68
+ * style keys).
69
+ */
70
+ export function diffData(
71
+ prev: ReadonlyMap<ID, Datum>,
72
+ next: { readonly nodes: readonly NodeData[]; readonly edges: readonly EdgeData[] },
73
+ ): Patch {
74
+ const add: Patch['add'] = { nodes: [], edges: [] };
75
+ const update: Patch['update'] = { nodes: [], edges: [] };
76
+ const remove: Patch['remove'] = { nodes: [], edges: [] };
77
+ const readd: Patch['readd'] = { nodes: [], edges: [] };
78
+ const seen = new Set<ID>();
79
+ for (const node of next.nodes) {
80
+ const old = prev.get(node.id);
81
+ if (old === undefined || 'source' in old) {
82
+ if (old === undefined) add.nodes.push(node);
83
+ else readd.nodes.push(node);
84
+ continue;
85
+ }
86
+ seen.add(node.id);
87
+ const style = changedKeys(old.style, node.style);
88
+ const dataChanged = !shallowEqual(old.data, node.data);
89
+ const typeChanged = old.type !== node.type;
90
+ if (style === null && !dataChanged && !typeChanged) continue;
91
+ const patch: NodeData = { id: node.id };
92
+ if (style !== null) patch.style = style;
93
+ if (dataChanged) patch.data = node.data;
94
+ if (typeChanged) patch.type = node.type;
95
+ update.nodes.push(patch);
96
+ }
97
+ for (const edge of next.edges) {
98
+ const id = edge.id as ID;
99
+ const old = prev.get(id);
100
+ if (old === undefined || !('source' in old)) {
101
+ if (old === undefined) add.edges.push(edge);
102
+ else readd.edges.push(edge);
103
+ continue;
104
+ }
105
+ seen.add(id);
106
+ const style = changedKeys(old.style, edge.style);
107
+ const dataChanged = !shallowEqual(old.data, edge.data);
108
+ const typeChanged = old.type !== edge.type;
109
+ const endsChanged = old.source !== edge.source || old.target !== edge.target;
110
+ if (style === null && !dataChanged && !typeChanged && !endsChanged) continue;
111
+ const patch: EdgeData = { id, source: edge.source, target: edge.target };
112
+ patch.style = { ...style, zIndex: edge.style?.zIndex };
113
+ if (dataChanged) patch.data = edge.data;
114
+ if (typeChanged) patch.type = edge.type;
115
+ update.edges.push(patch);
116
+ }
117
+ for (const [id, old] of prev) {
118
+ // Kept elements were marked seen; added ones were not in `prev` at all.
119
+ if (seen.has(id)) continue;
120
+ if ('source' in old) remove.edges.push(id);
121
+ else remove.nodes.push(id);
122
+ }
123
+ const size =
124
+ add.nodes.length +
125
+ add.edges.length +
126
+ update.nodes.length +
127
+ update.edges.length +
128
+ remove.nodes.length +
129
+ remove.edges.length +
130
+ readd.nodes.length +
131
+ readd.edges.length;
132
+ return { add, update, remove, readd, size };
133
+ }
package/src/replay.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Replay plan: the timeline times at which the controller resolves a frame and hands it to G6,
3
+ * which animates linearly from one sample to the next. Pure.
4
+ */
5
+ import type { Timeline } from '@kgdiff/scene';
6
+ import { REPLAY_SAMPLES } from './constants.js';
7
+
8
+ export interface ReplayStep {
9
+ /** Timeline time to resolve (ms at 1×). */
10
+ readonly t: number;
11
+ /** Time since the previous step (ms at 1×); the first step counts from 0. */
12
+ readonly dt: number;
13
+ /** On the first step of a unit (window, or chapter in chapter mode): its hunks, for the camera. */
14
+ readonly focus: readonly number[] | null;
15
+ }
16
+
17
+ interface Unit {
18
+ readonly start: number;
19
+ readonly end: number;
20
+ readonly hunks: readonly number[];
21
+ }
22
+
23
+ /**
24
+ * Sample every unit of `timeline` at `samples` (fractions of its duration). Units are the
25
+ * hunk windows, or the chapters in chapter mode (whose windows overlap and play together).
26
+ * Steps are sorted by time; samples closer than 1e-6 ms are merged. O(U log U).
27
+ */
28
+ export function planReplay(
29
+ timeline: Timeline,
30
+ samples: readonly number[] = REPLAY_SAMPLES,
31
+ ): ReplayStep[] {
32
+ const units: Unit[] =
33
+ timeline.mode === 'chapters'
34
+ ? timeline.chapters.map((c) => ({ start: c.start, end: c.end, hunks: c.hunks }))
35
+ : timeline.windows.map((w) => ({ start: w.start, end: w.end, hunks: [w.hunk] }));
36
+ const raw: { t: number; focus: readonly number[] | null; order: number }[] = [];
37
+ units.forEach((u, k) => {
38
+ if (!Number.isFinite(u.start) || !Number.isFinite(u.end)) return;
39
+ const length = Math.max(0, u.end - u.start);
40
+ samples.forEach((f, s) => {
41
+ raw.push({ t: u.start + f * length, focus: s === 0 ? u.hunks : null, order: k });
42
+ });
43
+ });
44
+ raw.sort((a, b) => a.t - b.t || a.order - b.order);
45
+ const steps: ReplayStep[] = [];
46
+ let prev = 0;
47
+ for (const r of raw) {
48
+ const last = steps[steps.length - 1];
49
+ if (last !== undefined && r.t - last.t < 1e-6) {
50
+ if (r.focus !== null && last.focus === null) {
51
+ steps[steps.length - 1] = { ...last, focus: r.focus };
52
+ }
53
+ continue;
54
+ }
55
+ steps.push({ t: r.t, dt: Math.max(0, r.t - prev), focus: r.focus });
56
+ prev = r.t;
57
+ }
58
+ return steps;
59
+ }