Skip to content

Commit 508daee

Browse files
committed
fix(visualize): show summary sources + review cleanup
- build_graph: summaries have no `sources` field — their origin is `full_text` (e.g. sources/nvda-10q.md). Include it so summary nodes show their source in the inspector instead of 'none'. - graph.html cleanup from review: cache per-node color (drops a per-frame colorOf lookup), store adjacency as a Set (O(1) neighbour test, was O(deg) per node per frame), one degree-sort feeding both hubs and labels, a TAU constant for the seven full-circle arcs, and a corrected comment on the spring degree-softening (it intentionally only damps hub-hub edges).
1 parent 0838dfd commit 508daee

3 files changed

Lines changed: 27 additions & 23 deletions

File tree

openkb/templates/graph.html

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@
174174
Work:"168,160,255", Event:"94,234,212", Entity:"148,163,184"
175175
};
176176
const colorOf = t => TYPE_COLORS[t] || "148,163,184";
177+
const TAU = Math.PI * 2; // full circle for ctx.arc
177178

178179
/* ===================== canvas + state ===================== */
179180
const canvas = document.getElementById("c");
@@ -189,23 +190,19 @@
189190
const nodes = GRAPH.nodes.map(n => ({
190191
...n, x:0, y:0, z:0, vx:0, vy:0, vz:0,
191192
r: 8 + Math.min(10, (n.in || 0) + (n.out || 0)), // radius scales with degree
193+
col: colorOf(n.type), // cached neon color → no per-frame lookup
192194
alpha: 1, // per-node fade (legend toggle)
193195
sx:0, sy:0, rz:0, pp:1, hl:1, pinned:false // projection cache + smoothed highlight + pinned (drag to pull out)
194196
}));
195197
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
196198
const edges = GRAPH.edges.filter(e => byId[e.source] && byId[e.target]);
197-
const adj = {}; nodes.forEach(n => adj[n.id] = []);
198-
edges.forEach(e => { adj[e.source].push(e.target); adj[e.target].push(e.source); });
199-
200-
// the few highest-degree hubs get an idle breathing glow
201-
const hubIds = new Set(
202-
[...nodes].sort((a,b) => ((b.in+b.out)-(a.in+a.out))).slice(0, Math.min(3, nodes.length)).map(n => n.id)
203-
);
204-
// labels shown at rest = the most-connected nodes; the rest reveal on hover/zoom
205-
const labelIds = new Set(
206-
[...nodes].sort((a,b) => ((b.in+b.out)-(a.in+a.out)))
207-
.slice(0, Math.max(6, Math.round(nodes.length*0.18))).map(n => n.id)
208-
);
199+
const adj = {}; nodes.forEach(n => adj[n.id] = new Set());
200+
edges.forEach(e => { adj[e.source].add(e.target); adj[e.target].add(e.source); }); // Set → O(1) neighbour test
201+
202+
// rank nodes by degree once; top 3 hubs breathe at rest, top ~18% keep labels at rest
203+
const byDeg = [...nodes].sort((a,b) => (b.in+b.out)-(a.in+a.out)).map(n => n.id);
204+
const hubIds = new Set(byDeg.slice(0, Math.min(3, nodes.length)));
205+
const labelIds = new Set(byDeg.slice(0, Math.max(6, Math.round(nodes.length*0.18))));
209206

210207
const hiddenTypes = new Set(); // legend filter
211208
const typeVisible = t => !hiddenTypes.has(t);
@@ -261,8 +258,9 @@
261258
const a = byId[e.source], b = byId[e.target];
262259
if(!visible(a) || !visible(b)) return;
263260
let dx = b.x-a.x, dy = b.y-a.y, dz = b.z-a.z, d = Math.hypot(dx,dy,dz) || 1;
264-
// normalize the pull by degree: a hub with N springs shouldn't feel N× the force
265-
const s = 1 / Math.min(adj[e.source].length || 1, adj[e.target].length || 1);
261+
// soften by the lower-degree endpoint so dense hub↔hub edges don't dominate
262+
// (a leaf↔hub edge stays full strength — that's intended)
263+
const s = 1 / Math.min(adj[e.source].size || 1, adj[e.target].size || 1);
266264
const f = (d-118)*0.012 * s * alpha;
267265
a.vx += dx/d*f; a.vy += dy/d*f; a.vz += dz/d*f;
268266
b.vx -= dx/d*f; b.vy -= dy/d*f; b.vz -= dz/d*f;
@@ -365,15 +363,15 @@
365363
const fx = ax + (bx-ax)*k, fy = ay + (by-ay)*k;
366364
ctx.shadowColor = "rgba(45,212,191,.9)"; ctx.shadowBlur = 8;
367365
ctx.fillStyle = "rgba(170,255,240,.98)";
368-
ctx.beginPath(); ctx.arc(fx, fy, 2.8, 0, 6.2832); ctx.fill();
366+
ctx.beginPath(); ctx.arc(fx, fy, 2.8, 0, TAU); ctx.fill();
369367
ctx.shadowBlur = 0;
370368
}
371369
ctx.globalAlpha = 1;
372370
}
373371

