|
122 | 122 | } |
123 | 123 | .controls .rst:hover{background:rgba(255,255,255,.08);color:var(--ink)} |
124 | 124 | .controls .sepc{width:1px;height:18px;background:var(--line)} |
| 125 | +.controls .tabs{display:flex;gap:2px;background:rgba(255,255,255,.05);border-radius:9px;padding:2px} |
| 126 | +.controls .tabs button{ |
| 127 | + border:none;background:none;color:var(--soft);cursor:pointer; |
| 128 | + border-radius:7px;padding:5px 11px;font:600 .58rem var(--mono);letter-spacing:.08em;text-transform:uppercase; |
| 129 | + transition:background .15s ease,color .15s ease; |
| 130 | +} |
| 131 | +.controls .tabs button:hover{color:var(--ink)} |
| 132 | +.controls .tabs button.on{background:rgba(56,189,248,.22);color:#bfe6ff} |
125 | 133 |
|
126 | 134 | /* ---- treemap infographic ---- */ |
127 | 135 | .treemap{position:fixed;left:0;top:58px;right:0;bottom:66px;z-index:3;overflow:hidden} |
|
233 | 241 | <div class="hint" id="hint"></div> |
234 | 242 |
|
235 | 243 | <div class="controls"> |
236 | | - <button class="rst" id="mode" title="switch between mind-map and 3D graph">3D graph</button> |
| 244 | + <div class="tabs" id="modeTabs"> |
| 245 | + <button data-m="mindmap">mind-map</button><button data-m="radial">radial</button><button data-m="graph">3D</button><button data-m="treemap">treemap</button> |
| 246 | + </div> |
237 | 247 | <span class="sepc"></span> |
238 | 248 | <label id="spacingCtl">spacing <input id="spread" type="range" min="0.5" max="3.5" step="0.1" value="1.7"></label> |
239 | 249 | <button class="rst" id="reset" title="reset view, spacing & focus">reset</button> |
|
260 | 270 | const legend = document.getElementById("legend"); |
261 | 271 | const search = document.getElementById("search"); |
262 | 272 | const hintEl = document.getElementById("hint"); |
263 | | -const modeBtn = document.getElementById("mode"); |
| 273 | +const modeTabs = document.getElementById("modeTabs"); |
264 | 274 | const DPR = Math.min(window.devicePixelRatio || 1, 2); |
265 | 275 | const W = () => canvas.clientWidth; |
266 | 276 | const H = () => canvas.clientHeight; |
|
271 | 281 | r: 3.5 + Math.min(18, Math.sqrt((n.in || 0) + (n.out || 0)) * 2.1), // sqrt-scaled by degree → hubs read big, leaves stay small |
272 | 282 | col: colorOf(n.type), // cached color → no per-frame lookup |
273 | 283 | alpha: 1, // per-node fade (legend toggle) |
274 | | - sx:0, sy:0, rz:0, pp:1, hl:1, pinned:false // projection cache + smoothed highlight + pinned (drag to pull out) |
| 284 | + sx:0, sy:0, rz:0, pp:1, hl:1, pinned:false, // projection cache + smoothed highlight + pinned (drag to pull out) |
| 285 | + tx:0, ty:0, show:true, depth:0 // radial layout target / visibility / layer |
275 | 286 | })); |
276 | 287 | const byId = Object.fromEntries(nodes.map(n => [n.id, n])); |
277 | 288 | const edges = GRAPH.edges.filter(e => byId[e.source] && byId[e.target]); |
|
292 | 303 | let scale = DEF_SCALE, panX = 0, panY = 0; |
293 | 304 | let yaw = DEF_YAW, pitch = DEF_PITCH; // 3D orbit angles — drag the background to rotate |
294 | 305 | let orbiting = false, oStartX = 0, oStartY = 0, yawStart = 0, pitchStart = 0; |
| 306 | +let panOrigX = 0, panOrigY = 0, pendingInspect = null; // radial: bg-drag pan + click-to-inspect |
295 | 307 | let didDrag = false; |
296 | 308 | let alpha = 1; // simulated-annealing "temperature"; cools to 0 so motion stops |
| 309 | +// synthetic centre for the radial view ("OpenKB" → documents → concepts) |
| 310 | +const ROOT = { id:"__openkb__", label:"OpenKB", type:"__root__", x:0, y:0, z:0, tx:0, ty:0, |
| 311 | + sx:0, sy:0, rz:0, pp:1, r:15, hl:1, alpha:1, depth:0, show:true }; |
| 312 | +let treeEdges = []; // radial hierarchy edges (root→doc, doc→concept) |
297 | 313 | const DEFAULT_SPREAD = 1.7; // open the nebula out by default so it isn't a clump |
298 | 314 | let spread = DEFAULT_SPREAD; // spacing knob: >1 spreads nodes apart, <1 packs them tighter |
299 | 315 | let mode = "mindmap"; // "mindmap" = OpenKB-rooted horizontal tree (default) · "graph" = 3D nebula · "treemap" = infographic |
|
392 | 408 | mmEl.querySelectorAll(".mm-node[data-id]").forEach(el => el.onclick = () => { const m = byId[el.dataset.id]; if(m) openPanel(m); }); |
393 | 409 | } |
394 | 410 |
|
| 411 | +/* ===================== radial view: same OpenKB→docs→concepts tree, fanned in a circle ===================== */ |
| 412 | +function layoutRadial(){ |
| 413 | + const docs = nodes.filter(n => n.id.startsWith("summaries/") && typeVisible(n.type)); |
| 414 | + const kidsOf = {}; docs.forEach(d => kidsOf[d.id] = []); |
| 415 | + const rootDirect = []; |
| 416 | + nodes.forEach(n => { |
| 417 | + if(n.id.startsWith("summaries/") || !typeVisible(n.type)){ n.show = false; return; } |
| 418 | + const p = (n.sources || []).map(mmResolveSrc).filter(Boolean)[0]; |
| 419 | + if(p && kidsOf[p]) kidsOf[p].push(n); else rootDirect.push(n); |
| 420 | + }); |
| 421 | + nodes.forEach(n => { n.show = false; }); |
| 422 | + const branches = docs.map(d => ({ node: d, kids: kidsOf[d.id], w: Math.max(1.6, kidsOf[d.id].length) })) |
| 423 | + .concat(rootDirect.map(c => ({ node: c, kids: [], w: 1 }))); |
| 424 | + const totW = branches.reduce((s, b) => s + b.w, 0) || 1; |
| 425 | + const R1 = 250 * (0.6 + 0.4 * spread), R2 = R1 + 175 * (0.6 + 0.4 * spread); |
| 426 | + treeEdges = []; ROOT.show = true; |
| 427 | + let a = -Math.PI; |
| 428 | + branches.forEach(b => { |
| 429 | + const wedge = 2 * Math.PI * b.w / totW, mid = a + wedge / 2; |
| 430 | + b.node.show = true; b.node.depth = 1; b.node.tx = Math.cos(mid) * R1; b.node.ty = Math.sin(mid) * R1; |
| 431 | + treeEdges.push({ a: ROOT, b: b.node }); |
| 432 | + b.kids.forEach((c, i) => { |
| 433 | + const ca = a + wedge * (i + 0.5) / Math.max(1, b.kids.length); |
| 434 | + c.show = true; c.depth = 2; c.tx = Math.cos(ca) * R2; c.ty = Math.sin(ca) * R2; |
| 435 | + treeEdges.push({ a: b.node, b: c }); |
| 436 | + }); |
| 437 | + a += wedge; |
| 438 | + }); |
| 439 | +} |
| 440 | +function stepRadial(){ |
| 441 | + for(const n of nodes){ |
| 442 | + n.alpha += ((n.show && typeVisible(n.type) ? 1 : 0) - n.alpha) * 0.14; |
| 443 | + if(n.show){ n.x += (n.tx - n.x) * 0.16; n.y += (n.ty - n.y) * 0.16; n.z += (0 - n.z) * 0.16; } |
| 444 | + } |
| 445 | + ROOT.x += (0 - ROOT.x) * 0.16; ROOT.y += (0 - ROOT.y) * 0.16; |
| 446 | + alpha += (0 - alpha) * 0.02; |
| 447 | +} |
| 448 | + |
395 | 449 | /* ===================== physics (adapted from okf-spec.html step()) ===================== */ |
396 | 450 | const VMAX = 9; // per-frame speed cap → no violent swings, even on a dense hub |
397 | 451 | const alphaTarget = n => typeVisible(n.type) ? 1 : 0; |
398 | 452 | function step(){ |
| 453 | + if(mode === "radial"){ stepRadial(); return; } |
399 | 454 | for(let i=0;i<nodes.length;i++){ |
400 | 455 | const a = nodes[i]; |
401 | 456 | if(!visible(a)) continue; |
|
526 | 581 | const m = byId[el.dataset.id]; if(m) openPanel(m); |
527 | 582 | }); |
528 | 583 | } |
529 | | -const MODE_LABEL = { mindmap: "mind-map", graph: "3D nebula", treemap: "treemap" }; |
| 584 | +const MODE_LABEL = { mindmap: "mind-map", radial: "radial", graph: "3D", treemap: "treemap" }; |
530 | 585 | function setMode(m){ |
531 | 586 | mode = m; |
532 | | - const canvasMode = (m === "graph"); // only the 3D nebula uses the canvas; mind-map & treemap are DOM |
| 587 | + const canvasMode = (m === "graph" || m === "radial"); // canvas: 3D nebula + radial. mind-map & treemap are DOM |
533 | 588 | canvas.style.display = canvasMode ? "block" : "none"; |
534 | 589 | tmEl.style.display = (m === "treemap") ? "block" : "none"; |
535 | 590 | mmEl.style.display = (m === "mindmap") ? "block" : "none"; |
536 | | - spacingCtl.style.display = canvasMode ? "flex" : "none"; |
| 591 | + spacingCtl.style.display = (m === "treemap" || m === "mindmap") ? "none" : "flex"; |
537 | 592 | hover = null; closePanel(); panX = 0; panY = 0; autoFit = true; |
538 | 593 | if(m === "treemap"){ buildTreemap(); } |
539 | 594 | else if(m === "mindmap"){ buildMindmap(); } |
| 595 | + else if(m === "radial"){ yaw = 0; pitch = 0; scale = DEF_SCALE; layoutRadial(); alpha = 1; } |
540 | 596 | else { yaw = DEF_YAW; pitch = DEF_PITCH; scale = DEF_SCALE; seed(); alpha = 1; } |
541 | | - modeBtn.textContent = MODE_LABEL[m]; |
| 597 | + [...modeTabs.children].forEach(b => b.classList.toggle("on", b.dataset.m === m)); |
542 | 598 | updateHint(); |
543 | 599 | } |
544 | 600 | function updateHint(){ |
545 | 601 | hintEl.innerHTML = |
546 | 602 | mode === "mindmap" ? 'OpenKB → documents → concepts <kbd>+/−</kbd> expand a branch <kbd>click</kbd> read details' |
| 603 | + : mode === "radial" ? 'centre <b>OpenKB</b> → documents → concepts faint = cross-refs <kbd>click</kbd> read <kbd>drag</kbd> pan' |
547 | 604 | : mode === "treemap" ? 'each block = a document tiles = its concepts (size = connections) <kbd>click</kbd> read' |
548 | 605 | : '<kbd>scroll</kbd> zoom <kbd>drag bg</kbd> rotate <kbd>click</kbd> inspect<br>' |
549 | 606 | + '<kbd>drag node</kbd> pull out & pin <kbd>dbl-click</kbd> release'; |
|
626 | 683 | return best; |
627 | 684 | } |
628 | 685 |
|
| 686 | +/* ===================== radial rendering ===================== */ |
| 687 | +function drawCrossEdge(e){ // faint wikilink cross-reference; lights up on hover |
| 688 | + const a = byId[e.source], b = byId[e.target]; |
| 689 | + if(!a.show || !b.show || a.alpha < 0.05 || b.alpha < 0.05) return; |
| 690 | + const hot = hover && (e.source === hover || e.target === hover); |
| 691 | + ctx.globalAlpha = hot ? 0.85 : 0.05; |
| 692 | + ctx.strokeStyle = hot ? "rgba(45,212,191,.9)" : "rgba(150,180,225,.6)"; |
| 693 | + ctx.lineWidth = hot ? 1.4 : 0.6; |
| 694 | + ctx.beginPath(); ctx.moveTo(a.sx, a.sy); ctx.lineTo(b.sx, b.sy); ctx.stroke(); |
| 695 | + ctx.globalAlpha = 1; |
| 696 | +} |
| 697 | +function drawTreeEdge(e){ // bright hierarchy spoke (root→doc, doc→concept) |
| 698 | + const a = e.a, b = e.b; |
| 699 | + if(b.alpha < 0.05) return; |
| 700 | + ctx.globalAlpha = 0.5 * (b.alpha || 1); |
| 701 | + ctx.strokeStyle = "rgba(255,255,255,.34)"; |
| 702 | + ctx.lineWidth = (a === ROOT) ? 1.7 : 1.0; |
| 703 | + ctx.beginPath(); ctx.moveTo(a.sx, a.sy); ctx.lineTo(b.sx, b.sy); ctx.stroke(); |
| 704 | + ctx.globalAlpha = 1; |
| 705 | +} |
| 706 | +function drawRoot(){ |
| 707 | + const x = ROOT.sx, y = ROOT.sy, r = ROOT.r; |
| 708 | + ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); |
| 709 | + ctx.fillStyle = "rgba(232,242,252,.96)"; |
| 710 | + ctx.shadowColor = "rgba(120,200,255,.95)"; ctx.shadowBlur = 24; ctx.fill(); ctx.shadowBlur = 0; |
| 711 | + ctx.fillStyle = "rgba(238,242,247,.98)"; ctx.font = "700 13px ui-monospace,monospace"; ctx.textAlign = "center"; |
| 712 | + ctx.shadowColor = "rgba(4,6,10,.95)"; ctx.shadowBlur = 5; |
| 713 | + ctx.fillText("OpenKB", x, y + r + 16); |
| 714 | + ctx.shadowBlur = 0; |
| 715 | +} |
| 716 | + |
629 | 717 | /* ===================== rendering ===================== */ |
630 | 718 | function drawEdge(e, t){ |
631 | 719 | const a = byId[e.source], b = byId[e.target]; |
|
724 | 812 | } |
725 | 813 | // (flat discs — no glossy core highlight) |
726 | 814 | // label — declutter: hubs at rest; hovered node + neighbors; selection; zoomed in; near depth only |
727 | | - const showLabel = (scale > 1.4) || (n.id === selected) || (hover ? hl > 0.55 : labelIds.has(n.id)); |
| 815 | + const showLabel = (mode === "radial" && n.depth <= 1) || (scale > 1.4) || (n.id === selected) || (hover ? hl > 0.55 : labelIds.has(n.id)); |
728 | 816 | if(showLabel && fog > 0.55){ |
729 | 817 | ctx.fillStyle = `rgba(238,242,247,${0.4 + 0.55*hl})`; |
730 | 818 | ctx.font = `600 11px ui-monospace,monospace`; |
|
738 | 826 | } |
739 | 827 |
|
740 | 828 | function frame(t){ |
741 | | - if(mode === "graph"){ // mind-map & treemap are DOM — the canvas idles |
| 829 | + if(mode === "radial"){ // canvas: OpenKB-rooted radial tree (flat) |
| 830 | + if(alpha > 0.005) step(); |
| 831 | + project(); |
| 832 | + ROOT.pp = 1; ROOT.rz = 0; |
| 833 | + ROOT.sx = W()/2 + panX + ROOT.x*scale; ROOT.sy = H()/2 + panY + ROOT.y*scale; |
| 834 | + if(autoFit && !orbiting) fitStep(); |
| 835 | + const focus = hover || selected; |
| 836 | + for(const n of nodes){ |
| 837 | + const near = !focus || n.id===focus || (adj[focus] && adj[focus].has(n.id)); |
| 838 | + n.hl += ((near ? 1 : 0.5) - n.hl) * 0.12; |
| 839 | + } |
| 840 | + ctx.setTransform(DPR,0,0,DPR,0,0); ctx.clearRect(0,0,W(),H()); |
| 841 | + edges.forEach(drawCrossEdge); |
| 842 | + treeEdges.forEach(drawTreeEdge); |
| 843 | + nodes.filter(visible).forEach(n => drawNode(n, t)); |
| 844 | + drawRoot(); |
| 845 | + } else if(mode === "graph"){ // mind-map & treemap are DOM — the canvas idles |
742 | 846 | if(alpha > 0.005) step(); |
743 | 847 | project(); |
744 | 848 | if(autoFit && !orbiting && !drag){ fitStep(); } // smoothly keep the cloud framed to the viewport |
|
770 | 874 | alpha = Math.max(alpha, 0.5); // reheat so neighbors re-settle around the dragged node |
771 | 875 | return; |
772 | 876 | } |
773 | | - if(orbiting){ // 3D → drag background to orbit |
774 | | - yaw = yawStart + (e.offsetX - oStartX) * 0.0045; |
775 | | - pitch = Math.max(-1.45, Math.min(1.45, pitchStart - (e.offsetY - oStartY) * 0.0045)); |
| 877 | + if(orbiting){ |
| 878 | + if(mode === "radial"){ // flat radial → drag background to pan |
| 879 | + panX = panOrigX + (e.offsetX - oStartX); |
| 880 | + panY = panOrigY + (e.offsetY - oStartY); |
| 881 | + } else { // 3D → drag background to orbit |
| 882 | + yaw = yawStart + (e.offsetX - oStartX) * 0.0045; |
| 883 | + pitch = Math.max(-1.45, Math.min(1.45, pitchStart - (e.offsetY - oStartY) * 0.0045)); |
| 884 | + } |
776 | 885 | didDrag = true; return; |
777 | 886 | } |
778 | 887 | const n = pick(e.offsetX, e.offsetY); |
|
782 | 891 | const n = pick(e.offsetX, e.offsetY); |
783 | 892 | didDrag = false; idleFrames = 0; autoFit = false; // user takes control → stop auto-framing |
784 | 893 | oStartX = e.offsetX; oStartY = e.offsetY; |
| 894 | + if(mode === "radial"){ // click node → inspect; drag bg → pan |
| 895 | + pendingInspect = n; orbiting = true; panOrigX = panX; panOrigY = panY; |
| 896 | + canvas.style.cursor = "grabbing"; return; |
| 897 | + } |
785 | 898 | if(n){ drag = n; hover = n.id; canvas.style.cursor = "grabbing"; } |
786 | 899 | else { orbiting = true; yawStart = yaw; pitchStart = pitch; canvas.style.cursor = "grabbing"; } |
787 | 900 | }); |
788 | 901 | window.addEventListener("mouseup", e => { |
789 | | - if(drag && !didDrag){ openPanel(drag); } // click (no drag) → inspect |
| 902 | + if(mode === "radial"){ |
| 903 | + if(pendingInspect && !didDrag) openPanel(pendingInspect); |
| 904 | + } else if(drag && !didDrag){ openPanel(drag); } // click (no drag) → inspect |
790 | 905 | else if(drag && didDrag){ drag.pinned = true; alpha = Math.max(alpha, 0.5); } // pulled out → pin it |
791 | | - drag = null; orbiting = false; |
| 906 | + drag = null; orbiting = false; pendingInspect = null; |
792 | 907 | canvas.style.cursor = "grab"; |
793 | 908 | }); |
794 | 909 | canvas.addEventListener("dblclick", e => { |
|
825 | 940 | panel.querySelectorAll("[data-n]").forEach(a => a.onclick = () => { |
826 | 941 | const m = byId[a.dataset.n]; |
827 | 942 | if(!m) return; |
828 | | - if(mode === "graph"){ hover = m.id; centerOn(m); } // graph: fly to the node |
829 | | - openPanel(m); // both modes: show its details |
| 943 | + if(mode === "graph" || mode === "radial"){ hover = m.id; centerOn(m); } // canvas views: bring it into view |
| 944 | + openPanel(m); // all modes: show its details |
830 | 945 | }); |
831 | 946 | } |
832 | 947 | function closePanel(){ panel.classList.remove("open"); selected = null; } |
|
846 | 961 | if(hiddenTypes.has(t)) hiddenTypes.delete(t); else hiddenTypes.add(t); |
847 | 962 | if(mode === "treemap") buildTreemap(); // rebuild without the hidden type |
848 | 963 | else if(mode === "mindmap") buildMindmap(); |
849 | | - else alpha = Math.max(alpha, 0.4); // graph: reheat so the layout re-settles |
| 964 | + else { if(mode === "radial") layoutRadial(); alpha = Math.max(alpha, 0.4); } // reheat / re-fan |
850 | 965 | }; |
851 | 966 | legend.appendChild(row); |
852 | 967 | }); |
|
870 | 985 | }); |
871 | 986 |
|
872 | 987 | /* ===================== controls (mode toggle + spacing slider + reset) ===================== */ |
873 | | -const MODE_CYCLE = ["mindmap", "graph", "treemap"]; |
874 | | -modeBtn.addEventListener("click", () => setMode(MODE_CYCLE[(MODE_CYCLE.indexOf(mode) + 1) % MODE_CYCLE.length])); |
| 988 | +[...modeTabs.children].forEach(b => b.addEventListener("click", () => setMode(b.dataset.m))); |
875 | 989 |
|
876 | 990 | const spreadEl = document.getElementById("spread"); |
877 | 991 | spreadEl.addEventListener("input", e => { |
878 | 992 | spread = parseFloat(e.target.value) || 1; |
| 993 | + if(mode === "radial") layoutRadial(); // re-fan the rings |
879 | 994 | alpha = Math.max(alpha, 0.6); autoFit = true; // reheat + re-frame to the new spacing |
880 | 995 | }); |
881 | 996 | document.getElementById("reset").addEventListener("click", () => { |
|
884 | 999 | tmQuery = ""; search.value = ""; buildTreemap(); |
885 | 1000 | } else if(mode === "mindmap"){ |
886 | 1001 | mmQuery = ""; search.value = ""; mmCollapsed.clear(); collapseDocs(); buildMindmap(); mmEl.scrollTop = 0; mmEl.scrollLeft = 0; |
| 1002 | + } else if(mode === "radial"){ |
| 1003 | + spread = DEFAULT_SPREAD; spreadEl.value = String(DEFAULT_SPREAD); panX = 0; panY = 0; |
| 1004 | + scale = DEF_SCALE; yaw = 0; pitch = 0; layoutRadial(); |
| 1005 | + alpha = Math.max(alpha, 0.9); autoFit = true; |
887 | 1006 | } else { |
888 | 1007 | spread = DEFAULT_SPREAD; spreadEl.value = String(DEFAULT_SPREAD); panX = 0; panY = 0; |
889 | 1008 | scale = DEF_SCALE; yaw = DEF_YAW; pitch = DEF_PITCH; |
|
0 commit comments