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