@kgdiff/renderer 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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -2
  3. package/dist/atlas.d.ts +55 -0
  4. package/dist/atlas.d.ts.map +1 -0
  5. package/dist/atlas.js +132 -0
  6. package/dist/atlas.js.map +1 -0
  7. package/dist/camera.d.ts +116 -0
  8. package/dist/camera.d.ts.map +1 -0
  9. package/dist/camera.js +368 -0
  10. package/dist/camera.js.map +1 -0
  11. package/dist/color.d.ts +12 -0
  12. package/dist/color.d.ts.map +1 -0
  13. package/dist/color.js +51 -0
  14. package/dist/color.js.map +1 -0
  15. package/dist/ctx.d.ts +57 -0
  16. package/dist/ctx.d.ts.map +1 -0
  17. package/dist/ctx.js +28 -0
  18. package/dist/ctx.js.map +1 -0
  19. package/dist/draw/background.d.ts +33 -0
  20. package/dist/draw/background.d.ts.map +1 -0
  21. package/dist/draw/background.js +105 -0
  22. package/dist/draw/background.js.map +1 -0
  23. package/dist/draw/badges.d.ts +26 -0
  24. package/dist/draw/badges.d.ts.map +1 -0
  25. package/dist/draw/badges.js +82 -0
  26. package/dist/draw/badges.js.map +1 -0
  27. package/dist/draw/collapsed.d.ts +15 -0
  28. package/dist/draw/collapsed.d.ts.map +1 -0
  29. package/dist/draw/collapsed.js +67 -0
  30. package/dist/draw/collapsed.js.map +1 -0
  31. package/dist/draw/edges.d.ts +14 -0
  32. package/dist/draw/edges.d.ts.map +1 -0
  33. package/dist/draw/edges.js +115 -0
  34. package/dist/draw/edges.js.map +1 -0
  35. package/dist/draw/glyphs.d.ts +31 -0
  36. package/dist/draw/glyphs.d.ts.map +1 -0
  37. package/dist/draw/glyphs.js +114 -0
  38. package/dist/draw/glyphs.js.map +1 -0
  39. package/dist/draw/halos.d.ts +19 -0
  40. package/dist/draw/halos.d.ts.map +1 -0
  41. package/dist/draw/halos.js +91 -0
  42. package/dist/draw/halos.js.map +1 -0
  43. package/dist/draw/labels.d.ts +55 -0
  44. package/dist/draw/labels.d.ts.map +1 -0
  45. package/dist/draw/labels.js +300 -0
  46. package/dist/draw/labels.js.map +1 -0
  47. package/dist/draw/link.d.ts +25 -0
  48. package/dist/draw/link.d.ts.map +1 -0
  49. package/dist/draw/link.js +122 -0
  50. package/dist/draw/link.js.map +1 -0
  51. package/dist/draw/nodes.d.ts +13 -0
  52. package/dist/draw/nodes.d.ts.map +1 -0
  53. package/dist/draw/nodes.js +165 -0
  54. package/dist/draw/nodes.js.map +1 -0
  55. package/dist/draw/shapes.d.ts +7 -0
  56. package/dist/draw/shapes.d.ts.map +1 -0
  57. package/dist/draw/shapes.js +26 -0
  58. package/dist/draw/shapes.js.map +1 -0
  59. package/dist/draw/view.d.ts +36 -0
  60. package/dist/draw/view.d.ts.map +1 -0
  61. package/dist/draw/view.js +16 -0
  62. package/dist/draw/view.js.map +1 -0
  63. package/dist/frame-cache.d.ts +103 -0
  64. package/dist/frame-cache.d.ts.map +1 -0
  65. package/dist/frame-cache.js +349 -0
  66. package/dist/frame-cache.js.map +1 -0
  67. package/dist/index.d.ts +162 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +19 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/input.d.ts +81 -0
  72. package/dist/input.d.ts.map +1 -0
  73. package/dist/input.js +329 -0
  74. package/dist/input.js.map +1 -0
  75. package/dist/labels.d.ts +103 -0
  76. package/dist/labels.d.ts.map +1 -0
  77. package/dist/labels.js +203 -0
  78. package/dist/labels.js.map +1 -0
  79. package/dist/math.d.ts +33 -0
  80. package/dist/math.d.ts.map +1 -0
  81. package/dist/math.js +138 -0
  82. package/dist/math.js.map +1 -0
  83. package/dist/minimap.d.ts +56 -0
  84. package/dist/minimap.d.ts.map +1 -0
  85. package/dist/minimap.js +501 -0
  86. package/dist/minimap.js.map +1 -0
  87. package/dist/paint-stats.d.ts +17 -0
  88. package/dist/paint-stats.d.ts.map +1 -0
  89. package/dist/paint-stats.js +23 -0
  90. package/dist/paint-stats.js.map +1 -0
  91. package/dist/paint.d.ts +64 -0
  92. package/dist/paint.d.ts.map +1 -0
  93. package/dist/paint.js +197 -0
  94. package/dist/paint.js.map +1 -0
  95. package/dist/pick.d.ts +26 -0
  96. package/dist/pick.d.ts.map +1 -0
  97. package/dist/pick.js +125 -0
  98. package/dist/pick.js.map +1 -0
  99. package/dist/renderer.d.ts +28 -0
  100. package/dist/renderer.d.ts.map +1 -0
  101. package/dist/renderer.js +470 -0
  102. package/dist/renderer.js.map +1 -0
  103. package/dist/smooth-zoom.d.ts +21 -0
  104. package/dist/smooth-zoom.d.ts.map +1 -0
  105. package/dist/smooth-zoom.js +52 -0
  106. package/dist/smooth-zoom.js.map +1 -0
  107. package/dist/spatial.d.ts +26 -0
  108. package/dist/spatial.d.ts.map +1 -0
  109. package/dist/spatial.js +103 -0
  110. package/dist/spatial.js.map +1 -0
  111. package/dist/text.d.ts +36 -0
  112. package/dist/text.d.ts.map +1 -0
  113. package/dist/text.js +123 -0
  114. package/dist/text.js.map +1 -0
  115. package/package.json +47 -3
  116. package/src/atlas.ts +171 -0
  117. package/src/camera.ts +482 -0
  118. package/src/color.ts +58 -0
  119. package/src/ctx.ts +102 -0
  120. package/src/draw/background.ts +139 -0
  121. package/src/draw/badges.ts +93 -0
  122. package/src/draw/collapsed.ts +86 -0
  123. package/src/draw/edges.ts +135 -0
  124. package/src/draw/glyphs.ts +168 -0
  125. package/src/draw/halos.ts +101 -0
  126. package/src/draw/labels.ts +399 -0
  127. package/src/draw/link.ts +157 -0
  128. package/src/draw/nodes.ts +184 -0
  129. package/src/draw/shapes.ts +36 -0
  130. package/src/draw/view.ts +37 -0
  131. package/src/frame-cache.ts +382 -0
  132. package/src/index.ts +161 -0
  133. package/src/input.ts +370 -0
  134. package/src/labels.ts +269 -0
  135. package/src/math.ts +185 -0
  136. package/src/minimap.ts +545 -0
  137. package/src/paint-stats.ts +38 -0
  138. package/src/paint.ts +235 -0
  139. package/src/pick.ts +173 -0
  140. package/src/renderer.ts +539 -0
  141. package/src/smooth-zoom.ts +65 -0
  142. package/src/spatial.ts +94 -0
  143. package/src/text.ts +130 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Node pass, per z level: style buckets (one path, one fill, one stroke each), then the
