Skip to content

Commit dd9dc18

Browse files
committed
feat(visualize): keep all four views behind a tab bar
Per request, retain multiple views instead of one. Bring back the radial 'circle' layout (OpenKB centre → documents → concepts, faint cross-refs) alongside the horizontal mind-map, 3D nebula, and treemap. Replace the single cycle button with a tab bar [mind-map · radial · 3D · treemap]; default = mind-map. Each view reuses the shared inspector, search, legend filter and reset.
1 parent 0a5cc4a commit dd9dc18

1 file changed

Lines changed: 138 additions & 19 deletions

File tree

‎openkb/templates/graph.html‎

Lines changed: 138 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@
122122
}
123123
.controls .rst:hover{background:rgba(255,255,255,.08);color:var(--ink)}
124124
.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}
125133

126134
/* ---- treemap infographic ---- */
127135
.treemap{position:fixed;left:0;top:58px;right:0;bottom:66px;z-index:3;overflow:hidden}
@@ -233,7 +241,9 @@
233241
<div class="hint" id="hint"></div>
234242

235243
<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>
237247
<span class="sepc"></span>
238248
<label id="spacingCtl">spacing <input id="spread" type="range" min="0.5" max="3.5" step="0.1" value="1.7"></label>
239249
<button class="rst" id="reset" title="reset view, spacing &amp; focus">reset</button>
@@ -260,7 +270,7 @@
260270
const legend = document.getElementById("legend");
261271
const search = document.getElementById("search");
262272
const hintEl = document.getElementById("hint");
263-
const modeBtn = document.getElementById("mode");
273+
const modeTabs = document.getElementById("modeTabs");
264274
const DPR = Math.min(window.devicePixelRatio || 1, 2);
265275
const W = () => canvas.clientWidth;
266276
const H = () => canvas.clientHeight;
@@ -271,7 +281,8 @@
271281
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
272282
col: colorOf(n.type), // cached color → no per-frame lookup
273283
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
275286
}));
276287
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
277288
const edges = GRAPH.edges.filter(e => byId[e.source] && byId[e.target]);
@@ -292,8 +303,13 @@
292303
let scale = DEF_SCALE, panX = 0, panY = 0;
293304
let yaw = DEF_YAW, pitch = DEF_PITCH; // 3D orbit angles — drag the background to rotate
294305
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
295307
let didDrag = false;
296308
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)
297313
const DEFAULT_SPREAD = 1.7; // open the nebula out by default so it isn't a clump
298314
let spread = DEFAULT_SPREAD; // spacing knob: >1 spreads nodes apart, <1 packs them tighter
299315
let mode = "mindmap"; // "mindmap" = OpenKB-rooted horizontal tree (default) · "graph" = 3D nebula · "treemap" = infographic
@@ -392,10 +408,49 @@
392408
mmEl.querySelectorAll(".mm-node[data-id]").forEach(el => el.onclick = () => { const m = byId[el.dataset.id]; if(m) openPanel(m); });
393409
}
394410

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+
395449
/* ===================== physics (adapted from okf-spec.html step()) ===================== */
396450
const VMAX = 9; // per-frame speed cap → no violent swings, even on a dense hub
397451
const alphaTarget = n => typeVisible(n.type) ? 1 : 0;
398452
function step(){
453+
if(mode === "radial"){ stepRadial(); return; }
399454
for(let i=0;i<nodes.length;i++){
400455
const a = nodes[i];
401456
if(!visible(a)) continue;
@@ -526,24 +581,26 @@
526581
const m = byId[el.dataset.id]; if(m) openPanel(m);
527582
});
528583
}
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" };
530585
function setMode(m){
531586
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
533588
canvas.style.display = canvasMode ? "block" : "none";
534589
tmEl.style.display = (m === "treemap") ? "block" : "none";
535590
mmEl.style.display = (m === "mindmap") ? "block" : "none";
536-
spacingCtl.style.display = canvasMode ? "flex" : "none";
591+
spacingCtl.style.display = (m === "treemap" || m === "mindmap") ? "none" : "flex";
537592
hover = null; closePanel(); panX = 0; panY = 0; autoFit = true;
538593
if(m === "treemap"){ buildTreemap(); }
539594
else if(m === "mindmap"){ buildMindmap(); }
595+
else if(m === "radial"){ yaw = 0; pitch = 0; scale = DEF_SCALE; layoutRadial(); alpha = 1; }
540596
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));
542598
updateHint();
543599
}
544600
function updateHint(){
545601
hintEl.innerHTML =
546602
mode === "mindmap" ? 'OpenKB → documents → concepts &nbsp; <kbd>+/−</kbd> expand a branch &nbsp; <kbd>click</kbd> read details'
603+
: mode === "radial" ? 'centre <b>OpenKB</b> → documents → concepts &nbsp; faint = cross-refs &nbsp; <kbd>click</kbd> read &nbsp; <kbd>drag</kbd> pan'
547604
: mode === "treemap" ? 'each block = a document &nbsp; tiles = its concepts (size = connections) &nbsp; <kbd>click</kbd> read'
548605
: '<kbd>scroll</kbd> zoom &nbsp; <kbd>drag bg</kbd> rotate &nbsp; <kbd>click</kbd> inspect<br>'
549606
+ '<kbd>drag node</kbd> pull out &amp; pin &nbsp; <kbd>dbl-click</kbd> release';
@@ -626,6 +683,37 @@
626683
return best;
627684
}
628685

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+
629717
/* ===================== rendering ===================== */
630718
function drawEdge(e, t){
631719
const a = byId[e.source], b = byId[e.target];
@@ -724,7 +812,7 @@
724812
}
725813
// (flat discs — no glossy core highlight)
726814
// 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));
728816
if(showLabel && fog > 0.55){
729817
ctx.fillStyle = `rgba(238,242,247,${0.4 + 0.55*hl})`;
730818
ctx.font = `600 11px ui-monospace,monospace`;
@@ -738,7 +826,23 @@
738826
}
739827

