Skip to content

Commit 3c3a49d

Browse files
committed
feat(visualize): radial mind-map mode (default) + 3D-graph toggle
The 3D force graph of a dense wikilink mesh (71 nodes / 800 edges, avg degree ~22) is intrinsically a hairball — no layout tuning makes its structure readable. Add a focus-based radial mind-map as the default, readable view: - one node centered, its direct neighbours fanned on 1–2 rings, spokes only (the mesh is hidden) → a clean tree where every label is legible - click any node (or any link in the inspector) to re-center on it and expand its neighbours; the panel shows the focused node's description, sources, and links - spacing slider stretches the rings; reset returns to the top hub - a MIND-MAP ⇄ 3D GRAPH toggle keeps the force layout available for the big-picture/cool overview Reuses the existing render (project/draw/pick), alpha-fade, highlight easing, zoom, legend filter, and inspector — map mode just swaps the layout (radial ease vs force physics) and what's visible.
1 parent babf8e9 commit 3c3a49d

1 file changed

Lines changed: 120 additions & 22 deletions

File tree

openkb/templates/graph.html

Lines changed: 120 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
text-transform:uppercase;cursor:pointer;transition:background .15s ease,color .15s ease;
122122
}
123123
.controls .rst:hover{background:rgba(255,255,255,.08);color:var(--ink)}
124+
.controls .sepc{width:1px;height:18px;background:var(--line)}
124125

125126
/* ---- glass inspector panel ---- */
126127
.panel{
@@ -184,14 +185,13 @@
184185

185186
<div class="legend" id="legend"></div>
186187

187-
<div class="hint">
188-
<kbd>scroll</kbd> zoom &nbsp; <kbd>drag bg</kbd> rotate &nbsp; <kbd>click</kbd> inspect<br>
189-
<kbd>drag node</kbd> pull out &amp; pin &nbsp; <kbd>dbl-click</kbd> release
190-
</div>
188+
<div class="hint" id="hint"></div>
191189

192190
<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>
193193
<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 &amp; pinned nodes">reset</button>
194+
<button class="rst" id="reset" title="reset view, spacing &amp; focus">reset</button>
195195
</div>
196196

197197
<div class="panel" id="panel"></div>
@@ -214,6 +214,8 @@
214214
const panel = document.getElementById("panel");
215215
const legend = document.getElementById("legend");
216216
const search = document.getElementById("search");
217+
const hintEl = document.getElementById("hint");
218+
const modeBtn = document.getElementById("mode");
217219
const DPR = Math.min(window.devicePixelRatio || 1, 2);
218220
const W = () => canvas.clientWidth;
219221
const H = () => canvas.clientHeight;
@@ -224,7 +226,8 @@
224226
r: 3.5 + Math.min(7, ((n.in || 0) + (n.out || 0)) * 0.42), // small crisp nodes, scaled by degree
225227
col: colorOf(n.type), // cached color → no per-frame lookup
226228
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
228231
}));
229232
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
230233
const edges = GRAPH.edges.filter(e => byId[e.source] && byId[e.target]);
@@ -245,9 +248,13 @@
245248
let scale = DEF_SCALE, panX = 0, panY = 0;
246249
let yaw = DEF_YAW, pitch = DEF_PITCH; // 3D orbit angles — drag the background to rotate
247250
let orbiting = false, oStartX = 0, oStartY = 0, yawStart = 0, pitchStart = 0;
251+
let panOrigX = 0, panOrigY = 0; // bg-drag pan origin (mind-map mode)
248252
let didDrag = false;
249253
let alpha = 1; // simulated-annealing "temperature"; cools to 0 so motion stops
250254
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
251258

252259
document.getElementById("ncount").textContent = nodes.length;
253260
document.getElementById("ecount").textContent = edges.length;
@@ -273,7 +280,9 @@
273280