3
+ * level's rings and replay pulses. Hollow ghosts are filled with the canvas background so
4
+ * edges do not show through; their dashes are stretched to tile the circumference evenly.
5
+ */
6
+ import type { FrameVisuals, NodeVisual, Theme } from '@kgdiff/scene';
7
+ import { type Ctx2D, NO_DASH } from '../ctx.js';
8
+ import { type FrameCache, type NodeBucket, zLevel } from '../frame-cache.js';
9
+ import { TAU } from '../math.js';
10
+ import { CULL_MARGIN, NODE_DASH_MIN_R, NODE_MIN_R, NODE_TINY_R, type View } from './view.js';
11
+
12
+ const evenDash: number[] = [];
13
+
14
+ /** `dash` scaled so a whole number of periods fits a circle of radius `r` (no seam). */
15
+ export function evenCircleDash(dash: readonly number[], r: number, out: number[]): number[] {
16
+ let period = 0;
17
+ for (const d of dash) period += d;
18
+ if (dash.length % 2 === 1) period *= 2;
19
+ out.length = dash.length;
20
+ const circumference = TAU * r;
21
+ const count = Math.max(1, Math.round(circumference / period));
22
+ const s = period > 0 ? circumference / (count * period) : 1;
23
+ for (let i = 0; i < dash.length; i++) out[i] = dash[i]! * s;
24
+ return out;
25
+ }
26
+
27
+ function visibleAt(v: View, x: number, y: number, r: number): boolean {
28
+ return (
29
+ x + r >= v.x0 - CULL_MARGIN &&
30
+ x - r <= v.x1 + CULL_MARGIN &&
31
+ y + r >= v.y0 - CULL_MARGIN &&
32
+ y - r <= v.y1 + CULL_MARGIN
33
+ );
34
+ }
35
+
36
+ function drawBucket(
37
+ ctx: Ctx2D,
38
+ b: NodeBucket,
39
+ nodes: readonly NodeVisual[],
40
+ v: View,
41
+ bg: string,
42
+ ): void {
43
+ const { k, ox, oy } = v;
44
+ const members = b.members;
45
+ const fill = b.fill ?? bg;
46
+ ctx.globalAlpha = b.alpha;
47
+
48
+ if (b.dash !== null && b.stroke !== null) {
49
+ // Dashed outlines (ghosts) are few: draw one by one with evenly spaced dashes.
50
+ ctx.fillStyle = fill;
51
+ ctx.strokeStyle = b.stroke;
52
+ ctx.lineWidth = b.strokeWidth;
53
+ ctx.lineCap = 'butt';
54
+ for (let m = 0; m < members.length; m++) {
55
+ const n = nodes[members[m]!]!;
56
+ const x = n.x * k + ox;
57
+ const y = n.y * k + oy;
58
+ const r = Math.max(n.r * k, NODE_MIN_R);
59
+ if (!visibleAt(v, x, y, r)) {
60
+ v.stats.culledNodes++;
61
+ continue;
62
+ }
63
+ v.stats.nodes++;
64
+ ctx.beginPath();
65
+ ctx.arc(x, y, r, 0, TAU);
66
+ ctx.fill();
67
+ ctx.setLineDash(r < NODE_DASH_MIN_R ? NO_DASH : evenCircleDash(b.dash, r, evenDash));
68
+ ctx.stroke();
69
+ }
70
+ ctx.setLineDash(NO_DASH);
71
+ return;
72
+ }
73
+
74
+ let started = false;
75
+ let tiny = 0;
76
+ for (let m = 0; m < members.length; m++) {
77
+ const n = nodes[members[m]!]!;
78
+ const x = n.x * k + ox;
79
+ const y = n.y * k + oy;
80
+ const r = Math.max(n.r * k, NODE_MIN_R);
81
+ if (!visibleAt(v, x, y, r)) {
82
+ v.stats.culledNodes++;
83
+ continue;
84
+ }
85
+ v.stats.nodes++;
86
+ if (r < NODE_TINY_R) {
87
+ tiny++;
88
+ continue;
89
+ }
90
+ if (!started) {
91
+ ctx.beginPath();
92
+ started = true;
93
+ }
94
+ ctx.moveTo(x + r, y);
95
+ ctx.arc(x, y, r, 0, TAU);
96
+ }
97
+ if (started) {
98
+ ctx.fillStyle = fill;
99
+ ctx.fill();
100
+ if (b.stroke !== null) {
101
+ ctx.strokeStyle = b.stroke;
102
+ ctx.lineWidth = b.strokeWidth;
103
+ ctx.setLineDash(NO_DASH);
104
+ ctx.stroke();
105
+ }
106
+ }
107
+ if (tiny > 0) {
108
+ // Sub-pixel nodes: squares in the most visible color, no stroke.
109
+ ctx.beginPath();
110
+ for (let m = 0; m < members.length; m++) {
111
+ const n = nodes[members[m]!]!;
112
+ const r = Math.max(n.r * k, NODE_MIN_R);
113
+ if (r >= NODE_TINY_R) continue;
114
+ const x = n.x * k + ox;
115
+ const y = n.y * k + oy;
116
+ if (!visibleAt(v, x, y, r)) continue;
117
+ ctx.rect(x - r, y - r, 2 * r, 2 * r);
118
+ }
119
+ ctx.fillStyle = b.fill ?? b.stroke ?? bg;
120
+ ctx.fill();
121
+ }
122
+ }
123
+
124
+ function drawDecor(ctx: Ctx2D, n: NodeVisual, v: View, theme: Theme): void {
125
+ const x = n.x * v.k + v.ox;
126
+ const y = n.y * v.k + v.oy;
127
+ const r = Math.max(n.r * v.k, NODE_MIN_R);
128
+ const ring = n.ring;
129
+ const reach = r + (ring ? ring.gap + ring.width : 0) + 24;
130
+ if (!visibleAt(v, x, y, reach)) return;
131
+ if (ring !== null && ring.width > 0) {
132
+ const a = ring.alpha * n.alpha;
133
+ if (a >= 0.01) {
134
+ ctx.globalAlpha = a;
135
+ ctx.strokeStyle = ring.color;
136
+ ctx.lineWidth = ring.width;
137
+ ctx.setLineDash(NO_DASH);
138
+ ctx.beginPath();
139
+ ctx.arc(x, y, r + ring.gap, 0, TAU);
140
+ ctx.stroke();
141
+ }
142
+ }
143
+ const p = n.pulse;
144
+ if (p > 0 && p < 1) {
145
+ // Expanding ring that fades out (replay emphasis).
146
+ const fade = 1 - p;
147
+ const a = fade * fade * 0.85 * n.alpha;
148
+ if (a >= 0.01) {
149
+ ctx.globalAlpha = a;
150
+ ctx.strokeStyle = ring?.color ?? n.stroke ?? n.fill ?? theme.accent.base;
151
+ ctx.lineWidth = 0.75 + 1.75 * fade;
152
+ ctx.setLineDash(NO_DASH);
153
+ ctx.beginPath();
154
+ ctx.arc(x, y, r + 3 + p * (12 + r * 0.6), 0, TAU);
155
+ ctx.stroke();
156
+ }
157
+ }
158
+ }
159
+
160
+ export function drawNodes(
161
+ ctx: Ctx2D,
162
+ frame: FrameVisuals,
163
+ cache: FrameCache,
164
+ v: View,
165
+ theme: Theme,
166
+ ): void {
167
+ const nodes = frame.nodes;
168
+ const buckets = cache.nodeBuckets;
169
+ const decor = cache.decor;
170
+ const bg = theme.canvas.background;
171
+ let d = 0;
172
+ for (let i = 0; i < buckets.length; i++) {
173
+ const b = buckets[i]!;
174
+ drawBucket(ctx, b, nodes, v, bg);
175
+ const next = buckets[i + 1];
176
+ if (next !== undefined && next.z === b.z) continue;
177
+ // End of a z level: its rings and pulses go on top of it, below the next level.
178
+ while (d < decor.length && zLevel(nodes[decor[d]!]!.z) <= b.z) {
179
+ drawDecor(ctx, nodes[decor[d]!]!, v, theme);
180
+ d++;
181
+ }
182
+ }
183
+ while (d < decor.length) drawDecor(ctx, nodes[decor[d++]!]!, v, theme);
184
+ }
@@ -0,0 +1,36 @@
1
+ /** Path helpers shared by passes. */
2
+ import type { Ctx2D } from '../ctx.js';
3
+ import { ARROW_LEN, ARROW_WIDTH } from './view.js';
4
+
5
+ /** Rounded rectangle subpath (radius clamped to half the shorter side). */
6
+ export function roundRectPath(
7
+ ctx: Ctx2D,
8
+ x: number,
9
+ y: number,
10
+ w: number,
11
+ h: number,
12
+ r: number,
13
+ ): void {
14
+ const rr = Math.max(0, Math.min(r, w / 2, h / 2));
15
+ ctx.moveTo(x + rr, y);
16
+ ctx.lineTo(x + w - rr, y);
17
+ ctx.arc(x + w - rr, y + rr, rr, -Math.PI / 2, 0);
18
+ ctx.lineTo(x + w, y + h - rr);
19
+ ctx.arc(x + w - rr, y + h - rr, rr, 0, Math.PI / 2);
20
+ ctx.lineTo(x + rr, y + h);
21
+ ctx.arc(x + rr, y + h - rr, rr, Math.PI / 2, Math.PI);
22
+ ctx.lineTo(x, y + rr);
23
+ ctx.arc(x + rr, y + rr, rr, Math.PI, Math.PI * 1.5);
24
+ ctx.closePath();
25
+ }
26
+
27
+ /** Arrowhead subpath with its tip at (x, y) pointing along the unit vector (ux, uy). */
28
+ export function arrowPath(ctx: Ctx2D, x: number, y: number, ux: number, uy: number): void {
29
+ const bx = x - ux * ARROW_LEN;
30
+ const by = y - uy * ARROW_LEN;
31
+ const hw = ARROW_WIDTH / 2;
32
+ ctx.moveTo(x, y);
33
+ ctx.lineTo(bx - uy * hw, by + ux * hw);
34
+ ctx.lineTo(bx + uy * hw, by - ux * hw);
35
+ ctx.closePath();
36
+ }
@@ -0,0 +1,37 @@
1
+ /** Shared per-pass state: the world → screen mapping and the region being drawn. */
2
+ import type { PaintStats } from '../paint-stats.js';
3
+
4
+ export interface View {
5
+ /** screen = world · k + (ox, oy), css px. */
6
+ k: number;
7
+ ox: number;
8
+ oy: number;
9
+ /** Canvas size, css px. */
10
+ width: number;
11
+ height: number;
12
+ dpr: number;
13
+ /** Region of the current layer (css px); the whole canvas unless swiping. */
14
+ x0: number;
15
+ x1: number;
16
+ y0: number;
17
+ y1: number;
18
+ /** Labels may not cross x0 / x1 (swipe divider). */
19
+ strictX: boolean;
20
+ stats: PaintStats;
21
+ }
22
+
23
+ /** Elements this far (px) outside the region are still drawn (strokes, halos, glows). */
24
+ export const CULL_MARGIN = 48;
25
+ /** Nodes never shrink below this screen radius (px), so far zoom-outs keep a visible graph. */
26
+ export const NODE_MIN_R = 1;
27
+ /** Below this screen radius nodes are drawn as filled squares without stroke. */
28
+ export const NODE_TINY_R = 1.25;
29
+ /** Below this screen radius dashed node outlines are drawn solid. */
30
+ export const NODE_DASH_MIN_R = 3;
31
+ export const ARROW_LEN = 7;
32
+ export const ARROW_WIDTH = 5;
33
+ /** Gap (px) between an arrow tip and the node outline. */
34
+ export const ARROW_GAP = 1.5;
35
+ /** Light-theme halo ring: `r + 5` px, 2 px wide. */
36
+ export const HALO_RING_OFFSET = 5;
37
+ export const HALO_RING_WIDTH = 2;
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Per-frame derived data, rebuilt when `setFrame` is called (never on pan / zoom):
3
+ *
4
+ * - style buckets for edges and nodes, keyed by every stroke / fill property plus `z`, sorted
5
+ * by z. Drawing a bucket is one path + one stroke (and one fill for arrows / node bodies),
6
+ * so a 10k-edge frame with a handful of styles issues a handful of strokes;
7
+ * - lists of nodes with halos, rings / pulses (sorted by z), badges; glyphs split into
8
+ * arcs and pivots;
9
+ * - lazily: a point grid over visible nodes (picking) and content bounds.
10
+ *
11
+ * Bucket keys are numbers built from interned style ids and quantized values, so building
12
+ * allocates nothing per element in steady state.
13
+ */
14
+ import type { Rect } from '@kgdiff/core';
15
+ import type { FrameVisuals } from '@kgdiff/scene';
16
+ import { clamp } from './math.js';
17
+ import { PointGrid } from './spatial.js';
18
+
19
+ const MAX_COLORS = 2047;
20
+ const MAX_DASHES = 63;
21
+
22
+ /** Interns style values to small integer ids for bucket keys. */
23
+ export class Interner {
24
+ private colors = new Map<string, number>();
25
+ private dashIds = new WeakMap<readonly number[], number>();
26
+ private dashKeys = new Map<string, number>();
27
+
28
+ /** Called before a build: start over when the id space is exhausted (animated rgba strings). */
29
+ maintain(): void {
30
+ if (this.colors.size > MAX_COLORS - 64) this.colors.clear();
31
+ if (this.dashKeys.size > MAX_DASHES - 8) {
32
+ this.dashKeys.clear();
33
+ this.dashIds = new WeakMap();
34
+ }
35
+ }
36
+
37
+ /** 0 is reserved for null. */
38
+ color(c: string | null): number {
39
+ if (c === null) return 0;
40
+ let id = this.colors.get(c);
41
+ if (id === undefined) {
42
+ id = this.colors.size + 1;
43
+ this.colors.set(c, id);
44
+ }
45
+ return Math.min(id, MAX_COLORS);
46
+ }
47
+
48
+ /** 0 is reserved for solid (null / empty). */
49
+ dash(d: readonly number[] | null): number {
50
+ if (d === null || d.length === 0) return 0;
51
+ let id = this.dashIds.get(d);
52
+ if (id === undefined) {
53
+ const key = d.join(',');
54
+ id = this.dashKeys.get(key);
55
+ if (id === undefined) {
56
+ id = this.dashKeys.size + 1;
57
+ this.dashKeys.set(key, id);
58
+ }
59
+ this.dashIds.set(d, id);
60
+ }
61
+ return Math.min(id, MAX_DASHES);
62
+ }
63
+ }
64
+
65
+ /** Alpha is quantized to 1/64 for bucketing (invisible differences, far fewer buckets). */
66
+ export function quantizeAlpha(a: number): number {
67
+ return Number.isFinite(a) ? Math.round(clamp(a, 0, 1) * 64) : 0;
68
+ }
69
+
70
+ function quantizeZ(z: number): number {
71
+ return zLevel(z) + 512;
72
+ }
73
+
74
+ /** The integer draw level a `z` value is bucketed into (non-finite → 0). */
75
+ export function zLevel(z: number): number {
76
+ return Number.isFinite(z) ? clamp(Math.round(z), -512, 511) : 0;
77
+ }
78
+
79
+ function quantizeWidth(w: number): number {
80
+ return Number.isFinite(w) ? Math.round(clamp(w, 0, 15.75) * 4) : 0;
81
+ }
82
+
83
+ /** Dash offset reduced to one period of the pattern, in half pixels. */
84
+ function quantizeOffset(offset: number, dash: readonly number[] | null): number {
85
+ if (dash === null || dash.length === 0 || !Number.isFinite(offset)) return 0;
86
+ let period = 0;
87
+ for (const d of dash) period += d;
88
+ if (dash.length % 2 === 1) period *= 2;
89
+ if (!(period > 0)) return 0;
90
+ const o = ((offset % period) + period) % period;
91
+ return clamp(Math.round(o * 2), 0, 511);
92
+ }
93
+
94
+ export interface EdgeBucket {
95
+ key: number;
96
+ seq: number;
97
+ z: number;
98
+ color: string;
99
+ width: number;
100
+ dash: readonly number[] | null;
101
+ dashOffset: number;
102
+ alpha: number;
103
+ readonly members: number[];
104
+ }
105
+
106
+ export interface NodeBucket {
107
+ key: number;
108
+ seq: number;
109
+ z: number;
110
+ /** null = hollow ghost (filled with the canvas background). */
111
+ fill: string | null;
112
+ stroke: string | null;
113
+ strokeWidth: number;
114
+ dash: readonly number[] | null;
115
+ alpha: number;
116
+ readonly members: number[];
117
+ }
118
+
119
+ function bySeq(a: { z: number; seq: number }, b: { z: number; seq: number }): number {
120
+ return a.z - b.z || a.seq - b.seq;
121
+ }
122
+
123
+ export class FrameCache {
124
+ frame: FrameVisuals | null = null;
125
+ version = -1;
126
+ /** Sorted by z, then first appearance. */
127
+ readonly edgeBuckets: EdgeBucket[] = [];
128
+ readonly nodeBuckets: NodeBucket[] = [];
129
+ /** Nodes with a ring or an active pulse, sorted by (z, index). */
130
+ readonly decor: number[] = [];
131
+ readonly halos: number[] = [];
132
+ readonly badges: number[] = [];
133
+ readonly glyphArcs: number[] = [];
134
+ readonly pivots: number[] = [];
135
+ /** Largest visible node radius (world). */
136
+ maxR = 0;
137
+ /** Visible node / edge counts. */
138
+ nodeCount = 0;
139
+ edgeCount = 0;
140
+ /**
141
+ * Collapsed pills shown by the last paint of this frame (1) or hidden by label collision (0).
142
+ * Valid while `pillsVersion === version`; picking ignores hidden pills.
143
+ */
144
+ pillShown = new Uint8Array(0);
145
+ pillsVersion = -1;
146
+
147
+ private readonly edgeMap = new Map<number, EdgeBucket>();
148
+ private readonly nodeMap = new Map<number, NodeBucket>();
149
+ private readonly edgePool: EdgeBucket[] = [];
150
+ private readonly nodePool: NodeBucket[] = [];
151
+ private gridVersion = -1;
152
+ private readonly grid = new PointGrid();
153
+ private xs = new Float64Array(0);
154
+ private ys = new Float64Array(0);
155
+ private rs = new Float64Array(0);
156
+ private ids = new Int32Array(0);
157
+ private boundsVersion = -1;
158
+ private boundsValue: Rect | null = null;
159
+
160
+ /** Rebuild when `frame` or `version` changed. */
161
+ sync(frame: FrameVisuals, version: number, interner: Interner): void {
162
+ if (frame === this.frame && version === this.version) return;
163
+ this.frame = frame;
164
+ this.version = version;
165
+ this.build(frame, interner);
166
+ }
167
+
168
+ private build(frame: FrameVisuals, interner: Interner): void {
169
+ interner.maintain();
170
+ this.edgeMap.clear();
171
+ this.nodeMap.clear();
172
+ this.edgeBuckets.length = 0;
173
+ this.nodeBuckets.length = 0;
174
+ this.decor.length = 0;
175
+ this.halos.length = 0;
176
+ this.badges.length = 0;
177
+ this.glyphArcs.length = 0;
178
+ this.pivots.length = 0;
179
+ this.maxR = 0;
180
+ this.nodeCount = 0;
181
+ this.edgeCount = 0;
182
+
183
+ const edges = frame.edges;
184
+ for (let i = 0; i < edges.length; i++) {
185
+ const e = edges[i]!;
186
+ if (!e.visible) continue;
187
+ const aq = quantizeAlpha(e.alpha);
188
+ if (aq === 0 || !(e.width > 0)) continue;
189
+ if (!Number.isFinite(e.x1 + e.y1 + e.x2 + e.y2)) continue;
190
+ const zq = quantizeZ(e.z);
191
+ const wq = quantizeWidth(e.width);
192
+ const dash = e.dash !== null && e.dash.length > 0 ? e.dash : null;
193
+ const oq = quantizeOffset(e.dashOffset, dash);
194
+ const key =
195
+ ((((zq * 2048 + interner.color(e.color)) * 64 + wq) * 64 + interner.dash(dash)) * 512 +
196
+ oq) *
197
+ 65 +
198
+ aq;
199
+ let b = this.edgeMap.get(key);
200
+ if (!b) {
201
+ b = this.edgePool[this.edgeBuckets.length];
202
+ if (!b) {
203
+ b = {
204
+ key: 0,
205
+ seq: 0,
206
+ z: 0,
207
+ color: '',
208
+ width: 0,
209
+ dash: null,
210
+ dashOffset: 0,
211
+ alpha: 0,
212
+ members: [],
213
+ };
214
+ this.edgePool.push(b);
215
+ }
216
+ b.key = key;
217
+ b.seq = this.edgeBuckets.length;
218
+ b.z = zq - 512;
219
+ b.color = e.color;
220
+ b.width = wq / 4;
221
+ b.dash = dash;
222
+ b.dashOffset = oq / 2;
223
+ b.alpha = aq / 64;
224
+ b.members.length = 0;
225
+ this.edgeMap.set(key, b);
226
+ this.edgeBuckets.push(b);
227
+ }
228
+ b.members.push(i);
229
+ this.edgeCount++;
230
+ }
231
+ this.edgeBuckets.sort(bySeq);
232
+
233
+ const nodes = frame.nodes;
234
+ for (let i = 0; i < nodes.length; i++) {
235
+ const n = nodes[i]!;
236
+ if (!n.visible) continue;
237
+ const aq = quantizeAlpha(n.alpha);
238
+ if (aq === 0 || !Number.isFinite(n.x + n.y + n.r) || n.r < 0) continue;
239
+ const zq = quantizeZ(n.z);
240
+ const stroke = n.stroke !== null && n.strokeWidth > 0 ? n.stroke : null;
241
+ const dash = stroke !== null && n.dash !== null && n.dash.length > 0 ? n.dash : null;
242
+ const key =
243
+ ((((zq * 2048 + interner.color(n.fill)) * 2048 + interner.color(stroke)) * 64 +
244
+ (stroke ? quantizeWidth(n.strokeWidth) : 0)) *
245
+ 64 +
246
+ interner.dash(dash)) *
247
+ 65 +
248
+ aq;
249
+ let b = this.nodeMap.get(key);
250
+ if (!b) {
251
+ b = this.nodePool[this.nodeBuckets.length];
252
+ if (!b) {
253
+ b = {
254
+ key: 0,
255
+ seq: 0,
256
+ z: 0,
257
+ fill: null,
258
+ stroke: null,
259
+ strokeWidth: 0,
260
+ dash: null,
261
+ alpha: 0,
262
+ members: [],
263
+ };
264
+ this.nodePool.push(b);
265
+ }
266
+ b.key = key;
267
+ b.seq = this.nodeBuckets.length;
268
+ b.z = zq - 512;
269
+ b.fill = n.fill;
270
+ b.stroke = stroke;
271
+ b.strokeWidth = stroke ? quantizeWidth(n.strokeWidth) / 4 : 0;
272
+ b.dash = dash;
273
+ b.alpha = aq / 64;
274
+ b.members.length = 0;
275
+ this.nodeMap.set(key, b);
276
+ this.nodeBuckets.push(b);
277
+ }
278
+ b.members.push(i);
279
+ this.nodeCount++;
280
+ if (n.r > this.maxR) this.maxR = n.r;
281
+ if (n.ring !== null || (n.pulse > 0 && n.pulse < 1)) this.decor.push(i);
282
+ if (n.halo !== null && n.halo.alpha > 0) this.halos.push(i);
283
+ if (n.badge !== null && n.badge.text.length > 0) this.badges.push(i);
284
+ }
285
+ this.nodeBuckets.sort(bySeq);
286
+ if (this.decor.length > 1) {
287
+ this.decor.sort((a, b) => quantizeZ(nodes[a]!.z) - quantizeZ(nodes[b]!.z) || a - b);
288
+ }
289
+
290
+ const glyphs = frame.glyphs;
291
+ for (let i = 0; i < glyphs.length; i++) {
292
+ const g = glyphs[i]!;
293
+ if (quantizeAlpha(g.alpha) === 0 || !Number.isFinite(g.x1 + g.y1 + g.x2 + g.y2)) continue;
294
+ if (g.kind === 'pivot') this.pivots.push(i);
295
+ else this.glyphArcs.push(i);
296
+ }
297
+ }
298
+
299
+ /** Whether collapsed pill `i` may be picked (shown by the last paint, or not yet placed). */
300
+ pillPickable(i: number): boolean {
301
+ return this.pillsVersion !== this.version || this.pillShown[i] === 1;
302
+ }
303
+
304
+ /** Spatial index over visible node centres (built on first use after a frame change). */
305
+ nodeGrid(): PointGrid {
306
+ const frame = this.frame;
307
+ if (this.gridVersion === this.version || !frame) return this.grid;
308
+ this.gridVersion = this.version;
309
+ const n = this.nodeCount;
310
+ if (this.xs.length < n) {
311
+ const cap = Math.max(n, this.xs.length * 2);
312
+ this.xs = new Float64Array(cap);
313
+ this.ys = new Float64Array(cap);
314
+ this.rs = new Float64Array(cap);
315
+ this.ids = new Int32Array(cap);
316
+ }
317
+ let j = 0;
318
+ for (const b of this.nodeBuckets) {
319
+ for (const i of b.members) {
320
+ const node = frame.nodes[i]!;
321
+ this.xs[j] = node.x;
322
+ this.ys[j] = node.y;
323
+ this.rs[j] = node.r;
324
+ this.ids[j] = i;
325
+ j++;
326
+ }
327
+ }
328
+ this.grid.build(this.xs, this.ys, this.rs, this.ids, j);
329
+ return this.grid;
330
+ }
331
+
332
+ /** Bounds of every visible node (with its radius), collapsed pill and glyph; null if empty. */
333
+ contentBounds(): Rect | null {
334
+ const frame = this.frame;
335
+ if (!frame) return null;
336
+ if (this.boundsVersion === this.version) return this.boundsValue;
337
+ this.boundsVersion = this.version;
338
+ let x0 = Number.POSITIVE_INFINITY;
339
+ let y0 = Number.POSITIVE_INFINITY;
340
+ let x1 = Number.NEGATIVE_INFINITY;
341
+ let y1 = Number.NEGATIVE_INFINITY;
342
+ for (const b of this.nodeBuckets) {
343
+ for (const i of b.members) {
344
+ const n = frame.nodes[i]!;
345
+ if (n.x - n.r < x0) x0 = n.x - n.r;
346
+ if (n.y - n.r < y0) y0 = n.y - n.r;
347
+ if (n.x + n.r > x1) x1 = n.x + n.r;
348
+ if (n.y + n.r > y1) y1 = n.y + n.r;
349
+ }
350
+ }
351
+ for (const g of frame.collapsed) {
352
+ if (!Number.isFinite(g.x + g.y)) continue;
353
+ x0 = Math.min(x0, g.x);
354
+ y0 = Math.min(y0, g.y);
355
+ x1 = Math.max(x1, g.x);
356
+ y1 = Math.max(y1, g.y);
357
+ }
358
+ for (const i of this.glyphArcs) {
359
+ const g = frame.glyphs[i]!;
360
+ x0 = Math.min(x0, g.x1, g.x2);
361
+ y0 = Math.min(y0, g.y1, g.y2);
362
+ x1 = Math.max(x1, g.x1, g.x2);
363
+ y1 = Math.max(y1, g.y1, g.y2);
364
+ }
365
+ this.boundsValue = x0 <= x1 ? { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } : null;
366
+ return this.boundsValue;
367
+ }
368
+ }
369
+
370
+ /** Union of two optional rectangles. */
371
+ export function unionBounds(a: Rect | null, b: Rect | null): Rect | null {
372
+ if (!a) return b;
373
+ if (!b) return a;
374
+ const x = Math.min(a.x, b.x);
375
+ const y = Math.min(a.y, b.y);
376
+ return {
377
+ x,
378
+ y,
379
+ width: Math.max(a.x + a.width, b.x + b.width) - x,
380
+ height: Math.max(a.y + a.height, b.y + b.height) - y,
381
+ };
382
+ }