@kgdiff/g6 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.
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/index.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @kgdiff/g6: render kgdiff scenes with AntV G6 v5.
3
+ *
4
+ * For teams whose product already uses G6: the same visual language as the
5
+ * kgdiff viewer (docs/visual-language.md), driven by the same `VisualResolver`,
6
+ * expressed as G6 data, styles and animations.
7
+ *
8
+ * map.ts frame → GraphData (pure)
9
+ * node.ts `kgdiff-node`: circle + ring, glow / offset-ring halo, pulse, struck old name
10
+ * options.ts generated G6 options (no G6 theme, hover states, animations)
11
+ * controller.ts view state → frames → setData / draw; replay; LOD; interaction
12
+ * replay.ts replay sampling plan
13
+ */
14
+ import { Graph, type GraphData } from '@antv/g6';
15
+ import type { FrameVisuals, Layer, Scene, Theme, ViewState } from '@kgdiff/scene';
16
+ import { DiffGraphControllerImpl } from './controller.js';
17
+ import { mapFrame } from './map.js';
18
+ import { registerKgdiffNode } from './node.js';
19
+
20
+ export type { KgdiffDatumData } from './map.js';
21
+
22
+ export interface ToG6DataOptions {
23
+ /** Prefix for generated G6 element ids (default ''). */
24
+ readonly idPrefix?: string;
25
+ /** Include invisible elements with `visibility: 'hidden'` instead of omitting them. Default false. */
26
+ readonly keepHidden?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Convert a resolved frame into G6 v5 `GraphData`: one node per scene node, one edge per
31
+ * scene edge (+ glyph edges for merge/move arcs), with positions and fully resolved styles.
32
+ * Pure; no G6 runtime needed.
33
+ *
34
+ * - Ids: `idPrefix + union id` for scene nodes and edges; glyphs
35
+ * `idPrefix + 'kgdiff-glyph-' + kind + ':' + JSON([source key, target key])` (keys are union
36
+ * ids, or anchor keys; stable across frames, so G6 animates them); anchors
37
+ * `idPrefix + 'kgdiff-anchor-' + JSON(key)`; collapsed pills
38
+ * `idPrefix + 'kgdiff-collapsed-' + group id`. A collision gets a `#2`, `#3`… suffix.
39
+ * - `datum.data` is a `KgdiffDatumData`: kind, scene index, hunk, family (flat primitives).
40
+ * - Edges are `line` or `quadratic` (`curveOffset` from the scene curvature) between the
41
+ * frame's endpoint nodes. Glyph arcs become extra edges between the visible nodes whose discs
42
+ * hold their endpoints; an end with no drawn node there (a split target before it emerges)
43
+ * attaches to an anchor: an invisible, non-interactive point node (`data.kgdiff: 'anchor'`),
44
+ * shared by the arcs of a frame. Arcs with both ends on one node are omitted. Pivot dots become
45
+ * small non-interactive nodes on their node's outline.
46
+ * - Sizes the scene gives in px are world units here (exact at zoom 1). Level of detail is
47
+ * evaluated at zoom 1: labels / badges / arrowheads whose `minZoom` is above 1 are switched
48
+ * off (`createDiffGraph` re-evaluates it at the live zoom).
49
+ * - Ring, glow, pulse and the struck old name use extra style keys drawn by the `kgdiff-node`
50
+ * type that `createDiffGraph` registers; on G6's plain `circle` they are ignored and the halo
51
+ * falls back to G6's built-in band.
52
+ *
53
+ * Deterministic; O(N + E + glyphs). Does not mutate its inputs.
54
+ *
55
+ * @throws KgdiffError(`invalid-input`) when `frame` was not resolved for `scene`.
56
+ */
57
+ export function toG6Data(
58
+ scene: Scene,
59
+ frame: FrameVisuals,
60
+ options: ToG6DataOptions = {},
61
+ ): GraphData {
62
+ return mapFrame(scene, frame, {
63
+ idPrefix: options.idPrefix ?? '',
64
+ keepHidden: options.keepHidden ?? false,
65
+ zoom: 1,
66
+ }).data;
67
+ }
68
+
69
+ export interface CreateDiffGraphOptions {
70
+ /** Initial view state. */
71
+ readonly view?: Partial<ViewState>;
72
+ /** Extra G6 options merged into the generated ones (behaviors, plugins…). */
73
+ readonly g6?: Record<string, unknown>;
74
+ /** Default true: zoom-canvas, drag-canvas, hover highlight, click to select a hunk. */
75
+ readonly interactive?: boolean;
76
+ }
77
+
78
+ export interface DiffGraphController {
79
+ readonly graph: Graph;
80
+ setLayer(layer: Exclude<Layer, 'replay'>): Promise<void>;
81
+ /** Select a hunk by index (-1 clears) and focus it. */
82
+ selectHunk(index: number): Promise<void>;
83
+ /** Play the replay hunk by hunk with G6 animations; resolves when finished or stopped. */
84
+ playReplay(options?: { readonly speed?: number }): Promise<void>;
85
+ stop(): void;
86
+ setTheme(theme: Theme): Promise<void>;
87
+ destroy(): void;
88
+ }
89
+
90
+ const wait = (ms: number): Promise<void> =>
91
+ new Promise((resolve) => {
92
+ setTimeout(resolve, Math.max(0, ms));
93
+ });
94
+
95
+ /**
96
+ * Create a G6 graph in `container` that renders `scene`.
97
+ *
98
+ * The graph uses no G6 theme (`theme: false`): kgdiff's own encodings, a background plugin in
99
+ * `theme.canvas.background`, the `kgdiff-node` node type and a private hover state. Each view
100
+ * change resolves one frame and sends G6 only the difference to the previous one (`addData`,
101
+ * `updateData` with just the changed style keys, `removeData`) before `graph.draw()`; G6
102
+ * animates it over `theme.motion.base` (layers, selection, themes) or the timeline (replay).
103
+ * Calls made while a draw is still running are merged into one update (latest wins). Elements
104
+ * the caller adds to `graph` are left alone; do not `setData` / `clear` the graph yourself.
105
+ *
106
+ * - `options.g6` is merged over the generated options: `behaviors` / `plugins` / `transforms`
107
+ * are appended, plain objects merge recursively, other values replace. `animation: false`
108
+ * disables every animation (replay still keeps its pace).
109
+ * - Interaction (`interactive`, default true): zoom-canvas, drag-canvas, hover (stroke + label
110
+ * like the native viewer), click a changed element → select its hunk, click an unchanged node
111
+ * → select it, click the canvas → clear.
112
+ * - Level of detail follows the camera: crossing a label / badge / arrow threshold re-maps.
113
+ * - `selectHunk(i)` spotlights the hunk and pans to its nodes; in the replay layer it seeks to the
114
+ * end of the hunk's window. `playReplay({ speed })` (default 1×) resolves every window (every
115
+ * chapter in chapter mode) at the end of its spotlight, mid-transform, settle start and end,
116
+ * and lets G6 animate between them; the camera pans to units less than 70% in view. It
117
+ * resolves when finished or `stop()`ped; `setLayer` / `selectHunk` stop it.
118
+ *
119
+ * @throws KgdiffError(`invalid-input`) for invalid arguments (also from the controller's methods,
120
+ * which reject at once after `destroy()`; calls still pending at `destroy()` resolve).
121
+ */
122
+ export async function createDiffGraph(
123
+ container: HTMLElement | string,
124
+ scene: Scene,
125
+ options: CreateDiffGraphOptions = {},
126
+ ): Promise<DiffGraphController> {
127
+ registerKgdiffNode();
128
+ const controller = new DiffGraphControllerImpl(scene, options, {
129
+ createGraph: (graphOptions) => new Graph(graphOptions),
130
+ wait,
131
+ deadline: wait,
132
+ });
133
+ try {
134
+ await controller.start(container);
135
+ } catch (error) {
136
+ controller.destroy();
137
+ throw error;
138
+ }
139
+ return controller;
140
+ }
package/src/locator.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Which node is at a point? Glyphs only carry endpoint coordinates, so glyph edges are attached
3
+ * to the node whose disc contains the endpoint. A uniform grid keeps each query O(1) on average
4
+ * (O(N) to build, done lazily once per frame and only when the frame has glyphs).
5
+ */
6
+ import type { NodeVisual } from '@kgdiff/scene';
7
+
8
+ export class NodeLocator {
9
+ private readonly cells = new Map<string, number[]>();
10
+ private readonly size: number;
11
+ private readonly reach: number;
12
+
13
+ /**
14
+ * @param nodes frame nodes (positions and radii, world units)
15
+ * @param accept which node indices may be returned (e.g. visible, present in the output)
16
+ */
17
+ constructor(
18
+ private readonly nodes: readonly NodeVisual[],
19
+ accept: (index: number) => boolean,
20
+ ) {
21
+ let maxR = 0;
22
+ const members: number[] = [];
23
+ for (let i = 0; i < nodes.length; i++) {
24
+ const n = nodes[i] as NodeVisual;
25
+ if (!accept(i) || !Number.isFinite(n.x) || !Number.isFinite(n.y)) continue;
26
+ members.push(i);
27
+ if (n.r > maxR) maxR = n.r;
28
+ }
29
+ this.reach = maxR;
30
+ this.size = Math.max(16, 2 * maxR);
31
+ for (const i of members) {
32
+ const n = nodes[i] as NodeVisual;
33
+ const key = this.key(Math.floor(n.x / this.size), Math.floor(n.y / this.size));
34
+ const cell = this.cells.get(key);
35
+ if (cell === undefined) this.cells.set(key, [i]);
36
+ else cell.push(i);
37
+ }
38
+ }
39
+
40
+ private key(ix: number, iy: number): string {
41
+ return `${ix}:${iy}`;
42
+ }
43
+
44
+ /**
45
+ * Index of the accepted node nearest to (x, y) among those within `r + slack` of it, or -1.
46
+ * Ties go to the lower index, so results are deterministic.
47
+ */
48
+ nearest(x: number, y: number, slack: number): number {
49
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return -1;
50
+ const ix = Math.floor(x / this.size);
51
+ const iy = Math.floor(y / this.size);
52
+ const rings = Math.max(1, Math.ceil((this.reach + Math.max(0, slack)) / this.size));
53
+ let best = -1;
54
+ let bestD = Number.POSITIVE_INFINITY;
55
+ for (let dx = -rings; dx <= rings; dx++) {
56
+ for (let dy = -rings; dy <= rings; dy++) {
57
+ const cell = this.cells.get(this.key(ix + dx, iy + dy));
58
+ if (cell === undefined) continue;
59
+ for (const i of cell) {
60
+ const n = this.nodes[i] as NodeVisual;
61
+ const d = Math.hypot(n.x - x, n.y - y);
62
+ if (d > Math.max(0, n.r) + slack) continue;
63
+ if (d < bestD || (d === bestD && i < best)) {
64
+ best = i;
65
+ bestD = d;
66
+ }
67
+ }
68
+ }
69
+ }
70
+ return best;
71
+ }
72
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Shared state and helpers of one `mapFrame` call (see map.ts): the element metadata type, LOD
3
+ * and id bookkeeping, and label text resolution.
4
+ */
5
+ import type { EdgeData, NodeData } from '@antv/g6';
6
+ import type { FrameVisuals, LabelVisual, Scene, Theme } from '@kgdiff/scene';
7
+ import { finite, unit } from './geometry.js';
8
+ import type { NodeLocator } from './locator.js';
9
+
10
+ export type NodeStyle = NonNullable<NodeData['style']>;
11
+ export type EdgeStyle = NonNullable<EdgeData['style']>;
12
+
13
+ /** Kind of a generated element, stored in `datum.data.kgdiff`. */
14
+ export type ElementKind = 'node' | 'edge' | 'glyph' | 'anchor' | 'collapsed';
15
+
16
+ /**
17
+ * `datum.data` of every generated element. Flat primitives only (G6 compares `data` shallowly).
18
+ * - `index`: scene node / edge index; for glyphs the index in `frame.glyphs`; for collapsed
19
+ * pills the index in `frame.collapsed`; for anchors (invisible points glyph arcs attach to
20
+ * where no node is drawn) the scene node at that point, or -1.
21
+ * - `hunk`: index into `scene.hunks` (glyphs: the hunk of their source node, else target), or -1.
22
+ * - `label`: the element's plain label (revealed on hover even when the frame hides it).
23
+ */
24
+ export type KgdiffDatumData = {
25
+ readonly kgdiff: ElementKind;
26
+ readonly index: number;
27
+ readonly hunk: number;
28
+ readonly family: string;
29
+ readonly changed: boolean;
30
+ readonly label: string;
31
+ readonly glyph: string;
32
+ };
33
+
34
+ export interface Ctx {
35
+ readonly scene: Scene;
36
+ readonly frame: FrameVisuals;
37
+ readonly theme: Theme;
38
+ readonly bg: string;
39
+ readonly prefix: string;
40
+ readonly keepHidden: boolean;
41
+ readonly zoom: number;
42
+ /** 1 when the node is part of the output. */
43
+ readonly present: Uint8Array;
44
+ /** G6 id of every node index (also those left out). */
45
+ readonly nodeIds: string[];
46
+ readonly used: Set<string>;
47
+ readonly lod: Set<number>;
48
+ /** Drawn nodes, for attaching glyph ends (built lazily). */
49
+ locator: NodeLocator | null;
50
+ /** All scene nodes, for naming glyph anchors (built lazily). */
51
+ anyLocator: NodeLocator | null;
52
+ /** Anchor key → anchor node id, shared by the glyphs of one frame. */
53
+ readonly anchors: Map<string, string>;
54
+ /** Frozen default styles, built once per frame and spread into every element's style. */
55
+ nodeBase: Readonly<NodeStyle> | null;
56
+ edgeBase: Readonly<EdgeStyle> | null;
57
+ }
58
+
59
+ /** LOD: visible from `minZoom` on; a missing / NaN threshold shows it (native renderer rule). */
60
+ export function shown(minZoom: number, zoom: number): boolean {
61
+ return !(zoom < minZoom);
62
+ }
63
+
64
+ export function noteLod(ctx: Ctx, minZoom: number): void {
65
+ if (minZoom > 0 && Number.isFinite(minZoom)) ctx.lod.add(minZoom);
66
+ }
67
+
68
+ /** A unique id: `base`, else `base#2`, `base#3`… (deterministic in output order). */
69
+ export function claim(ctx: Ctx, base: string): string {
70
+ let id = base;
71
+ for (let k = 2; ctx.used.has(id); k++) id = `${base}#${k}`;
72
+ ctx.used.add(id);
73
+ return id;
74
+ }
75
+
76
+ export interface Text {
77
+ readonly text: string;
78
+ readonly show: boolean;
79
+ readonly fill: string;
80
+ readonly size: number;
81
+ readonly weight: number;
82
+ /** Absolute (owner alpha × label alpha). */
83
+ readonly opacity: number;
84
+ readonly strike: boolean;
85
+ readonly offset: number;
86
+ }
87
+
88
+ export function text(
89
+ l: LabelVisual | null,
90
+ ownerAlpha: number,
91
+ ctx: Ctx,
92
+ fallbackSize: number,
93
+ fallbackColor: string,
94
+ ): Text {
95
+ if (l === null) {
96
+ return {
97
+ text: '',
98
+ show: false,
99
+ fill: fallbackColor,
100
+ size: fallbackSize,
101
+ weight: ctx.theme.font.weightRegular,
102
+ opacity: 0,
103
+ strike: false,
104
+ offset: 0,
105
+ };
106
+ }
107
+ noteLod(ctx, l.minZoom);
108
+ const opacity = unit(ownerAlpha * l.alpha);
109
+ return {
110
+ text: l.text,
111
+ show: l.text.length > 0 && opacity > 0 && shown(l.minZoom, ctx.zoom),
112
+ fill: l.color,
113
+ size: finite(l.size, fallbackSize),
114
+ weight: finite(l.weight, ctx.theme.font.weightRegular),
115
+ opacity,
116
+ strike: l.strike,
117
+ offset: finite(l.offset, 0),
118
+ };
119
+ }