Skip to content

Commit 424fc72

Browse files
committed
feat(visualize): provenance tree (document → concepts) as the default view
The radial 1-hop view wasn't a real tree. Replace it with the wiki's actual hierarchy, rendered as a collapsible indented outline: - each ingested document (summary page) is a root; under it, the concepts/entities it produced — resolved from each concept's `sources` frontmatter (stripping the .md so "summaries/x.md" matches node id "summaries/x"); a concept appears under every document it came from - roots ordered by how many concepts they produced; type-colored dots; fold/unfold per row; click a row to inspect (panel with description, sources, links); search and legend filter the outline live - DOM outline (not canvas) → crisp text, scrollable, no legibility issues - a TREE ⇄ 3D GRAPH toggle keeps the force layout for the visual overview; the spacing slider only shows in graph mode Removes the rejected radial mind-map mode.
1 parent 3c3a49d commit 424fc72

1 file changed

Lines changed: 134 additions & 108 deletions

File tree

openkb/templates/graph.html

Lines changed: 134 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,32 @@
123123
.controls .rst:hover{background:rgba(255,255,255,.08);color:var(--ink)}
124124
.controls .sepc{width:1px;height:18px;background:var(--line)}
125125

126+
/* ---- provenance tree (outline) ---- */
127+
.tree{
128+
position:fixed;left:0;top:60px;right:0;bottom:70px;z-index:3;
129+
overflow:auto;padding:14px 30px 30px;
130+
font:13px/1.65 var(--mono);color:var(--soft);
131+
}
132+
.tree .t-row{
133+
display:flex;align-items:center;gap:8px;padding:3px 9px;border-radius:7px;
134+
cursor:pointer;white-space:nowrap;transition:background .12s ease,color .12s ease;
135+
}
136+
.tree .t-row:hover{background:var(--glass);color:var(--ink)}
137+
.tree .t-caret{width:11px;flex:none;color:var(--mut);font-size:.62rem;text-align:center;transition:transform .15s ease}
138+
.tree .t-dot{width:9px;height:9px;border-radius:50%;flex:none;
139+
background:rgb(var(--c,148,163,184));box-shadow:0 0 7px rgba(var(--c,148,163,184),.55)}
140+
.tree .t-ic{flex:none;opacity:.85}
141+
.tree .t-ct{margin-left:9px;color:var(--mut);font-size:.7rem}
142+
.tree .t-doc{color:var(--ink);font-weight:600}
143+
.tree .t-leaf{color:var(--soft)}
144+
.tree .t-children{margin-left:15px;padding-left:9px;border-left:1px solid var(--line)}
145+
.tree .t-group.collapsed > .t-children{display:none}
146+
.tree .t-group.collapsed > .t-row .t-caret{transform:rotate(-90deg)}
147+
.tree .t-docgroup{margin:9px 0}
148+
.tree .t-empty{color:var(--mut);padding:24px}
149+
.tree::-webkit-scrollbar{width:9px}
150+
.tree::-webkit-scrollbar-thumb{background:rgba(255,255,255,.12);border-radius:8px}
151+
126152
/* ---- glass inspector panel ---- */
127153
.panel{
128154
position:fixed;top:0;right:0;z-index:6;width:360px;max-width:88vw;height:100%;
@@ -173,6 +199,7 @@
173199
<div class="grain"></div>
174200
<div class="vignette"></div>
175201
<canvas id="c"></canvas>
202+
<div class="tree" id="tree"></div>
176203

177204
<div class="brand">openkb<small>knowledge graph</small></div>
178205

@@ -190,7 +217,7 @@
190217
<div class="controls">
191218
<button class="rst" id="mode" title="switch between mind-map and 3D graph">3D graph</button>
192219
<span class="sepc"></span>
193-
<label>spacing <input id="spread" type="range" min="0.5" max="3" step="0.1" value="1"></label>
220+
<label id="spacingCtl">spacing <input id="spread" type="range" min="0.5" max="3" step="0.1" value="1"></label>
194221
<button class="rst" id="reset" title="reset view, spacing &amp; focus">reset</button>
195222
</div>
196223

@@ -226,8 +253,7 @@
226253
r: 3.5 + Math.min(7, ((n.in || 0) + (n.out || 0)) * 0.42), // small crisp nodes, scaled by degree
227254
col: colorOf(n.type), // cached color → no per-frame lookup
228255
alpha: 1, // per-node fade (legend toggle)
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
256+
sx:0, sy:0, rz:0, pp:1, hl:1, pinned:false // projection cache + smoothed highlight + pinned (drag to pull out)
231257
}));
232258
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
233259
const edges = GRAPH.edges.filter(e => byId[e.source] && byId[e.target]);
@@ -248,13 +274,10 @@
248274
let scale = DEF_SCALE, panX = 0, panY = 0;
249275
let yaw = DEF_YAW, pitch = DEF_PITCH; // 3D orbit angles — drag the background to rotate
250276
let orbiting = false, oStartX = 0, oStartY = 0, yawStart = 0, pitchStart = 0;
251-
let panOrigX = 0, panOrigY = 0; // bg-drag pan origin (mind-map mode)
252277
let didDrag = false;
253278
let alpha = 1; // simulated-annealing "temperature"; cools to 0 so motion stops
254279
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
280+
let mode = "tree"; // "tree" = provenance outline (default), "graph" = 3D force layout
258281

