|
290 | 290 | let didDrag = false; |
291 | 291 | let alpha = 1; // simulated-annealing "temperature"; cools to 0 so motion stops |
292 | 292 | // synthetic centre for the radial view ("OpenKB" → documents → concepts) |
293 | | -const ROOT = { id:"__openkb__", label:"OpenKB", type:"__root__", x:0, y:0, z:0, tx:0, ty:0, |
294 | | - sx:0, sy:0, rz:0, pp:1, r:15, hl:1, alpha:1, depth:0, show:true }; |
| 293 | +const ROOT = { id:"__openkb__", label:"OpenKB", type:"__root__", x:0, y:0, sx:0, sy:0, rz:0, pp:1, r:15, show:true }; |
295 | 294 | let treeEdges = []; // radial hierarchy edges (root→doc, doc→concept) |
296 | 295 | const DEFAULT_SPREAD = 1.7; // open the nebula out by default so it isn't a clump |
297 | 296 | let spread = DEFAULT_SPREAD; // spacing knob: >1 spreads nodes apart, <1 packs them tighter |
|
327 | 326 | let mmQuery = "", mmRootY = 0; |
328 | 327 | const mmResolveSrc = s => { const id = String(s).replace(/\.md$/i, ""); return (byId[id] && byId[id].id.startsWith("summaries/")) ? id : null; }; |
329 | 328 | const ROOT_W = 104, NODE_W = 184, COL = 234, ROW = 31, PADX = 34, PADY = 30, NH = 22; |
330 | | -function buildMindmap(){ |
331 | | - const q = mmQuery.toLowerCase(); |
332 | | - const keep = n => !q || n.label.toLowerCase().includes(q) || n.id.toLowerCase().includes(q); |
| 329 | +// shared OpenKB → documents → concepts grouping (each concept under its primary source summary) |
| 330 | +function buildProvenance(){ |
333 | 331 | const docs = nodes.filter(n => n.id.startsWith("summaries/") && typeVisible(n.type)); |
334 | 332 | const kidsOf = {}; docs.forEach(d => kidsOf[d.id] = []); |
335 | 333 | const rootDirect = []; |
|
338 | 336 | const p = (n.sources || []).map(mmResolveSrc).filter(Boolean)[0]; // primary document |
339 | 337 | if(p && kidsOf[p]) kidsOf[p].push(n); else rootDirect.push(n); |
340 | 338 | }); |
| 339 | + return { docs, kidsOf, rootDirect }; |
| 340 | +} |
| 341 | +function buildMindmap(){ |
| 342 | + const q = mmQuery.toLowerCase(); |
| 343 | + const keep = n => !q || n.label.toLowerCase().includes(q) || n.id.toLowerCase().includes(q); |
| 344 | + const { docs, kidsOf, rootDirect } = buildProvenance(); |
341 | 345 | const mk = n => ({ id:n.id, label:n.label, type:n.type, kids:[] }); |
342 | 346 | const root = { id:"__openkb__", label:"OpenKB", type:"__root__", kids:[] }; |
343 | 347 | docs.forEach(d => { |
|
374 | 378 | let html = ""; |
375 | 379 | flat.forEach(it => { |
376 | 380 | if(it.id === "__openkb__"){ html += `<div class="mm-node mm-root" style="left:${it.x}px;top:${it.y - 4}px">OpenKB</div>`; return; } |
377 | | - const col = colorOf(it.type), kn = it.kids.length || (mmCollapsed.has(it.id) ? 1 : 0); |
| 381 | + const col = colorOf(it.type); |
378 | 382 | const hasKids = it.id.startsWith("summaries/") && (kidsOf[it.id] || []).length; |
379 | 383 | html += `<div class="mm-node" data-id="${esc(it.id)}" title="${esc(it.label)}" ` |
380 | 384 | + `style="left:${it.x}px;top:${it.y}px;border-color:rgba(${col},.5);background:rgba(${col},.12)">` |
|
395 | 399 |
|
396 | 400 | /* ===================== radial view: same OpenKB→docs→concepts tree, fanned in a circle ===================== */ |
397 | 401 | function layoutRadial(){ |
398 | | - const docs = nodes.filter(n => n.id.startsWith("summaries/") && typeVisible(n.type)); |
399 | | - const kidsOf = {}; docs.forEach(d => kidsOf[d.id] = []); |
400 | | - const rootDirect = []; |
401 | | - nodes.forEach(n => { |
402 | | - if(n.id.startsWith("summaries/") || !typeVisible(n.type)){ n.show = false; return; } |
403 | | - const p = (n.sources || []).map(mmResolveSrc).filter(Boolean)[0]; |
404 | | - if(p && kidsOf[p]) kidsOf[p].push(n); else rootDirect.push(n); |
405 | | - }); |
| 402 | + const { docs, kidsOf, rootDirect } = buildProvenance(); |
406 | 403 | nodes.forEach(n => { n.show = false; }); |
407 | 404 | const branches = docs.map(d => ({ node: d, kids: kidsOf[d.id], w: Math.max(1.6, kidsOf[d.id].length) })) |
408 | 405 | .concat(rootDirect.map(c => ({ node: c, kids: [], w: 1 }))); |
|
476 | 473 | } |
477 | 474 |
|
478 | 475 | const spacingCtl = document.getElementById("spacingCtl"); |
479 | | -const MODE_LABEL = { mindmap: "mind-map", radial: "radial", graph: "3D" }; |
480 | 476 | function setMode(m){ |
481 | 477 | mode = m; |
482 | 478 | const canvasMode = (m === "graph" || m === "radial"); // canvas: 3D nebula + radial. mind-map is DOM |
483 | 479 | canvas.style.display = canvasMode ? "block" : "none"; |
484 | 480 | mmEl.style.display = (m === "mindmap") ? "block" : "none"; |
485 | 481 | spacingCtl.style.display = (m === "mindmap") ? "none" : "flex"; |
486 | 482 | hover = null; closePanel(); panX = 0; panY = 0; autoFit = true; |
| 483 | + drag = null; orbiting = false; pendingInspect = null; didDrag = false; canvas.style.cursor = "grab"; // clear in-flight interaction |
| 484 | + nodes.forEach(n => n.pinned = false); // pins don't carry across views / reseed |
| 485 | + search.value = ""; mmQuery = ""; // keep the search box in sync with the (cleared) filter |
487 | 486 | if(m === "mindmap"){ buildMindmap(); mmEl.scrollTop = Math.max(0, mmRootY - mmEl.clientHeight / 2); } |
488 | 487 | else if(m === "radial"){ yaw = 0; pitch = 0; scale = DEF_SCALE; layoutRadial(); alpha = 1; } |
489 | 488 | else { yaw = DEF_YAW; pitch = DEF_PITCH; scale = DEF_SCALE; seed(); alpha = 1; } |
|
540 | 539 | if(maxX <= minX || maxY <= minY) return; |
541 | 540 | const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2; |
542 | 541 | const factor = Math.min(W() * 0.9 / (maxX - minX), H() * 0.88 / (maxY - minY)); |
543 | | - const tScale = scale * factor; |
| 542 | + const tScale = Math.min(scale * factor, 4); // cap so a tiny graph (e.g. 1 node) doesn't balloon past the zoom range |
544 | 543 | const Xc = (cx - (W()/2 + panX)) / scale, Yc = (cy - (H()/2 + panY)) / scale; |
545 | 544 | const tPanX = -Xc * tScale, tPanY = -Yc * tScale; |
546 | 545 | const e = 0.07; // ease 7%/frame → smooth glide into frame |
|
702 | 701 | ctx.beginPath(); ctx.arc(x - r*0.28, y - r*0.28, r*0.3, 0, TAU); |
703 | 702 | ctx.fillStyle = `rgba(255,255,255,${0.35*fog})`; ctx.fill(); |
704 | 703 | } |
705 | | - // (flat discs — no glossy core highlight) |
706 | 704 | // label — declutter: hubs at rest; hovered node + neighbors; selection; zoomed in; near depth only |
707 | 705 | const showLabel = (mode === "radial" && n.depth <= 1) || (scale > 1.4) || (n.id === selected) || (hover ? hl > 0.55 : labelIds.has(n.id)); |
708 | 706 | if(showLabel && fog > 0.55){ |
|
742 | 740 | if(alpha < 0.05 && idleFrames > 150) yaw += 0.0011; // gentle auto-rotation once settled & idle |
743 | 741 | const focus = hover || selected; // hover/selection spotlights a node + its neighbours |
744 | 742 | for(const n of nodes){ |
745 | | - const near = !focus || n.id===focus || adj[focus].has(n.id); |
| 743 | + const near = !focus || n.id===focus || (adj[focus] && adj[focus].has(n.id)); |
746 | 744 | n.hl += ((near ? 1 : 0.22) - n.hl) * 0.12; |
747 | 745 | } |
748 | 746 | ctx.setTransform(DPR,0,0,DPR,0,0); |
|
832 | 830 | panel.querySelectorAll("[data-n]").forEach(a => a.onclick = () => { |
833 | 831 | const m = byId[a.dataset.n]; |
834 | 832 | if(!m) return; |
835 | | - if(mode === "graph" || mode === "radial"){ hover = m.id; centerOn(m); } // canvas views: bring it into view |
| 833 | + if(mode === "graph" || mode === "radial"){ hover = m.id; autoFit = false; centerOn(m); } // canvas views: bring it into view (stop auto-fit so the pan sticks) |
836 | 834 | openPanel(m); // all modes: show its details |
837 | 835 | }); |
838 | 836 | } |
|
865 | 863 | if(!q){ hover = null; return; } |
866 | 864 | const m = nodes.find(n => n.label.toLowerCase().includes(q)) |
867 | 865 | || nodes.find(n => n.id.toLowerCase().includes(q)); |
868 | | - if(m){ hover = m.id; centerOn(m); } |
| 866 | + if(m){ hover = m.id; autoFit = false; centerOn(m); } // stop auto-fit so the centring sticks |
869 | 867 | }); |
870 | 868 | search.addEventListener("keydown", e => { |
871 | 869 | if(e.key === "Enter"){ |
|
0 commit comments