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