274281
/* ===================== physics (adapted from okf-spec.html step()) ===================== */
275282
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;
276284
function step(){
285+
if(mode === "map"){ stepMap(); return; }
277286
for(let i=0;i<nodes.length;i++){
278287
const a = nodes[i];
279288
if(!visible(a)) continue;
@@ -302,8 +311,7 @@
302311
});
303312
nodes.forEach(n => {
304313
// 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;
307315
if(n === drag || n.pinned){ n.vx = n.vy = n.vz = 0; return; } // dragged or pinned: held in place
308316
n.vx *= 0.84; n.vy *= 0.84; n.vz *= 0.84; // stronger damping dissipates energy fast
309317
if(n.vx > VMAX) n.vx = VMAX; else if(n.vx < -VMAX) n.vx = -VMAX;
@@ -314,6 +322,70 @@
314322
alpha += (0 - alpha) * 0.02; // anneal: forces fade → the layout settles and stops
315323
}
316324

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 &amp; expand &nbsp; <kbd>drag bg</kbd> pan &nbsp; <kbd>scroll</kbd> zoom'
385+
: '<kbd>scroll</kbd> zoom &nbsp; <kbd>drag bg</kbd> rotate &nbsp; <kbd>click</kbd> inspect<br>'
386+
+ '<kbd>drag node</kbd> pull out &amp; pin &nbsp; <kbd>dbl-click</kbd> release';
387+
}
388+
317389
/* ===================== 3D orbit projection + perspective ===================== */
318390
const FOCAL = 900;
319391
function project(){
@@ -370,6 +442,8 @@
370442

371443
/* ===================== rendering ===================== */
372444
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;
373447
const a = byId[e.source], b = byId[e.target];
374448
const al = Math.min(a.alpha, b.alpha);
375449
if(al < 0.02) return;
@@ -440,7 +514,7 @@
440514
ctx.shadowBlur = 0;
441515
// (flat discs — no glossy core highlight)
442516
// 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));
444518
if(showLabel && fog > 0.55){
445519
ctx.fillStyle = `rgba(238,242,247,${0.4 + 0.55*hl})`;
446520
ctx.font = `600 11px ui-monospace,monospace`;
@@ -481,8 +555,13 @@
481555
return;
482556
}
483557
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+
}
486565
didDrag = true; return;
487566
}
488567
const n = pick(e.offsetX, e.offsetY);
@@ -491,14 +570,21 @@
491570
canvas.addEventListener("mousedown", e => {
492571
const n = pick(e.offsetX, e.offsetY);
493572
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+
}
495579
if(n){ drag = n; hover = n.id; canvas.style.cursor = "grabbing"; }
496580
else { orbiting = true; yawStart = yaw; pitchStart = pitch; canvas.style.cursor = "grabbing"; }
497581
});
498582
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
500586
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;
502588
canvas.style.cursor = "grab";
503589
});
504590
canvas.addEventListener("dblclick", e => {
@@ -534,7 +620,9 @@
534620
panel.querySelector(".x").onclick = closePanel;
535621
panel.querySelectorAll("[data-n]").forEach(a => a.onclick = () => {
536622
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); }
538626
});
539627
}
540628
function closePanel(){ panel.classList.remove("open"); selected = null; }
@@ -572,23 +660,33 @@
572660
}
573661
});
574662

575-
/* ===================== controls (spacing slider + reset) ===================== */
663+
/* ===================== controls (mode toggle + spacing slider + reset) ===================== */
664+
modeBtn.addEventListener("click", () => setMode(mode === "map" ? "graph" : "map"));
665+
576666
const spreadEl = document.getElementById("spread");
577667
spreadEl.addEventListener("input", e => {
578668
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
580671
});
581672
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";
585674
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
587682
});
588683

589684
/* ===================== boot ===================== */
590685
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();
592690
requestAnimationFrame(frame);
593691
</script>
594692
</body>

0 commit comments

Comments
 (0)