|
121 | 121 | text-transform:uppercase;cursor:pointer;transition:background .15s ease,color .15s ease; |
122 | 122 | } |
123 | 123 | .controls .rst:hover{background:rgba(255,255,255,.08);color:var(--ink)} |
| 124 | +.controls .sepc{width:1px;height:18px;background:var(--line)} |
124 | 125 |
|
125 | 126 | /* ---- glass inspector panel ---- */ |
126 | 127 | .panel{ |
|
184 | 185 |
|
185 | 186 | <div class="legend" id="legend"></div> |
186 | 187 |
|
187 | | -<div class="hint"> |
188 | | - <kbd>scroll</kbd> zoom <kbd>drag bg</kbd> rotate <kbd>click</kbd> inspect<br> |
189 | | - <kbd>drag node</kbd> pull out & pin <kbd>dbl-click</kbd> release |
190 | | -</div> |
| 188 | +<div class="hint" id="hint"></div> |
191 | 189 |
|
192 | 190 | <div class="controls"> |
| 191 | + <button class="rst" id="mode" title="switch between mind-map and 3D graph">3D graph</button> |
| 192 | + <span class="sepc"></span> |
193 | 193 | <label>spacing <input id="spread" type="range" min="0.5" max="3" step="0.1" value="1"></label> |
194 | | - <button class="rst" id="reset" title="reset view, spacing & pinned nodes">reset</button> |
| 194 | + <button class="rst" id="reset" title="reset view, spacing & focus">reset</button> |
195 | 195 | </div> |
196 | 196 |
|
197 | 197 | <div class="panel" id="panel"></div> |
|
214 | 214 | const panel = document.getElementById("panel"); |
215 | 215 | const legend = document.getElementById("legend"); |
216 | 216 | const search = document.getElementById("search"); |
| 217 | +const hintEl = document.getElementById("hint"); |
| 218 | +const modeBtn = document.getElementById("mode"); |
217 | 219 | const DPR = Math.min(window.devicePixelRatio || 1, 2); |
218 | 220 | const W = () => canvas.clientWidth; |
219 | 221 | const H = () => canvas.clientHeight; |
|
224 | 226 | r: 3.5 + Math.min(7, ((n.in || 0) + (n.out || 0)) * 0.42), // small crisp nodes, scaled by degree |
225 | 227 | col: colorOf(n.type), // cached color → no per-frame lookup |
226 | 228 | alpha: 1, // per-node fade (legend toggle) |
227 | | - sx:0, sy:0, rz:0, pp:1, hl:1, pinned:false // projection cache + smoothed highlight + pinned (drag to pull out) |
| 229 | + sx:0, sy:0, rz:0, pp:1, hl:1, pinned:false, // projection cache + smoothed highlight + pinned (drag to pull out) |
| 230 | + tx:0, ty:0, mapShow:true // mind-map: radial target position + whether shown in current focus |
228 | 231 | })); |
229 | 232 | const byId = Object.fromEntries(nodes.map(n => [n.id, n])); |
230 | 233 | const edges = GRAPH.edges.filter(e => byId[e.source] && byId[e.target]); |
|
245 | 248 | let scale = DEF_SCALE, panX = 0, panY = 0; |
246 | 249 | let yaw = DEF_YAW, pitch = DEF_PITCH; // 3D orbit angles — drag the background to rotate |
247 | 250 | let orbiting = false, oStartX = 0, oStartY = 0, yawStart = 0, pitchStart = 0; |
| 251 | +let panOrigX = 0, panOrigY = 0; // bg-drag pan origin (mind-map mode) |
248 | 252 | let didDrag = false; |
249 | 253 | let alpha = 1; // simulated-annealing "temperature"; cools to 0 so motion stops |
250 | 254 | let spread = 1; // spacing knob: >1 spreads nodes apart, <1 packs them tighter |
| 255 | +let mode = "map"; // "map" = radial mind-map (focus + neighbors), "graph" = 3D force layout |
| 256 | +let focusId = null; // centered node in mind-map mode |
| 257 | +let pendingFocus = null; // node pressed in map mode, awaiting click-release |
251 | 258 |
|
252 | 259 | document.getElementById("ncount").textContent = nodes.length; |
253 | 260 | document.getElementById("ecount").textContent = edges.length; |
|
273 | 280 |
|
274 | 281 | /* ===================== physics (adapted from okf-spec.html step()) ===================== */ |
275 | 282 | const VMAX = 9; // per-frame speed cap → no violent swings, even on a dense hub |
| 283 | +const alphaTarget = n => (typeVisible(n.type) && (mode === "graph" || n.mapShow)) ? 1 : 0; |
276 | 284 | function step(){ |
| 285 | + if(mode === "map"){ stepMap(); return; } |
277 | 286 | for(let i=0;i<nodes.length;i++){ |
278 | 287 | const a = nodes[i]; |
279 | 288 | if(!visible(a)) continue; |
|
302 | 311 | }); |
303 | 312 | nodes.forEach(n => { |
304 | 313 | // smoothly approach the legend-toggle target opacity |
305 | | - const target = typeVisible(n.type) ? 1 : 0; |
306 | | - n.alpha += (target - n.alpha) * 0.12; |
| 314 | + n.alpha += (alphaTarget(n) - n.alpha) * 0.12; |
307 | 315 | if(n === drag || n.pinned){ n.vx = n.vy = n.vz = 0; return; } // dragged or pinned: held in place |
308 | 316 | n.vx *= 0.84; n.vy *= 0.84; n.vz *= 0.84; // stronger damping dissipates energy fast |
309 | 317 | if(n.vx > VMAX) n.vx = VMAX; else if(n.vx < -VMAX) n.vx = -VMAX; |
|
314 | 322 | alpha += (0 - alpha) * 0.02; // anneal: forces fade → the layout settles and stops |
315 | 323 | } |
316 | 324 |
|
| 325 | +/* ===================== mind-map (radial focus layout) ===================== */ |
| 326 | +// Place the focus node at the origin and fan its direct neighbours out on 1–2 rings. |
| 327 | +// Only focus + neighbours are shown; everything else fades out. Clicking a neighbour re-focuses. |
| 328 | +function layoutMap(){ |
| 329 | + const f = byId[focusId]; |
| 330 | + if(!f){ return; } |
| 331 | + nodes.forEach(n => { n.mapShow = false; }); |
| 332 | + f.mapShow = true; f.tx = 0; f.ty = 0; |
| 333 | + const nbrs = [...adj[focusId]].filter(id => byId[id] && typeVisible(byId[id].type)); |
| 334 | + const N = nbrs.length; |
| 335 | + const twoRings = N > 18; |
| 336 | + const split = twoRings ? Math.ceil(N * 0.42) : N; // inner-ring count |
| 337 | + nbrs.forEach((id, i) => { |
| 338 | + const ring = (twoRings && i >= split) ? 1 : 0; |
| 339 | + const start = ring === 0 ? 0 : split; |
| 340 | + const count = ring === 0 ? Math.min(split, N) : (N - split); |
| 341 | + const idx = i - start; |
| 342 | + const R = (ring === 0 ? 215 : 380) * (0.7 + 0.3 * spread); // spacing slider stretches the rings too |
| 343 | + const ang = (idx / Math.max(1, count)) * TAU - Math.PI / 2 + ring * 0.5; |
| 344 | + const nb = byId[id]; |
| 345 | + nb.mapShow = true; |
| 346 | + nb.tx = Math.cos(ang) * R; |
| 347 | + nb.ty = Math.sin(ang) * R; |
| 348 | + }); |
| 349 | +} |
| 350 | +function stepMap(){ |
| 351 | + for(const n of nodes){ |
| 352 | + n.alpha += (alphaTarget(n) - n.alpha) * 0.14; |
| 353 | + if(n.mapShow){ // ease toward radial target (flat: z→0) |
| 354 | + n.x += (n.tx - n.x) * 0.16; |
| 355 | + n.y += (n.ty - n.y) * 0.16; |
| 356 | + n.z += (0 - n.z) * 0.16; |
| 357 | + } |
| 358 | + } |
| 359 | + alpha += (0 - alpha) * 0.02; // reuse the anneal gate as a "still animating" timer |
| 360 | +} |
| 361 | +function setFocus(id){ |
| 362 | + if(!byId[id]) return; |
| 363 | + focusId = id; |
| 364 | + layoutMap(); |
| 365 | + openPanel(byId[id]); // show the centered node's details + links |
| 366 | + alpha = 1; // animate the re-layout |
| 367 | +} |
| 368 | +function setMode(m){ |
| 369 | + mode = m; |
| 370 | + if(m === "map"){ |
| 371 | + yaw = 0; pitch = 0; scale = 1.0; panX = 0; panY = 0; |
| 372 | + if(!focusId) focusId = byDeg[0]; |
| 373 | + layoutMap(); |
| 374 | + } else { |
| 375 | + yaw = DEF_YAW; pitch = DEF_PITCH; scale = DEF_SCALE; panX = 0; panY = 0; |
| 376 | + closePanel(); seed(); // fresh 3D sphere |
| 377 | + } |
| 378 | + hover = null; alpha = 1; |
| 379 | + modeBtn.textContent = (m === "map") ? "3D graph" : "mind-map"; |
| 380 | + updateHint(); |
| 381 | +} |
| 382 | +function updateHint(){ |
| 383 | + hintEl.innerHTML = (mode === "map") |
| 384 | + ? '<kbd>click</kbd> focus & expand <kbd>drag bg</kbd> pan <kbd>scroll</kbd> zoom' |
| 385 | + : '<kbd>scroll</kbd> zoom <kbd>drag bg</kbd> rotate <kbd>click</kbd> inspect<br>' |
| 386 | + + '<kbd>drag node</kbd> pull out & pin <kbd>dbl-click</kbd> release'; |
| 387 | +} |
| 388 | + |
317 | 389 | /* ===================== 3D orbit projection + perspective ===================== */ |
318 | 390 | const FOCAL = 900; |
319 | 391 | function project(){ |
|
370 | 442 |
|
371 | 443 | /* ===================== rendering ===================== */ |
372 | 444 | function drawEdge(e, t){ |
| 445 | + // mind-map: draw only the spokes radiating from the focus node (a clean tree, not the mesh) |
| 446 | + if(mode === "map" && focusId && e.source !== focusId && e.target !== focusId) return; |
373 | 447 | const a = byId[e.source], b = byId[e.target]; |
374 | 448 | const al = Math.min(a.alpha, b.alpha); |
375 | 449 | if(al < 0.02) return; |
|
440 | 514 | ctx.shadowBlur = 0; |
441 | 515 | // (flat discs — no glossy core highlight) |
442 | 516 | // label — declutter: hubs at rest; hovered node + neighbors; selection; zoomed in; near depth only |
443 | | - const showLabel = (scale > 1.4) || (n.id === selected) || (hover ? hl > 0.55 : labelIds.has(n.id)); |
| 517 | + const showLabel = (mode === "map") || (scale > 1.4) || (n.id === selected) || (hover ? hl > 0.55 : labelIds.has(n.id)); |
444 | 518 | if(showLabel && fog > 0.55){ |
445 | 519 | ctx.fillStyle = `rgba(238,242,247,${0.4 + 0.55*hl})`; |
446 | 520 | ctx.font = `600 11px ui-monospace,monospace`; |
|
481 | 555 | return; |
482 | 556 | } |
483 | 557 | if(orbiting){ |
484 | | - yaw = yawStart + (e.offsetX - oStartX) * 0.0045; |
485 | | - pitch = Math.max(-1.45, Math.min(1.45, pitchStart - (e.offsetY - oStartY) * 0.0045)); |
| 558 | + if(mode === "map"){ // flat map → drag background to pan |
| 559 | + panX = panOrigX + (e.offsetX - oStartX); |
| 560 | + panY = panOrigY + (e.offsetY - oStartY); |
| 561 | + } else { // 3D → drag background to orbit |
| 562 | + yaw = yawStart + (e.offsetX - oStartX) * 0.0045; |
| 563 | + pitch = Math.max(-1.45, Math.min(1.45, pitchStart - (e.offsetY - oStartY) * 0.0045)); |
| 564 | + } |
486 | 565 | didDrag = true; return; |
487 | 566 | } |
488 | 567 | const n = pick(e.offsetX, e.offsetY); |
|
491 | 570 | canvas.addEventListener("mousedown", e => { |
492 | 571 | const n = pick(e.offsetX, e.offsetY); |
493 | 572 | didDrag = false; |
494 | | - oStartX = e.offsetX; oStartY = e.offsetY; // press origin — for the drag threshold and for orbit |
| 573 | + oStartX = e.offsetX; oStartY = e.offsetY; // press origin — for the drag threshold and for orbit/pan |
| 574 | + if(mode === "map"){ |
| 575 | + if(n){ pendingFocus = n; hover = n.id; } |
| 576 | + else { orbiting = true; panOrigX = panX; panOrigY = panY; canvas.style.cursor = "grabbing"; } |
| 577 | + return; |
| 578 | + } |
495 | 579 | if(n){ drag = n; hover = n.id; canvas.style.cursor = "grabbing"; } |
496 | 580 | else { orbiting = true; yawStart = yaw; pitchStart = pitch; canvas.style.cursor = "grabbing"; } |
497 | 581 | }); |
498 | 582 | window.addEventListener("mouseup", e => { |
499 | | - if(drag && !didDrag){ openPanel(drag); } // click (no drag) → inspect |
| 583 | + if(mode === "map"){ |
| 584 | + if(pendingFocus && !didDrag) setFocus(pendingFocus.id); // click a node → re-center on it |
| 585 | + } else if(drag && !didDrag){ openPanel(drag); } // click (no drag) → inspect |
500 | 586 | else if(drag && didDrag){ drag.pinned = true; alpha = Math.max(alpha, 0.5); } // pulled out → pin it |
501 | | - drag = null; orbiting = false; |
| 587 | + drag = null; orbiting = false; pendingFocus = null; |
502 | 588 | canvas.style.cursor = "grab"; |
503 | 589 | }); |
504 | 590 | canvas.addEventListener("dblclick", e => { |
|
534 | 620 | panel.querySelector(".x").onclick = closePanel; |
535 | 621 | panel.querySelectorAll("[data-n]").forEach(a => a.onclick = () => { |
536 | 622 | const m = byId[a.dataset.n]; |
537 | | - if(m){ hover = m.id; centerOn(m); openPanel(m); } |
| 623 | + if(!m) return; |
| 624 | + if(mode === "map"){ setFocus(m.id); } // navigate the mind-map by clicking a link |
| 625 | + else { hover = m.id; centerOn(m); openPanel(m); } |
538 | 626 | }); |
539 | 627 | } |
540 | 628 | function closePanel(){ panel.classList.remove("open"); selected = null; } |
|
572 | 660 | } |
573 | 661 | }); |
574 | 662 |
|
575 | | -/* ===================== controls (spacing slider + reset) ===================== */ |
| 663 | +/* ===================== controls (mode toggle + spacing slider + reset) ===================== */ |
| 664 | +modeBtn.addEventListener("click", () => setMode(mode === "map" ? "graph" : "map")); |
| 665 | + |
576 | 666 | const spreadEl = document.getElementById("spread"); |
577 | 667 | spreadEl.addEventListener("input", e => { |
578 | 668 | spread = parseFloat(e.target.value) || 1; |
579 | | - alpha = Math.max(alpha, 0.6); // reheat so the layout re-settles at the new spacing |
| 669 | + if(mode === "map") layoutMap(); // spacing stretches the rings |
| 670 | + alpha = Math.max(alpha, 0.6); // reheat so the layout re-settles at the new spacing |
580 | 671 | }); |
581 | 672 | document.getElementById("reset").addEventListener("click", () => { |
582 | | - scale = DEF_SCALE; panX = 0; panY = 0; yaw = DEF_YAW; pitch = DEF_PITCH; // view |
583 | | - spread = 1; spreadEl.value = "1"; // spacing |
584 | | - nodes.forEach(n => n.pinned = false); // release pinned |
| 673 | + spread = 1; spreadEl.value = "1"; |
585 | 674 | hover = null; closePanel(); |
586 | | - alpha = Math.max(alpha, 0.8); // re-settle from the reset state |
| 675 | + if(mode === "map"){ |
| 676 | + scale = 1.0; panX = 0; panY = 0; focusId = byDeg[0]; layoutMap(); // back to the top hub |
| 677 | + } else { |
| 678 | + scale = DEF_SCALE; panX = 0; panY = 0; yaw = DEF_YAW; pitch = DEF_PITCH; // view |
| 679 | + nodes.forEach(n => n.pinned = false); // release pinned |
| 680 | + } |
| 681 | + alpha = Math.max(alpha, 0.9); // re-settle from the reset state |
587 | 682 | }); |
588 | 683 |
|
589 | 684 | /* ===================== boot ===================== */ |
590 | 685 | window.addEventListener("resize", size); |
591 | | -size(); seed(); |
| 686 | +size(); |
| 687 | +focusId = byDeg[0]; layoutMap(); // open on the most-connected node as the mind-map root |
| 688 | +modeBtn.textContent = "3D graph"; // button switches TO the other mode |
| 689 | +updateHint(); |
592 | 690 | requestAnimationFrame(frame); |
593 | 691 | </script> |
594 | 692 | </body> |
|
0 commit comments