259282
document.getElementById("ncount").textContent = nodes.length;
260283
document.getElementById("ecount").textContent = edges.length;
@@ -280,9 +303,8 @@
280303

281304
/* ===================== physics (adapted from okf-spec.html step()) ===================== */
282305
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;
306+
const alphaTarget = n => typeVisible(n.type) ? 1 : 0;
284307
function step(){
285-
if(mode === "map"){ stepMap(); return; }
286308
for(let i=0;i<nodes.length;i++){
287309
const a = nodes[i];
288310
if(!visible(a)) continue;
@@ -322,66 +344,82 @@
322344
alpha += (0 - alpha) * 0.02; // anneal: forces fade → the layout settles and stops
323345
}
324346

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;
347+
/* ===================== provenance tree (document → summary → concept/entity) ===================== */
348+
// The wiki's real hierarchy lives in the frontmatter: each summary's `sources` is its origin
349+
// document (full_text), and each concept/entity's `sources` are the summaries it was drawn from.
350+
// Render that as a collapsible indented outline — a true tree, not the dense wikilink mesh.
351+
const treeEl = document.getElementById("tree");
352+
const spacingCtl = document.getElementById("spacingCtl");
353+
let treeQuery = "";
354+
355+
// concept/entity `sources` look like "summaries/foo.md"; summary node ids are "summaries/foo" → strip .md
356+
const resolveSrc = s => {
357+
const id = String(s).replace(/\.md$/i, "");
358+
return (byId[id] && byId[id].id.startsWith("summaries/")) ? id : null;
359+
};
360+
function buildTree(){
361+
const childrenOf = {}; // summaryId → [concept/entity node]
362+
nodes.forEach(n => { if(n.id.startsWith("summaries/")) childrenOf[n.id] = []; });
363+
const unlinked = []; // concepts/entities with no resolvable summary parent
364+
nodes.forEach(n => {
365+
if(n.id.startsWith("summaries/")) return;
366+
const parents = [...new Set((n.sources || []).map(resolveSrc).filter(Boolean))];
367+
if(parents.length) parents.forEach(p => childrenOf[p].push(n));
368+
else unlinked.push(n);
348369
});
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-
}
370+
371+
const q = treeQuery.toLowerCase();
372+
const matches = n => (!q || n.label.toLowerCase().includes(q) || n.id.toLowerCase().includes(q)) && typeVisible(n.type);
373+
const byLabel = (a, b) => a.label.localeCompare(b.label);
374+
const leafRow = n => matches(n)
375+
? `<div class="t-row t-leaf" data-id="${esc(n.id)}" style="--c:${colorOf(n.type)}"><span class="t-dot"></span>${esc(n.label)}</div>`
376+
: "";
377+
378+
// each summary = one ingested document (root); ordered by how many concepts it produced
379+
const summaries = nodes.filter(n => n.id.startsWith("summaries/"))
380+
.sort((a, b) => (childrenOf[b.id].length - childrenOf[a.id].length) || byLabel(a, b));
381+
let html = "";
382+
summaries.forEach(s => {
383+
const kids = (childrenOf[s.id] || []).slice().sort(byLabel);
384+
const kidRows = kids.map(leafRow).filter(Boolean).join("");
385+
if(!(matches(s) || kidRows)) return;
386+
html += `<div class="t-group t-docgroup"><div class="t-row t-doc" data-id="${esc(s.id)}" style="--c:${colorOf(s.type)}">`
387+
+ `<span class="t-caret">▾</span><span class="t-ic">📄</span>${esc(s.label)}<span class="t-ct">${kids.length}</span></div>`
388+
+ `<div class="t-children">${kidRows}</div></div>`;
389+
});
390+
const ul = unlinked.slice().sort(byLabel).map(leafRow).filter(Boolean).join("");
391+
if(ul){
392+
html += `<div class="t-group t-docgroup"><div class="t-row t-doc">`
393+
+ `<span class="t-caret">▾</span><span class="t-ic">○</span>unsourced<span class="t-ct">${unlinked.length}</span></div>`
394+
+ `<div class="t-children">${ul}</div></div>`;
358395
}
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
396+
treeEl.innerHTML = html || '<div class="t-empty">Nothing to show.</div>';
397+
398+
treeEl.querySelectorAll(".t-caret").forEach(c => c.onclick = e => {
399+
e.stopPropagation();
400+
c.closest(".t-group").classList.toggle("collapsed");
401+
});
402+
treeEl.querySelectorAll(".t-row").forEach(r => r.onclick = () => {
403+
const id = r.dataset.id;
404+
if(id && byId[id]) openPanel(byId[id]); // real node → inspect
405+
else { const g = r.closest(".t-group"); if(g) g.classList.toggle("collapsed"); } // doc row → fold
406+
});
367407
}
368408
function setMode(m){
369409
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";
410+
const tree = (m === "tree");
411+
canvas.style.display = tree ? "none" : "block";
412+
treeEl.style.display = tree ? "block" : "none";
413+
spacingCtl.style.display = tree ? "none" : "flex"; // spacing only affects the force layout
414+
hover = null; closePanel();
415+
if(tree){ buildTree(); }
416+
else { yaw = DEF_YAW; pitch = DEF_PITCH; scale = DEF_SCALE; panX = 0; panY = 0; seed(); alpha = 1; }
417+
modeBtn.textContent = tree ? "3D graph" : "tree";
380418
updateHint();
381419
}
382420
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'
421+
hintEl.innerHTML = (mode === "tree")
422+
? '<kbd>click</kbd> fold / unfold &nbsp; <kbd>click row</kbd> details'
385423
: '<kbd>scroll</kbd> zoom &nbsp; <kbd>drag bg</kbd> rotate &nbsp; <kbd>click</kbd> inspect<br>'
386424
+ '<kbd>drag node</kbd> pull out &amp; pin &nbsp; <kbd>dbl-click</kbd> release';
387425
}
@@ -442,8 +480,6 @@
442480

