@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
package/src/paint.ts ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Paints one frame (or a swipe pair) into a `Ctx2D`. Pass order (docs/visual-language.md):
3
+ * background + grid → collapsed pills → edges → glyph arcs → halos → nodes (+ rings, pulses)
4
+ * → pivots → badges → labels. Swipe draws the background once, then each side clipped to its
5
+ * half with its own label placement.
6
+ */
7
+
8
+ import type { FrameVisuals, Theme } from '@kgdiff/scene';
9
+ import { LabelAtlas } from './atlas.js';
10
+ import type { Ctx2D, SurfaceFactory } from './ctx.js';
11
+ import { drawBackground, GridTiles } from './draw/background.js';
12
+ import { BadgePass } from './draw/badges.js';
13
+ import { drawCollapsed } from './draw/collapsed.js';
14
+ import { drawEdges } from './draw/edges.js';
15
+ import { drawGlyphArcs, drawPivots } from './draw/glyphs.js';
16
+ import { drawHalos, HaloSprites } from './draw/halos.js';
17
+ import { LabelPass } from './draw/labels.js';
18
+ import { drawNodes } from './draw/nodes.js';
19
+ import type { View } from './draw/view.js';
20
+ import { type FrameCache, Interner } from './frame-cache.js';
21
+ import type { CameraState, SplitFrame } from './index.js';
22
+ import { CollisionGrid } from './labels.js';
23
+ import { clamp, snap } from './math.js';
24
+ import { createStats, type PaintStats, resetStats } from './paint-stats.js';
25
+ import { Fonts, TextMeasurer } from './text.js';
26
+
27
+ export function isSplit(frame: FrameVisuals | SplitFrame): frame is SplitFrame {
28
+ return 'before' in frame && 'after' in frame && 'split' in frame;
29
+ }
30
+
31
+ /** Divider position (css px, on the device-pixel grid) for a split fraction. */
32
+ export function splitX(split: number, width: number, dpr: number): number {
33
+ return snap(clamp(Number.isFinite(split) ? split : 0.5, 0, 1) * width, dpr);
34
+ }
35
+
36
+ /** Caches that live as long as a renderer: fonts, text metrics, sprites, atlas, tiles. */
37
+ export class Resources {
38
+ readonly fonts: Fonts;
39
+ readonly measurer: TextMeasurer;
40
+ readonly atlas: LabelAtlas | null;
41
+ readonly halos: HaloSprites;
42
+ readonly tiles: GridTiles;
43
+ readonly interner = new Interner();
44
+ private dpr = 0;
45
+ private theme: Theme;
46
+
47
+ constructor(
48
+ readonly factory: SurfaceFactory,
49
+ theme: Theme,
50
+ fallbackMeasureCtx: Ctx2D | null,
51
+ ) {
52
+ this.theme = theme;
53
+ this.fonts = new Fonts(theme);
54
+ // A private context for measuring, so measuring never disturbs drawing state.
55
+ this.measurer = new TextMeasurer(factory(1, 1)?.ctx ?? fallbackMeasureCtx);
56
+ this.atlas = new LabelAtlas(factory);
57
+ this.halos = new HaloSprites(factory);
58
+ this.tiles = new GridTiles(factory);
59
+ }
60
+
61
+ setTheme(theme: Theme): void {
62
+ if (theme === this.theme) return;
63
+ this.theme = theme;
64
+ this.fonts.setTheme(theme);
65
+ this.measurer.clear();
66
+ this.halos.clear();
67
+ this.tiles.clear();
68
+ this.atlas?.configure(this.dpr || 1, theme.canvas.background);
69
+ }
70
+
71
+ /** Called before painting: re-keys pixel-ratio dependent caches. */
72
+ prepare(dpr: number): void {
73
+ if (dpr === this.dpr) return;
74
+ this.dpr = dpr;
75
+ this.tiles.clear();
76
+ this.atlas?.configure(dpr, this.theme.canvas.background);
77
+ }
78
+
79
+ /** Web fonts finished loading: metrics and rasterized labels are stale. */
80
+ fontsChanged(): void {
81
+ this.measurer.clear();
82
+ this.atlas?.wipe();
83
+ }
84
+ }
85
+
86
+ export interface PaintTarget {
87
+ readonly ctx: Ctx2D;
88
+ /** css px */
89
+ readonly width: number;
90
+ readonly height: number;
91
+ readonly dpr: number;
92
+ }
93
+
94
+ export interface PaintCaches {
95
+ readonly main: FrameCache;
96
+ readonly before: FrameCache;
97
+ readonly after: FrameCache;
98
+ }
99
+
100
+ /** New label rasterizations per frame (≈ 3–5 ms of text rendering on mid-range hardware). */
101
+ export const LABEL_RASTER_BUDGET = 160;
102
+
103
+ export class Painter {
104
+ readonly stats: PaintStats = createStats();
105
+ /** Override for exports, which must contain every label in one paint. */
106
+ rasterBudget = LABEL_RASTER_BUDGET;
107
+ private readonly labels = new LabelPass();
108
+ private readonly badges = new BadgePass();
109
+ private readonly grid = new CollisionGrid();
110
+ private readonly view: View = {
111
+ k: 1,
112
+ ox: 0,
113
+ oy: 0,
114
+ width: 0,
115
+ height: 0,
116
+ dpr: 1,
117
+ x0: 0,
118
+ x1: 0,
119
+ y0: 0,
120
+ y1: 0,
121
+ strictX: false,
122
+ stats: this.stats,
123
+ };
124
+
125
+ constructor(private readonly res: Resources) {}
126
+
127
+ paint(
128
+ target: PaintTarget,
129
+ cam: CameraState,
130
+ frame: FrameVisuals | SplitFrame | null,
131
+ caches: PaintCaches,
132
+ version: number,
133
+ theme: Theme,
134
+ grid: boolean,
135
+ ): PaintStats {
136
+ const { ctx, width, height, dpr } = target;
137
+ const v = this.view;
138
+ resetStats(this.stats);
139
+ this.res.prepare(dpr);
140
+ this.res.atlas?.beginFrame(this.rasterBudget);
141
+ v.k = cam.k;
142
+ v.ox = width / 2 - cam.x * cam.k;
143
+ v.oy = height / 2 - cam.y * cam.k;
144
+ v.width = width;
145
+ v.height = height;
146
+ v.dpr = dpr;
147
+ v.x0 = 0;
148
+ v.x1 = width;
149
+ v.y0 = 0;
150
+ v.y1 = height;
151
+ v.strictX = false;
152
+
153
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
154
+ ctx.imageSmoothingEnabled = true;
155
+ const split = frame !== null && isSplit(frame);
156
+ const spotlight =
157
+ frame === null
158
+ ? 0
159
+ : split
160
+ ? Math.max(frame.before.spotlight, frame.after.spotlight)
161
+ : frame.spotlight;
162
+ drawBackground(ctx, v, theme, grid ? this.res.tiles : null, spotlight);
163
+ this.stats.phases.push('background');
164
+ if (frame === null) return this.stats;
165
+
166
+ if (!split) {
167
+ caches.main.sync(frame, version, this.res.interner);
168
+ this.layers(ctx, frame, caches.main, theme);
169
+ return this.stats;
170
+ }
171
+
172
+ const sx = splitX(frame.split, width, dpr);
173
+ caches.before.sync(frame.before, version, this.res.interner);
174
+ caches.after.sync(frame.after, version, this.res.interner);
175
+ if (sx > 0) {
176
+ v.x0 = 0;
177
+ v.x1 = sx;
178
+ v.strictX = true;
179
+ ctx.save();
180
+ ctx.beginPath();
181
+ ctx.rect(0, 0, sx, height);
182
+ ctx.clip();
183
+ this.layers(ctx, frame.before, caches.before, theme);
184
+ ctx.restore();
185
+ }
186
+ if (sx < width) {
187
+ v.x0 = sx;
188
+ v.x1 = width;
189
+ v.strictX = true;
190
+ ctx.save();
191
+ ctx.beginPath();
192
+ ctx.rect(sx, 0, width - sx, height);
193
+ ctx.clip();
194
+ this.layers(ctx, frame.after, caches.after, theme);
195
+ ctx.restore();
196
+ }
197
+ if (sx > 0 && sx < width) {
198
+ // 1 css px divider on whole device pixels.
199
+ const w = Math.max(1, Math.round(dpr));
200
+ ctx.globalAlpha = 1;
201
+ ctx.fillStyle = theme.surface.borderStrong;
202
+ ctx.fillRect(Math.round(sx * dpr - w / 2) / dpr, 0, w / dpr, height);
203
+ }
204
+ this.stats.phases.push('divider');
205
+ return this.stats;
206
+ }
207
+
208
+ private layers(ctx: Ctx2D, frame: FrameVisuals, cache: FrameCache, theme: Theme): void {
209
+ const v = this.view;
210
+ const res = this.res;
211
+ const phases = this.stats.phases;
212
+ // Screen-space layout first: node discs and badges are obstacles; labels and pills compete
213
+ // for the rest by priority. Drawing then follows the documented pass order.
214
+ this.labels.begin(frame, cache, v, this.grid);
215
+ this.badges.layout(frame, cache, v, res.fonts, res.measurer, this.grid);
216
+ this.labels.place(frame, cache, v, res, this.grid);
217
+ drawCollapsed(ctx, frame.collapsed, cache.pillShown, v, theme, res.fonts, res.measurer);
218
+ phases.push('collapsed');
219
+ drawEdges(ctx, frame, cache, v);
220
+ phases.push('edges');
221
+ drawGlyphArcs(ctx, frame, cache, v, theme, res.fonts);
222
+ phases.push('glyphs');
223
+ drawHalos(ctx, frame, cache, v, theme, res.halos);
224
+ phases.push('halos');
225
+ drawNodes(ctx, frame, cache, v, theme);
226
+ phases.push('nodes');
227
+ drawPivots(ctx, frame, cache, v, theme);
228
+ phases.push('pivots');
229
+ this.badges.draw(ctx, frame, theme);
230
+ phases.push('badges');
231
+ this.stats.labels += this.labels.draw(ctx, v, theme, res);
232
+ this.stats.deferredLabels = res.atlas?.deferred ?? 0;
233
+ phases.push('labels');
234
+ }
235
+ }
package/src/pick.ts ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Hit testing at a screen point, topmost first: nodes (grid lookup; a point inside a disc beats
3
+ * the tolerance ring, then higher z), then edges (distance to the segment / curve / loop, higher
4
+ * z first), then collapsed pills. Invisible and fully transparent elements, and pills hidden by
5
+ * label collision, are never picked.
6
+ */
7
+ import type { FrameVisuals } from '@kgdiff/scene';
8
+ import { pillRect } from './draw/collapsed.js';
9
+ import { isLoop } from './draw/edges.js';
10
+ import { loopCenterY, loopRadius } from './draw/link.js';
11
+ import { NODE_MIN_R } from './draw/view.js';
12
+ import type { FrameCache } from './frame-cache.js';
13
+ import type { PickResult } from './index.js';
14
+ import { distToQuadSq, distToSegmentSq } from './math.js';
15
+ import type { Fonts, TextMeasurer } from './text.js';
16
+
17
+ /** Extra reach (px) around node discs. */
18
+ export const NODE_PICK_TOLERANCE = 3;
19
+ /** Extra reach (px) beyond half an edge's width. */
20
+ export const EDGE_PICK_TOLERANCE = 4;
21
+
22
+ export interface PickView {
23
+ readonly k: number;
24
+ readonly ox: number;
25
+ readonly oy: number;
26
+ readonly dpr: number;
27
+ }
28
+
29
+ const candidates: number[] = [];
30
+ const rect = new Float64Array(4);
31
+
32
+ export function pickNode(
33
+ frame: FrameVisuals,
34
+ cache: FrameCache,
35
+ v: PickView,
36
+ sx: number,
37
+ sy: number,
38
+ ): number {
39
+ if (cache.nodeCount === 0) return -1;
40
+ const { k } = v;
41
+ const wx = (sx - v.ox) / k;
42
+ const wy = (sy - v.oy) / k;
43
+ const grid = cache.nodeGrid();
44
+ const reach = Math.max(grid.maxR, NODE_MIN_R / k) + NODE_PICK_TOLERANCE / k;
45
+ grid.query(wx - reach, wy - reach, wx + reach, wy + reach, candidates);
46
+ let best = -1;
47
+ let bestInside = false;
48
+ let bestZ = Number.NEGATIVE_INFINITY;
49
+ let bestD = Number.POSITIVE_INFINITY;
50
+ for (const i of candidates) {
51
+ const n = frame.nodes[i]!;
52
+ const r = Math.max(n.r, NODE_MIN_R / k);
53
+ const d = Math.hypot(n.x - wx, n.y - wy);
54
+ if (d > r + NODE_PICK_TOLERANCE / k) continue;
55
+ const inside = d <= r;
56
+ const better =
57
+ best < 0 ||
58
+ (inside && !bestInside) ||
59
+ (inside === bestInside &&
60
+ (n.z > bestZ || (n.z === bestZ && (inside ? i > best : d < bestD))));
61
+ if (better) {
62
+ best = i;
63
+ bestInside = inside;
64
+ bestZ = n.z;
65
+ bestD = d;
66
+ }
67
+ }
68
+ return best;
69
+ }
70
+
71
+ export function pickEdge(
72
+ frame: FrameVisuals,
73
+ cache: FrameCache,
74
+ v: PickView,
75
+ sx: number,
76
+ sy: number,
77
+ ): number {
78
+ const { k } = v;
79
+ const wx = (sx - v.ox) / k;
80
+ const wy = (sy - v.oy) / k;
81
+ let best = -1;
82
+ let bestZ = Number.NEGATIVE_INFINITY;
83
+ let bestD = Number.POSITIVE_INFINITY;
84
+ for (const b of cache.edgeBuckets) {
85
+ const tol = (b.width / 2 + EDGE_PICK_TOLERANCE) / k;
86
+ const tol2 = tol * tol;
87
+ for (const i of b.members) {
88
+ const e = frame.edges[i]!;
89
+ let d2: number;
90
+ if (isLoop(e.x1, e.y1, e.x2, e.y2)) {
91
+ const r = Math.max(e.r1 * k, NODE_MIN_R);
92
+ const rho = loopRadius(r, e.curvature) / k;
93
+ const cy = loopCenterY(e.y1 * k, r, rho * k) / k;
94
+ const d = Math.abs(Math.hypot(wx - e.x1, wy - cy) - rho);
95
+ d2 = d * d;
96
+ } else if (e.curvature === 0) {
97
+ if (
98
+ wx < Math.min(e.x1, e.x2) - tol ||
99
+ wx > Math.max(e.x1, e.x2) + tol ||
100
+ wy < Math.min(e.y1, e.y2) - tol ||
101
+ wy > Math.max(e.y1, e.y2) + tol
102
+ ) {
103
+ continue;
104
+ }
105
+ d2 = distToSegmentSq(wx, wy, e.x1, e.y1, e.x2, e.y2);
106
+ } else {
107
+ const cx = (e.x1 + e.x2) / 2 - (e.y2 - e.y1) * e.curvature;
108
+ const cy = (e.y1 + e.y2) / 2 + (e.x2 - e.x1) * e.curvature;
109
+ if (
110
+ wx < Math.min(e.x1, e.x2, cx) - tol ||
111
+ wx > Math.max(e.x1, e.x2, cx) + tol ||
112
+ wy < Math.min(e.y1, e.y2, cy) - tol ||
113
+ wy > Math.max(e.y1, e.y2, cy) + tol
114
+ ) {
115
+ continue;
116
+ }
117
+ d2 = distToQuadSq(wx, wy, e.x1, e.y1, cx, cy, e.x2, e.y2);
118
+ }
119
+ if (d2 > tol2) continue;
120
+ if (b.z > bestZ || (b.z === bestZ && d2 < bestD)) {
121
+ best = i;
122
+ bestZ = b.z;
123
+ bestD = d2;
124
+ }
125
+ }
126
+ }
127
+ return best;
128
+ }
129
+
130
+ export function pickCollapsed(
131
+ frame: FrameVisuals,
132
+ cache: FrameCache,
133
+ v: PickView,
134
+ sx: number,
135
+ sy: number,
136
+ fonts: Fonts,
137
+ measurer: TextMeasurer,
138
+ ): number {
139
+ const groups = frame.collapsed;
140
+ for (let i = groups.length - 1; i >= 0; i--) {
141
+ const g = groups[i]!;
142
+ if (!(g.count > 0) || !Number.isFinite(g.x + g.y) || !cache.pillPickable(i)) continue;
143
+ pillRect(g, v, fonts, measurer, rect);
144
+ if (
145
+ sx >= rect[0]! &&
146
+ sx <= rect[0]! + rect[2]! &&
147
+ sy >= rect[1]! &&
148
+ sy <= rect[1]! + rect[3]!
149
+ ) {
150
+ return i;
151
+ }
152
+ }
153
+ return -1;
154
+ }
155
+
156
+ /** Topmost element at screen point (sx, sy), or null. */
157
+ export function pickAt(
158
+ frame: FrameVisuals,
159
+ cache: FrameCache,
160
+ v: PickView,
161
+ sx: number,
162
+ sy: number,
163
+ fonts: Fonts,
164
+ measurer: TextMeasurer,
165
+ ): PickResult | null {
166
+ const node = pickNode(frame, cache, v, sx, sy);
167
+ if (node >= 0) return { type: 'node', index: node };
168
+ const edge = pickEdge(frame, cache, v, sx, sy);
169
+ if (edge >= 0) return { type: 'edge', index: edge };
170
+ const group = pickCollapsed(frame, cache, v, sx, sy, fonts, measurer);
171
+ if (group >= 0) return { type: 'collapsed', index: group };
172
+ return null;
173
+ }