|
158 | 158 | <div class="legend" id="legend"></div> |
159 | 159 |
|
160 | 160 | <div class="hint"> |
161 | | - <kbd>scroll</kbd> zoom <kbd>drag bg</kbd> pan<br> |
162 | | - <kbd>drag node</kbd> pin <kbd>click</kbd> inspect |
| 161 | + <kbd>scroll</kbd> zoom <kbd>drag bg</kbd> rotate<br> |
| 162 | + <kbd>drag node</kbd> move <kbd>click</kbd> inspect |
163 | 163 | </div> |
164 | 164 |
|
165 | 165 | <div class="panel" id="panel"></div> |
|
187 | 187 |
|
188 | 188 | // build working nodes/edges from GRAPH (object-form edges: e.source/e.target) |
189 | 189 | const nodes = GRAPH.nodes.map(n => ({ |
190 | | - ...n, x:0, y:0, vx:0, vy:0, |
| 190 | + ...n, x:0, y:0, z:0, vx:0, vy:0, vz:0, |
191 | 191 | r: 8 + Math.min(10, (n.in || 0) + (n.out || 0)), // radius scales with degree |
192 | | - alpha: 1 // per-node fade (legend toggle) |
| 192 | + alpha: 1, // per-node fade (legend toggle) |
| 193 | + sx:0, sy:0, rz:0, pp:1 // per-frame 3D projection cache |
193 | 194 | })); |
194 | 195 | const byId = Object.fromEntries(nodes.map(n => [n.id, n])); |
195 | 196 | const edges = GRAPH.edges.filter(e => byId[e.source] && byId[e.target]); |
|
211 | 212 | const visible = n => n.alpha > 0.02; // for physics/picking (fully-faded skipped) |
212 | 213 |
|
213 | 214 | let hover = null, drag = null, selected = null; |
214 | | -let scale = 1, panX = 0, panY = 0; |
215 | | -let panning = false, panStartX = 0, panStartY = 0, panOrigX = 0, panOrigY = 0; |
| 215 | +let scale = 1.35, panX = 0, panY = 0; |
| 216 | +let yaw = 0.5, pitch = -0.28; // 3D orbit angles — drag the background to rotate |
| 217 | +let orbiting = false, oStartX = 0, oStartY = 0, yawStart = 0, pitchStart = 0; |
216 | 218 | let didDrag = false; |
217 | 219 | let alpha = 1; // simulated-annealing "temperature"; cools to 0 so motion stops |
218 | 220 |
|
|
225 | 227 | canvas.height = H() * DPR; |
226 | 228 | } |
227 | 229 | function seed(){ |
228 | | - const cx = W()/2, cy = H()/2; |
| 230 | + const R = 250; |
229 | 231 | nodes.forEach((n,i) => { |
230 | | - const a = i / Math.max(1, nodes.length) * 6.28318; |
231 | | - const rad = 150 + Math.random()*60; |
232 | | - n.x = cx + Math.cos(a)*rad + (Math.random()-.5)*40; |
233 | | - n.y = cy + Math.sin(a)*(rad*0.8) + (Math.random()-.5)*40; |
234 | | - n.vx = 0; n.vy = 0; |
| 232 | + // spread nodes over a sphere (golden-angle spiral) so 3D structure shows from the start |
| 233 | + const phi = Math.acos(1 - 2*(i+0.5)/Math.max(1,nodes.length)); |
| 234 | + const theta = 2.399963 * i; |
| 235 | + const rad = R * (0.55 + Math.random()*0.45); |
| 236 | + n.x = rad*Math.sin(phi)*Math.cos(theta); |
| 237 | + n.y = rad*Math.sin(phi)*Math.sin(theta); |
| 238 | + n.z = rad*Math.cos(phi); |
| 239 | + n.vx = n.vy = n.vz = 0; |
235 | 240 | }); |
236 | 241 | } |
237 | 242 |
|
238 | 243 | /* ===================== physics (adapted from okf-spec.html step()) ===================== */ |
239 | 244 | const VMAX = 9; // per-frame speed cap → no violent swings, even on a dense hub |
240 | 245 | function step(){ |
241 | | - const cx = W()/2, cy = H()/2; |
242 | 246 | for(let i=0;i<nodes.length;i++){ |
243 | 247 | const a = nodes[i]; |
244 | 248 | if(!visible(a)) continue; |
245 | 249 | for(let j=i+1;j<nodes.length;j++){ |
246 | 250 | const b = nodes[j]; |
247 | 251 | if(!visible(b)) continue; |
248 | | - let dx = a.x-b.x, dy = a.y-b.y, d = Math.hypot(dx,dy) || 1; |
| 252 | + let dx = a.x-b.x, dy = a.y-b.y, dz = a.z-b.z, d = Math.hypot(dx,dy,dz) || 1; |
249 | 253 | const dd = Math.max(d, 18); // floor distance so near-coincident nodes don't explode |
250 | 254 | const f = 2600/(dd*dd) * alpha; |
251 | | - a.vx += dx/d*f; a.vy += dy/d*f; |
252 | | - b.vx -= dx/d*f; b.vy -= dy/d*f; |
| 255 | + a.vx += dx/d*f; a.vy += dy/d*f; a.vz += dz/d*f; |
| 256 | + b.vx -= dx/d*f; b.vy -= dy/d*f; b.vz -= dz/d*f; |
253 | 257 | } |
254 | | - a.vx += (cx-a.x)*0.0016*alpha; a.vy += (cy-a.y)*0.0016*alpha; |
| 258 | + a.vx += -a.x*0.0016*alpha; a.vy += -a.y*0.0016*alpha; a.vz += -a.z*0.0016*alpha; // pull toward origin |
255 | 259 | } |
256 | 260 | edges.forEach(e => { |
257 | 261 | const a = byId[e.source], b = byId[e.target]; |
258 | 262 | if(!visible(a) || !visible(b)) return; |
259 | | - let dx = b.x-a.x, dy = b.y-a.y, d = Math.hypot(dx,dy) || 1; |
| 263 | + let dx = b.x-a.x, dy = b.y-a.y, dz = b.z-a.z, d = Math.hypot(dx,dy,dz) || 1; |
260 | 264 | // normalize the pull by degree: a hub with N springs shouldn't feel N× the force |
261 | 265 | const s = 1 / Math.min(adj[e.source].length || 1, adj[e.target].length || 1); |
262 | 266 | const f = (d-118)*0.012 * s * alpha; |
263 | | - a.vx += dx/d*f; a.vy += dy/d*f; |
264 | | - b.vx -= dx/d*f; b.vy -= dy/d*f; |
| 267 | + a.vx += dx/d*f; a.vy += dy/d*f; a.vz += dz/d*f; |
| 268 | + b.vx -= dx/d*f; b.vy -= dy/d*f; b.vz -= dz/d*f; |
265 | 269 | }); |
266 | 270 | nodes.forEach(n => { |
267 | 271 | // smoothly approach the legend-toggle target opacity |
268 | 272 | const target = typeVisible(n.type) ? 1 : 0; |
269 | 273 | n.alpha += (target - n.alpha) * 0.12; |
270 | | - if(n === drag){ n.vx = n.vy = 0; return; } // pinned: position set by drag handler |
271 | | - n.vx *= 0.84; n.vy *= 0.84; // stronger damping dissipates energy fast |
| 274 | + if(n === drag){ n.vx = n.vy = n.vz = 0; return; } // pinned: position set by drag handler |
| 275 | + n.vx *= 0.84; n.vy *= 0.84; n.vz *= 0.84; // stronger damping dissipates energy fast |
272 | 276 | if(n.vx > VMAX) n.vx = VMAX; else if(n.vx < -VMAX) n.vx = -VMAX; |
273 | 277 | if(n.vy > VMAX) n.vy = VMAX; else if(n.vy < -VMAX) n.vy = -VMAX; |
274 | | - n.x += n.vx; n.y += n.vy; |
| 278 | + if(n.vz > VMAX) n.vz = VMAX; else if(n.vz < -VMAX) n.vz = -VMAX; |
| 279 | + n.x += n.vx; n.y += n.vy; n.z += n.vz; |
275 | 280 | }); |
276 | 281 | alpha += (0 - alpha) * 0.02; // anneal: forces fade → the layout settles and stops |
277 | 282 | } |
278 | 283 |
|
279 | | -/* ===================== zoom / pan transform ===================== */ |
280 | | -function applyView(){ ctx.setTransform(DPR*scale, 0, 0, DPR*scale, DPR*panX, DPR*panY); } |
281 | | -function toWorld(px,py){ return { x:(px-panX)/scale, y:(py-panY)/scale }; } |
282 | | -function centerOn(n){ panX = W()/2 - n.x*scale; panY = H()/2 - n.y*scale; } |
| 284 | +/* ===================== 3D orbit projection + perspective ===================== */ |
| 285 | +const FOCAL = 900; |
| 286 | +function project(){ |
| 287 | + const cyw = Math.cos(yaw), syw = Math.sin(yaw), cpt = Math.cos(pitch), spt = Math.sin(pitch); |
| 288 | + const ox = W()/2 + panX, oy = H()/2 + panY; |
| 289 | + for(const n of nodes){ |
| 290 | + const x1 = n.x*cyw + n.z*syw; // rotate about Y (yaw) |
| 291 | + const z1 = -n.x*syw + n.z*cyw; |
| 292 | + const y2 = n.y*cpt - z1*spt; // then about X (pitch) |
| 293 | + const z2 = n.y*spt + z1*cpt; |
| 294 | + const pp = FOCAL/(FOCAL + z2); // perspective: near → larger |
| 295 | + n.rz = z2; n.pp = pp; |
| 296 | + n.sx = ox + x1*pp*scale; |
| 297 | + n.sy = oy + y2*pp*scale; |
| 298 | + } |
| 299 | +} |
| 300 | +// inverse of project for one node, holding its current depth — used while dragging |
| 301 | +function unproject(sx, sy, n){ |
| 302 | + const cyw = Math.cos(yaw), syw = Math.sin(yaw), cpt = Math.cos(pitch), spt = Math.sin(pitch); |
| 303 | + const ox = W()/2 + panX, oy = H()/2 + panY; |
| 304 | + const pp = n.pp || 1, z2 = n.rz || 0; |
| 305 | + const x1 = (sx - ox)/(pp*scale); |
| 306 | + const y2 = (sy - oy)/(pp*scale); |
| 307 | + const y = y2*cpt + z2*spt; // invert pitch |
| 308 | + const z1 = -y2*spt + z2*cpt; |
| 309 | + return { x: x1*cyw - z1*syw, y, z: x1*syw + z1*cyw }; // invert yaw |
| 310 | +} |
| 311 | +function centerOn(n){ panX += (W()/2 - n.sx); panY += (H()/2 - n.sy); } |
283 | 312 |
|
284 | 313 | canvas.addEventListener("wheel", e => { |
285 | 314 | e.preventDefault(); |
286 | 315 | const f = Math.exp(-e.deltaY * 0.0015); |
287 | | - const ns = Math.max(0.25, Math.min(4, scale*f)); |
288 | | - const k = ns/scale; |
289 | | - const mx = e.offsetX, my = e.offsetY; |
290 | | - panX = mx - (mx-panX)*k; panY = my - (my-panY)*k; |
291 | | - scale = ns; |
| 316 | + scale = Math.max(0.3, Math.min(4.5, scale*f)); // zoom about the screen centre |
292 | 317 | }, {passive:false}); |
293 | 318 |
|
294 | 319 | /* ===================== picking ===================== */ |
295 | | -function pick(wx,wy){ |
296 | | - for(let i=nodes.length-1;i>=0;i--){ |
297 | | - const n = nodes[i]; |
| 320 | +function pick(sx, sy){ |
| 321 | + let best = null, bestDepth = Infinity; |
| 322 | + for(const n of nodes){ |
298 | 323 | if(!visible(n)) continue; |
299 | | - if(Math.hypot(n.x-wx, n.y-wy) < n.r + 6) return n; |
| 324 | + const rr = n.r*n.pp*scale + 6; |
| 325 | + if(Math.hypot(n.sx - sx, n.sy - sy) < rr && n.rz < bestDepth){ |
| 326 | + best = n; bestDepth = n.rz; // nearest (smallest depth) wins where discs overlap |
| 327 | + } |
300 | 328 | } |
301 | | - return null; |
| 329 | + return best; |
302 | 330 | } |
303 | 331 |
|
304 | 332 | /* ===================== rendering ===================== */ |
|
308 | 336 | if(al < 0.02) return; |
309 | 337 | const hot = hover && (e.source===hover || e.target===hover); |
310 | 338 | const dim = hover && !hot; |
311 | | - ctx.globalAlpha = al; |
312 | | - ctx.strokeStyle = hot ? "rgba(45,212,191,.85)" : (dim ? "rgba(255,255,255,.04)" : "rgba(255,255,255,.13)"); |
313 | | - ctx.lineWidth = (hot ? 2 : 1) / scale; |
314 | | - ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke(); |
315 | | - // arrowhead at b, backed off by b's radius |
316 | | - const ang = Math.atan2(b.y-a.y, b.x-a.x); |
317 | | - const off = b.r + 4; |
318 | | - const hx = b.x - Math.cos(ang)*off, hy = b.y - Math.sin(ang)*off; |
319 | | - const ah = 8/Math.max(.6,scale); |
320 | | - ctx.fillStyle = hot ? "rgba(45,212,191,.9)" : (dim ? "rgba(255,255,255,.04)" : "rgba(255,255,255,.22)"); |
| 339 | + const ax=a.sx, ay=a.sy, bx=b.sx, by=b.sy; |
| 340 | + const fog = Math.max(0.2, Math.min(1, 1 - (a.rz+b.rz)/1300)); // far edges recede |
| 341 | + ctx.globalAlpha = al * (hot ? 1 : fog); |
| 342 | + ctx.strokeStyle = hot ? "rgba(45,212,191,.85)" : (dim ? "rgba(255,255,255,.035)" : "rgba(255,255,255,.12)"); |
| 343 | + ctx.lineWidth = hot ? 2 : 1; |
| 344 | + ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke(); |
| 345 | + // arrowhead at b, backed off by b's projected radius |
| 346 | + const ang = Math.atan2(by-ay, bx-ax); |
| 347 | + const off = b.r*b.pp*scale + 4; |
| 348 | + const hx = bx - Math.cos(ang)*off, hy = by - Math.sin(ang)*off; |
| 349 | + const ah = 7; |
| 350 | + ctx.fillStyle = hot ? "rgba(45,212,191,.9)" : (dim ? "rgba(255,255,255,.035)" : "rgba(255,255,255,.2)"); |
321 | 351 | ctx.beginPath(); |
322 | 352 | ctx.moveTo(hx, hy); |
323 | 353 | ctx.lineTo(hx - Math.cos(ang-.4)*ah, hy - Math.sin(ang-.4)*ah); |
|
326 | 356 | // animated flow particle along highlighted edges |
327 | 357 | if(hot){ |
328 | 358 | const k = (t*0.0006) % 1; |
329 | | - const fx = a.x + (b.x-a.x)*k, fy = a.y + (b.y-a.y)*k; |
| 359 | + const fx = ax + (bx-ax)*k, fy = ay + (by-ay)*k; |
330 | 360 | ctx.shadowColor = "rgba(45,212,191,.9)"; ctx.shadowBlur = 8; |
331 | 361 | ctx.fillStyle = "rgba(170,255,240,.98)"; |
332 | | - ctx.beginPath(); ctx.arc(fx, fy, 2.6/Math.max(.6,scale), 0, 6.2832); ctx.fill(); |
| 362 | + ctx.beginPath(); ctx.arc(fx, fy, 2.8, 0, 6.2832); ctx.fill(); |
333 | 363 | ctx.shadowBlur = 0; |
334 | 364 | } |
335 | 365 | ctx.globalAlpha = 1; |
|
343 | 373 | // idle breathing for hubs (when nothing is hovered) |
344 | 374 | const breathe = (!hover && hubIds.has(n.id)) ? (1 + 0.10*Math.sin(t*0.0024)) : 1; |
345 | 375 | const pulse = (n.id===hover) ? (1.32 + 0.10*Math.sin(t*0.006)) : 1; |
346 | | - const r = n.r * pulse * breathe; |
347 | | - ctx.globalAlpha = n.alpha; |
| 376 | + const r = Math.max(2, n.r * pulse * breathe * n.pp * scale); // perspective: near = larger |
| 377 | + const fog = Math.max(0.32, Math.min(1, 1 - n.rz/640)); // depth cue: far nodes recede |
| 378 | + const x = n.sx, y = n.sy; |
| 379 | + ctx.globalAlpha = n.alpha * (faded ? 0.9 : fog); |
348 | 380 | // selected ring |
349 | 381 | if(n.id === selected){ |
350 | | - ctx.beginPath(); ctx.arc(n.x, n.y, r+6, 0, 6.2832); |
351 | | - ctx.strokeStyle = `rgba(${col},.85)`; ctx.lineWidth = 2/scale; ctx.stroke(); |
| 382 | + ctx.beginPath(); ctx.arc(x, y, r+6, 0, 6.2832); |
| 383 | + ctx.strokeStyle = `rgba(${col},.85)`; ctx.lineWidth = 2; ctx.stroke(); |
352 | 384 | } |
353 | 385 | // hover halo |
354 | 386 | if(n.id === hover){ |
355 | | - ctx.beginPath(); ctx.arc(n.x, n.y, r+11, 0, 6.2832); |
| 387 | + ctx.beginPath(); ctx.arc(x, y, r+11, 0, 6.2832); |
356 | 388 | ctx.fillStyle = `rgba(${col},.16)`; ctx.fill(); |
357 | 389 | } |
358 | 390 | // disc + glow |
359 | | - ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, 6.2832); |
| 391 | + ctx.beginPath(); ctx.arc(x, y, r, 0, 6.2832); |
360 | 392 | ctx.fillStyle = `rgba(${col},${faded ? .25 : 1})`; |
361 | 393 | ctx.shadowColor = `rgba(${col},.85)`; |
362 | | - ctx.shadowBlur = (faded ? 0 : (n.id===hover ? 22 : 14)) / Math.max(.6, scale); |
| 394 | + ctx.shadowBlur = (faded ? 0 : (n.id===hover ? 22 : 14)) * fog; |
363 | 395 | ctx.fill(); |
364 | 396 | ctx.shadowBlur = 0; |
365 | 397 | // bright inner core |
366 | 398 | if(!faded){ |
367 | | - ctx.beginPath(); ctx.arc(n.x-r*0.28, n.y-r*0.28, r*0.34, 0, 6.2832); |
| 399 | + ctx.beginPath(); ctx.arc(x-r*0.28, y-r*0.28, r*0.34, 0, 6.2832); |
368 | 400 | ctx.fillStyle = "rgba(255,255,255,.55)"; ctx.fill(); |
369 | 401 | } |
370 | | - // label — declutter: hubs at rest; hovered node + neighbors; selection; all when zoomed in |
| 402 | + // label — declutter: hubs at rest; hovered node + neighbors; selection; zoomed in; near depth only |
371 | 403 | const showLabel = (scale > 1.4) || (n.id === selected) || (hover ? near : labelIds.has(n.id)); |
372 | | - if(showLabel){ |
| 404 | + if(showLabel && fog > 0.55){ |
373 | 405 | ctx.fillStyle = faded ? "rgba(170,184,199,.3)" : "rgba(238,242,247,.95)"; |
374 | | - ctx.font = `600 ${11/Math.max(.7,scale)}px ui-monospace,monospace`; |
| 406 | + ctx.font = `600 11px ui-monospace,monospace`; |
375 | 407 | ctx.textAlign = "center"; |
376 | 408 | ctx.shadowColor = "rgba(4,6,10,.92)"; ctx.shadowBlur = 4; // legibility over the edge web |
377 | | - ctx.fillText(n.label, n.x, n.y + r + 13/Math.max(.7,scale)); |
| 409 | + ctx.fillText(n.label, x, y + r + 12); |
378 | 410 | ctx.shadowBlur = 0; |
379 | 411 | } |
380 | 412 | ctx.globalAlpha = 1; |
381 | 413 | } |
382 | 414 |
|
383 | 415 | function frame(t){ |
384 | 416 | if(alpha > 0.005) step(); // once cooled, stop integrating — the graph holds still |
| 417 | + project(); // 3D → screen coords for this frame |
385 | 418 | ctx.setTransform(DPR,0,0,DPR,0,0); |
386 | 419 | ctx.clearRect(0,0,W(),H()); |
387 | | - applyView(); |
388 | 420 | edges.forEach(e => drawEdge(e, t)); |
389 | | - nodes.forEach(n => drawNode(n, t)); |
| 421 | + // draw nodes far → near so nearer discs occlude farther ones (painter's algorithm) |
| 422 | + nodes.filter(visible).sort((a,b) => b.rz - a.rz).forEach(n => drawNode(n, t)); |
390 | 423 | requestAnimationFrame(frame); |
391 | 424 | } |
392 | 425 |
|
393 | 426 | /* ===================== hover / drag / pan ===================== */ |
394 | 427 | canvas.addEventListener("mousemove", e => { |
395 | | - const w = toWorld(e.offsetX, e.offsetY); |
396 | 428 | if(drag){ |
397 | | - drag.x = w.x; drag.y = w.y; drag.vx = drag.vy = 0; didDrag = true; |
| 429 | + const w = unproject(e.offsetX, e.offsetY, drag); // move the node within the current view plane |
| 430 | + drag.x = w.x; drag.y = w.y; drag.z = w.z; drag.vx = drag.vy = drag.vz = 0; didDrag = true; |
398 | 431 | alpha = Math.max(alpha, 0.5); // reheat so neighbors re-settle around the dragged node |
399 | 432 | return; |
400 | 433 | } |
401 | | - if(panning){ |
402 | | - panX = panOrigX + (e.offsetX - panStartX); |
403 | | - panY = panOrigY + (e.offsetY - panStartY); |
| 434 | + if(orbiting){ |
| 435 | + yaw = yawStart + (e.offsetX - oStartX) * 0.01; |
| 436 | + pitch = Math.max(-1.45, Math.min(1.45, pitchStart - (e.offsetY - oStartY) * 0.01)); |
404 | 437 | didDrag = true; return; |
405 | 438 | } |
406 | | - const n = pick(w.x, w.y); |
| 439 | + const n = pick(e.offsetX, e.offsetY); |
407 | 440 | hover = n ? n.id : null; |
408 | | - canvas.style.cursor = n ? "grab" : "grab"; |
409 | 441 | }); |
410 | 442 | canvas.addEventListener("mousedown", e => { |
411 | | - const w = toWorld(e.offsetX, e.offsetY); |
412 | | - const n = pick(w.x, w.y); |
| 443 | + const n = pick(e.offsetX, e.offsetY); |
413 | 444 | didDrag = false; |
414 | 445 | if(n){ drag = n; hover = n.id; canvas.style.cursor = "grabbing"; } |
415 | | - else { panning = true; panStartX = e.offsetX; panStartY = e.offsetY; panOrigX = panX; panOrigY = panY; } |
| 446 | + else { orbiting = true; oStartX = e.offsetX; oStartY = e.offsetY; yawStart = yaw; pitchStart = pitch; canvas.style.cursor = "grabbing"; } |
416 | 447 | }); |
417 | 448 | window.addEventListener("mouseup", e => { |
418 | 449 | if(drag && !didDrag){ openPanel(drag); } // click (no drag) on a node → inspect |
419 | | - drag = null; panning = false; |
| 450 | + drag = null; orbiting = false; |
420 | 451 | canvas.style.cursor = "grab"; |
421 | 452 | }); |
422 | | -canvas.addEventListener("mouseleave", () => { if(!drag && !panning) hover = null; }); |
| 453 | +canvas.addEventListener("mouseleave", () => { if(!drag && !orbiting) hover = null; }); |
423 | 454 |
|
424 | 455 | /* ===================== inspector panel ===================== */ |
425 | 456 | function esc(s){ return String(s == null ? "" : s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); } |
|
0 commit comments