@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.
- package/CHANGELOG.md +7 -0
- package/README.md +107 -0
- package/dist/auth.js +20 -0
- package/dist/auth.js.map +1 -0
- package/dist/crypto.js +42 -0
- package/dist/crypto.js.map +1 -0
- package/dist/db.js +111 -0
- package/dist/db.js.map +1 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/auth.js +16 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/routes/admin.js +207 -0
- package/dist/routes/admin.js.map +1 -0
- package/dist/routes/auth.js +163 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/github.js +354 -0
- package/dist/routes/github.js.map +1 -0
- package/dist/routes/groups.js +180 -0
- package/dist/routes/groups.js.map +1 -0
- package/dist/routes/pm.js +2446 -0
- package/dist/routes/pm.js.map +1 -0
- package/dist/routes/projects.js +151 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/sharing.js +155 -0
- package/dist/routes/sharing.js.map +1 -0
- package/dist/server.js +64 -0
- package/dist/server.js.map +1 -0
- package/dist/services/pm-runner.js +190 -0
- package/dist/services/pm-runner.js.map +1 -0
- package/dist/services/sse.js +111 -0
- package/dist/services/sse.js.map +1 -0
- package/manifest.json +15 -0
- package/package.json +111 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/index.html +265 -0
- package/public/manifest.json +66 -0
- package/public/src/api.js +28 -0
- package/public/src/api.js.map +1 -0
- package/public/src/api.ts +29 -0
- package/public/src/app.js +926 -0
- package/public/src/app.js.map +1 -0
- package/public/src/app.ts +929 -0
- package/public/src/components/modals.js +62 -0
- package/public/src/components/modals.js.map +1 -0
- package/public/src/components/modals.ts +73 -0
- package/public/src/components/toast.js +10 -0
- package/public/src/components/toast.js.map +1 -0
- package/public/src/components/toast.ts +13 -0
- package/public/src/constants.js +30 -0
- package/public/src/constants.js.map +1 -0
- package/public/src/constants.ts +41 -0
- package/public/src/state.js +15 -0
- package/public/src/state.js.map +1 -0
- package/public/src/state.ts +19 -0
- package/public/src/types.js +5 -0
- package/public/src/types.js.map +1 -0
- package/public/src/types.ts +253 -0
- package/public/src/utils.js +57 -0
- package/public/src/utils.js.map +1 -0
- package/public/src/utils.ts +56 -0
- package/public/src/views/activity.js +47 -0
- package/public/src/views/activity.js.map +1 -0
- package/public/src/views/activity.ts +41 -0
- package/public/src/views/admin.js +435 -0
- package/public/src/views/admin.js.map +1 -0
- package/public/src/views/admin.ts +504 -0
- package/public/src/views/auth.js +81 -0
- package/public/src/views/auth.js.map +1 -0
- package/public/src/views/auth.ts +74 -0
- package/public/src/views/calendar.js +133 -0
- package/public/src/views/calendar.js.map +1 -0
- package/public/src/views/calendar.ts +129 -0
- package/public/src/views/comments-audit.js +109 -0
- package/public/src/views/comments-audit.js.map +1 -0
- package/public/src/views/comments-audit.ts +108 -0
- package/public/src/views/config.js +322 -0
- package/public/src/views/config.js.map +1 -0
- package/public/src/views/config.ts +344 -0
- package/public/src/views/context.js +98 -0
- package/public/src/views/context.js.map +1 -0
- package/public/src/views/context.ts +100 -0
- package/public/src/views/create.js +293 -0
- package/public/src/views/create.js.map +1 -0
- package/public/src/views/create.ts +246 -0
- package/public/src/views/dedupe.js +51 -0
- package/public/src/views/dedupe.js.map +1 -0
- package/public/src/views/dedupe.ts +43 -0
- package/public/src/views/export.js +300 -0
- package/public/src/views/export.js.map +1 -0
- package/public/src/views/export.ts +274 -0
- package/public/src/views/github.js +360 -0
- package/public/src/views/github.js.map +1 -0
- package/public/src/views/github.ts +308 -0
- package/public/src/views/graph-canvas.js +1986 -0
- package/public/src/views/graph-canvas.js.map +1 -0
- package/public/src/views/graph-canvas.ts +2218 -0
- package/public/src/views/graph.js +1824 -0
- package/public/src/views/graph.js.map +1 -0
- package/public/src/views/graph.ts +1891 -0
- package/public/src/views/groups.js +186 -0
- package/public/src/views/groups.js.map +1 -0
- package/public/src/views/groups.ts +172 -0
- package/public/src/views/guide.js +151 -0
- package/public/src/views/guide.js.map +1 -0
- package/public/src/views/guide.ts +162 -0
- package/public/src/views/health.js +105 -0
- package/public/src/views/health.js.map +1 -0
- package/public/src/views/health.ts +102 -0
- package/public/src/views/items.js +1306 -0
- package/public/src/views/items.js.map +1 -0
- package/public/src/views/items.ts +1196 -0
- package/public/src/views/normalize.js +67 -0
- package/public/src/views/normalize.js.map +1 -0
- package/public/src/views/normalize.ts +58 -0
- package/public/src/views/plan.js +454 -0
- package/public/src/views/plan.js.map +1 -0
- package/public/src/views/plan.ts +496 -0
- package/public/src/views/projects.js +204 -0
- package/public/src/views/projects.js.map +1 -0
- package/public/src/views/projects.ts +196 -0
- package/public/src/views/router.js +227 -0
- package/public/src/views/router.js.map +1 -0
- package/public/src/views/router.ts +188 -0
- package/public/src/views/search.js +103 -0
- package/public/src/views/search.js.map +1 -0
- package/public/src/views/search.ts +94 -0
- package/public/src/views/settings.js +272 -0
- package/public/src/views/settings.js.map +1 -0
- package/public/src/views/settings.ts +190 -0
- package/public/src/views/shared.js +49 -0
- package/public/src/views/shared.js.map +1 -0
- package/public/src/views/shared.ts +49 -0
- package/public/src/views/sharing.js +152 -0
- package/public/src/views/sharing.js.map +1 -0
- package/public/src/views/sharing.ts +139 -0
- package/public/src/views/stats.js +92 -0
- package/public/src/views/stats.js.map +1 -0
- package/public/src/views/stats.ts +88 -0
- package/public/src/views/templates.js +117 -0
- package/public/src/views/templates.js.map +1 -0
- package/public/src/views/templates.ts +113 -0
- package/public/src/views/validate.js +54 -0
- package/public/src/views/validate.js.map +1 -0
- package/public/src/views/validate.ts +48 -0
- package/public/styles.css +2231 -0
- package/public/sw.js +318 -0
- package/public/tsconfig.json +20 -0
- 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, '&')
|
|
223
|
+
.replace(/</g, '<')
|
|
224
|
+
.replace(/>/g, '>')
|
|
225
|
+
.replace(/"/g, '"');
|
|
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
|
+
}
|