@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
@@ -0,0 +1,272 @@
1
+ /** Scene nodes and collapsed-group pills → G6 node data (see map.ts for the conventions). */
2
+ import type { NodeBadgeStyleProps, NodeData } from '@antv/g6';
3
+ import type { CollapsedGroup, NodeVisual, SceneNode } from '@kgdiff/scene';
4
+ import { composite } from './color.js';
5
+ import {
6
+ BADGE_FONT_SIZE,
7
+ BADGE_HEIGHT,
8
+ BADGE_PAD_X,
9
+ COLLAPSED_ID_PREFIX,
10
+ GLOW_MIN_SCALE,
11
+ HALO_RING_OFFSET,
12
+ HALO_RING_WIDTH,
13
+ LABEL_MAX_WIDTH,
14
+ PILL_FONT_SIZE,
15
+ PILL_HEIGHT,
16
+ PILL_PAD_X,
17
+ TEXT_HALO_ALPHA,
18
+ TEXT_HALO_WIDTH,
19
+ Z,
20
+ } from './constants.js';
21
+ import { band, evenCircleDash, finite, unit } from './geometry.js';
22
+ import {
23
+ type Ctx,
24
+ claim,
25
+ type KgdiffDatumData,
26
+ type NodeStyle,
27
+ noteLod,
28
+ shown,
29
+ text,
30
+ } from './map-context.js';
31
+
32
+ const NO_BADGES = Object.freeze([]) as unknown as NodeBadgeStyleProps[];
33
+
34
+ /** Style keys shared by every node so updates never inherit stale values (see file comment). */
35
+ export function nodeBase(ctx: Ctx): Readonly<NodeStyle> {
36
+ if (ctx.nodeBase === null) ctx.nodeBase = Object.freeze(buildNodeBase(ctx));
37
+ return ctx.nodeBase;
38
+ }
39
+
40
+ function buildNodeBase(ctx: Ctx): NodeStyle {
41
+ const t = ctx.theme;
42
+ return {
43
+ x: 0,
44
+ y: 0,
45
+ size: 0,
46
+ zIndex: Z.node,
47
+ visibility: 'visible',
48
+ cursor: 'pointer',
49
+ pointerEvents: 'auto',
50
+ opacity: 1,
51
+ fill: ctx.bg,
52
+ fillOpacity: 1,
53
+ stroke: ctx.bg,
54
+ strokeOpacity: 1,
55
+ lineWidth: 0,
56
+ lineDash: 0,
57
+ halo: false,
58
+ haloStroke: ctx.bg,
59
+ haloStrokeOpacity: 0,
60
+ haloLineWidth: 0,
61
+ haloLineDash: 0,
62
+ haloGlow: t.glow,
63
+ haloRadius: 0,
64
+ ring: false,
65
+ ringStroke: ctx.bg,
66
+ ringLineWidth: 0,
67
+ ringGap: 0,
68
+ ringOpacity: 0,
69
+ pulse: 0,
70
+ pulseStroke: t.accent.base,
71
+ label: false,
72
+ labelText: '',
73
+ labelFill: t.neutral.label,
74
+ labelFontSize: t.font.size,
75
+ labelFontWeight: t.font.weightRegular,
76
+ labelFontFamily: t.font.family,
77
+ labelOpacity: 0,
78
+ labelPlacement: 'bottom',
79
+ labelOffsetX: 0,
80
+ labelOffsetY: 0,
81
+ labelTextDecorationLine: 'none',
82
+ labelTextDecorationColor: t.neutral.label,
83
+ labelStroke: ctx.bg,
84
+ labelLineWidth: TEXT_HALO_WIDTH,
85
+ labelStrokeOpacity: 0,
86
+ labelLineJoin: 'round',
87
+ labelMaxWidth: LABEL_MAX_WIDTH,
88
+ labelWordWrap: true,
89
+ labelMaxLines: 1,
90
+ labelTextOverflow: '…',
91
+ labelBackground: false,
92
+ labelBackgroundFill: ctx.bg,
93
+ labelBackgroundStroke: ctx.bg,
94
+ labelBackgroundLineWidth: 0,
95
+ labelBackgroundRadius: 0,
96
+ labelBackgroundOpacity: 1,
97
+ labelPadding: 0,
98
+ sublabel: false,
99
+ sublabelText: '',
100
+ sublabelFill: t.text.muted,
101
+ sublabelFontSize: t.font.size,
102
+ sublabelFontWeight: t.font.weightRegular,
103
+ sublabelFontFamily: t.font.family,
104
+ sublabelOpacity: 0,
105
+ sublabelOffsetY: 0,
106
+ sublabelTextDecorationLine: 'none',
107
+ sublabelStroke: ctx.bg,
108
+ sublabelStrokeOpacity: 0,
109
+ badge: false,
110
+ badges: NO_BADGES,
111
+ };
112
+ }
113
+
114
+ function badgeOf(v: NodeVisual, r: number, ctx: Ctx): NodeBadgeStyleProps[] {
115
+ const b = v.badge;
116
+ if (b === null) return NO_BADGES;
117
+ noteLod(ctx, b.minZoom);
118
+ if (b.text.length === 0 || !shown(b.minZoom, ctx.zoom)) return NO_BADGES;
119
+ // The native pill sits at (x + 0.7·R − 2, y − 0.7·R − 15 + 2), R = outermost outline (ring
120
+ // included). G6 anchors 'right-top' badges with their text's top-left at the key's corner.
121
+ const ring = v.ring;
122
+ const outer = r + (ring !== null ? ring.gap + ring.width / 2 : 0);
123
+ const padY = (BADGE_HEIGHT - BADGE_FONT_SIZE * 1.2) / 2;
124
+ return [
125
+ {
126
+ text: b.text,
127
+ placement: 'right-top',
128
+ offsetX: 0.7 * outer - 2 + BADGE_PAD_X - r,
129
+ offsetY: r - 0.7 * outer - BADGE_HEIGHT + 2 + padY,
130
+ fill: b.color,
131
+ fontSize: BADGE_FONT_SIZE,
132
+ fontWeight: 600,
133
+ fontFamily: ctx.theme.font.mono,
134
+ // Translucent tints are composited over the canvas, like the native opaque underlay.
135
+ backgroundFill: composite(b.background, ctx.bg),
136
+ backgroundOpacity: 1,
137
+ padding: [padY, BADGE_PAD_X, padY, BADGE_PAD_X],
138
+ },
139
+ ];
140
+ }
141
+
142
+ export function mapNode(sn: SceneNode, v: NodeVisual, ctx: Ctx): NodeData | null {
143
+ if (!v.visible && !ctx.keepHidden) return null;
144
+ const t = ctx.theme;
145
+ const alpha = unit(v.alpha);
146
+ const r = Math.max(0, finite(v.r, 0));
147
+ const hasStroke = v.stroke !== null && v.strokeWidth > 0;
148
+ const ring = v.ring !== null && v.ring.width > 0 ? v.ring : null;
149
+ const halo = v.halo;
150
+ const label = text(v.label, alpha, ctx, t.font.size, t.neutral.label);
151
+ const sub = text(v.secondaryLabel, alpha, ctx, t.font.size, t.text.muted);
152
+ const badges = badgeOf(v, r, ctx);
153
+ const changed = v.family !== 'neutral';
154
+ const pulse = v.pulse > 0 && v.pulse < 1 ? v.pulse : 0;
155
+ const style: NodeStyle = {
156
+ ...nodeBase(ctx),
157
+ x: finite(v.x, 0),
158
+ y: finite(v.y, 0),
159
+ size: 2 * r,
160
+ zIndex: band(Z.node, v.z, Z.span),
161
+ visibility: v.visible ? 'visible' : 'hidden',
162
+ opacity: alpha,
163
+ // Ghosts (fill null) are filled with the canvas so edges do not show through.
164
+ fill: v.fill ?? ctx.bg,
165
+ stroke: v.stroke ?? v.fill ?? ctx.bg,
166
+ lineWidth: hasStroke ? v.strokeWidth : 0,
167
+ lineDash: hasStroke && v.dash !== null && v.dash.length > 0 ? evenCircleDash(v.dash, r) : 0,
168
+ halo: halo !== null,
169
+ haloStroke: halo?.color ?? ctx.bg,
170
+ haloStrokeOpacity: halo !== null ? unit(halo.alpha) : 0,
171
+ // Plain G6 circles draw the halo as a band centred on the outline: cover r → glow radius
172
+ // (dark) or the offset ring (light). kgdiff-node replaces it with the real glow / ring.
173
+ haloLineWidth:
174
+ halo === null
175
+ ? 0
176
+ : t.glow
177
+ ? 2 * Math.max(0, Math.max(halo.radius, GLOW_MIN_SCALE * r) - r)
178
+ : 2 * (HALO_RING_OFFSET + HALO_RING_WIDTH),
179
+ haloRadius: halo !== null ? Math.max(0, finite(halo.radius, r)) : 0,
180
+ ring: ring !== null,
181
+ ringStroke: ring?.color ?? ctx.bg,
182
+ ringLineWidth: ring?.width ?? 0,
183
+ ringGap: ring?.gap ?? 0,
184
+ ringOpacity: ring !== null ? unit(ring.alpha) : 0,
185
+ pulse,
186
+ pulseStroke: ring?.color ?? v.stroke ?? v.fill ?? t.accent.base,
187
+ label: label.show,
188
+ labelText: label.text,
189
+ labelFill: label.fill,
190
+ labelFontSize: label.size,
191
+ labelFontWeight: label.weight,
192
+ labelOpacity: label.opacity,
193
+ labelOffsetY: label.offset,
194
+ labelTextDecorationLine: label.strike ? 'line-through' : 'none',
195
+ // G strikes with the current stroke style unless told otherwise — the background halo.
196
+ labelTextDecorationColor: label.fill,
197
+ labelStrokeOpacity: TEXT_HALO_ALPHA * label.opacity,
198
+ sublabel: sub.show,
199
+ sublabelText: sub.text,
200
+ sublabelFill: sub.fill,
201
+ sublabelFontSize: sub.size,
202
+ sublabelFontWeight: sub.weight,
203
+ sublabelOpacity: sub.opacity,
204
+ sublabelOffsetY: sub.offset,
205
+ sublabelTextDecorationLine: sub.strike ? 'line-through' : 'none',
206
+ sublabelStrokeOpacity: TEXT_HALO_ALPHA * sub.opacity,
207
+ badge: badges.length > 0,
208
+ badges,
209
+ };
210
+ const data: KgdiffDatumData = {
211
+ kgdiff: 'node',
212
+ index: sn.index,
213
+ hunk: sn.hunk,
214
+ family: v.family,
215
+ changed,
216
+ label: label.text.length > 0 ? label.text : sn.label,
217
+ glyph: '',
218
+ };
219
+ return { id: ctx.nodeIds[sn.index] as string, style, data };
220
+ }
221
+
222
+ // ─── Collapsed groups ───────────────────────────────────────────────────────
223
+
224
+ function pillText(g: CollapsedGroup): string {
225
+ return typeof g.label === 'string' && g.label.length > 0
226
+ ? g.label
227
+ : `+${g.count.toLocaleString('en-US')}`;
228
+ }
229
+
230
+ /** “+38 unchanged” pill: an invisible point node whose centred label has a pill background. */
231
+ export function mapCollapsed(g: CollapsedGroup, i: number, ctx: Ctx): NodeData | null {
232
+ if (!(g.count > 0) || !Number.isFinite(g.x) || !Number.isFinite(g.y)) return null;
233
+ const t = ctx.theme;
234
+ const padY = (PILL_HEIGHT - PILL_FONT_SIZE * 1.2) / 2;
235
+ const label = pillText(g);
236
+ const style: NodeStyle = {
237
+ ...nodeBase(ctx),
238
+ x: g.x,
239
+ y: g.y,
240
+ size: 1,
241
+ zIndex: Z.collapsed,
242
+ cursor: 'default',
243
+ fill: 'transparent',
244
+ stroke: 'transparent',
245
+ label: true,
246
+ labelText: label,
247
+ labelFill: t.text.secondary,
248
+ labelFontSize: PILL_FONT_SIZE,
249
+ labelFontWeight: 500,
250
+ labelOpacity: 1,
251
+ labelPlacement: 'center',
252
+ labelLineWidth: 0,
253
+ labelWordWrap: false,
254
+ labelBackground: true,
255
+ labelBackgroundFill: t.surface.raised,
256
+ labelBackgroundStroke: t.surface.borderStrong,
257
+ labelBackgroundLineWidth: 1,
258
+ labelBackgroundRadius: PILL_HEIGHT / 2,
259
+ labelBackgroundOpacity: 1,
260
+ labelPadding: [padY, PILL_PAD_X, padY, PILL_PAD_X],
261
+ };
262
+ const data: KgdiffDatumData = {
263
+ kgdiff: 'collapsed',
264
+ index: i,
265
+ hunk: -1,
266
+ family: 'neutral',
267
+ changed: false,
268
+ label,
269
+ glyph: '',
270
+ };
271
+ return { id: claim(ctx, `${ctx.prefix}${COLLAPSED_ID_PREFIX}${g.id}`), style, data };
272
+ }
package/src/map.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Resolved frame → G6 v5 `GraphData`. Pure: no G6 runtime, no DOM, no shared mutable state.
3
+ *
4
+ * Every element always carries the full set of style keys it can ever use (switched off with
5
+ * `false` / 0 / ''), because G6's `setData` / `updateData` *merge* an update into the previous
6
+ * style: a key that is merely omitted would keep its old value. `datum.data` holds flat
7
+ * primitives only, so G6's shallow equality check can skip unchanged elements.
8
+ *
9
+ * Sizes the scene gives in screen px are emitted as world units (exact at zoom 1); level of
10
+ * detail (`minZoom`, `arrowMinZoom`) is applied for the zoom passed in `zoom`.
11
+ */
12
+ import type { EdgeData, GraphData, NodeData } from '@antv/g6';
13
+ import { KgdiffError } from '@kgdiff/core';
14
+ import type {
15
+ EdgeVisual,
16
+ FrameVisuals,
17
+ NodeVisual,
18
+ Scene,
19
+ SceneEdge,
20
+ SceneNode,
21
+ } from '@kgdiff/scene';
22
+ import { type Ctx, claim, type KgdiffDatumData } from './map-context.js';
23
+ import { mapEdge, mapGlyph } from './map-edge.js';
24
+ import { mapCollapsed, mapNode } from './map-node.js';
25
+
26
+ export type { ElementKind, KgdiffDatumData } from './map-context.js';
27
+
28
+ export interface MapOptions {
29
+ readonly idPrefix: string;
30
+ readonly keepHidden: boolean;
31
+ /** Camera zoom the level of detail is evaluated at. */
32
+ readonly zoom: number;
33
+ }
34
+
35
+ export interface MappedFrame {
36
+ readonly data: { nodes: NodeData[]; edges: EdgeData[] };
37
+ /**
38
+ * Distinct positive LOD thresholds present in the frame (sorted): the zooms at which the
39
+ * mapping would change. Crossing one calls for a re-map.
40
+ */
41
+ readonly lod: readonly number[];
42
+ }
43
+
44
+ // ─── Frame ──────────────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Map a resolved frame. Output order: scene nodes (by index), collapsed pills, then glyph anchors
48
+ * and pivot dots; scene edges (by index), then glyph arcs (frame order). O(N + E + G), plus O(N)
49
+ * grids when the frame has glyphs.
50
+ *
51
+ * @throws KgdiffError(`invalid-input`) when the frame was not resolved for `scene`.
52
+ */
53
+ export function mapFrame(scene: Scene, frame: FrameVisuals, options: MapOptions): MappedFrame {
54
+ if (frame.nodes.length !== scene.nodes.length || frame.edges.length !== scene.edges.length) {
55
+ throw new KgdiffError(
56
+ 'invalid-input',
57
+ `The frame does not belong to this scene: it has ${frame.nodes.length} nodes / ${frame.edges.length} edges, the scene ${scene.nodes.length} / ${scene.edges.length}. Resolve it with createVisualResolver(scene).`,
58
+ );
59
+ }
60
+ if (typeof options.idPrefix !== 'string') {
61
+ throw new KgdiffError('invalid-input', 'idPrefix must be a string.');
62
+ }
63
+ const N = scene.nodes.length;
64
+ const ctx: Ctx = {
65
+ scene,
66
+ frame,
67
+ theme: scene.theme,
68
+ bg: scene.theme.canvas.background,
69
+ prefix: options.idPrefix,
70
+ keepHidden: options.keepHidden,
71
+ zoom: options.zoom,
72
+ present: new Uint8Array(N),
73
+ nodeIds: new Array<string>(N),
74
+ used: new Set<string>(),
75
+ lod: new Set<number>(),
76
+ locator: null,
77
+ anyLocator: null,
78
+ anchors: new Map(),
79
+ nodeBase: null,
80
+ edgeBase: null,
81
+ };
82
+ const nodes: NodeData[] = [];
83
+ const edges: EdgeData[] = [];
84
+ for (let i = 0; i < N; i++) {
85
+ const sn = scene.nodes[i] as SceneNode;
86
+ const v = frame.nodes[i] as NodeVisual;
87
+ ctx.nodeIds[i] = claim(ctx, options.idPrefix + sn.id);
88
+ const datum = mapNode(sn, v, ctx);
89
+ if (datum === null) continue;
90
+ ctx.present[i] = 1;
91
+ nodes.push(datum);
92
+ }
93
+ frame.collapsed.forEach((g, i) => {
94
+ const datum = mapCollapsed(g, i, ctx);
95
+ if (datum !== null) nodes.push(datum);
96
+ });
97
+ for (let j = 0; j < scene.edges.length; j++) {
98
+ const datum = mapEdge(scene.edges[j] as SceneEdge, frame.edges[j] as EdgeVisual, ctx);
99
+ if (datum !== null) edges.push(datum);
100
+ }
101
+ frame.glyphs.forEach((g, i) => {
102
+ const datum = mapGlyph(g, i, ctx, nodes);
103
+ if (datum === null) return;
104
+ if ('edge' in datum) edges.push(datum.edge);
105
+ else nodes.push(datum.node);
106
+ });
107
+ return { data: { nodes, edges }, lod: [...ctx.lod].sort((a, b) => a - b) };
108
+ }
109
+
110
+ /** True when moving the camera from zoom `a` to `b` crosses one of the `lod` thresholds. */
111
+ export function crossesLod(lod: readonly number[], a: number, b: number): boolean {
112
+ for (const m of lod) if (a < m !== b < m) return true;
113
+ return false;
114
+ }
115
+
116
+ /** The kgdiff part of a datum's `data`, or null for elements this package did not generate. */
117
+ export function kgdiffData(data: unknown): KgdiffDatumData | null {
118
+ if (data === null || typeof data !== 'object') return null;
119
+ const d = data as Partial<KgdiffDatumData>;
120
+ return d.kgdiff === 'node' ||
121
+ d.kgdiff === 'edge' ||
122
+ d.kgdiff === 'glyph' ||
123
+ d.kgdiff === 'collapsed' ||
124
+ d.kgdiff === 'anchor'
125
+ ? (d as KgdiffDatumData)
126
+ : null;
127
+ }
128
+
129
+ /** Narrowing helper for consumers of `toG6Data`: the graph data with both arrays present. */
130
+ export type MappedGraphData = Required<Pick<GraphData, 'nodes' | 'edges'>>;
package/src/node.ts ADDED
@@ -0,0 +1,240 @@
1
+ /**
2
+ * `kgdiff-node`: G6's built-in `circle` plus what it cannot express — a concentric ring, a replay
3
+ * pulse, a struck-through old name under the label and visible strike-throughs — and a halo drawn the way
4
+ * the visual language specifies it (a soft radial glow in dark themes, a crisp offset ring in
5
+ * light ones) instead of G6's flat band.
6
+ *
7
+ * It reads the extra keys `toG6Data` emits (`ring*`, `pulse*`, `sublabel*`, `haloGlow`,
8
+ * `haloRadius`); a plain `circle` ignores them, so the same data degrades gracefully. Every
9
+ * sub-shape has a `get<Name>Style` method, which is what G6 animates during updates.
10
+ */
11
+ import {
12
+ Circle,
13
+ type CircleStyleProps,
14
+ ExtensionCategory,
15
+ Label,
16
+ type LabelStyleProps,
17
+ register,
18
+ } from '@antv/g6';
19
+ import { glowGradient } from './color.js';
20
+ import {
21
+ GLOW_MIN_SCALE,
22
+ HALO_RING_OFFSET,
23
+ HALO_RING_WIDTH,
24
+ LABEL_MAX_WIDTH,
25
+ NODE_TYPE,
26
+ TEXT_HALO_WIDTH,
27
+ } from './constants.js';
28
+
29
+ /** Style keys of kgdiff-node beyond G6's circle (all optional; see map.ts for their meaning). */
30
+ export interface KgdiffNodeStyleProps extends CircleStyleProps {
31
+ haloGlow?: boolean;
32
+ haloRadius?: number;
33
+ ring?: boolean;
34
+ ringStroke?: string;
35
+ ringLineWidth?: number;
36
+ ringGap?: number;
37
+ /** Relative to the node's opacity. */
38
+ ringOpacity?: number;
39
+ /** Replay pulse progress in (0, 1); 0 = none. */
40
+ pulse?: number;
41
+ pulseStroke?: string;
42
+ sublabel?: boolean;
43
+ sublabelText?: string;
44
+ sublabelFill?: string;
45
+ sublabelFontSize?: number;
46
+ sublabelFontWeight?: number;
47
+ sublabelFontFamily?: string;
48
+ /** Absolute. */
49
+ sublabelOpacity?: number;
50
+ sublabelOffsetY?: number;
51
+ sublabelTextDecorationLine?: 'none' | 'line-through';
52
+ sublabelStroke?: string;
53
+ sublabelStrokeOpacity?: number;
54
+ }
55
+
56
+ type Attrs = Required<CircleStyleProps>;
57
+ type Extras = Partial<Omit<KgdiffNodeStyleProps, keyof CircleStyleProps>>;
58
+ type Container = NonNullable<Parameters<Circle['render']>[1]>;
59
+ type ShapeStyle = Record<string, unknown>;
60
+
61
+ /** Keys of ours that must not leak onto the key circle. */
62
+ const OWN = /^(ring|pulse|sublabel)/;
63
+
64
+ function num(v: unknown, fallback: number): number {
65
+ return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
66
+ }
67
+
68
+ function str(v: unknown, fallback: string): string {
69
+ return typeof v === 'string' && v.length > 0 ? v : fallback;
70
+ }
71
+
72
+ export class KgdiffNode extends Circle {
73
+ private extras(attributes: Attrs): Extras {
74
+ // G6 stores every style key on the element; ours are simply not in its declared type.
75
+ return attributes as Attrs & Extras;
76
+ }
77
+
78
+ private radius(attributes: Attrs): number {
79
+ return Math.min(...this.getSize(attributes)) / 2;
80
+ }
81
+
82
+ protected override getKeyStyle(attributes: Attrs): ReturnType<Circle['getKeyStyle']> {
83
+ const style = super.getKeyStyle(attributes);
84
+ for (const key of Object.keys(style)) if (OWN.test(key)) Reflect.deleteProperty(style, key);
85
+ return style;
86
+ }
87
+
88
+ /** Dark: radial glow of radius max(haloRadius, 1.6·r). Light: 2-wide ring 5 outside r (or the ring). */
89
+ protected override getHaloStyle(attributes: Attrs): false | ShapeStyle {
90
+ if (attributes.halo === false) return false;
91
+ const a = this.extras(attributes);
92
+ const r = this.radius(attributes);
93
+ const color = str(attributes.haloStroke, '#888');
94
+ const opacity = num(attributes.opacity, 1) * num(attributes.haloStrokeOpacity, 0);
95
+ const common = { opacity, pointerEvents: 'none', zIndex: -2, lineDash: 0 };
96
+ if (a.haloGlow !== false) {
97
+ return {
98
+ ...common,
99
+ r: Math.max(num(a.haloRadius, 0), GLOW_MIN_SCALE * r),
100
+ fill: glowGradient(color),
101
+ stroke: 'none',
102
+ lineWidth: 0,
103
+ };
104
+ }
105
+ const ringOuter = a.ring === true ? r + num(a.ringGap, 0) + num(a.ringLineWidth, 0) / 2 : 0;
106
+ const base = Math.max(num(a.haloRadius, r), 0);
107
+ return {
108
+ ...common,
109
+ r: Math.max(base + HALO_RING_OFFSET, ringOuter + 1.5 + HALO_RING_WIDTH / 2),
110
+ fill: 'none',
111
+ stroke: color,
112
+ lineWidth: HALO_RING_WIDTH,
113
+ };
114
+ }
115
+
116
+ /** Concentric ring at r + gap. */
117
+ protected getRingStyle(attributes: Attrs): false | ShapeStyle {
118
+ const a = this.extras(attributes);
119
+ const width = num(a.ringLineWidth, 0);
120
+ if (a.ring !== true || width <= 0) return false;
121
+ return {
122
+ r: this.radius(attributes) + num(a.ringGap, 0),
123
+ fill: 'none',
124
+ stroke: str(a.ringStroke, '#888'),
125
+ lineWidth: width,
126
+ opacity: num(attributes.opacity, 1) * num(a.ringOpacity, 1),
127
+ pointerEvents: 'none',
128
+ zIndex: -1,
129
+ };
130
+ }
131
+
132
+ /** One expanding ring that fades out as `pulse` goes 0 → 1 (native renderer geometry). */
133
+ protected getPulseStyle(attributes: Attrs): false | ShapeStyle {
134
+ const a = this.extras(attributes);
135
+ const p = num(a.pulse, 0);
136
+ if (!(p > 0 && p < 1)) return false;
137
+ const r = this.radius(attributes);
138
+ const fade = 1 - p;
139
+ return {
140
+ r: r + 3 + p * (12 + r * 0.6),
141
+ fill: 'none',
142
+ stroke: str(a.pulseStroke, '#888'),
143
+ lineWidth: 0.75 + 1.75 * fade,
144
+ opacity: num(attributes.opacity, 1) * fade * fade * 0.85,
145
+ pointerEvents: 'none',
146
+ zIndex: -1,
147
+ };
148
+ }
149
+
150
+ /** The old name, struck through, `sublabelOffsetY` below the node (like the primary label). */
151
+ protected getSublabelStyle(attributes: Attrs): false | LabelStyleProps {
152
+ const a = this.extras(attributes);
153
+ const text = typeof a.sublabelText === 'string' ? a.sublabelText : '';
154
+ if (a.sublabel !== true || text.length === 0) return false;
155
+ const key = this.getShape('key');
156
+ const bottom = key ? key.getLocalBounds().max[1] : this.radius(attributes);
157
+ const opacity = num(a.sublabelOpacity, 1);
158
+ const fill = str(a.sublabelFill, '#888');
159
+ return {
160
+ text,
161
+ fill,
162
+ fontSize: num(a.sublabelFontSize, 11),
163
+ fontWeight: num(a.sublabelFontWeight, 400),
164
+ fontFamily: str(a.sublabelFontFamily, 'system-ui, sans-serif'),
165
+ opacity,
166
+ textAlign: 'center',
167
+ textBaseline: 'top',
168
+ // Struck by `getSubstrikeStyle`; G's own decoration ignores its color (see below).
169
+ textDecorationLine: 'none',
170
+ stroke: str(a.sublabelStroke, 'transparent'),
171
+ lineWidth: TEXT_HALO_WIDTH,
172
+ strokeOpacity: num(a.sublabelStrokeOpacity, 0),
173
+ lineJoin: 'round',
174
+ wordWrap: true,
175
+ wordWrapWidth: LABEL_MAX_WIDTH,
176
+ maxLines: 1,
177
+ textOverflow: '…',
178
+ transform: [['translate', 0, num(bottom, 0) + num(a.sublabelOffsetY, 0)]],
179
+ pointerEvents: 'none',
180
+ zIndex: 0,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * G 6.3 keeps `textDecorationColor` unparsed and then reads `.r/.g/.b` from it, so a
186
+ * `line-through` is drawn in whatever stroke style is current — the background-colored text
187
+ * halo, i.e. invisible. kgdiff-node turns G's decoration off and strikes with a line instead.
188
+ */
189
+ protected override getLabelStyle(attributes: Attrs): false | LabelStyleProps {
190
+ const style = super.getLabelStyle(attributes);
191
+ return style === false ? false : { ...style, textDecorationLine: 'none' };
192
+ }
193
+
194
+ /** A 1-wide line through the middle of a label shape's text, in node coordinates. */
195
+ private strike(shape: string, color: string, opacity: number): false | ShapeStyle {
196
+ const label = this.getShape(shape);
197
+ if (!label) return false;
198
+ // Local bounds include the text halo's stroke; G also shifts the text by half of it.
199
+ const b = label.getLocalBounds();
200
+ const inset = TEXT_HALO_WIDTH / 2;
201
+ const x1 = b.min[0] + inset;
202
+ const x2 = b.max[0] - inset;
203
+ const y = (b.min[1] + b.max[1]) / 2;
204
+ if (!(x2 > x1) || !Number.isFinite(y)) return false;
205
+ return { x1, y1: y, x2, y2: y, stroke: color, lineWidth: 1, opacity, pointerEvents: 'none' };
206
+ }
207
+
208
+ protected getStrikeStyle(attributes: Attrs): false | ShapeStyle {
209
+ if (attributes.label === false || attributes.labelTextDecorationLine !== 'line-through') {
210
+ return false;
211
+ }
212
+ const color = str(attributes.labelFill, '#888');
213
+ return this.strike('label', color, num(attributes.labelOpacity, num(attributes.opacity, 1)));
214
+ }
215
+
216
+ protected getSubstrikeStyle(attributes: Attrs): false | ShapeStyle {
217
+ const a = this.extras(attributes);
218
+ if (a.sublabel !== true || a.sublabelTextDecorationLine !== 'line-through') return false;
219
+ return this.strike('sublabel', str(a.sublabelFill, '#888'), num(a.sublabelOpacity, 1));
220
+ }
221
+
222
+ public override render(attributes = this.parsedAttributes, container: Container = this): void {
223
+ super.render(attributes, container);
224
+ if (!this.getShape('key')) return;
225
+ this.upsert('ring', 'circle', this.getRingStyle(attributes), container);
226
+ this.upsert('pulse', 'circle', this.getPulseStyle(attributes), container);
227
+ this.upsert('sublabel', Label, this.getSublabelStyle(attributes), container);
228
+ this.upsert('strike', 'line', this.getStrikeStyle(attributes), container);
229
+ this.upsert('substrike', 'line', this.getSubstrikeStyle(attributes), container);
230
+ }
231
+ }
232
+
233
+ let registered = false;
234
+
235
+ /** Register `kgdiff-node` with G6 (once per module instance). */
236
+ export function registerKgdiffNode(): void {
237
+ if (registered) return;
238
+ register(ExtensionCategory.NODE, NODE_TYPE, KgdiffNode);
239
+ registered = true;
240
+ }