443481
/* ===================== rendering ===================== */
444482
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;
447483
const a = byId[e.source], b = byId[e.target];
448484
const al = Math.min(a.alpha, b.alpha);
449485
if(al < 0.02) return;
@@ -514,7 +550,7 @@
514550
ctx.shadowBlur = 0;
515551
// (flat discs — no glossy core highlight)
516552
// label — declutter: hubs at rest; hovered node + neighbors; selection; zoomed in; near depth only
517-
const showLabel = (mode === "map") || (scale > 1.4) || (n.id === selected) || (hover ? hl > 0.55 : labelIds.has(n.id));
553+
const showLabel = (scale > 1.4) || (n.id === selected) || (hover ? hl > 0.55 : labelIds.has(n.id));
518554
if(showLabel && fog > 0.55){
519555
ctx.fillStyle = `rgba(238,242,247,${0.4 + 0.55*hl})`;
520556
ctx.font = `600 11px ui-monospace,monospace`;
@@ -527,19 +563,21 @@
527563
}
528564

529565
function frame(t){
530-
if(alpha > 0.005) step(); // once cooled, stop integrating — the graph holds still
531-
project(); // 3D → screen coords for this frame
532-
// ease each node's highlight toward its target so hover brightens/dims smoothly (no flash)
533-
const focus = hover || selected; // hovering OR an open inspector keeps that node's connections lit
534-
for(const n of nodes){
535-
const near = !focus || n.id===focus || adj[focus].has(n.id);
536-
n.hl += ((near ? 1 : 0.22) - n.hl) * 0.12;
566+
if(mode === "graph"){ // tree mode is DOM — the canvas idles
567+
if(alpha > 0.005) step(); // once cooled, stop integrating — the graph holds still
568+
project(); // 3D → screen coords for this frame
569+
// ease each node's highlight toward its target so hover brightens/dims smoothly (no flash)
570+
const focus = hover || selected; // hovering OR an open inspector keeps that node's connections lit
571+
for(const n of nodes){
572+
const near = !focus || n.id===focus || adj[focus].has(n.id);
573+
n.hl += ((near ? 1 : 0.22) - n.hl) * 0.12;
574+
}
575+
ctx.setTransform(DPR,0,0,DPR,0,0);
576+
ctx.clearRect(0,0,W(),H());
577+
edges.forEach(e => drawEdge(e, t));
578+
// draw nodes far → near so nearer discs occlude farther ones (painter's algorithm)
579+
nodes.filter(visible).sort((a,b) => b.rz - a.rz).forEach(n => drawNode(n, t));
537580
}
538-
ctx.setTransform(DPR,0,0,DPR,0,0);
539-
ctx.clearRect(0,0,W(),H());
540-
edges.forEach(e => drawEdge(e, t));
541-
// draw nodes far → near so nearer discs occlude farther ones (painter's algorithm)
542-
nodes.filter(visible).sort((a,b) => b.rz - a.rz).forEach(n => drawNode(n, t));
543581
requestAnimationFrame(frame);
544582
}
545583

@@ -554,14 +592,9 @@
554592
alpha = Math.max(alpha, 0.5); // reheat so neighbors re-settle around the dragged node
555593
return;
556594
}
557-
if(orbiting){
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-
}
595+
if(orbiting){ // 3D → drag background to orbit
596+
yaw = yawStart + (e.offsetX - oStartX) * 0.0045;
597+
pitch = Math.max(-1.45, Math.min(1.45, pitchStart - (e.offsetY - oStartY) * 0.0045));
565598
didDrag = true; return;
566599
}
567600
const n = pick(e.offsetX, e.offsetY);
@@ -570,21 +603,14 @@
570603
canvas.addEventListener("mousedown", e => {
571604
const n = pick(e.offsetX, e.offsetY);
572605
didDrag = false;
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-
}
606+
oStartX = e.offsetX; oStartY = e.offsetY; // press origin — for the drag threshold and for orbit
579607
if(n){ drag = n; hover = n.id; canvas.style.cursor = "grabbing"; }
580608
else { orbiting = true; yawStart = yaw; pitchStart = pitch; canvas.style.cursor = "grabbing"; }
581609
});
582610
window.addEventListener("mouseup", e => {
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
611+
if(drag && !didDrag){ openPanel(drag); } // click (no drag) → inspect
586612
else if(drag && didDrag){ drag.pinned = true; alpha = Math.max(alpha, 0.5); } // pulled out → pin it
587-
drag = null; orbiting = false; pendingFocus = null;
613+
drag = null; orbiting = false;
588614
canvas.style.cursor = "grab";
589615
});
590616
canvas.addEventListener("dblclick", e => {
@@ -621,8 +647,8 @@
621647
panel.querySelectorAll("[data-n]").forEach(a => a.onclick = () => {
622648
const m = byId[a.dataset.n];
623649
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); }
650+
if(mode === "graph"){ hover = m.id; centerOn(m); } // graph: fly to the node
651+
openPanel(m); // both modes: show its details
626652
});
627653
}
628654
function closePanel(){ panel.classList.remove("open"); selected = null; }
@@ -640,14 +666,17 @@
640666
row.onclick = () => {
641667
row.classList.toggle("off");
642668
if(hiddenTypes.has(t)) hiddenTypes.delete(t); else hiddenTypes.add(t);
643-
alpha = Math.max(alpha, 0.4); // reheat so the remaining nodes re-pack into the freed space
669+
if(mode === "tree") buildTree(); // re-filter the outline
670+
else alpha = Math.max(alpha, 0.4); // reheat so the remaining nodes re-pack into the freed space
644671
};
645672
legend.appendChild(row);
646673
});
647674