740828
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
742846
if(alpha > 0.005) step();
743847
project();
744848
if(autoFit && !orbiting && !drag){ fitStep(); } // smoothly keep the cloud framed to the viewport
@@ -770,9 +874,14 @@
770874
alpha = Math.max(alpha, 0.5); // reheat so neighbors re-settle around the dragged node
771875
return;
772876
}
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+
}
776885
didDrag = true; return;
777886
}
778887
const n = pick(e.offsetX, e.offsetY);
@@ -782,13 +891,19 @@
782891
const n = pick(e.offsetX, e.offsetY);
783892
didDrag = false; idleFrames = 0; autoFit = false; // user takes control → stop auto-framing
784893
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+
}
785898
if(n){ drag = n; hover = n.id; canvas.style.cursor = "grabbing"; }
786899
else { orbiting = true; yawStart = yaw; pitchStart = pitch; canvas.style.cursor = "grabbing"; }
787900
});
788901
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
790905
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;
792907
canvas.style.cursor = "grab";
793908
});
794909
canvas.addEventListener("dblclick", e => {
@@ -825,8 +940,8 @@
825940
panel.querySelectorAll("[data-n]").forEach(a => a.onclick = () => {
826941
const m = byId[a.dataset.n];
827942
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
830945
});
831946
}
832947
function closePanel(){ panel.classList.remove("open"); selected = null; }
@@ -846,7 +961,7 @@
846961
if(hiddenTypes.has(t)) hiddenTypes.delete(t); else hiddenTypes.add(t);
847962
if(mode === "treemap") buildTreemap(); // rebuild without the hidden type
848963
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
850965
};
851966
legend.appendChild(row);
852967
});
@@ -870,12 +985,12 @@
870985
});
871986

872987
/* ===================== 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)));
875989

876990
const spreadEl = document.getElementById("spread");
877991
spreadEl.addEventListener("input", e => {
878992
spread = parseFloat(e.target.value) || 1;
993+
if(mode === "radial") layoutRadial(); // re-fan the rings
879994
alpha = Math.max(alpha, 0.6); autoFit = true; // reheat + re-frame to the new spacing
880995
});
881996
document.getElementById("reset").addEventListener("click", () => {
@@ -884,6 +999,10 @@
884999
tmQuery = ""; search.value = ""; buildTreemap();
8851000
} else if(mode === "mindmap"){
8861001
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;
8871006
} else {
8881007
spread = DEFAULT_SPREAD; spreadEl.value = String(DEFAULT_SPREAD); panX = 0; panY = 0;
8891008
scale = DEF_SCALE; yaw = DEF_YAW; pitch = DEF_PITCH;

0 commit comments

Comments
 (0)