@unbrained/pm-web 1.0.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 (150) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +107 -0
  3. package/dist/auth.js +20 -0
  4. package/dist/auth.js.map +1 -0
  5. package/dist/crypto.js +42 -0
  6. package/dist/crypto.js.map +1 -0
  7. package/dist/db.js +111 -0
  8. package/dist/db.js.map +1 -0
  9. package/dist/index.js +88 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/middleware/auth.js +16 -0
  12. package/dist/middleware/auth.js.map +1 -0
  13. package/dist/routes/admin.js +207 -0
  14. package/dist/routes/admin.js.map +1 -0
  15. package/dist/routes/auth.js +163 -0
  16. package/dist/routes/auth.js.map +1 -0
  17. package/dist/routes/github.js +354 -0
  18. package/dist/routes/github.js.map +1 -0
  19. package/dist/routes/groups.js +180 -0
  20. package/dist/routes/groups.js.map +1 -0
  21. package/dist/routes/pm.js +2446 -0
  22. package/dist/routes/pm.js.map +1 -0
  23. package/dist/routes/projects.js +151 -0
  24. package/dist/routes/projects.js.map +1 -0
  25. package/dist/routes/sharing.js +155 -0
  26. package/dist/routes/sharing.js.map +1 -0
  27. package/dist/server.js +64 -0
  28. package/dist/server.js.map +1 -0
  29. package/dist/services/pm-runner.js +190 -0
  30. package/dist/services/pm-runner.js.map +1 -0
  31. package/dist/services/sse.js +111 -0
  32. package/dist/services/sse.js.map +1 -0
  33. package/manifest.json +15 -0
  34. package/package.json +111 -0
  35. package/public/icons/icon-192.png +0 -0
  36. package/public/icons/icon-512.png +0 -0
  37. package/public/index.html +265 -0
  38. package/public/manifest.json +66 -0
  39. package/public/src/api.js +28 -0
  40. package/public/src/api.js.map +1 -0
  41. package/public/src/api.ts +29 -0
  42. package/public/src/app.js +926 -0
  43. package/public/src/app.js.map +1 -0
  44. package/public/src/app.ts +929 -0
  45. package/public/src/components/modals.js +62 -0
  46. package/public/src/components/modals.js.map +1 -0
  47. package/public/src/components/modals.ts +73 -0
  48. package/public/src/components/toast.js +10 -0
  49. package/public/src/components/toast.js.map +1 -0
  50. package/public/src/components/toast.ts +13 -0
  51. package/public/src/constants.js +30 -0
  52. package/public/src/constants.js.map +1 -0
  53. package/public/src/constants.ts +41 -0
  54. package/public/src/state.js +15 -0
  55. package/public/src/state.js.map +1 -0
  56. package/public/src/state.ts +19 -0
  57. package/public/src/types.js +5 -0
  58. package/public/src/types.js.map +1 -0
  59. package/public/src/types.ts +253 -0
  60. package/public/src/utils.js +57 -0
  61. package/public/src/utils.js.map +1 -0
  62. package/public/src/utils.ts +56 -0
  63. package/public/src/views/activity.js +47 -0
  64. package/public/src/views/activity.js.map +1 -0
  65. package/public/src/views/activity.ts +41 -0
  66. package/public/src/views/admin.js +435 -0
  67. package/public/src/views/admin.js.map +1 -0
  68. package/public/src/views/admin.ts +504 -0
  69. package/public/src/views/auth.js +81 -0
  70. package/public/src/views/auth.js.map +1 -0
  71. package/public/src/views/auth.ts +74 -0
  72. package/public/src/views/calendar.js +133 -0
  73. package/public/src/views/calendar.js.map +1 -0
  74. package/public/src/views/calendar.ts +129 -0
  75. package/public/src/views/comments-audit.js +109 -0
  76. package/public/src/views/comments-audit.js.map +1 -0
  77. package/public/src/views/comments-audit.ts +108 -0
  78. package/public/src/views/config.js +322 -0
  79. package/public/src/views/config.js.map +1 -0
  80. package/public/src/views/config.ts +344 -0
  81. package/public/src/views/context.js +98 -0
  82. package/public/src/views/context.js.map +1 -0
  83. package/public/src/views/context.ts +100 -0
  84. package/public/src/views/create.js +293 -0
  85. package/public/src/views/create.js.map +1 -0
  86. package/public/src/views/create.ts +246 -0
  87. package/public/src/views/dedupe.js +51 -0
  88. package/public/src/views/dedupe.js.map +1 -0
  89. package/public/src/views/dedupe.ts +43 -0
  90. package/public/src/views/export.js +300 -0
  91. package/public/src/views/export.js.map +1 -0
  92. package/public/src/views/export.ts +274 -0
  93. package/public/src/views/github.js +360 -0
  94. package/public/src/views/github.js.map +1 -0
  95. package/public/src/views/github.ts +308 -0
  96. package/public/src/views/graph-canvas.js +1986 -0
  97. package/public/src/views/graph-canvas.js.map +1 -0
  98. package/public/src/views/graph-canvas.ts +2218 -0
  99. package/public/src/views/graph.js +1824 -0
  100. package/public/src/views/graph.js.map +1 -0
  101. package/public/src/views/graph.ts +1891 -0
  102. package/public/src/views/groups.js +186 -0
  103. package/public/src/views/groups.js.map +1 -0
  104. package/public/src/views/groups.ts +172 -0
  105. package/public/src/views/guide.js +151 -0
  106. package/public/src/views/guide.js.map +1 -0
  107. package/public/src/views/guide.ts +162 -0
  108. package/public/src/views/health.js +105 -0
  109. package/public/src/views/health.js.map +1 -0
  110. package/public/src/views/health.ts +102 -0
  111. package/public/src/views/items.js +1306 -0
  112. package/public/src/views/items.js.map +1 -0
  113. package/public/src/views/items.ts +1196 -0
  114. package/public/src/views/normalize.js +67 -0
  115. package/public/src/views/normalize.js.map +1 -0
  116. package/public/src/views/normalize.ts +58 -0
  117. package/public/src/views/plan.js +454 -0
  118. package/public/src/views/plan.js.map +1 -0
  119. package/public/src/views/plan.ts +496 -0
  120. package/public/src/views/projects.js +204 -0
  121. package/public/src/views/projects.js.map +1 -0
  122. package/public/src/views/projects.ts +196 -0
  123. package/public/src/views/router.js +227 -0
  124. package/public/src/views/router.js.map +1 -0
  125. package/public/src/views/router.ts +188 -0
  126. package/public/src/views/search.js +103 -0
  127. package/public/src/views/search.js.map +1 -0
  128. package/public/src/views/search.ts +94 -0
  129. package/public/src/views/settings.js +272 -0
  130. package/public/src/views/settings.js.map +1 -0
  131. package/public/src/views/settings.ts +190 -0
  132. package/public/src/views/shared.js +49 -0
  133. package/public/src/views/shared.js.map +1 -0
  134. package/public/src/views/shared.ts +49 -0
  135. package/public/src/views/sharing.js +152 -0
  136. package/public/src/views/sharing.js.map +1 -0
  137. package/public/src/views/sharing.ts +139 -0
  138. package/public/src/views/stats.js +92 -0
  139. package/public/src/views/stats.js.map +1 -0
  140. package/public/src/views/stats.ts +88 -0
  141. package/public/src/views/templates.js +117 -0
  142. package/public/src/views/templates.js.map +1 -0
  143. package/public/src/views/templates.ts +113 -0
  144. package/public/src/views/validate.js +54 -0
  145. package/public/src/views/validate.js.map +1 -0
  146. package/public/src/views/validate.ts +48 -0
  147. package/public/styles.css +2231 -0
  148. package/public/sw.js +318 -0
  149. package/public/tsconfig.json +20 -0
  150. package/sql/schema.sql +105 -0