374372
function drawNode(n, t){
375373
if(n.alpha < 0.02) return;
376-
const col = colorOf(n.type);
374+
const col = n.col;
377375
const hl = n.hl; // smoothed highlight: 1 = lit; eases down (not snaps) when another node is hovered
378376
const breathe = (!hover && hubIds.has(n.id)) ? (1 + 0.05*Math.sin(t*0.0024)) : 1;
379377
const pulse = (n.id===hover) ? (1.16 + 0.03*Math.sin(t*0.006)) : 1;
@@ -383,30 +381,30 @@
383381
ctx.globalAlpha = n.alpha * fog * (0.34 + 0.66*hl); // dim non-neighbors gently, never to black
384382
// selected ring
385383
if(n.id === selected){
386-
ctx.beginPath(); ctx.arc(x, y, r+6, 0, 6.2832);
384+
ctx.beginPath(); ctx.arc(x, y, r+6, 0, TAU);
387385
ctx.strokeStyle = `rgba(${col},.85)`; ctx.lineWidth = 2; ctx.stroke();
388386
}
389387
// pinned marker (dashed ring → "pulled out and held")
390388
if(n.pinned){
391-
ctx.beginPath(); ctx.arc(x, y, r+4, 0, 6.2832);
389+
ctx.beginPath(); ctx.arc(x, y, r+4, 0, TAU);
392390
ctx.strokeStyle = "rgba(255,255,255,.65)"; ctx.lineWidth = 1.5;
393391
ctx.setLineDash([3,3]); ctx.stroke(); ctx.setLineDash([]);
394392
}
395393
// hover halo
396394
if(n.id === hover){
397-
ctx.beginPath(); ctx.arc(x, y, r+11, 0, 6.2832);
395+
ctx.beginPath(); ctx.arc(x, y, r+11, 0, TAU);
398396
ctx.fillStyle = `rgba(${col},.16)`; ctx.fill();
399397
}
400398
// disc + glow (glow eases with highlight so hovering doesn't flash the whole scene)
401-
ctx.beginPath(); ctx.arc(x, y, r, 0, 6.2832);
399+
ctx.beginPath(); ctx.arc(x, y, r, 0, TAU);
402400
ctx.fillStyle = `rgba(${col},${0.5 + 0.5*hl})`;
403401
ctx.shadowColor = `rgba(${col},.8)`;
404402
ctx.shadowBlur = (n.id===hover ? 17 : 11) * fog * hl;
405403
ctx.fill();
406404
ctx.shadowBlur = 0;
407405
// bright inner core
408406
if(hl > 0.35){
409-
ctx.beginPath(); ctx.arc(x-r*0.28, y-r*0.28, r*0.34, 0, 6.2832);
407+
ctx.beginPath(); ctx.arc(x-r*0.28, y-r*0.28, r*0.34, 0, TAU);
410408
ctx.fillStyle = `rgba(255,255,255,${0.5*hl})`; ctx.fill();
411409
}
412410
// label — declutter: hubs at rest; hovered node + neighbors; selection; zoomed in; near depth only
@@ -428,7 +426,7 @@
428426
// ease each node's highlight toward its target so hover brightens/dims smoothly (no flash)
429427
const focus = hover || selected; // hovering OR an open inspector keeps that node's connections lit
430428
for(const n of nodes){
431-
const near = !focus || n.id===focus || adj[focus].includes(n.id);
429+
const near = !focus || n.id===focus || adj[focus].has(n.id);
432430
n.hl += ((near ? 1 : 0.22) - n.hl) * 0.12;
433431
}
434432
ctx.setTransform(DPR,0,0,DPR,0,0);

openkb/visualize.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ def build_graph(wiki_dir: Path) -> dict:
3737
desc = desc.strip() if isinstance(desc, str) else ""
3838
srcs = fm.get("sources")
3939
srcs = [str(s) for s in srcs] if isinstance(srcs, list) else []
40+
ft = fm.get("full_text") # summaries record their origin document here, not in `sources`
41+
if isinstance(ft, str) and ft.strip():
42+
srcs.insert(0, ft.strip())
4043
nodes[nid] = {"id": nid, "label": p.stem, "type": t,
4144
"description": desc, "sources": srcs, "out": 0, "in": 0}
4245

tests/test_visualize.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def _wiki(tmp_path: Path) -> Path:
1414
def test_build_graph_nodes_edges_types(tmp_path):
1515
wiki = _wiki(tmp_path)
1616
(wiki / "summaries" / "paper.md").write_text(
17-
'---\ntype: "Summary"\ndescription: "A paper."\n---\n\n'
17+
'---\ntype: "Summary"\ndescription: "A paper."\nfull_text: "sources/paper.json"\n---\n\n'
1818
"Discusses [[concepts/attention]] and [[entities/anthropic]].\n", encoding="utf-8")
1919
(wiki / "concepts" / "attention.md").write_text(
2020
'---\ntype: "Concept"\ndescription: "Focus."\nsources: ["summaries/paper"]\n---\n\n'
@@ -37,6 +37,9 @@ def test_build_graph_nodes_edges_types(tmp_path):
3737
assert not any(e["source"] == e["target"] for e in g["edges"])
3838
assert by["concepts/attention"]["in"] == 1 and by["summaries/paper"]["out"] == 2
3939
assert g["types"] == ["Concept", "Organization", "Summary"]
40+
# sources: concepts use the `sources` field; summaries fall back to `full_text` (the origin doc)
41+
assert by["concepts/attention"]["sources"] == ["summaries/paper"]
42+
assert by["summaries/paper"]["sources"] == ["sources/paper.json"]
4043

4144

4245
def test_build_graph_empty_wiki(tmp_path):

0 commit comments

Comments
 (0)