648675
/* ===================== search ===================== */
649676
search.addEventListener("input", e => {
650-
const q = e.target.value.toLowerCase().trim();
677+
const raw = e.target.value.trim();
678+
if(mode === "tree"){ treeQuery = raw; buildTree(); return; } // filter the outline
679+
const q = raw.toLowerCase();
651680
if(!q){ hover = null; return; }
652681
const m = nodes.find(n => n.label.toLowerCase().includes(q))
653682
|| nodes.find(n => n.id.toLowerCase().includes(q));
@@ -661,32 +690,29 @@
661690
});
662691

663692
/* ===================== controls (mode toggle + spacing slider + reset) ===================== */
664-
modeBtn.addEventListener("click", () => setMode(mode === "map" ? "graph" : "map"));
693+
modeBtn.addEventListener("click", () => setMode(mode === "tree" ? "graph" : "tree"));
665694

666695
const spreadEl = document.getElementById("spread");
667696
spreadEl.addEventListener("input", e => {
668697
spread = parseFloat(e.target.value) || 1;
669-
if(mode === "map") layoutMap(); // spacing stretches the rings
670698
alpha = Math.max(alpha, 0.6); // reheat so the layout re-settles at the new spacing
671699
});
672700
document.getElementById("reset").addEventListener("click", () => {
673-
spread = 1; spreadEl.value = "1";
674701
hover = null; closePanel();
675-
if(mode === "map"){
676-
scale = 1.0; panX = 0; panY = 0; focusId = byDeg[0]; layoutMap(); // back to the top hub
702+
if(mode === "tree"){
703+
treeQuery = ""; search.value = ""; buildTree(); treeEl.scrollTop = 0; // collapse-all back to top
677704
} else {
705+
spread = 1; spreadEl.value = "1";
678706
scale = DEF_SCALE; panX = 0; panY = 0; yaw = DEF_YAW; pitch = DEF_PITCH; // view
679707
nodes.forEach(n => n.pinned = false); // release pinned
708+
alpha = Math.max(alpha, 0.9); // re-settle from the reset state
680709
}
681-
alpha = Math.max(alpha, 0.9); // re-settle from the reset state
682710
});
683711

684712
/* ===================== boot ===================== */
685713
window.addEventListener("resize", size);
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();
714+
size(); seed(); // seed positions so a later switch to 3D has a layout to settle
715+
setMode("tree"); // open on the readable provenance outline
690716
requestAnimationFrame(frame);
691717
</script>
692718
</body>

0 commit comments

Comments
 (0)