@@ -0,0 +1,1986 @@
1
+ // ═══════════════════════════════════════════════════════════════
2
+ // GRAPH CANVAS — Obsidian-quality force-directed knowledge graph
3
+ // Canvas 2D + physics simulation, no external dependencies
4
+ // Features: minimap, animated edge particles, dot grid, tooltip,
5
+ // keyboard nav, fly-to, gradient nodes, zoom HUD,
6
+ // spatial partitioning, edge bundling, hierarchical layout
7
+ // ═══════════════════════════════════════════════════════════════
8
+ // ── Palette ───────────────────────────────────────────────────
9
+ const STATUS_COLORS = {
10
+ open: '#2dd4bf',
11
+ 'in-progress': '#fb923c',
12
+ in_progress: '#fb923c',
13
+ closed: '#64748b',
14
+ blocked: '#f87171',
15
+ draft: '#94a3b8',
16
+ };
17
+ const TYPE_COLORS = {
18
+ task: '#2dd4bf',
19
+ feature: '#60a5fa',
20
+ epic: '#a78bfa',
21
+ bug: '#f87171',
22
+ milestone: '#fbbf24',
23
+ story: '#34d399',
24
+ chore: '#94a3b8',
25
+ release: '#38bdf8',
26
+ };
27
+ const TYPE_COLOR_DEFAULT = '#64748b';
28
+ const TAG_PALETTE = ['#2dd4bf', '#60a5fa', '#a78bfa', '#f87171', '#fbbf24', '#34d399', '#fb923c', '#e879f9'];
29
+ const TYPE_ABBR = {
30
+ task: 'T',
31
+ feature: 'F',
32
+ epic: 'E',
33
+ bug: 'B',
34
+ milestone: 'M',
35
+ story: 'S',
36
+ chore: 'C',
37
+ release: 'R',
38
+ };
39
+ const LANE_COLOR = {
40
+ item: '#2dd4bf',
41
+ facet: '#60a5fa',
42
+ external: '#f87171',
43
+ };
44
+ const EDGE_COLOR = {
45
+ PARENT_OF: '#60a5fa',
46
+ CHILD_OF: '#60a5fa',
47
+ DEPENDS_ON: '#fb923c',
48
+ BLOCKED_BY: '#f87171',
49
+ HAS_TAG: '#8b5cf6',
50
+ HAS_ASSIGNEE: '#34d399',
51
+ IN_SPRINT: '#38bdf8',
52
+ IN_RELEASE: '#a78bfa',
53
+ };
54
+ const EDGE_DEFAULT = 'rgba(148,163,184,0.3)';
55
+ // ── Helpers ───────────────────────────────────────────────────
56
+ function nodeRadius(degree) {
57
+ return Math.max(8, Math.min(28, 8 + Math.sqrt(Math.max(0, degree)) * 4.2));
58
+ }
59
+ function statusColor(node) {
60
+ if (node.lane === 'facet')
61
+ return LANE_COLOR.facet;
62
+ if (node.lane === 'external')
63
+ return LANE_COLOR.external;
64
+ return STATUS_COLORS[node.status] ?? LANE_COLOR.item;
65
+ }
66
+ function getEdgeColor(type) {
67
+ return EDGE_COLOR[type] ?? EDGE_DEFAULT;
68
+ }
69
+ function truncate(text, maxLen) {
70
+ return text.length > maxLen ? text.slice(0, maxLen - 1) + '…' : text;
71
+ }
72
+ function hexToRgb(hex) {
73
+ const m = /^#([0-9a-f]{6})$/i.exec(hex);
74
+ if (!m)
75
+ return null;
76
+ const n = parseInt(m[1], 16);
77
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
78
+ }
79
+ function hexAlpha(hex, a) {
80
+ const c = hexToRgb(hex);
81
+ if (!c)
82
+ return hex;
83
+ return `rgba(${c.r},${c.g},${c.b},${a})`;
84
+ }
85
+ function initialPositions(nodes) {
86
+ const golden = 2.399963;
87
+ return nodes.map((_, i) => {
88
+ const radius = 70 + Math.sqrt(i) * 60;
89
+ const angle = i * golden;
90
+ return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius };
91
+ });
92
+ }
93
+ function lerp(a, b, t) {
94
+ return a + (b - a) * t;
95
+ }
96
+ function easeOutQuart(t) {
97
+ return 1 - Math.pow(1 - t, 4);
98
+ }
99
+ /** Graham scan convex hull — returns hull in counter-clockwise order */
100
+ function convexHull(pts) {
101
+ if (pts.length < 3)
102
+ return pts.slice();
103
+ let bot = pts[0];
104
+ for (const p of pts)
105
+ if (p.y < bot.y || (p.y === bot.y && p.x < bot.x))
106
+ bot = p;
107
+ const rest = pts.filter((p) => p !== bot);
108
+ rest.sort((a, b) => {
109
+ const ax = a.x - bot.x, ay = a.y - bot.y;
110
+ const bx = b.x - bot.x, by = b.y - bot.y;
111
+ const cross = ax * by - ay * bx;
112
+ if (Math.abs(cross) < 1e-9)
113
+ return (ax * ax + ay * ay) - (bx * bx + by * by);
114
+ return -cross;
115
+ });
116
+ const hull = [bot];
117
+ for (const p of rest) {
118
+ while (hull.length >= 2) {
119
+ const a = hull[hull.length - 2], b = hull[hull.length - 1];
120
+ if ((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x) >= 0)
121
+ hull.pop();
122
+ else
123
+ break;
124
+ }
125
+ hull.push(p);
126
+ }
127
+ return hull;
128
+ }
129
+ // escHtml is imported from utils.ts in graph.ts — canvas uses its own for DOM tooltip only
130
+ function escHtml(s) {
131
+ return s
132
+ .replace(/&/g, '&amp;')
133
+ .replace(/</g, '&lt;')
134
+ .replace(/>/g, '&gt;')
135
+ .replace(/"/g, '&quot;');
136
+ }
137
+ // ═══════════════════════════════════════════════════════════════
138
+ // GraphCanvas class
139
+ // ═══════════════════════════════════════════════════════════════
140
+ export class GraphCanvas {
141
+ canvas;
142
+ ctx;
143
+ dpr;
144
+ w = 0;
145
+ h = 0;
146
+ // Simulation
147
+ nodes = [];
148
+ edges = [];
149
+ nodeMap = new Map();
150
+ alpha = 1;
151
+ paused = false;
152
+ // Physics constants (mutable for live slider control)
153
+ ALPHA_DECAY = 0.020;
154
+ ALPHA_MIN = 0.001;
155
+ VEL_DECAY = 0.56;
156
+ REPULSE = 2000;
157
+ SPRING = 0.065;
158
+ REST_LEN = 140;
159
+ CENTER = 0.010;
160
+ LINK_DIST_FAC = 80;
161
+ // Camera
162
+ tx = 0;
163
+ ty = 0;
164
+ scale = 1;
165
+ // Camera fly-to
166
+ flyTarget = null;
167
+ // Interaction
168
+ isDraggingNode = false;
169
+ isDraggingCanvas = false;
170
+ dragNode = null;
171
+ lastX = 0;
172
+ lastY = 0;
173
+ downX = 0;
174
+ downY = 0;
175
+ hasMoved = false;
176
+ hoveredId = null;
177
+ hoveredEdge = null;
178
+ // Touch
179
+ touchDist = 0;
180
+ touchMidX = 0;
181
+ touchMidY = 0;
182
+ // Particles
183
+ particles = [];
184
+ lastParticleSpawn = 0;
185
+ // Pulse animation (selected node)
186
+ pulseT = 0;
187
+ // Animated dash offset for cluster borders
188
+ dashOffset = 0;
189
+ // Keyboard nav: ordered list of visible node ids
190
+ navOrder = [];
191
+ // Filter
192
+ filter = {
193
+ visibleNodeIds: null,
194
+ selectedId: null,
195
+ query: '',
196
+ highlightRelTypes: new Set(),
197
+ colorMode: 'status',
198
+ colorTag: '',
199
+ criticalPathIds: new Set(),
200
+ };
201
+ // RAF + cleanup
202
+ rafId = null;
203
+ destroyed = false;
204
+ abortCtrl = new AbortController();
205
+ ro;
206
+ // Bidirectional edge pairs (precomputed in setData)
207
+ biDirPairs = new Set();
208
+ // Tag→color map for tag colorMode (recomputed in recolorNodes)
209
+ tagColorMap = new Map();
210
+ // Layout mode
211
+ layout = 'force';
212
+ // Edge bundling
213
+ edgeBundling = false;
214
+ // Spatial grid for culling
215
+ gridCells = new Map();
216
+ gridCellSize = 200;
217
+ gridOriginX = 0;
218
+ gridOriginY = 0;
219
+ // Initial load zoom-to-fit
220
+ initialFitDone = false;
221
+ initialFitTimer = null;
222
+ // Callbacks
223
+ onSelectNode;
224
+ onOpenNode;
225
+ onContextMenu;
226
+ onContextMenuEdge;
227
+ onExportPng;
228
+ constructor(container, options) {
229
+ this.onSelectNode = options.onSelectNode;
230
+ this.onOpenNode = options.onOpenNode;
231
+ this.onContextMenu = options.onContextMenu;
232
+ this.onContextMenuEdge = options.onContextMenuEdge;
233
+ this.onExportPng = options.onExportPng;
234
+ this.layout = options.layout ?? 'force';
235
+ this.edgeBundling = options.edgeBundling ?? false;
236
+ this.canvas = document.createElement('canvas');
237
+ this.canvas.style.cssText =
238
+ 'width:100%;height:100%;display:block;touch-action:none;cursor:grab;outline:none;';
239
+ this.canvas.tabIndex = 0;
240
+ container.appendChild(this.canvas);
241
+ const ctx = this.canvas.getContext('2d');
242
+ if (!ctx)
243
+ throw new Error('Canvas 2D not available');
244
+ this.ctx = ctx;
245
+ this.dpr = Math.min(window.devicePixelRatio || 1, 2);
246
+ this.ro = new ResizeObserver(() => this.onResize());
247
+ this.ro.observe(container);
248
+ this.onResize();
249
+ this.bindEvents();
250
+ this.startLoop();
251
+ }
252
+ // ── Public API ─────────────────────────────────────────────
253
+ setData(nodes, edges) {
254
+ const prevPos = new Map(this.nodes.map((n) => [n.id, { x: n.x, y: n.y }]));
255
+ const initPos = initialPositions(nodes);
256
+ this.nodes = nodes.map((node, i) => {
257
+ const prev = prevPos.get(node.id);
258
+ return {
259
+ ...node,
260
+ x: prev?.x ?? initPos[i].x,
261
+ y: prev?.y ?? initPos[i].y,
262
+ vx: 0,
263
+ vy: 0,
264
+ fx: null,
265
+ fy: null,
266
+ r: nodeRadius(node.degree),
267
+ color: statusColor(node),
268
+ };
269
+ });
270
+ this.nodeMap = new Map(this.nodes.map((n) => [n.id, n]));
271
+ this.edges = edges.flatMap((e) => {
272
+ const source = this.nodeMap.get(e.from);
273
+ const target = this.nodeMap.get(e.to);
274
+ return source && target ? [{ ...e, source, target }] : [];
275
+ });
276
+ this.alpha = 1;
277
+ this.particles = [];
278
+ this.navOrder = nodes.map((n) => n.id);
279
+ // Precompute bidirectional edge pairs
280
+ const edgeKeySet = new Set(this.edges.map((e) => `${e.source.id}→${e.target.id}`));
281
+ this.biDirPairs = new Set();
282
+ for (const e of this.edges) {
283
+ if (edgeKeySet.has(`${e.target.id}→${e.source.id}`)) {
284
+ this.biDirPairs.add([e.source.id, e.target.id].sort().join('|'));
285
+ }
286
+ }
287
+ this.recolorNodes();
288
+ // Initial zoom-to-fit animation
289
+ this.initialFitDone = false;
290
+ if (this.initialFitTimer)
291
+ clearTimeout(this.initialFitTimer);
292
+ this.initialFitTimer = setTimeout(() => {
293
+ this.fitView();
294
+ this.initialFitDone = true;
295
+ }, 1400);
296
+ // Apply hierarchical layout if selected
297
+ if (this.layout === 'hierarchical') {
298
+ this.applyHierarchicalLayout();
299
+ }
300
+ this.hoveredId = null;
301
+ this.hoveredEdge = null;
302
+ this.hideTooltip();
303
+ }
304
+ setFilter(filter) {
305
+ const prevMode = this.filter.colorMode;
306
+ const prevTag = this.filter.colorTag;
307
+ this.filter = { ...this.filter, ...filter };
308
+ this.navOrder = this.nodes
309
+ .filter((n) => !this.filter.visibleNodeIds || this.filter.visibleNodeIds.has(n.id))
310
+ .map((n) => n.id);
311
+ if ('selectedId' in filter) {
312
+ this.particles = [];
313
+ this.lastParticleSpawn = 0;
314
+ }
315
+ if (filter.colorMode !== undefined && filter.colorMode !== prevMode)
316
+ this.recolorNodes();
317
+ else if (filter.colorTag !== undefined && filter.colorTag !== prevTag)
318
+ this.recolorNodes();
319
+ }
320
+ getTagColorMap() { return this.tagColorMap; }
321
+ // Live physics control — used by the physics sliders panel
322
+ setPhysicsParams(params) {
323
+ if (params.repulsion !== undefined)
324
+ this.REPULSE = params.repulsion;
325
+ if (params.linkDistance !== undefined) {
326
+ this.REST_LEN = params.linkDistance;
327
+ this.LINK_DIST_FAC = params.linkDistance * 0.57;
328
+ }
329
+ if (params.centerForce !== undefined)
330
+ this.CENTER = params.centerForce;
331
+ if (params.linkStrength !== undefined)
332
+ this.SPRING = params.linkStrength;
333
+ this.reheat();
334
+ }
335
+ getPhysicsParams() {
336
+ return { repulsion: this.REPULSE, linkDistance: this.REST_LEN, centerForce: this.CENTER, linkStrength: this.SPRING };
337
+ }
338
+ setSelected(id) {
339
+ this.filter = { ...this.filter, selectedId: id };
340
+ this.particles = [];
341
+ this.lastParticleSpawn = 0;
342
+ if (id) {
343
+ const node = this.nodeMap.get(id);
344
+ if (node)
345
+ this.flyTo(node);
346
+ }
347
+ }
348
+ jumpToNode(id) {
349
+ const node = this.nodeMap.get(id);
350
+ if (node)
351
+ this.flyTo(node);
352
+ }
353
+ fitView() {
354
+ if (!this.nodes.length)
355
+ return;
356
+ const vis = this.filter.visibleNodeIds;
357
+ const ns = vis ? this.nodes.filter((n) => vis.has(n.id)) : this.nodes;
358
+ if (!ns.length)
359
+ return;
360
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
361
+ for (const n of ns) {
362
+ minX = Math.min(minX, n.x - n.r);
363
+ minY = Math.min(minY, n.y - n.r);
364
+ maxX = Math.max(maxX, n.x + n.r);
365
+ maxY = Math.max(maxY, n.y + n.r);
366
+ }
367
+ const pad = 72;
368
+ const gw = maxX - minX + pad * 2;
369
+ const gh = maxY - minY + pad * 2;
370
+ const s = Math.min(this.w / gw, this.h / gh, 2.5);
371
+ this.flyTarget = {
372
+ tx: this.w / 2 - ((minX + maxX) / 2) * s,
373
+ ty: this.h / 2 - ((minY + maxY) / 2) * s,
374
+ scale: s,
375
+ };
376
+ }
377
+ togglePhysics() {
378
+ this.paused = !this.paused;
379
+ if (!this.paused)
380
+ this.alpha = Math.max(this.alpha, 0.05);
381
+ return this.paused;
382
+ }
383
+ reheat() {
384
+ this.alpha = 0.3;
385
+ this.paused = false;
386
+ }
387
+ setLayout(layout) {
388
+ if (this.layout === layout)
389
+ return;
390
+ this.layout = layout;
391
+ if (layout === 'hierarchical') {
392
+ this.applyHierarchicalLayout();
393
+ }
394
+ else {
395
+ // Scatter nodes slightly and reheat force simulation
396
+ for (const n of this.nodes) {
397
+ n.x += (Math.random() - 0.5) * 40;
398
+ n.y += (Math.random() - 0.5) * 40;
399
+ }
400
+ this.reheat();
401
+ }
402
+ }
403
+ setEdgeBundling(enabled) {
404
+ this.edgeBundling = enabled;
405
+ }
406
+ exportPng() {
407
+ if (this.onExportPng) {
408
+ this.onExportPng(this.canvas);
409
+ return;
410
+ }
411
+ // Default: trigger download
412
+ try {
413
+ const link = document.createElement('a');
414
+ link.download = 'graph-export.png';
415
+ link.href = this.canvas.toDataURL('image/png');
416
+ link.click();
417
+ }
418
+ catch { /* Canvas tainted — cannot export */ }
419
+ }
420
+ applyHierarchicalLayout() {
421
+ // Topological-sort-based hierarchical layout
422
+ // Build adjacency for layering
423
+ const inDeg = new Map();
424
+ const adjOut = new Map();
425
+ for (const n of this.nodes) {
426
+ inDeg.set(n.id, 0);
427
+ adjOut.set(n.id, []);
428
+ }
429
+ for (const e of this.edges) {
430
+ inDeg.set(e.target.id, (inDeg.get(e.target.id) ?? 0) + 1);
431
+ adjOut.get(e.source.id)?.push(e.target.id);
432
+ }
433
+ // BFS layering from roots (nodes with in-degree 0)
434
+ const layers = [];
435
+ const assigned = new Set();
436
+ let frontier = [...inDeg.entries()].filter(([, d]) => d === 0).map(([id]) => id);
437
+ if (!frontier.length && this.nodes.length > 0) {
438
+ frontier = [this.nodes[0].id];
439
+ }
440
+ while (frontier.length) {
441
+ layers.push(frontier);
442
+ for (const id of frontier)
443
+ assigned.add(id);
444
+ const next = new Set();
445
+ for (const id of frontier) {
446
+ for (const child of adjOut.get(id) ?? []) {
447
+ if (!assigned.has(child)) {
448
+ next.add(child);
449
+ }
450
+ }
451
+ }
452
+ frontier = [...next];
453
+ }
454
+ // Assign unassigned nodes to last layer
455
+ for (const n of this.nodes) {
456
+ if (!assigned.has(n.id)) {
457
+ layers[layers.length - 1]?.push(n.id) ?? layers.push([n.id]);
458
+ }
459
+ }
460
+ // Position nodes in layers
461
+ const layerSpacing = 180;
462
+ const nodeSpacing = 100;
463
+ const startY = -((layers.length - 1) * layerSpacing) / 2;
464
+ for (let li = 0; li < layers.length; li++) {
465
+ const layer = layers[li];
466
+ const startX = -((layer.length - 1) * nodeSpacing) / 2;
467
+ for (let ni = 0; ni < layer.length; ni++) {
468
+ const node = this.nodeMap.get(layer[ni]);
469
+ if (node) {
470
+ node.x = startX + ni * nodeSpacing;
471
+ node.y = startY + li * layerSpacing;
472
+ node.vx = 0;
473
+ node.vy = 0;
474
+ }
475
+ }
476
+ }
477
+ // Freeze positions for hierarchical mode
478
+ this.alpha = 0.005;
479
+ }
480
+ /** Rebuild the spatial grid for culling */
481
+ rebuildGrid() {
482
+ this.gridCells.clear();
483
+ if (!this.nodes.length)
484
+ return;
485
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
486
+ for (const n of this.nodes) {
487
+ minX = Math.min(minX, n.x);
488
+ minY = Math.min(minY, n.y);
489
+ maxX = Math.max(maxX, n.x);
490
+ maxY = Math.max(maxY, n.y);
491
+ }
492
+ this.gridOriginX = minX - this.gridCellSize;
493
+ this.gridOriginY = minY - this.gridCellSize;
494
+ const cs = this.gridCellSize;
495
+ for (const n of this.nodes) {
496
+ const cx = Math.floor((n.x - this.gridOriginX) / cs);
497
+ const cy = Math.floor((n.y - this.gridOriginY) / cs);
498
+ const key = cx * 10000 + cy;
499
+ let cell = this.gridCells.get(key);
500
+ if (!cell) {
501
+ cell = [];
502
+ this.gridCells.set(key, cell);
503
+ }
504
+ cell.push(n);
505
+ }
506
+ }
507
+ /** Get all nodes within a world-space rectangle */
508
+ getNodesInRect(wx1, wy1, wx2, wy2) {
509
+ const cs = this.gridCellSize;
510
+ const cx1 = Math.floor((wx1 - this.gridOriginX) / cs);
511
+ const cy1 = Math.floor((wy1 - this.gridOriginY) / cs);
512
+ const cx2 = Math.floor((wx2 - this.gridOriginX) / cs);
513
+ const cy2 = Math.floor((wy2 - this.gridOriginY) / cs);
514
+ const result = [];
515
+ for (let cx = cx1; cx <= cx2; cx++) {
516
+ for (let cy = cy1; cy <= cy2; cy++) {
517
+ const cell = this.gridCells.get(cx * 10000 + cy);
518
+ if (cell) {
519
+ for (const n of cell) {
520
+ if (n.x >= wx1 && n.x <= wx2 && n.y >= wy1 && n.y <= wy2) {
521
+ result.push(n);
522
+ }
523
+ }
524
+ }
525
+ }
526
+ }
527
+ return result;
528
+ }
529
+ isSameEdge(a, b) {
530
+ if (!b)
531
+ return false;
532
+ return a.source.id === b.source.id && a.target.id === b.target.id && a.type === b.type;
533
+ }
534
+ buildEdgeGeometry(edge) {
535
+ const { source: s, target: t } = edge;
536
+ const dx = t.x - s.x || 0.01;
537
+ const dy = t.y - s.y || 0.01;
538
+ const len = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
539
+ const nx = dx / len;
540
+ const ny = dy / len;
541
+ const px = -dy / len;
542
+ const py = dx / len;
543
+ const key = [s.id, t.id].sort().join('|');
544
+ const isBiDir = this.biDirPairs.has(key);
545
+ const curveDir = isBiDir ? (s.id < t.id ? 1 : -1) : 0;
546
+ const cpOffset = isBiDir ? len * 0.22 * curveDir : 0;
547
+ const cpX = (s.x + t.x) / 2 + px * cpOffset;
548
+ const cpY = (s.y + t.y) / 2 + py * cpOffset;
549
+ const x1 = s.x + nx * s.r;
550
+ const y1 = s.y + ny * s.r;
551
+ const x2 = t.x - nx * (t.r + 7);
552
+ const y2 = t.y - ny * (t.r + 7);
553
+ const qX = (u) => (1 - u) * (1 - u) * x1 + 2 * (1 - u) * u * cpX + u * u * x2;
554
+ const qY = (u) => (1 - u) * (1 - u) * y1 + 2 * (1 - u) * u * cpY + u * u * y2;
555
+ const midU = 0.5;
556
+ const labelX = isBiDir ? qX(midU) : (x1 + x2) / 2;
557
+ const labelY = isBiDir ? qY(midU) : (y1 + y2) / 2;
558
+ return {
559
+ source: s,
560
+ target: t,
561
+ isBiDir,
562
+ key,
563
+ cpX,
564
+ cpY,
565
+ len,
566
+ x1,
567
+ y1,
568
+ x2,
569
+ y2,
570
+ labelX,
571
+ labelY,
572
+ hitDistBase: 0.8 + (9 / Math.max(this.scale, 0.1)),
573
+ midpointX: (x1 + x2) / 2,
574
+ midpointY: (y1 + y2) / 2,
575
+ };
576
+ }
577
+ distancePointToSegmentSq(px, py, x1, y1, x2, y2) {
578
+ const vx = x2 - x1;
579
+ const vy = y2 - y1;
580
+ const wx = px - x1;
581
+ const wy = py - y1;
582
+ const lenSq = vx * vx + vy * vy;
583
+ if (lenSq < 1e-9) {
584
+ return wx * wx + wy * wy;
585
+ }
586
+ let t = (wx * vx + wy * vy) / lenSq;
587
+ t = Math.max(0, Math.min(1, t));
588
+ const cx = x1 + vx * t;
589
+ const cy = y1 + vy * t;
590
+ const dx = px - cx;
591
+ const dy = py - cy;
592
+ return dx * dx + dy * dy;
593
+ }
594
+ distancePointToQuadraticSq(px, py, x1, y1, x2, y2, cpx, cpy) {
595
+ let best = Infinity;
596
+ const steps = 24;
597
+ for (let i = 0; i <= steps; i++) {
598
+ const t = i / steps;
599
+ const omt = 1 - t;
600
+ const x = omt * omt * x1 + 2 * omt * t * cpx + t * t * x2;
601
+ const y = omt * omt * y1 + 2 * omt * t * cpy + t * t * y2;
602
+ const dx = px - x;
603
+ const dy = py - y;
604
+ const d2 = dx * dx + dy * dy;
605
+ if (d2 < best)
606
+ best = d2;
607
+ }
608
+ return best;
609
+ }
610
+ hitTestEdge(wx, wy) {
611
+ const vis = this.filter.visibleNodeIds;
612
+ let best = null;
613
+ let bestDist = Infinity;
614
+ for (const edge of this.edges) {
615
+ if (vis && !vis.has(edge.source.id) && !vis.has(edge.target.id))
616
+ continue;
617
+ const g = this.buildEdgeGeometry(edge);
618
+ const d2 = g.isBiDir
619
+ ? this.distancePointToQuadraticSq(wx, wy, g.x1, g.y1, g.x2, g.y2, g.cpX, g.cpY)
620
+ : this.distancePointToSegmentSq(wx, wy, g.x1, g.y1, g.x2, g.y2);
621
+ if (d2 <= g.hitDistBase * g.hitDistBase && d2 < bestDist) {
622
+ best = edge;
623
+ bestDist = d2;
624
+ }
625
+ }
626
+ return best;
627
+ }
628
+ destroy() {
629
+ this.destroyed = true;
630
+ if (this.rafId !== null)
631
+ cancelAnimationFrame(this.rafId);
632
+ if (this.initialFitTimer)
633
+ clearTimeout(this.initialFitTimer);
634
+ this.abortCtrl.abort();
635
+ this.ro.disconnect();
636
+ this.canvas.remove();
637
+ const tt = document.getElementById('gc-tooltip');
638
+ if (tt)
639
+ tt.remove();
640
+ }
641
+ // ── Color computation ──────────────────────────────────────
642
+ computeNodeColor(node) {
643
+ if (node.lane === 'facet')
644
+ return LANE_COLOR.facet;
645
+ if (node.lane === 'external')
646
+ return LANE_COLOR.external;
647
+ const mode = this.filter.colorMode;
648
+ if (mode === 'type') {
649
+ return TYPE_COLORS[node.type.toLowerCase()] ?? TYPE_COLOR_DEFAULT;
650
+ }
651
+ if (mode === 'tag') {
652
+ for (const t of node.tags ?? []) {
653
+ const c = this.tagColorMap.get(t);
654
+ if (c)
655
+ return c;
656
+ }
657
+ return 'rgba(100,116,139,0.45)';
658
+ }
659
+ return STATUS_COLORS[node.status] ?? LANE_COLOR.item;
660
+ }
661
+ recolorNodes() {
662
+ if (this.filter.colorMode === 'tag') {
663
+ const freq = new Map();
664
+ for (const n of this.nodes) {
665
+ for (const t of n.tags ?? [])
666
+ freq.set(t, (freq.get(t) ?? 0) + 1);
667
+ }
668
+ const top = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, TAG_PALETTE.length).map(([t]) => t);
669
+ this.tagColorMap = new Map(top.map((t, i) => [t, TAG_PALETTE[i]]));
670
+ }
671
+ else {
672
+ this.tagColorMap = new Map();
673
+ }
674
+ for (const node of this.nodes)
675
+ node.color = this.computeNodeColor(node);
676
+ }
677
+ // ── Fly-to ─────────────────────────────────────────────────
678
+ flyTo(node) {
679
+ const targetScale = Math.min(Math.max(this.scale, 1.2), 2.2);
680
+ this.flyTarget = {
681
+ tx: this.w / 2 - node.x * targetScale,
682
+ ty: this.h / 2 - node.y * targetScale,
683
+ scale: targetScale,
684
+ };
685
+ }
686
+ advanceFly() {
687
+ if (!this.flyTarget)
688
+ return;
689
+ const speed = 0.08;
690
+ this.tx = lerp(this.tx, this.flyTarget.tx, speed);
691
+ this.ty = lerp(this.ty, this.flyTarget.ty, speed);
692
+ this.scale = lerp(this.scale, this.flyTarget.scale, speed);
693
+ const distTx = Math.abs(this.tx - this.flyTarget.tx);
694
+ const distTy = Math.abs(this.ty - this.flyTarget.ty);
695
+ const distScale = Math.abs(this.scale - this.flyTarget.scale);
696
+ if (distTx < 0.5 && distTy < 0.5 && distScale < 0.002) {
697
+ this.tx = this.flyTarget.tx;
698
+ this.ty = this.flyTarget.ty;
699
+ this.scale = this.flyTarget.scale;
700
+ this.flyTarget = null;
701
+ }
702
+ }
703
+ // ── Keyboard navigation ────────────────────────────────────
704
+ navigateToNeighbor(direction) {
705
+ const sel = this.filter.selectedId;
706
+ if (direction === 'first-neighbor' && sel) {
707
+ const outEdge = this.edges.find((e) => e.source.id === sel || e.target.id === sel);
708
+ if (!outEdge)
709
+ return;
710
+ const neighbor = outEdge.source.id === sel ? outEdge.target : outEdge.source;
711
+ this.filter = { ...this.filter, selectedId: neighbor.id };
712
+ this.particles = [];
713
+ this.onSelectNode(neighbor.id);
714
+ this.flyTo(neighbor);
715
+ return;
716
+ }
717
+ const order = this.navOrder;
718
+ if (!order.length)
719
+ return;
720
+ const idx = sel ? order.indexOf(sel) : -1;
721
+ let nextIdx;
722
+ if (direction === 'prev') {
723
+ nextIdx = idx <= 0 ? order.length - 1 : idx - 1;
724
+ }
725
+ else {
726
+ nextIdx = idx >= order.length - 1 ? 0 : idx + 1;
727
+ }
728
+ const nextId = order[nextIdx];
729
+ this.filter = { ...this.filter, selectedId: nextId };
730
+ this.particles = [];
731
+ this.onSelectNode(nextId);
732
+ const node = this.nodeMap.get(nextId);
733
+ if (node)
734
+ this.flyTo(node);
735
+ }
736
+ // ── Physics simulation ─────────────────────────────────────
737
+ tick(dt) {
738
+ if (this.paused || this.alpha < this.ALPHA_MIN)
739
+ return;
740
+ const nodes = this.nodes;
741
+ const edges = this.edges;
742
+ const n = nodes.length;
743
+ const a = this.alpha;
744
+ const dtS = Math.min(dt / 1000, 0.05);
745
+ // Repulsion O(n²) — fine for ≤400 nodes
746
+ for (let i = 0; i < n; i++) {
747
+ for (let j = i + 1; j < n; j++) {
748
+ const A = nodes[i], B = nodes[j];
749
+ const dx = B.x - A.x || 0.01;
750
+ const dy = B.y - A.y || 0.01;
751
+ const d2 = dx * dx + dy * dy;
752
+ if (d2 < 0.01)
753
+ continue;
754
+ const d = Math.sqrt(d2);
755
+ const f = (this.REPULSE / d2) * a;
756
+ const fx = (dx / d) * f;
757
+ const fy = (dy / d) * f;
758
+ A.vx -= fx;
759
+ A.vy -= fy;
760
+ B.vx += fx;
761
+ B.vy += fy;
762
+ }
763
+ }
764
+ // Spring forces
765
+ for (const e of edges) {
766
+ const { source: s, target: t } = e;
767
+ const dx = t.x - s.x || 0.01;
768
+ const dy = t.y - s.y || 0.01;
769
+ const d = Math.sqrt(dx * dx + dy * dy);
770
+ const restLen = (s.lane === 'facet' || t.lane === 'facet')
771
+ ? this.LINK_DIST_FAC
772
+ : this.REST_LEN;
773
+ const f = (d - restLen) * this.SPRING * a;
774
+ const nx = dx / d;
775
+ const ny = dy / d;
776
+ s.vx += nx * f;
777
+ s.vy += ny * f;
778
+ t.vx -= nx * f;
779
+ t.vy -= ny * f;
780
+ }
781
+ // Center gravity
782
+ for (const nd of nodes) {
783
+ nd.vx -= nd.x * this.CENTER * a;
784
+ nd.vy -= nd.y * this.CENTER * a;
785
+ }
786
+ // Tag centroid grouping — nodes sharing a tag gently attract each other
787
+ if (this.filter.colorMode === 'tag' && this.tagColorMap.size > 0) {
788
+ const tagCent = new Map();
789
+ for (const nd of nodes) {
790
+ for (const t of nd.tags ?? []) {
791
+ if (!this.tagColorMap.has(t))
792
+ continue;
793
+ let c = tagCent.get(t);
794
+ if (!c) {
795
+ c = { x: 0, y: 0, count: 0 };
796
+ tagCent.set(t, c);
797
+ }
798
+ c.x += nd.x;
799
+ c.y += nd.y;
800
+ c.count++;
801
+ break;
802
+ }
803
+ }
804
+ const cs = 0.004 * a;
805
+ for (const nd of nodes) {
806
+ for (const t of nd.tags ?? []) {
807
+ if (!this.tagColorMap.has(t))
808
+ continue;
809
+ const c = tagCent.get(t);
810
+ if (c && c.count >= 2) {
811
+ nd.vx += (c.x / c.count - nd.x) * cs;
812
+ nd.vy += (c.y / c.count - nd.y) * cs;
813
+ }
814
+ break;
815
+ }
816
+ }
817
+ }
818
+ // Integrate
819
+ for (const nd of nodes) {
820
+ nd.vx *= this.VEL_DECAY;
821
+ nd.vy *= this.VEL_DECAY;
822
+ if (nd.fx !== null) {
823
+ nd.x = nd.fx;
824
+ nd.vx = 0;
825
+ }
826
+ else
827
+ nd.x += nd.vx;
828
+ if (nd.fy !== null) {
829
+ nd.y = nd.fy;
830
+ nd.vy = 0;
831
+ }
832
+ else
833
+ nd.y += nd.vy;
834
+ }
835
+ this.alpha *= (1 - this.ALPHA_DECAY);
836
+ // Advance particles
837
+ for (const p of this.particles) {
838
+ p.t += p.speed * dtS;
839
+ }
840
+ this.particles = this.particles.filter((p) => p.t < 1);
841
+ // Spawn particles on selected-node edges
842
+ const sel = this.filter.selectedId;
843
+ if (sel && Date.now() - this.lastParticleSpawn > 90) {
844
+ const selEdges = this.edges.filter((e) => e.source.id === sel || e.target.id === sel);
845
+ for (const e of selEdges.slice(0, 10)) {
846
+ this.particles.push({ edge: e, t: Math.random() * 0.15, speed: 0.25 + Math.random() * 0.25 });
847
+ }
848
+ this.lastParticleSpawn = Date.now();
849
+ }
850
+ // Pulse timer
851
+ this.pulseT = (this.pulseT + dtS * 2.5) % (Math.PI * 2);
852
+ // Cluster border animation
853
+ this.dashOffset = (this.dashOffset - dtS * 8) % 18;
854
+ }
855
+ // ── Draw ───────────────────────────────────────────────────
856
+ draw() {
857
+ const { ctx, w, h } = this;
858
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
859
+ ctx.clearRect(0, 0, w * this.dpr, h * this.dpr);
860
+ // Background
861
+ ctx.fillStyle = '#080d1a';
862
+ ctx.fillRect(0, 0, w * this.dpr, h * this.dpr);
863
+ // Dot grid (screen-space)
864
+ this.drawGrid();
865
+ // World-space transforms
866
+ ctx.save();
867
+ ctx.scale(this.dpr, this.dpr);
868
+ ctx.translate(this.tx, this.ty);
869
+ ctx.scale(this.scale, this.scale);
870
+ const sel = this.filter.selectedId;
871
+ const vis = this.filter.visibleNodeIds;
872
+ const hrel = this.filter.highlightRelTypes;
873
+ const hov = this.hoveredId;
874
+ const isHighlightedEdge = (e) => {
875
+ if (sel && (e.source.id === sel || e.target.id === sel))
876
+ return true;
877
+ if (hov && (e.source.id === hov || e.target.id === hov))
878
+ return true;
879
+ if (hrel.size > 0 && hrel.has(e.type))
880
+ return true;
881
+ return false;
882
+ };
883
+ // Precompute hovered-node neighbors for dimming
884
+ const hovNeighbors = hov ? new Set(this.edges
885
+ .filter((e) => e.source.id === hov || e.target.id === hov)
886
+ .flatMap((e) => [e.source.id, e.target.id])) : null;
887
+ const nodeOpacity = (nd) => {
888
+ if (vis && !vis.has(nd.id))
889
+ return 0.07;
890
+ // Selected node dims unrelated nodes
891
+ if (sel) {
892
+ if (nd.id === sel)
893
+ return 1;
894
+ const connectedToSel = this.edges.some((e) => (e.source.id === sel && e.target.id === nd.id) ||
895
+ (e.target.id === sel && e.source.id === nd.id));
896
+ return connectedToSel ? 0.85 : 0.15;
897
+ }
898
+ // Hovered node softly highlights neighbors
899
+ if (hov && hovNeighbors && !sel) {
900
+ if (nd.id === hov)
901
+ return 1;
902
+ return hovNeighbors.has(nd.id) ? 0.85 : 0.45;
903
+ }
904
+ return 1;
905
+ };
906
+ // Rebuild spatial grid for culling
907
+ this.rebuildGrid();
908
+ // Compute viewport in world-space for culling
909
+ const vpLeft = -this.tx / this.scale;
910
+ const vpTop = -this.ty / this.scale;
911
+ const vpRight = vpLeft + this.w / this.scale;
912
+ const vpBottom = vpTop + this.h / this.scale;
913
+ const cullPad = 100;
914
+ const visibleNodesSet = new Set();
915
+ const vpNodes = this.getNodesInRect(vpLeft - cullPad, vpTop - cullPad, vpRight + cullPad, vpBottom + cullPad);
916
+ for (const n of vpNodes)
917
+ visibleNodesSet.add(n.id);
918
+ // Tag cluster blobs (behind everything)
919
+ this.drawTagClusters(visibleNodesSet);
920
+ // Edges (back) — cull edges whose endpoints are both off-screen
921
+ if (this.edgeBundling && !sel) {
922
+ this.drawBundledEdges(nodeOpacity, isHighlightedEdge, visibleNodesSet);
923
+ }
924
+ else {
925
+ for (const e of this.edges) {
926
+ if (!visibleNodesSet.has(e.source.id) && !visibleNodesSet.has(e.target.id))
927
+ continue;
928
+ const op = Math.min(nodeOpacity(e.source), nodeOpacity(e.target));
929
+ this.drawEdge(e, op, isHighlightedEdge(e));
930
+ }
931
+ }
932
+ // Particles
933
+ this.drawParticles();
934
+ // Nodes — only draw those in viewport
935
+ for (const nd of vpNodes) {
936
+ this.drawNode(nd, nodeOpacity(nd), nd.id === sel, nd.id === this.hoveredId);
937
+ }
938
+ ctx.restore();
939
+ // Screen-space HUD + minimap
940
+ ctx.save();
941
+ ctx.scale(this.dpr, this.dpr);
942
+ this.drawHud();
943
+ this.drawMinimap();
944
+ ctx.restore();
945
+ // DOM tooltip
946
+ this.renderTooltip();
947
+ }
948
+ // ── Tag cluster blobs ─────────────────────────────────────
949
+ drawTagClusters(visibleNodesSet) {
950
+ if (this.filter.colorMode !== 'tag' || this.tagColorMap.size === 0)
951
+ return;
952
+ const vis = this.filter.visibleNodeIds;
953
+ const { ctx } = this;
954
+ // Group visible, on-screen nodes by their primary tag
955
+ const tagGroups = new Map();
956
+ for (const nd of this.nodes) {
957
+ if (!visibleNodesSet.has(nd.id))
958
+ continue;
959
+ if (vis && !vis.has(nd.id))
960
+ continue;
961
+ for (const t of nd.tags ?? []) {
962
+ if (this.tagColorMap.has(t)) {
963
+ if (!tagGroups.has(t))
964
+ tagGroups.set(t, []);
965
+ tagGroups.get(t).push(nd);
966
+ break;
967
+ }
968
+ }
969
+ }
970
+ for (const [tag, nodes] of tagGroups) {
971
+ if (nodes.length < 1)
972
+ continue;
973
+ const color = this.tagColorMap.get(tag);
974
+ const rgb = hexToRgb(color);
975
+ if (!rgb)
976
+ continue;
977
+ ctx.save();
978
+ ctx.globalAlpha = 1;
979
+ const PAD = 30;
980
+ if (nodes.length === 1) {
981
+ // Single node: radial halo
982
+ const nd = nodes[0];
983
+ const haloR = nd.r + PAD * 1.2;
984
+ const grad = ctx.createRadialGradient(nd.x, nd.y, nd.r, nd.x, nd.y, haloR);
985
+ grad.addColorStop(0, `rgba(${rgb.r},${rgb.g},${rgb.b},0.10)`);
986
+ grad.addColorStop(1, `rgba(${rgb.r},${rgb.g},${rgb.b},0)`);
987
+ ctx.beginPath();
988
+ ctx.arc(nd.x, nd.y, haloR, 0, Math.PI * 2);
989
+ ctx.fillStyle = grad;
990
+ ctx.fill();
991
+ }
992
+ else {
993
+ // Multiple nodes: smooth convex hull blob
994
+ const hull = convexHull(nodes.map((nd) => ({ x: nd.x, y: nd.y })));
995
+ if (hull.length < 2) {
996
+ ctx.restore();
997
+ continue;
998
+ }
999
+ // Expand hull points outward from centroid
1000
+ const centX = hull.reduce((s, p) => s + p.x, 0) / hull.length;
1001
+ const centY = hull.reduce((s, p) => s + p.y, 0) / hull.length;
1002
+ const expanded = hull.map((p) => {
1003
+ const dx = p.x - centX;
1004
+ const dy = p.y - centY;
1005
+ const d = Math.sqrt(dx * dx + dy * dy) || 1;
1006
+ return { x: p.x + (dx / d) * PAD, y: p.y + (dy / d) * PAD };
1007
+ });
1008
+ const hn = expanded.length;
1009
+ // Draw blob using catmull-rom spline through expanded hull
1010
+ ctx.beginPath();
1011
+ for (let i = 0; i < hn; i++) {
1012
+ const p0 = expanded[(i - 1 + hn) % hn];
1013
+ const p1 = expanded[i];
1014
+ const p2 = expanded[(i + 1) % hn];
1015
+ const p3 = expanded[(i + 2) % hn];
1016
+ if (i === 0) {
1017
+ ctx.moveTo((p0.x + p1.x) / 2, (p0.y + p1.y) / 2);
1018
+ }
1019
+ const cp1x = p1.x + (p2.x - p0.x) / 6;
1020
+ const cp1y = p1.y + (p2.y - p0.y) / 6;
1021
+ const cp2x = p2.x - (p3.x - p1.x) / 6;
1022
+ const cp2y = p2.y - (p3.y - p1.y) / 6;
1023
+ ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, (p1.x + p2.x) / 2, (p1.y + p2.y) / 2);
1024
+ }
1025
+ ctx.closePath();
1026
+ // Translucent fill
1027
+ ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.055)`;
1028
+ ctx.fill();
1029
+ // Animated glowing border (marching ants)
1030
+ ctx.shadowColor = color;
1031
+ ctx.shadowBlur = 12;
1032
+ ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.28)`;
1033
+ ctx.lineWidth = 1.5;
1034
+ ctx.setLineDash([5, 4]);
1035
+ ctx.lineDashOffset = this.dashOffset;
1036
+ ctx.stroke();
1037
+ ctx.setLineDash([]);
1038
+ ctx.lineDashOffset = 0;
1039
+ ctx.shadowBlur = 0;
1040
+ // Tag label near centroid top edge
1041
+ if (this.scale > 0.18) {
1042
+ const topY = Math.min(...expanded.map((p) => p.y)) - 6;
1043
+ ctx.font = `600 10px Inter, sans-serif`;
1044
+ ctx.textAlign = 'center';
1045
+ ctx.textBaseline = 'bottom';
1046
+ ctx.globalAlpha = 0.55;
1047
+ ctx.fillStyle = color;
1048
+ ctx.shadowColor = color;
1049
+ ctx.shadowBlur = 4;
1050
+ ctx.fillText(`#${tag}`, centX, topY);
1051
+ ctx.shadowBlur = 0;
1052
+ ctx.globalAlpha = 1;
1053
+ }
1054
+ }
1055
+ ctx.restore();
1056
+ }
1057
+ }
1058
+ // ── Dot grid ───────────────────────────────────────────────
1059
+ drawGrid() {
1060
+ const { ctx, dpr } = this;
1061
+ const W = this.w * dpr;
1062
+ const H = this.h * dpr;
1063
+ const spacing = 28 * this.scale * dpr;
1064
+ if (spacing < 8)
1065
+ return;
1066
+ const ox = ((this.tx * dpr % spacing) + spacing) % spacing;
1067
+ const oy = ((this.ty * dpr % spacing) + spacing) % spacing;
1068
+ ctx.save();
1069
+ ctx.fillStyle = 'rgba(148,163,184,0.06)';
1070
+ const r = 1.2 * dpr;
1071
+ for (let x = ox; x < W; x += spacing) {
1072
+ for (let y = oy; y < H; y += spacing) {
1073
+ ctx.beginPath();
1074
+ ctx.arc(x, y, r, 0, Math.PI * 2);
1075
+ ctx.fill();
1076
+ }
1077
+ }
1078
+ ctx.restore();
1079
+ }
1080
+ // ── Minimap ────────────────────────────────────────────────
1081
+ drawMinimap() {
1082
+ if (this.nodes.length < 3)
1083
+ return;
1084
+ const { ctx, w, h } = this;
1085
+ const mmW = 148, mmH = 108;
1086
+ const mmX = w - mmW - 14;
1087
+ const mmY = h - mmH - 14;
1088
+ const pad = 12;
1089
+ // Graph bounds
1090
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1091
+ for (const nd of this.nodes) {
1092
+ minX = Math.min(minX, nd.x);
1093
+ minY = Math.min(minY, nd.y);
1094
+ maxX = Math.max(maxX, nd.x);
1095
+ maxY = Math.max(maxY, nd.y);
1096
+ }
1097
+ const gw = (maxX - minX) || 1;
1098
+ const gh = (maxY - minY) || 1;
1099
+ const s = Math.min((mmW - pad * 2) / gw, (mmH - pad * 2) / gh);
1100
+ const toMm = (x, y) => ({
1101
+ x: mmX + pad + (x - minX) * s,
1102
+ y: mmY + pad + (y - minY) * s,
1103
+ });
1104
+ ctx.save();
1105
+ // Panel background
1106
+ ctx.fillStyle = 'rgba(8,13,26,0.90)';
1107
+ ctx.strokeStyle = 'rgba(148,163,184,0.15)';
1108
+ ctx.lineWidth = 1;
1109
+ if (ctx.roundRect) {
1110
+ ctx.beginPath();
1111
+ ctx.roundRect(mmX, mmY, mmW, mmH, 8);
1112
+ ctx.fill();
1113
+ ctx.stroke();
1114
+ }
1115
+ else {
1116
+ ctx.fillRect(mmX, mmY, mmW, mmH);
1117
+ ctx.strokeRect(mmX, mmY, mmW, mmH);
1118
+ }
1119
+ // Clip to minimap area
1120
+ ctx.beginPath();
1121
+ ctx.rect(mmX + 1, mmY + 1, mmW - 2, mmH - 2);
1122
+ ctx.clip();
1123
+ // Edges
1124
+ ctx.strokeStyle = 'rgba(148,163,184,0.12)';
1125
+ ctx.lineWidth = 0.7;
1126
+ for (const e of this.edges) {
1127
+ const a = toMm(e.source.x, e.source.y);
1128
+ const b = toMm(e.target.x, e.target.y);
1129
+ ctx.beginPath();
1130
+ ctx.moveTo(a.x, a.y);
1131
+ ctx.lineTo(b.x, b.y);
1132
+ ctx.stroke();
1133
+ }
1134
+ // Nodes
1135
+ const sel = this.filter.selectedId;
1136
+ const vis = this.filter.visibleNodeIds;
1137
+ for (const nd of this.nodes) {
1138
+ const { x, y } = toMm(nd.x, nd.y);
1139
+ const r = nd.id === sel ? 4 : 2;
1140
+ const dim = !!(vis && !vis.has(nd.id));
1141
+ ctx.globalAlpha = dim ? 0.18 : nd.id === sel ? 1 : 0.65;
1142
+ if (nd.id === sel) {
1143
+ ctx.shadowColor = nd.color;
1144
+ ctx.shadowBlur = 6;
1145
+ }
1146
+ ctx.fillStyle = nd.color;
1147
+ ctx.beginPath();
1148
+ ctx.arc(x, y, r, 0, Math.PI * 2);
1149
+ ctx.fill();
1150
+ ctx.shadowBlur = 0;
1151
+ }
1152
+ ctx.globalAlpha = 1;
1153
+ // Viewport box
1154
+ const vpX1 = (-this.tx) / this.scale;
1155
+ const vpY1 = (-this.ty) / this.scale;
1156
+ const vpX2 = vpX1 + this.w / this.scale;
1157
+ const vpY2 = vpY1 + this.h / this.scale;
1158
+ const va = toMm(vpX1, vpY1);
1159
+ const vb = toMm(vpX2, vpY2);
1160
+ ctx.strokeStyle = 'rgba(45,212,191,0.6)';
1161
+ ctx.lineWidth = 1.2;
1162
+ ctx.strokeRect(Math.min(va.x, vb.x), Math.min(va.y, vb.y), Math.abs(vb.x - va.x), Math.abs(vb.y - va.y));
1163
+ ctx.restore();
1164
+ }
1165
+ // ── Particles ──────────────────────────────────────────────
1166
+ drawParticles() {
1167
+ const { ctx } = this;
1168
+ for (const p of this.particles) {
1169
+ const e = p.edge;
1170
+ const s = e.source;
1171
+ const t = e.target;
1172
+ const x = lerp(s.x, t.x, p.t);
1173
+ const y = lerp(s.y, t.y, p.t);
1174
+ const color = getEdgeColor(e.type);
1175
+ const fade = p.t < 0.12 ? p.t / 0.12 : p.t > 0.88 ? (1 - p.t) / 0.12 : 1;
1176
+ ctx.save();
1177
+ ctx.globalAlpha = 0.88 * fade;
1178
+ ctx.fillStyle = color;
1179
+ ctx.shadowColor = color;
1180
+ ctx.shadowBlur = 8;
1181
+ ctx.beginPath();
1182
+ ctx.arc(x, y, 2.8, 0, Math.PI * 2);
1183
+ ctx.fill();
1184
+ ctx.restore();
1185
+ }
1186
+ }
1187
+ // ── Bundled edges ────────────────────────────────────────
1188
+ drawBundledEdges(nodeOpacity, isHighlightedEdge, visibleNodesSet) {
1189
+ const { ctx } = this;
1190
+ // Group edges by type and draw as bundled curves through centroid
1191
+ const byType = new Map();
1192
+ for (const e of this.edges) {
1193
+ if (!visibleNodesSet.has(e.source.id) && !visibleNodesSet.has(e.target.id))
1194
+ continue;
1195
+ let arr = byType.get(e.type);
1196
+ if (!arr) {
1197
+ arr = [];
1198
+ byType.set(e.type, arr);
1199
+ }
1200
+ arr.push(e);
1201
+ }
1202
+ for (const [type, edges] of byType) {
1203
+ if (edges.length < 4) {
1204
+ // Too few edges to bundle — draw normally
1205
+ for (const e of edges) {
1206
+ const op = Math.min(nodeOpacity(e.source), nodeOpacity(e.target));
1207
+ this.drawEdge(e, op, isHighlightedEdge(e));
1208
+ }
1209
+ continue;
1210
+ }
1211
+ const color = getEdgeColor(type);
1212
+ ctx.save();
1213
+ ctx.strokeStyle = color;
1214
+ ctx.lineWidth = 0.5;
1215
+ ctx.globalAlpha = 0.25;
1216
+ // Compute centroid of all edge endpoints
1217
+ let cx = 0, cy = 0, count = 0;
1218
+ for (const e of edges) {
1219
+ cx += e.source.x + e.target.x;
1220
+ cy += e.source.y + e.target.y;
1221
+ count += 2;
1222
+ }
1223
+ cx /= count;
1224
+ cy /= count;
1225
+ for (const e of edges) {
1226
+ const op = Math.min(nodeOpacity(e.source), nodeOpacity(e.target));
1227
+ if (op < 0.1)
1228
+ continue;
1229
+ ctx.globalAlpha = op * 0.3;
1230
+ ctx.beginPath();
1231
+ ctx.moveTo(e.source.x, e.source.y);
1232
+ // Bezier through a point biased toward centroid
1233
+ const mx = (e.source.x + e.target.x) / 2;
1234
+ const my = (e.source.y + e.target.y) / 2;
1235
+ const bpx = mx + (cx - mx) * 0.3;
1236
+ const bpy = my + (cy - my) * 0.3;
1237
+ ctx.quadraticCurveTo(bpx, bpy, e.target.x, e.target.y);
1238
+ ctx.stroke();
1239
+ }
1240
+ ctx.restore();
1241
+ }
1242
+ }
1243
+ // ── Edge ───────────────────────────────────────────────────
1244
+ drawEdge(edge, opacity, highlighted) {
1245
+ const { ctx } = this;
1246
+ const { source: s, target: t } = edge;
1247
+ const onCritPath = this.filter.criticalPathIds.has(s.id) && this.filter.criticalPathIds.has(t.id);
1248
+ const dx = t.x - s.x;
1249
+ const dy = t.y - s.y;
1250
+ const len = Math.sqrt(dx * dx + dy * dy);
1251
+ if (len < 1)
1252
+ return;
1253
+ // Perpendicular unit vector
1254
+ const px = -dy / len;
1255
+ const py = dx / len;
1256
+ // Determine curvature direction for bidirectional edges
1257
+ const key = [s.id, t.id].sort().join('|');
1258
+ const isBiDir = this.biDirPairs.has(key);
1259
+ const curveDir = isBiDir ? (s.id < t.id ? 1 : -1) : 0;
1260
+ const curvature = isBiDir ? 0.22 : 0.0;
1261
+ const cpFactor = len * curvature * curveDir;
1262
+ // Control point (on the perpendicular bisector)
1263
+ const cpX = (s.x + t.x) / 2 + px * cpFactor;
1264
+ const cpY = (s.y + t.y) / 2 + py * cpFactor;
1265
+ // Start/end points offset from node radii
1266
+ const nx = dx / len;
1267
+ const ny = dy / len;
1268
+ const x1 = s.x + nx * s.r;
1269
+ const y1 = s.y + ny * s.r;
1270
+ const x2 = t.x - nx * (t.r + 7);
1271
+ const y2 = t.y - ny * (t.r + 7);
1272
+ const color = onCritPath ? '#fbbf24' : getEdgeColor(edge.type);
1273
+ const isActive = highlighted || onCritPath;
1274
+ ctx.save();
1275
+ ctx.globalAlpha = opacity * (isActive ? 0.95 : 0.42);
1276
+ ctx.lineWidth = isActive ? (onCritPath && !highlighted ? 2.0 : 1.8) : 1.1;
1277
+ if (isActive) {
1278
+ // Gradient stroke from source to target node color for highlighted edges
1279
+ if (!onCritPath && s.color !== t.color) {
1280
+ const grad = ctx.createLinearGradient(x1, y1, x2, y2);
1281
+ const sRgb = hexToRgb(s.color);
1282
+ const tRgb = hexToRgb(t.color);
1283
+ if (sRgb && tRgb) {
1284
+ grad.addColorStop(0, `rgba(${sRgb.r},${sRgb.g},${sRgb.b},0.9)`);
1285
+ grad.addColorStop(1, `rgba(${tRgb.r},${tRgb.g},${tRgb.b},0.9)`);
1286
+ ctx.strokeStyle = grad;
1287
+ }
1288
+ else {
1289
+ ctx.strokeStyle = color;
1290
+ }
1291
+ }
1292
+ else {
1293
+ ctx.strokeStyle = color;
1294
+ }
1295
+ ctx.shadowColor = color;
1296
+ ctx.shadowBlur = onCritPath ? 8 : 5;
1297
+ }
1298
+ else {
1299
+ ctx.strokeStyle = EDGE_DEFAULT;
1300
+ }
1301
+ // Draw straight line or quadratic bezier
1302
+ ctx.beginPath();
1303
+ ctx.moveTo(x1, y1);
1304
+ if (isBiDir) {
1305
+ ctx.quadraticCurveTo(cpX, cpY, x2, y2);
1306
+ }
1307
+ else {
1308
+ ctx.lineTo(x2, y2);
1309
+ }
1310
+ ctx.stroke();
1311
+ // Arrowhead: tangent direction at the endpoint
1312
+ if (len > 28) {
1313
+ let angle;
1314
+ if (isBiDir) {
1315
+ // Tangent at end of quadratic bezier: direction from CP to endpoint
1316
+ angle = Math.atan2(y2 - cpY, x2 - cpX);
1317
+ }
1318
+ else {
1319
+ angle = Math.atan2(dy, dx);
1320
+ }
1321
+ const aw = isBiDir ? 8 : 7;
1322
+ const aa = isBiDir ? 0.38 : 0.42;
1323
+ // Position arrowhead at the actual edge endpoint (where bezier meets target offset)
1324
+ const ax = x2;
1325
+ const ay = y2;
1326
+ ctx.fillStyle = isActive ? color : 'rgba(148,163,184,0.4)';
1327
+ ctx.globalAlpha = opacity * (isActive ? 0.92 : 0.42);
1328
+ ctx.shadowBlur = 0;
1329
+ ctx.beginPath();
1330
+ ctx.moveTo(ax, ay);
1331
+ ctx.lineTo(ax - aw * Math.cos(angle - aa), ay - aw * Math.sin(angle - aa));
1332
+ // Small notch for better definition on curved edges
1333
+ const notchLen = aw * 0.35;
1334
+ ctx.lineTo(ax - notchLen * Math.cos(angle), ay - notchLen * Math.sin(angle));
1335
+ ctx.lineTo(ax - aw * Math.cos(angle + aa), ay - aw * Math.sin(angle + aa));
1336
+ ctx.closePath();
1337
+ ctx.fill();
1338
+ }
1339
+ // Edge label — midpoint of the bezier curve
1340
+ if (isActive && this.scale > 0.55) {
1341
+ const mx = isBiDir ? (x1 + 2 * cpX + x2) / 4 : (x1 + x2) / 2;
1342
+ const my = isBiDir ? (y1 + 2 * cpY + y2) / 4 : (y1 + y2) / 2;
1343
+ ctx.globalAlpha = opacity * 0.82;
1344
+ ctx.shadowBlur = 0;
1345
+ ctx.font = '9px JetBrains Mono, monospace';
1346
+ ctx.textAlign = 'center';
1347
+ ctx.textBaseline = 'middle';
1348
+ const tw = ctx.measureText(edge.type).width + 8;
1349
+ ctx.fillStyle = 'rgba(8,13,26,0.78)';
1350
+ ctx.fillRect(mx - tw / 2, my - 7, tw, 14);
1351
+ ctx.fillStyle = hexAlpha(color, 0.9);
1352
+ ctx.fillText(edge.type, mx, my);
1353
+ }
1354
+ ctx.restore();
1355
+ }
1356
+ // ── Node ───────────────────────────────────────────────────
1357
+ drawNode(node, opacity, selected, hovered) {
1358
+ const { ctx } = this;
1359
+ const { x, y, r, color } = node;
1360
+ const prominent = selected || hovered;
1361
+ const pulse = selected ? (Math.sin(this.pulseT) * 0.5 + 0.5) : 0;
1362
+ const onCritPath = this.filter.criticalPathIds.has(node.id);
1363
+ ctx.save();
1364
+ ctx.globalAlpha = opacity;
1365
+ // Critical path outer ring (gold/amber)
1366
+ if (onCritPath && !selected) {
1367
+ const cp = Math.sin(this.pulseT * 0.7) * 0.5 + 0.5;
1368
+ ctx.strokeStyle = hexAlpha('#fbbf24', 0.55 + cp * 0.30);
1369
+ ctx.lineWidth = 2.2;
1370
+ ctx.shadowColor = '#fbbf24';
1371
+ ctx.shadowBlur = 10 + cp * 8;
1372
+ ctx.beginPath();
1373
+ ctx.arc(x, y, r + 5, 0, Math.PI * 2);
1374
+ ctx.stroke();
1375
+ ctx.shadowBlur = 0;
1376
+ }
1377
+ // Outer pulse ring (selected)
1378
+ if (selected) {
1379
+ ctx.strokeStyle = hexAlpha(color, 0.28 + pulse * 0.38);
1380
+ ctx.lineWidth = 2;
1381
+ ctx.shadowColor = color;
1382
+ ctx.shadowBlur = 14 + pulse * 18;
1383
+ ctx.beginPath();
1384
+ ctx.arc(x, y, r + 6 + pulse * 5, 0, Math.PI * 2);
1385
+ ctx.stroke();
1386
+ ctx.shadowBlur = 0;
1387
+ }
1388
+ // Ambient glow on all nodes (subtle for non-selected, stronger for selected/hovered)
1389
+ ctx.shadowColor = color;
1390
+ ctx.shadowBlur = selected ? (18 + pulse * 10) : hovered ? 10 : (r > 10 ? 5 : 3);
1391
+ // Radial gradient fill — more vibrant on non-selected nodes than before
1392
+ const grad = ctx.createRadialGradient(x - r * 0.32, y - r * 0.32, r * 0.08, x, y, r);
1393
+ if (selected) {
1394
+ grad.addColorStop(0, hexAlpha(color, 1.0));
1395
+ grad.addColorStop(1, hexAlpha(color, 0.65));
1396
+ }
1397
+ else if (hovered) {
1398
+ grad.addColorStop(0, hexAlpha(color, 0.72));
1399
+ grad.addColorStop(1, hexAlpha(color, 0.28));
1400
+ }
1401
+ else {
1402
+ grad.addColorStop(0, hexAlpha(color, 0.55));
1403
+ grad.addColorStop(1, hexAlpha(color, 0.15));
1404
+ }
1405
+ ctx.beginPath();
1406
+ ctx.arc(x, y, r, 0, Math.PI * 2);
1407
+ ctx.fillStyle = grad;
1408
+ ctx.fill();
1409
+ // Stroke — more vivid for all nodes
1410
+ ctx.shadowBlur = 0;
1411
+ ctx.strokeStyle = hexAlpha(color, selected ? 1 : hovered ? 0.92 : 0.72);
1412
+ ctx.lineWidth = selected ? 2.2 : hovered ? 1.8 : 1.2;
1413
+ ctx.stroke();
1414
+ // Type icon or inner dot
1415
+ const abbr = node.lane === 'item' ? (TYPE_ABBR[node.type.toLowerCase()] ?? '') : '';
1416
+ const showIcon = this.scale > 0.20 || prominent;
1417
+ if (r >= 9 && showIcon && abbr) {
1418
+ const iSize = Math.max(7, Math.min(12, r * 0.62));
1419
+ ctx.font = `700 ${iSize}px 'JetBrains Mono', monospace`;
1420
+ ctx.textAlign = 'center';
1421
+ ctx.textBaseline = 'middle';
1422
+ ctx.shadowBlur = 0;
1423
+ ctx.fillStyle = selected
1424
+ ? 'rgba(255,255,255,0.92)'
1425
+ : hovered
1426
+ ? 'rgba(226,232,240,0.88)'
1427
+ : hexAlpha(color, 0.78);
1428
+ ctx.fillText(abbr, x, y);
1429
+ }
1430
+ else if (r >= 11 && node.lane === 'item') {
1431
+ // Fallback dot when no abbr or low zoom
1432
+ ctx.beginPath();
1433
+ ctx.arc(x, y, r * 0.25, 0, Math.PI * 2);
1434
+ ctx.fillStyle = selected ? 'rgba(255,255,255,0.75)' : hexAlpha(color, 0.55);
1435
+ ctx.fill();
1436
+ }
1437
+ // Label — always show at scale > 0.14 (was 0.32), with background pill for readability
1438
+ const showLabel = this.scale > 0.14 || prominent;
1439
+ if (showLabel) {
1440
+ const maxLen = this.scale > 0.72 ? 28 : this.scale > 0.45 ? 20 : this.scale > 0.25 ? 15 : 10;
1441
+ const label = truncate(node.label || node.id, maxLen);
1442
+ const fSize = prominent ? Math.max(10, Math.min(13, r * 0.95)) : Math.max(9, Math.min(12, r * 0.85));
1443
+ const labelAlpha = selected ? 1.0 : hovered ? 0.96 : this.scale > 0.45 ? 0.82 : 0.62;
1444
+ ctx.font = `${selected ? 600 : 400} ${fSize}px Inter, sans-serif`;
1445
+ ctx.textAlign = 'center';
1446
+ ctx.textBaseline = 'top';
1447
+ const labelY = y + r + 5;
1448
+ const textW = ctx.measureText(label).width;
1449
+ const pillW = textW + 8;
1450
+ const pillH = fSize + 5;
1451
+ // Background pill for readability (skip when very faint)
1452
+ if (labelAlpha > 0.35) {
1453
+ ctx.globalAlpha = opacity * labelAlpha * 0.72;
1454
+ ctx.fillStyle = 'rgba(8,13,26,0.75)';
1455
+ ctx.shadowBlur = 0;
1456
+ if (ctx.roundRect) {
1457
+ ctx.beginPath();
1458
+ ctx.roundRect(x - pillW / 2, labelY - 2, pillW, pillH, 3);
1459
+ ctx.fill();
1460
+ }
1461
+ else {
1462
+ ctx.fillRect(x - pillW / 2, labelY - 2, pillW, pillH);
1463
+ }
1464
+ }
1465
+ ctx.globalAlpha = opacity * labelAlpha;
1466
+ ctx.shadowBlur = prominent ? 6 : 0;
1467
+ ctx.shadowColor = '#000';
1468
+ ctx.fillStyle = selected
1469
+ ? '#ffffff'
1470
+ : hovered
1471
+ ? 'rgba(226,232,240,0.98)'
1472
+ : 'rgba(203,213,225,0.88)';
1473
+ ctx.fillText(label, x, labelY);
1474
+ ctx.shadowBlur = 0;
1475
+ ctx.globalAlpha = opacity;
1476
+ }
1477
+ ctx.restore();
1478
+ }
1479
+ // ── HUD (screen-space) ─────────────────────────────────────
1480
+ drawHud() {
1481
+ const { ctx, w, h } = this;
1482
+ const simActive = !this.paused && this.alpha > this.ALPHA_MIN;
1483
+ ctx.save();
1484
+ // Keyboard hint (top bar, very subtle)
1485
+ if (this.nodes.length > 0) {
1486
+ ctx.globalAlpha = 0.28;
1487
+ ctx.font = '10px Inter, sans-serif';
1488
+ ctx.fillStyle = '#94a3b8';
1489
+ ctx.textAlign = 'left';
1490
+ ctx.textBaseline = 'top';
1491
+ ctx.fillText('Tab: next Shift+Tab: prev ↑↓ or →: neighbor Enter: open F: fit Esc: deselect', 12, 10);
1492
+ ctx.globalAlpha = 1;
1493
+ }
1494
+ // Sim activity dot (top-right)
1495
+ if (simActive) {
1496
+ ctx.globalAlpha = 0.7;
1497
+ ctx.fillStyle = '#2dd4bf';
1498
+ ctx.shadowColor = '#2dd4bf';
1499
+ ctx.shadowBlur = 8;
1500
+ ctx.beginPath();
1501
+ ctx.arc(w - 18, 18, 5, 0, Math.PI * 2);
1502
+ ctx.fill();
1503
+ ctx.shadowBlur = 0;
1504
+ ctx.globalAlpha = 1;
1505
+ }
1506
+ // Node + edge count (bottom-left)
1507
+ const nodeCount = this.filter.visibleNodeIds
1508
+ ? `${this.filter.visibleNodeIds.size}/${this.nodes.length} nodes`
1509
+ : `${this.nodes.length} nodes · ${this.edges.length} edges`;
1510
+ ctx.globalAlpha = 0.45;
1511
+ ctx.font = '10px Inter, sans-serif';
1512
+ ctx.fillStyle = '#94a3b8';
1513
+ ctx.textAlign = 'left';
1514
+ ctx.textBaseline = 'bottom';
1515
+ ctx.fillText(nodeCount, 14, h - 36);
1516
+ ctx.globalAlpha = 1;
1517
+ // Zoom badge (bottom-left)
1518
+ const zoomTxt = `${Math.round(this.scale * 100)}%`;
1519
+ ctx.font = '11px JetBrains Mono, monospace';
1520
+ const tw = ctx.measureText(zoomTxt).width + 18;
1521
+ ctx.fillStyle = 'rgba(8,13,26,0.75)';
1522
+ ctx.globalAlpha = 0.9;
1523
+ if (ctx.roundRect) {
1524
+ ctx.beginPath();
1525
+ ctx.roundRect(12, h - 30, tw, 20, 4);
1526
+ ctx.fill();
1527
+ }
1528
+ else {
1529
+ ctx.fillRect(12, h - 30, tw, 20);
1530
+ }
1531
+ ctx.fillStyle = '#94a3b8';
1532
+ ctx.textAlign = 'left';
1533
+ ctx.textBaseline = 'middle';
1534
+ ctx.fillText(zoomTxt, 21, h - 20);
1535
+ ctx.globalAlpha = 1;
1536
+ ctx.restore();
1537
+ }
1538
+ // ── Tooltip (DOM) ──────────────────────────────────────────
1539
+ renderTooltip() {
1540
+ const edge = this.hoveredEdge;
1541
+ const node = this.hoveredId ? this.nodeMap.get(this.hoveredId) : null;
1542
+ if ((!edge && (!node || node.id === this.filter.selectedId))) {
1543
+ this.hideTooltip();
1544
+ return;
1545
+ }
1546
+ let tt = document.getElementById('gc-tooltip');
1547
+ if (!tt) {
1548
+ tt = document.createElement('div');
1549
+ tt.id = 'gc-tooltip';
1550
+ tt.style.cssText = [
1551
+ 'position:fixed',
1552
+ 'z-index:9999',
1553
+ 'pointer-events:none',
1554
+ 'background:rgba(10,15,30,0.96)',
1555
+ 'border:1px solid rgba(148,163,184,0.18)',
1556
+ 'border-radius:10px',
1557
+ 'padding:10px 14px',
1558
+ 'font-family:Inter,sans-serif',
1559
+ 'font-size:12px',
1560
+ 'color:#e2e8f0',
1561
+ 'max-width:240px',
1562
+ 'backdrop-filter:blur(12px)',
1563
+ 'box-shadow:0 8px 32px rgba(0,0,0,0.6)',
1564
+ 'transition:opacity 0.1s ease',
1565
+ 'line-height:1.5',
1566
+ ].join(';');
1567
+ document.body.appendChild(tt);
1568
+ }
1569
+ const tooltipData = edge ? {
1570
+ kind: 'edge',
1571
+ edge,
1572
+ fromNode: this.nodeMap.get(edge.source.id),
1573
+ toNode: this.nodeMap.get(edge.target.id),
1574
+ } : {
1575
+ kind: 'node',
1576
+ node,
1577
+ };
1578
+ if (tooltipData.kind === 'edge') {
1579
+ const fromName = tooltipData.fromNode?.label || tooltipData.fromNode?.id || tooltipData.edge.source.id;
1580
+ const toName = tooltipData.toNode?.label || tooltipData.toNode?.id || tooltipData.edge.target.id;
1581
+ const directionText = `${escHtml(fromName)} → ${escHtml(toName)}`;
1582
+ tt.innerHTML = `
1583
+ <div style="font-weight:600;font-size:13px;margin-bottom:6px;color:#f1f5f9;word-break:break-all;display:flex;align-items:center;gap:7px;">
1584
+ <span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:${getEdgeColor(tooltipData.edge.type)};flex-shrink:0;box-shadow:0 0 6px ${getEdgeColor(tooltipData.edge.type)}"></span>
1585
+ Relationship
1586
+ </div>
1587
+ <div style="color:#94a3b8;font-size:10px;letter-spacing:0.02em;font-family:'JetBrains Mono',monospace;margin-bottom:6px;">${tooltipData.edge.type}</div>
1588
+ <div style="font-size:11px;line-height:1.4;color:#e2e8f0;"><strong style="color:#64748b">From:</strong> ${directionText}</div>
1589
+ <div style="margin-top:4px;padding-top:7px;border-top:1px solid rgba(148,163,184,0.1);color:#475569;font-size:10px;">
1590
+ Right-click for actions
1591
+ </div>
1592
+ `;
1593
+ }
1594
+ else {
1595
+ const n = tooltipData.node;
1596
+ if (!n) {
1597
+ return;
1598
+ }
1599
+ const degText = `${n.degree} link${n.degree !== 1 ? 's' : ''}`;
1600
+ const laneLabel = n.lane === 'facet' ? 'Metadata' : n.lane === 'external' ? 'External' : 'Item';
1601
+ const tags = n.tags ?? [];
1602
+ const tagHtml = tags.length > 0
1603
+ ? `<div style="margin-top:7px;display:flex;flex-wrap:wrap;gap:3px;">${tags.slice(0, 6).map((t) => {
1604
+ const tc = this.tagColorMap.get(t) ?? '#64748b';
1605
+ return `<span style="font-size:9px;padding:1px 5px;border-radius:8px;background:${hexAlpha(tc, 0.18)};color:${tc};border:1px solid ${hexAlpha(tc, 0.35)}">#${escHtml(t)}</span>`;
1606
+ }).join('')}</div>` : '';
1607
+ tt.innerHTML = `
1608
+ <div style="font-weight:600;font-size:13px;margin-bottom:5px;color:#f1f5f9;word-break:break-all;display:flex;align-items:center;gap:7px;">
1609
+ <span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:${n.color};flex-shrink:0;box-shadow:0 0 6px ${n.color}"></span>
1610
+ ${escHtml(n.label || n.id)}
1611
+ </div>
1612
+ <div style="color:#475569;font-size:10px;font-family:'JetBrains Mono',monospace;margin-bottom:8px;word-break:break-all;">${escHtml(n.id)}</div>
1613
+ <div style="display:grid;grid-template-columns:auto 1fr;gap:3px 10px;font-size:11px;">
1614
+ <span style="color:#64748b;">Type</span><span>${escHtml(n.type)}</span>
1615
+ <span style="color:#64748b;">Status</span><span style="color:${n.color};">${escHtml(n.status)}</span>
1616
+ ${n.priority !== undefined && n.priority !== null ? `<span style="color:#64748b;">Priority</span><span>${escHtml(String(n.priority))}</span>` : ''}
1617
+ ${n.assignee ? `<span style="color:#64748b;">Assignee</span><span>${escHtml(n.assignee)}</span>` : ''}
1618
+ <span style="color:#64748b;">Lane</span><span>${escHtml(laneLabel)}</span>
1619
+ <span style="color:#64748b;">Links</span><span>${escHtml(degText)}</span>
1620
+ </div>
1621
+ ${tagHtml}
1622
+ <div style="margin-top:8px;padding-top:7px;border-top:1px solid rgba(148,163,184,0.1);color:#475569;font-size:10px;">
1623
+ Click to select · Double-click to open
1624
+ </div>
1625
+ `;
1626
+ }
1627
+ tt.style.display = 'block';
1628
+ tt.style.opacity = '1';
1629
+ // Position near cursor / node
1630
+ const rect = this.canvas.getBoundingClientRect();
1631
+ let rawX, rawY;
1632
+ if (tooltipData.kind === 'node' && tooltipData.node) {
1633
+ rawX = tooltipData.node.x;
1634
+ rawY = tooltipData.node.y;
1635
+ }
1636
+ else if (tooltipData.kind === 'edge' && tooltipData.fromNode && tooltipData.toNode) {
1637
+ rawX = (tooltipData.fromNode.x + tooltipData.toNode.x) / 2;
1638
+ rawY = (tooltipData.fromNode.y + tooltipData.toNode.y) / 2;
1639
+ }
1640
+ else {
1641
+ rawX = 0;
1642
+ rawY = 0;
1643
+ }
1644
+ const sx = rawX * this.scale + this.tx + rect.left;
1645
+ const sy = rawY * this.scale + this.ty + rect.top;
1646
+ const ttW = 250;
1647
+ const left = Math.min(sx + 18, window.innerWidth - ttW - 10);
1648
+ const top = Math.max(sy - 70, 8);
1649
+ tt.style.left = `${left}px`;
1650
+ tt.style.top = `${top}px`;
1651
+ }
1652
+ hideTooltip() {
1653
+ const tt = document.getElementById('gc-tooltip');
1654
+ if (tt)
1655
+ tt.style.opacity = '0';
1656
+ }
1657
+ // ── Camera ─────────────────────────────────────────────────
1658
+ zoom(delta, px, py) {
1659
+ const factor = delta > 0 ? 1.11 : 1 / 1.11;
1660
+ const newScale = Math.max(0.04, Math.min(6, this.scale * factor));
1661
+ this.tx = px - (px - this.tx) * (newScale / this.scale);
1662
+ this.ty = py - (py - this.ty) * (newScale / this.scale);
1663
+ this.scale = newScale;
1664
+ this.flyTarget = null;
1665
+ }
1666
+ toWorld(px, py) {
1667
+ return [(px - this.tx) / this.scale, (py - this.ty) / this.scale];
1668
+ }
1669
+ hitTest(wx, wy) {
1670
+ const vis = this.filter.visibleNodeIds;
1671
+ // Use spatial grid for O(1) cell lookup
1672
+ const cs = this.gridCellSize;
1673
+ const cx = Math.floor((wx - this.gridOriginX) / cs);
1674
+ const cy = Math.floor((wy - this.gridOriginY) / cs);
1675
+ // Check this cell and immediate neighbors
1676
+ let best = null;
1677
+ let bestDist = Infinity;
1678
+ for (let dx = -1; dx <= 1; dx++) {
1679
+ for (let dy = -1; dy <= 1; dy++) {
1680
+ const cell = this.gridCells.get((cx + dx) * 10000 + (cy + dy));
1681
+ if (!cell)
1682
+ continue;
1683
+ for (const nd of cell) {
1684
+ if (vis && !vis.has(nd.id))
1685
+ continue;
1686
+ const ddx = wx - nd.x;
1687
+ const ddy = wy - nd.y;
1688
+ const hitR = (nd.r + 6);
1689
+ const d2 = ddx * ddx + ddy * ddy;
1690
+ if (d2 <= hitR * hitR && d2 < bestDist) {
1691
+ best = nd;
1692
+ bestDist = d2;
1693
+ }
1694
+ }
1695
+ }
1696
+ }
1697
+ return best;
1698
+ }
1699
+ // ── Events ─────────────────────────────────────────────────
1700
+ getPos(e) {
1701
+ const r = this.canvas.getBoundingClientRect();
1702
+ return { x: e.clientX - r.left, y: e.clientY - r.top };
1703
+ }
1704
+ bindEvents() {
1705
+ const sig = { signal: this.abortCtrl.signal };
1706
+ this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e), sig);
1707
+ window.addEventListener('mousemove', (e) => this.onMouseMove(e), sig);
1708
+ window.addEventListener('mouseup', (e) => this.onMouseUp(e), sig);
1709
+ this.canvas.addEventListener('wheel', (e) => this.onWheel(e), { ...sig, passive: false });
1710
+ this.canvas.addEventListener('dblclick', (e) => this.onDblClick(e), sig);
1711
+ this.canvas.addEventListener('keydown', (e) => this.onKeyDown(e), sig);
1712
+ this.canvas.addEventListener('mouseleave', () => { this.hideTooltip(); this.hoveredId = null; }, sig);
1713
+ this.canvas.addEventListener('contextmenu', (e) => this.onCtxMenu(e), sig);
1714
+ this.canvas.addEventListener('touchstart', (e) => this.onTouchStart(e), { ...sig, passive: false });
1715
+ this.canvas.addEventListener('touchmove', (e) => this.onTouchMove(e), { ...sig, passive: false });
1716
+ this.canvas.addEventListener('touchend', (e) => this.onTouchEnd(e), { ...sig, passive: false });
1717
+ }
1718
+ onCtxMenu(e) {
1719
+ e.preventDefault();
1720
+ const { x, y } = this.getPos(e);
1721
+ const [wx, wy] = this.toWorld(x, y);
1722
+ const hit = this.hitTest(wx, wy);
1723
+ if (!hit)
1724
+ return;
1725
+ this.canvas.focus();
1726
+ this.onContextMenu(hit.id, e.clientX, e.clientY);
1727
+ }
1728
+ onKeyDown(e) {
1729
+ switch (e.key) {
1730
+ case 'Tab':
1731
+ e.preventDefault();
1732
+ this.navigateToNeighbor(e.shiftKey ? 'prev' : 'next');
1733
+ break;
1734
+ case 'ArrowRight':
1735
+ case 'ArrowDown':
1736
+ e.preventDefault();
1737
+ this.navigateToNeighbor('first-neighbor');
1738
+ break;
1739
+ case 'ArrowLeft':
1740
+ case 'ArrowUp':
1741
+ e.preventDefault();
1742
+ this.navigateToNeighbor('prev');
1743
+ break;
1744
+ case 'Escape':
1745
+ if (this.filter.selectedId) {
1746
+ this.filter = { ...this.filter, selectedId: null };
1747
+ this.particles = [];
1748
+ this.onSelectNode(null);
1749
+ }
1750
+ break;
1751
+ case 'Enter':
1752
+ if (this.filter.selectedId) {
1753
+ this.onOpenNode(this.filter.selectedId);
1754
+ }
1755
+ break;
1756
+ case 'f':
1757
+ case 'F':
1758
+ this.fitView();
1759
+ break;
1760
+ }
1761
+ }
1762
+ onMouseDown(e) {
1763
+ if (e.button !== 0)
1764
+ return;
1765
+ this.canvas.focus();
1766
+ const { x, y } = this.getPos(e);
1767
+ this.lastX = x;
1768
+ this.lastY = y;
1769
+ this.downX = x;
1770
+ this.downY = y;
1771
+ this.hasMoved = false;
1772
+ const [wx, wy] = this.toWorld(x, y);
1773
+ const hit = this.hitTest(wx, wy);
1774
+ if (hit) {
1775
+ this.isDraggingNode = true;
1776
+ this.dragNode = hit;
1777
+ hit.fx = hit.x;
1778
+ hit.fy = hit.y;
1779
+ this.alpha = Math.max(this.alpha, 0.25);
1780
+ this.canvas.style.cursor = 'grabbing';
1781
+ }
1782
+ else {
1783
+ this.isDraggingCanvas = true;
1784
+ this.canvas.style.cursor = 'grabbing';
1785
+ }
1786
+ }
1787
+ onMouseMove(e) {
1788
+ const r = this.canvas.getBoundingClientRect();
1789
+ const x = e.clientX - r.left;
1790
+ const y = e.clientY - r.top;
1791
+ const dx = x - this.lastX;
1792
+ const dy = y - this.lastY;
1793
+ if (Math.abs(x - this.downX) > 3 || Math.abs(y - this.downY) > 3)
1794
+ this.hasMoved = true;
1795
+ if (this.isDraggingNode && this.dragNode) {
1796
+ const [wx, wy] = this.toWorld(x, y);
1797
+ this.dragNode.fx = wx;
1798
+ this.dragNode.fy = wy;
1799
+ this.flyTarget = null;
1800
+ }
1801
+ else if (this.isDraggingCanvas) {
1802
+ this.tx += dx;
1803
+ this.ty += dy;
1804
+ this.flyTarget = null;
1805
+ }
1806
+ else {
1807
+ const [wx, wy] = this.toWorld(x, y);
1808
+ const hit = this.hitTest(wx, wy);
1809
+ const newHov = hit?.id ?? null;
1810
+ if (newHov !== this.hoveredId) {
1811
+ this.hoveredId = newHov;
1812
+ this.canvas.style.cursor = newHov ? 'pointer' : 'grab';
1813
+ if (!newHov)
1814
+ this.hideTooltip();
1815
+ }
1816
+ }
1817
+ this.lastX = x;
1818
+ this.lastY = y;
1819
+ }
1820
+ onMouseUp(e) {
1821
+ if (e.button !== 0)
1822
+ return;
1823
+ if (this.isDraggingNode && this.dragNode) {
1824
+ if (!this.hasMoved) {
1825
+ const id = this.dragNode.id;
1826
+ const newSel = this.filter.selectedId === id ? null : id;
1827
+ this.filter = { ...this.filter, selectedId: newSel };
1828
+ this.particles = [];
1829
+ this.lastParticleSpawn = 0;
1830
+ this.onSelectNode(newSel);
1831
+ if (newSel)
1832
+ this.flyTo(this.dragNode);
1833
+ }
1834
+ this.dragNode.fx = null;
1835
+ this.dragNode.fy = null;
1836
+ this.dragNode = null;
1837
+ this.isDraggingNode = false;
1838
+ }
1839
+ else if (this.isDraggingCanvas) {
1840
+ this.isDraggingCanvas = false;
1841
+ if (!this.hasMoved && this.filter.selectedId) {
1842
+ this.filter = { ...this.filter, selectedId: null };
1843
+ this.particles = [];
1844
+ this.onSelectNode(null);
1845
+ }
1846
+ }
1847
+ this.canvas.style.cursor = this.hoveredId ? 'pointer' : 'grab';
1848
+ }
1849
+ onWheel(e) {
1850
+ e.preventDefault();
1851
+ const { x, y } = this.getPos(e);
1852
+ const delta = e.deltaMode === 1 ? -e.deltaY * 20 : -e.deltaY;
1853
+ this.zoom(delta, x, y);
1854
+ }
1855
+ onDblClick(e) {
1856
+ const { x, y } = this.getPos(e);
1857
+ const [wx, wy] = this.toWorld(x, y);
1858
+ const hit = this.hitTest(wx, wy);
1859
+ if (hit)
1860
+ this.onOpenNode(hit.id);
1861
+ }
1862
+ onTouchStart(e) {
1863
+ e.preventDefault();
1864
+ const rect = this.canvas.getBoundingClientRect();
1865
+ if (e.touches.length === 1) {
1866
+ const x = e.touches[0].clientX - rect.left;
1867
+ const y = e.touches[0].clientY - rect.top;
1868
+ this.lastX = x;
1869
+ this.lastY = y;
1870
+ this.downX = x;
1871
+ this.downY = y;
1872
+ this.hasMoved = false;
1873
+ const [wx, wy] = this.toWorld(x, y);
1874
+ const hit = this.hitTest(wx, wy);
1875
+ if (hit) {
1876
+ this.isDraggingNode = true;
1877
+ this.dragNode = hit;
1878
+ hit.fx = hit.x;
1879
+ hit.fy = hit.y;
1880
+ this.alpha = Math.max(this.alpha, 0.25);
1881
+ }
1882
+ else {
1883
+ this.isDraggingCanvas = true;
1884
+ }
1885
+ }
1886
+ else if (e.touches.length === 2) {
1887
+ this.isDraggingNode = false;
1888
+ this.isDraggingCanvas = false;
1889
+ if (this.dragNode) {
1890
+ this.dragNode.fx = null;
1891
+ this.dragNode.fy = null;
1892
+ this.dragNode = null;
1893
+ }
1894
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
1895
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
1896
+ this.touchDist = Math.sqrt(dx * dx + dy * dy);
1897
+ this.touchMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
1898
+ this.touchMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
1899
+ }
1900
+ }
1901
+ onTouchMove(e) {
1902
+ e.preventDefault();
1903
+ const rect = this.canvas.getBoundingClientRect();
1904
+ if (e.touches.length === 1) {
1905
+ const x = e.touches[0].clientX - rect.left;
1906
+ const y = e.touches[0].clientY - rect.top;
1907
+ const dx = x - this.lastX;
1908
+ const dy = y - this.lastY;
1909
+ if (Math.abs(x - this.downX) > 5 || Math.abs(y - this.downY) > 5)
1910
+ this.hasMoved = true;
1911
+ if (this.isDraggingNode && this.dragNode) {
1912
+ const [wx, wy] = this.toWorld(x, y);
1913
+ this.dragNode.fx = wx;
1914
+ this.dragNode.fy = wy;
1915
+ }
1916
+ else if (this.isDraggingCanvas) {
1917
+ this.tx += dx;
1918
+ this.ty += dy;
1919
+ this.flyTarget = null;
1920
+ }
1921
+ this.lastX = x;
1922
+ this.lastY = y;
1923
+ }
1924
+ else if (e.touches.length === 2) {
1925
+ const dx = e.touches[0].clientX - e.touches[1].clientX;
1926
+ const dy = e.touches[0].clientY - e.touches[1].clientY;
1927
+ const dist = Math.sqrt(dx * dx + dy * dy);
1928
+ const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
1929
+ const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
1930
+ this.zoom((dist - this.touchDist) * 0.6, midX, midY);
1931
+ this.tx += midX - this.touchMidX;
1932
+ this.ty += midY - this.touchMidY;
1933
+ this.touchDist = dist;
1934
+ this.touchMidX = midX;
1935
+ this.touchMidY = midY;
1936
+ this.flyTarget = null;
1937
+ }
1938
+ }
1939
+ onTouchEnd(e) {
1940
+ e.preventDefault();
1941
+ if (e.touches.length === 0) {
1942
+ if (this.isDraggingNode && this.dragNode) {
1943
+ if (!this.hasMoved) {
1944
+ const id = this.dragNode.id;
1945
+ const newSel = this.filter.selectedId === id ? null : id;
1946
+ this.filter = { ...this.filter, selectedId: newSel };
1947
+ this.particles = [];
1948
+ this.lastParticleSpawn = 0;
1949
+ this.onSelectNode(newSel);
1950
+ if (newSel)
1951
+ this.flyTo(this.dragNode);
1952
+ }
1953
+ this.dragNode.fx = null;
1954
+ this.dragNode.fy = null;
1955
+ this.dragNode = null;
1956
+ }
1957
+ this.isDraggingNode = false;
1958
+ this.isDraggingCanvas = false;
1959
+ }
1960
+ }
1961
+ // ── Resize ─────────────────────────────────────────────────
1962
+ onResize() {
1963
+ const rect = this.canvas.getBoundingClientRect();
1964
+ this.w = rect.width;
1965
+ this.h = rect.height;
1966
+ this.canvas.width = rect.width * this.dpr;
1967
+ this.canvas.height = rect.height * this.dpr;
1968
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
1969
+ }
1970
+ // ── RAF loop ───────────────────────────────────────────────
1971
+ startLoop() {
1972
+ let last = performance.now();
1973
+ const loop = (now) => {
1974
+ if (this.destroyed)
1975
+ return;
1976
+ const dt = Math.min(now - last, 80);
1977
+ last = now;
1978
+ this.tick(dt);
1979
+ this.advanceFly();
1980
+ this.draw();
1981
+ this.rafId = requestAnimationFrame(loop);
1982
+ };
1983
+ this.rafId = requestAnimationFrame(loop);
1984
+ }
1985
+ }
1986
+ //# sourceMappingURL=graph-canvas.js.map