From 9b564b2c8104a9560d6fde890d3155332eb1648b Mon Sep 17 00:00:00 2001 From: mountain Date: Thu, 18 Jun 2026 13:11:49 +0800 Subject: [PATCH 01/15] =?UTF-8?q?feat(visualize):=20build=5Fgraph=20?= =?UTF-8?q?=E2=80=94=20collect=20nodes/edges/types=20from=20wikilinks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openkb/visualize.py | 51 +++++++++++++++++++++++++++++++++++++++++ tests/test_visualize.py | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 openkb/visualize.py create mode 100644 tests/test_visualize.py diff --git a/openkb/visualize.py b/openkb/visualize.py new file mode 100644 index 00000000..bb8b7d3e --- /dev/null +++ b/openkb/visualize.py @@ -0,0 +1,51 @@ +"""Render the wiki's [[wikilink]] graph as a self-contained interactive HTML page.""" +from __future__ import annotations + +from pathlib import Path + +from openkb import frontmatter +from openkb.lint import _extract_wikilinks, _normalize_target + +_NODE_DIRS = ("summaries", "concepts", "entities") +_FALLBACK_TYPE = {"summaries": "Summary", "concepts": "Concept", "entities": "Entity"} + + +def build_graph(wiki_dir: Path) -> dict: + """Collect nodes (pages), directed edges (wikilinks), and the set of types.""" + nodes: dict[str, dict] = {} + for sub in _NODE_DIRS: + d = wiki_dir / sub + if not d.exists(): + continue + for p in sorted(d.glob("*.md")): + nid = f"{sub}/{p.stem}" + fm = frontmatter.parse(p.read_text(encoding="utf-8")) + t = fm.get("type") + t = t.strip() if isinstance(t, str) and t.strip() else _FALLBACK_TYPE[sub] + desc = fm.get("description") + desc = desc.strip() if isinstance(desc, str) else "" + srcs = fm.get("sources") + srcs = [str(s) for s in srcs] if isinstance(srcs, list) else [] + nodes[nid] = {"id": nid, "label": p.stem, "type": t, + "description": desc, "sources": srcs, "out": 0, "in": 0} + + norm = {_normalize_target(nid): nid for nid in nodes} + edges: list[dict] = [] + seen: set[tuple[str, str]] = set() + for sub in _NODE_DIRS: + d = wiki_dir / sub + if not d.exists(): + continue + for p in sorted(d.glob("*.md")): + src = f"{sub}/{p.stem}" + for raw in _extract_wikilinks(p.read_text(encoding="utf-8")): + tgt = norm.get(_normalize_target(raw)) + if not tgt or tgt == src or (src, tgt) in seen: + continue + seen.add((src, tgt)) + edges.append({"source": src, "target": tgt}) + nodes[src]["out"] += 1 + nodes[tgt]["in"] += 1 + + types = sorted({n["type"] for n in nodes.values()}) + return {"nodes": list(nodes.values()), "edges": edges, "types": types} diff --git a/tests/test_visualize.py b/tests/test_visualize.py new file mode 100644 index 00000000..d30e0c4a --- /dev/null +++ b/tests/test_visualize.py @@ -0,0 +1,43 @@ +from pathlib import Path + +from openkb.visualize import build_graph + + +def _wiki(tmp_path: Path) -> Path: + wiki = tmp_path / "wiki" + for sub in ("summaries", "concepts", "entities", "reports", "sources"): + (wiki / sub).mkdir(parents=True) + (wiki / "index.md").write_text("# Index\n", encoding="utf-8") + return wiki + + +def test_build_graph_nodes_edges_types(tmp_path): + wiki = _wiki(tmp_path) + (wiki / "summaries" / "paper.md").write_text( + '---\ntype: "Summary"\ndescription: "A paper."\n---\n\n' + "Discusses [[concepts/attention]] and [[entities/anthropic]].\n", encoding="utf-8") + (wiki / "concepts" / "attention.md").write_text( + '---\ntype: "Concept"\ndescription: "Focus."\nsources: ["summaries/paper"]\n---\n\n' + "Used by [[concepts/attention]] (self) and [[concepts/missing]] (broken).\n", encoding="utf-8") + (wiki / "entities" / "anthropic.md").write_text( + '---\ntype: "Organization"\ndescription: "AI lab."\n---\n\n' + "# Anthropic\n", encoding="utf-8") + (wiki / "concepts" / "orphan.md").write_text("# Orphan\n\nNo links.\n", encoding="utf-8") + + g = build_graph(wiki) + ids = {n["id"] for n in g["nodes"]} + assert ids == {"summaries/paper", "concepts/attention", "entities/anthropic", "concepts/orphan"} + by = {n["id"]: n for n in g["nodes"]} + assert by["concepts/orphan"]["type"] == "Concept" + assert by["entities/anthropic"]["type"] == "Organization" + edge_pairs = {(e["source"], e["target"]) for e in g["edges"]} + assert ("summaries/paper", "concepts/attention") in edge_pairs + assert ("summaries/paper", "entities/anthropic") in edge_pairs + assert not any(e["target"] == "concepts/missing" for e in g["edges"]) + assert not any(e["source"] == e["target"] for e in g["edges"]) + assert by["concepts/attention"]["in"] == 1 and by["summaries/paper"]["out"] == 2 + assert g["types"] == ["Concept", "Organization", "Summary"] + + +def test_build_graph_empty_wiki(tmp_path): + assert build_graph(_wiki(tmp_path)) == {"nodes": [], "edges": [], "types": []} From ec8b5f3488e7d7e5e8dd050e14b1886cfaf26548 Mon Sep 17 00:00:00 2001 From: mountain Date: Thu, 18 Jun 2026 13:28:56 +0800 Subject: [PATCH 02/15] feat(visualize): dark-aurora interactive graph template (zoom/pan, inspect, filter, flow) --- openkb/templates/graph.html | 473 ++++++++++++++++++++++++++++++++++++ 1 file changed, 473 insertions(+) create mode 100644 openkb/templates/graph.html diff --git a/openkb/templates/graph.html b/openkb/templates/graph.html new file mode 100644 index 00000000..cb3daa09 --- /dev/null +++ b/openkb/templates/graph.html @@ -0,0 +1,473 @@ + + + + + +openkb · knowledge graph + + + +
+
+
+ + +
openkbknowledge graph
+ +
+ 0nodes + · + 0edges +
+
+ +
+ +
+ scroll zoom   drag bg pan
+ drag node pin   click inspect +
+ +
+ + + + From 928d22d7bf257cebba47df570b91e11553258baf Mon Sep 17 00:00:00 2001 From: mountain Date: Thu, 18 Jun 2026 13:31:36 +0800 Subject: [PATCH 03/15] =?UTF-8?q?feat(visualize):=20render=5Fhtml=20?= =?UTF-8?q?=E2=80=94=20inject=20graph=20JSON=20into=20template=20(self-con?= =?UTF-8?q?tained)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openkb/visualize.py | 9 +++++++++ tests/test_visualize.py | 13 ++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/openkb/visualize.py b/openkb/visualize.py index bb8b7d3e..17bf1f7c 100644 --- a/openkb/visualize.py +++ b/openkb/visualize.py @@ -1,6 +1,8 @@ """Render the wiki's [[wikilink]] graph as a self-contained interactive HTML page.""" from __future__ import annotations +import json +from importlib import resources from pathlib import Path from openkb import frontmatter @@ -49,3 +51,10 @@ def build_graph(wiki_dir: Path) -> dict: types = sorted({n["type"] for n in nodes.values()}) return {"nodes": list(nodes.values()), "edges": edges, "types": types} + + +def render_html(graph: dict) -> str: + """Inject the graph as JSON into the self-contained HTML template.""" + template = resources.files("openkb").joinpath("templates/graph.html").read_text(encoding="utf-8") + data = json.dumps(graph, ensure_ascii=False).replace(" breakout + return template.replace("__GRAPH_DATA__", data) diff --git a/tests/test_visualize.py b/tests/test_visualize.py index d30e0c4a..3233151f 100644 --- a/tests/test_visualize.py +++ b/tests/test_visualize.py @@ -1,6 +1,6 @@ from pathlib import Path -from openkb.visualize import build_graph +from openkb.visualize import build_graph, render_html def _wiki(tmp_path: Path) -> Path: @@ -41,3 +41,14 @@ def test_build_graph_nodes_edges_types(tmp_path): def test_build_graph_empty_wiki(tmp_path): assert build_graph(_wiki(tmp_path)) == {"nodes": [], "edges": [], "types": []} + + +def test_render_html_self_contained(): + g = {"nodes":[{"id":"concepts/a","label":"a","type":"Concept","description":"x—y","sources":[],"out":0,"in":0}], + "edges":[], "types":["Concept"]} + html = render_html(g) + assert " Date: Thu, 18 Jun 2026 13:33:49 +0800 Subject: [PATCH 04/15] feat(cli): add visualize command --- openkb/cli.py | 25 ++++++++++++++++++++++++ tests/test_visualize_cli.py | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 tests/test_visualize_cli.py diff --git a/openkb/cli.py b/openkb/cli.py index b48ebc17..413b263b 100644 --- a/openkb/cli.py +++ b/openkb/cli.py @@ -1494,6 +1494,31 @@ def lint(ctx, fix): asyncio.run(run_lint(kb_dir)) +@cli.command() +@click.option("--open", "open_browser", is_flag=True, default=False, + help="Open the generated graph in your browser.") +@click.pass_context +@_with_kb_lock(exclusive=False) +def visualize(ctx, open_browser): + """Render the wiki's [[wikilink]] graph as a self-contained interactive HTML page.""" + kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override")) + if kb_dir is None: + click.echo("No knowledge base found. Run `openkb init` first.") + return + from openkb import visualize as viz + graph = viz.build_graph(kb_dir / "wiki") + if not graph["nodes"]: + click.echo("No wiki pages to visualize yet. Run `openkb add` first.") + return + out = kb_dir / "output" / "visualize" / "graph.html" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(viz.render_html(graph), encoding="utf-8") + click.echo(f"Graph written to {out} ({len(graph['nodes'])} nodes, {len(graph['edges'])} edges)") + if open_browser: + import webbrowser + webbrowser.open(out.as_uri()) + + def print_list(kb_dir: Path) -> None: """Print all documents in the knowledge base. Usable from CLI and chat REPL.""" openkb_dir = kb_dir / ".openkb" diff --git a/tests/test_visualize_cli.py b/tests/test_visualize_cli.py new file mode 100644 index 00000000..cfb079a0 --- /dev/null +++ b/tests/test_visualize_cli.py @@ -0,0 +1,39 @@ +from pathlib import Path +from unittest.mock import patch +from click.testing import CliRunner +from openkb.cli import cli + + +def _kb(tmp_path: Path) -> Path: + for sub in ("summaries", "concepts", "entities"): + (tmp_path / "wiki" / sub).mkdir(parents=True) + (tmp_path / ".openkb").mkdir() + (tmp_path / ".openkb" / "config.yaml").write_text("model: gpt-4o-mini\n", encoding="utf-8") + (tmp_path / "wiki" / "concepts" / "a.md").write_text( + '---\ntype: "Concept"\ndescription: "d"\n---\n\nlinks [[concepts/b]]\n', encoding="utf-8") + (tmp_path / "wiki" / "concepts" / "b.md").write_text( + '---\ntype: "Concept"\ndescription: "d2"\n---\n\n# B\n', encoding="utf-8") + return tmp_path + + +def test_visualize_writes_html(tmp_path): + kb = _kb(tmp_path) + with patch("openkb.cli._find_kb_dir", return_value=kb): + result = CliRunner().invoke(cli, ["visualize"]) + assert result.exit_code == 0, result.output + out = kb / "output" / "visualize" / "graph.html" + assert out.exists() + html = out.read_text(encoding="utf-8") + assert " Date: Thu, 18 Jun 2026 13:40:52 +0800 Subject: [PATCH 05/15] fix(test): align stale deck default-skill expectation with shipped openkb-deck-neon #101 made openkb-deck-neon the default deck skill (creator.py DEFAULT_DECK_SKILL) but left this test asserting the old openkb-deck-editorial, so it has been red on main. Unrelated to the visualize feature on this branch. --- tests/test_generator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_generator.py b/tests/test_generator.py index 4ce9f145..67dbca61 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -85,7 +85,7 @@ async def test_generator_deck_dispatches_to_deck_creator(tmp_path): # validation up to self.validation. from openkb.agent.skill_runner import SkillRunResult fake_run_result = SkillRunResult( - skill_name="openkb-deck-editorial", + skill_name="openkb-deck-neon", output_path=gen.output_dir / "index.html", validation=DeckValidationResult(), metadata={"mode": "deck"}, @@ -102,7 +102,7 @@ async def test_generator_deck_dispatches_to_deck_creator(tmp_path): intent="…", model="openai/gpt-4o", critique=False, - skill_name="openkb-deck-editorial", + skill_name="openkb-deck-neon", ) regen.assert_not_called() # marketplace is skill-only assert result == gen.output_dir From 7a19020b7248134ac00292f5825cc9bc1b1d71e2 Mon Sep 17 00:00:00 2001 From: mountain Date: Thu, 18 Jun 2026 13:54:02 +0800 Subject: [PATCH 06/15] fix(visualize): fill viewport (retina-sharp) + declutter labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - canvas was a replaced element with width:auto, so inset:0 was ignored and it stayed at its intrinsic backing-store size — a doubling feedback loop (clientWidth←backing, backing←clientWidth*DPR) that never matched the window and rendered soft on retina. Add width:100%/height:100% so the CSS box tracks the viewport and the DPR backing store is correct. - with 71 nodes / 800 edges every label drew at rest → unreadable pileup. Show labels only for the most-connected hubs at rest; reveal the rest on hover (node + neighbors), on selection, or when zoomed in (scale>1.4). Add a dark text shadow for legibility over the edge web. --- openkb/templates/graph.html | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/openkb/templates/graph.html b/openkb/templates/graph.html index cb3daa09..63608355 100644 --- a/openkb/templates/graph.html +++ b/openkb/templates/graph.html @@ -39,7 +39,7 @@ position:fixed;inset:0;z-index:1;pointer-events:none; background:radial-gradient(120vw 120vh at 50% 50%,transparent 55%,rgba(0,0,0,.55) 100%); } -#c{position:fixed;inset:0;z-index:2;display:block;cursor:grab} +#c{position:fixed;inset:0;width:100%;height:100%;z-index:2;display:block;cursor:grab} #c:active{cursor:grabbing} /* ---- masthead ---- */ @@ -200,6 +200,11 @@ const hubIds = new Set( [...nodes].sort((a,b) => ((b.in+b.out)-(a.in+a.out))).slice(0, Math.min(3, nodes.length)).map(n => n.id) ); +// labels shown at rest = the most-connected nodes; the rest reveal on hover/zoom +const labelIds = new Set( + [...nodes].sort((a,b) => ((b.in+b.out)-(a.in+a.out))) + .slice(0, Math.max(6, Math.round(nodes.length*0.18))).map(n => n.id) +); const hiddenTypes = new Set(); // legend filter const typeVisible = t => !hiddenTypes.has(t); @@ -353,11 +358,16 @@ ctx.beginPath(); ctx.arc(n.x-r*0.28, n.y-r*0.28, r*0.34, 0, 6.2832); ctx.fillStyle = "rgba(255,255,255,.55)"; ctx.fill(); } - // label - ctx.fillStyle = faded ? "rgba(170,184,199,.3)" : "rgba(238,242,247,.92)"; - ctx.font = `600 ${11/Math.max(.7,scale)}px ui-monospace,monospace`; - ctx.textAlign = "center"; - ctx.fillText(n.label, n.x, n.y + r + 13/Math.max(.7,scale)); + // label — declutter: hubs at rest; hovered node + neighbors; selection; all when zoomed in + const showLabel = (scale > 1.4) || (n.id === selected) || (hover ? near : labelIds.has(n.id)); + if(showLabel){ + ctx.fillStyle = faded ? "rgba(170,184,199,.3)" : "rgba(238,242,247,.95)"; + ctx.font = `600 ${11/Math.max(.7,scale)}px ui-monospace,monospace`; + ctx.textAlign = "center"; + ctx.shadowColor = "rgba(4,6,10,.92)"; ctx.shadowBlur = 4; // legibility over the edge web + ctx.fillText(n.label, n.x, n.y + r + 13/Math.max(.7,scale)); + ctx.shadowBlur = 0; + } ctx.globalAlpha = 1; } From cc3f28f2da5b6317c6919c100b5500888428cfd8 Mon Sep 17 00:00:00 2001 From: mountain Date: Thu, 18 Jun 2026 14:02:08 +0800 Subject: [PATCH 07/15] fix(visualize): resolve() output path before as_uri() for --open A relative --kb-dir override (or relative default_kb config) left the output path relative; Path.as_uri() raises on relative paths, so `visualize --open` would crash after writing the HTML. resolve() first. --- openkb/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openkb/cli.py b/openkb/cli.py index 413b263b..0327e70c 100644 --- a/openkb/cli.py +++ b/openkb/cli.py @@ -1516,7 +1516,7 @@ def visualize(ctx, open_browser): click.echo(f"Graph written to {out} ({len(graph['nodes'])} nodes, {len(graph['edges'])} edges)") if open_browser: import webbrowser - webbrowser.open(out.as_uri()) + webbrowser.open(out.resolve().as_uri()) # resolve() so a relative --kb-dir still yields a valid file URI def print_list(kb_dir: Path) -> None: From 0fab16e0e2b2d725886be785534abb05c802278e Mon Sep 17 00:00:00 2001 From: mountain Date: Thu, 18 Jun 2026 14:37:29 +0800 Subject: [PATCH 08/15] fix(visualize): stabilize dense-graph physics so the layout settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dense KB (here 71 nodes / 800 edges, avg degree ~22) made the force-directed layout swing violently and never settle — hubs were pulled by ~22 springs at once, the summed force outran the damping, and nodes flew around illegibly. Legend filtering looked broken only because the motion masked it. Fixes: - degree-normalize spring pull (1/min(deg_a,deg_b)) so a hub doesn't feel N x the force - floor the repulsion distance so near-coincident nodes don't explode - cap per-frame speed (VMAX) and damp harder (0.9 -> 0.84) - simulated annealing: an alpha temperature cools to ~0 so physics freezes and the graph holds still; dragging a node or toggling a legend type reheats it to re-settle. Verified in-browser on the real KB: settles in ~1.5s, fully still by ~5s, and legend filtering now visibly re-packs the remaining nodes. --- openkb/templates/graph.html | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/openkb/templates/graph.html b/openkb/templates/graph.html index 63608355..4d53bfc8 100644 --- a/openkb/templates/graph.html +++ b/openkb/templates/graph.html @@ -214,6 +214,7 @@ let scale = 1, panX = 0, panY = 0; let panning = false, panStartX = 0, panStartY = 0, panOrigX = 0, panOrigY = 0; let didDrag = false; +let alpha = 1; // simulated-annealing "temperature"; cools to 0 so motion stops document.getElementById("ncount").textContent = nodes.length; document.getElementById("ecount").textContent = edges.length; @@ -235,6 +236,7 @@ } /* ===================== physics (adapted from okf-spec.html step()) ===================== */ +const VMAX = 9; // per-frame speed cap → no violent swings, even on a dense hub function step(){ const cx = W()/2, cy = H()/2; for(let i=0;i { const a = byId[e.source], b = byId[e.target]; if(!visible(a) || !visible(b)) return; - let dx = b.x-a.x, dy = b.y-a.y, d = Math.hypot(dx,dy) || 1, f = (d-118)*0.012; + let dx = b.x-a.x, dy = b.y-a.y, d = Math.hypot(dx,dy) || 1; + // normalize the pull by degree: a hub with N springs shouldn't feel N× the force + const s = 1 / Math.min(adj[e.source].length || 1, adj[e.target].length || 1); + const f = (d-118)*0.012 * s * alpha; a.vx += dx/d*f; a.vy += dy/d*f; b.vx -= dx/d*f; b.vy -= dy/d*f; }); @@ -262,9 +268,12 @@ const target = typeVisible(n.type) ? 1 : 0; n.alpha += (target - n.alpha) * 0.12; if(n === drag){ n.vx = n.vy = 0; return; } // pinned: position set by drag handler - n.vx *= 0.9; n.vy *= 0.9; + n.vx *= 0.84; n.vy *= 0.84; // stronger damping dissipates energy fast + if(n.vx > VMAX) n.vx = VMAX; else if(n.vx < -VMAX) n.vx = -VMAX; + if(n.vy > VMAX) n.vy = VMAX; else if(n.vy < -VMAX) n.vy = -VMAX; n.x += n.vx; n.y += n.vy; }); + alpha += (0 - alpha) * 0.02; // anneal: forces fade → the layout settles and stops } /* ===================== zoom / pan transform ===================== */ @@ -372,7 +381,7 @@ } function frame(t){ - step(); + if(alpha > 0.005) step(); // once cooled, stop integrating — the graph holds still ctx.setTransform(DPR,0,0,DPR,0,0); ctx.clearRect(0,0,W(),H()); applyView(); @@ -385,7 +394,9 @@ canvas.addEventListener("mousemove", e => { const w = toWorld(e.offsetX, e.offsetY); if(drag){ - drag.x = w.x; drag.y = w.y; drag.vx = drag.vy = 0; didDrag = true; return; + drag.x = w.x; drag.y = w.y; drag.vx = drag.vy = 0; didDrag = true; + alpha = Math.max(alpha, 0.5); // reheat so neighbors re-settle around the dragged node + return; } if(panning){ panX = panOrigX + (e.offsetX - panStartX); @@ -455,6 +466,7 @@ row.onclick = () => { row.classList.toggle("off"); if(hiddenTypes.has(t)) hiddenTypes.delete(t); else hiddenTypes.add(t); + alpha = Math.max(alpha, 0.4); // reheat so the remaining nodes re-pack into the freed space }; legend.appendChild(row); }); From c20f866cf3d46ed685dd4c280579c87e18bc5ea4 Mon Sep 17 00:00:00 2001 From: mountain Date: Thu, 18 Jun 2026 14:58:29 +0800 Subject: [PATCH 09/15] =?UTF-8?q?feat(visualize):=203D=20orbit=20graph=20?= =?UTF-8?q?=E2=80=94=20rotatable=20with=20perspective=20depth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade the force-directed graph from 2D to 3D so it can be rotated: - nodes now carry a z coordinate; physics (repulsion, degree-normalized springs, origin pull, annealing, speed cap) all run in 3D - seed on a golden-angle sphere so 3D structure reads immediately - per-frame project(): yaw/pitch rotation + perspective projection, with near=larger discs and depth fog (far nodes dim/recede) - draw nodes far→near (painter's algorithm) so nearer ones occlude - drag the background to orbit (yaw/pitch); scroll still zooms; dragging a node moves it in the current view plane via unproject(); pick() is now screen-space so hover/click stay accurate at any angle - hover highlight + flow particles, click→glass panel, legend filter all preserved and verified in-browser on the real KB. Default zoom bumped (scale 1.35) so the sphere fills more of the canvas. --- openkb/templates/graph.html | 181 +++++++++++++++++++++--------------- 1 file changed, 106 insertions(+), 75 deletions(-) diff --git a/openkb/templates/graph.html b/openkb/templates/graph.html index 4d53bfc8..492c837d 100644 --- a/openkb/templates/graph.html +++ b/openkb/templates/graph.html @@ -158,8 +158,8 @@
- scroll zoom   drag bg pan
- drag node pin   click inspect + scroll zoom   drag bg rotate
+ drag node move   click inspect
@@ -187,9 +187,10 @@ // build working nodes/edges from GRAPH (object-form edges: e.source/e.target) const nodes = GRAPH.nodes.map(n => ({ - ...n, x:0, y:0, vx:0, vy:0, + ...n, x:0, y:0, z:0, vx:0, vy:0, vz:0, r: 8 + Math.min(10, (n.in || 0) + (n.out || 0)), // radius scales with degree - alpha: 1 // per-node fade (legend toggle) + alpha: 1, // per-node fade (legend toggle) + sx:0, sy:0, rz:0, pp:1 // per-frame 3D projection cache })); const byId = Object.fromEntries(nodes.map(n => [n.id, n])); const edges = GRAPH.edges.filter(e => byId[e.source] && byId[e.target]); @@ -211,8 +212,9 @@ const visible = n => n.alpha > 0.02; // for physics/picking (fully-faded skipped) let hover = null, drag = null, selected = null; -let scale = 1, panX = 0, panY = 0; -let panning = false, panStartX = 0, panStartY = 0, panOrigX = 0, panOrigY = 0; +let scale = 1.35, panX = 0, panY = 0; +let yaw = 0.5, pitch = -0.28; // 3D orbit angles — drag the background to rotate +let orbiting = false, oStartX = 0, oStartY = 0, yawStart = 0, pitchStart = 0; let didDrag = false; let alpha = 1; // simulated-annealing "temperature"; cools to 0 so motion stops @@ -225,80 +227,106 @@ canvas.height = H() * DPR; } function seed(){ - const cx = W()/2, cy = H()/2; + const R = 250; nodes.forEach((n,i) => { - const a = i / Math.max(1, nodes.length) * 6.28318; - const rad = 150 + Math.random()*60; - n.x = cx + Math.cos(a)*rad + (Math.random()-.5)*40; - n.y = cy + Math.sin(a)*(rad*0.8) + (Math.random()-.5)*40; - n.vx = 0; n.vy = 0; + // spread nodes over a sphere (golden-angle spiral) so 3D structure shows from the start + const phi = Math.acos(1 - 2*(i+0.5)/Math.max(1,nodes.length)); + const theta = 2.399963 * i; + const rad = R * (0.55 + Math.random()*0.45); + n.x = rad*Math.sin(phi)*Math.cos(theta); + n.y = rad*Math.sin(phi)*Math.sin(theta); + n.z = rad*Math.cos(phi); + n.vx = n.vy = n.vz = 0; }); } /* ===================== physics (adapted from okf-spec.html step()) ===================== */ const VMAX = 9; // per-frame speed cap → no violent swings, even on a dense hub function step(){ - const cx = W()/2, cy = H()/2; for(let i=0;i { const a = byId[e.source], b = byId[e.target]; if(!visible(a) || !visible(b)) return; - let dx = b.x-a.x, dy = b.y-a.y, d = Math.hypot(dx,dy) || 1; + let dx = b.x-a.x, dy = b.y-a.y, dz = b.z-a.z, d = Math.hypot(dx,dy,dz) || 1; // normalize the pull by degree: a hub with N springs shouldn't feel N× the force const s = 1 / Math.min(adj[e.source].length || 1, adj[e.target].length || 1); const f = (d-118)*0.012 * s * alpha; - a.vx += dx/d*f; a.vy += dy/d*f; - b.vx -= dx/d*f; b.vy -= dy/d*f; + a.vx += dx/d*f; a.vy += dy/d*f; a.vz += dz/d*f; + b.vx -= dx/d*f; b.vy -= dy/d*f; b.vz -= dz/d*f; }); nodes.forEach(n => { // smoothly approach the legend-toggle target opacity const target = typeVisible(n.type) ? 1 : 0; n.alpha += (target - n.alpha) * 0.12; - if(n === drag){ n.vx = n.vy = 0; return; } // pinned: position set by drag handler - n.vx *= 0.84; n.vy *= 0.84; // stronger damping dissipates energy fast + if(n === drag){ n.vx = n.vy = n.vz = 0; return; } // pinned: position set by drag handler + n.vx *= 0.84; n.vy *= 0.84; n.vz *= 0.84; // stronger damping dissipates energy fast if(n.vx > VMAX) n.vx = VMAX; else if(n.vx < -VMAX) n.vx = -VMAX; if(n.vy > VMAX) n.vy = VMAX; else if(n.vy < -VMAX) n.vy = -VMAX; - n.x += n.vx; n.y += n.vy; + if(n.vz > VMAX) n.vz = VMAX; else if(n.vz < -VMAX) n.vz = -VMAX; + n.x += n.vx; n.y += n.vy; n.z += n.vz; }); alpha += (0 - alpha) * 0.02; // anneal: forces fade → the layout settles and stops } -/* ===================== zoom / pan transform ===================== */ -function applyView(){ ctx.setTransform(DPR*scale, 0, 0, DPR*scale, DPR*panX, DPR*panY); } -function toWorld(px,py){ return { x:(px-panX)/scale, y:(py-panY)/scale }; } -function centerOn(n){ panX = W()/2 - n.x*scale; panY = H()/2 - n.y*scale; } +/* ===================== 3D orbit projection + perspective ===================== */ +const FOCAL = 900; +function project(){ + const cyw = Math.cos(yaw), syw = Math.sin(yaw), cpt = Math.cos(pitch), spt = Math.sin(pitch); + const ox = W()/2 + panX, oy = H()/2 + panY; + for(const n of nodes){ + const x1 = n.x*cyw + n.z*syw; // rotate about Y (yaw) + const z1 = -n.x*syw + n.z*cyw; + const y2 = n.y*cpt - z1*spt; // then about X (pitch) + const z2 = n.y*spt + z1*cpt; + const pp = FOCAL/(FOCAL + z2); // perspective: near → larger + n.rz = z2; n.pp = pp; + n.sx = ox + x1*pp*scale; + n.sy = oy + y2*pp*scale; + } +} +// inverse of project for one node, holding its current depth — used while dragging +function unproject(sx, sy, n){ + const cyw = Math.cos(yaw), syw = Math.sin(yaw), cpt = Math.cos(pitch), spt = Math.sin(pitch); + const ox = W()/2 + panX, oy = H()/2 + panY; + const pp = n.pp || 1, z2 = n.rz || 0; + const x1 = (sx - ox)/(pp*scale); + const y2 = (sy - oy)/(pp*scale); + const y = y2*cpt + z2*spt; // invert pitch + const z1 = -y2*spt + z2*cpt; + return { x: x1*cyw - z1*syw, y, z: x1*syw + z1*cyw }; // invert yaw +} +function centerOn(n){ panX += (W()/2 - n.sx); panY += (H()/2 - n.sy); } canvas.addEventListener("wheel", e => { e.preventDefault(); const f = Math.exp(-e.deltaY * 0.0015); - const ns = Math.max(0.25, Math.min(4, scale*f)); - const k = ns/scale; - const mx = e.offsetX, my = e.offsetY; - panX = mx - (mx-panX)*k; panY = my - (my-panY)*k; - scale = ns; + scale = Math.max(0.3, Math.min(4.5, scale*f)); // zoom about the screen centre }, {passive:false}); /* ===================== picking ===================== */ -function pick(wx,wy){ - for(let i=nodes.length-1;i>=0;i--){ - const n = nodes[i]; +function pick(sx, sy){ + let best = null, bestDepth = Infinity; + for(const n of nodes){ if(!visible(n)) continue; - if(Math.hypot(n.x-wx, n.y-wy) < n.r + 6) return n; + const rr = n.r*n.pp*scale + 6; + if(Math.hypot(n.sx - sx, n.sy - sy) < rr && n.rz < bestDepth){ + best = n; bestDepth = n.rz; // nearest (smallest depth) wins where discs overlap + } } - return null; + return best; } /* ===================== rendering ===================== */ @@ -308,16 +336,18 @@ if(al < 0.02) return; const hot = hover && (e.source===hover || e.target===hover); const dim = hover && !hot; - ctx.globalAlpha = al; - ctx.strokeStyle = hot ? "rgba(45,212,191,.85)" : (dim ? "rgba(255,255,255,.04)" : "rgba(255,255,255,.13)"); - ctx.lineWidth = (hot ? 2 : 1) / scale; - ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke(); - // arrowhead at b, backed off by b's radius - const ang = Math.atan2(b.y-a.y, b.x-a.x); - const off = b.r + 4; - const hx = b.x - Math.cos(ang)*off, hy = b.y - Math.sin(ang)*off; - const ah = 8/Math.max(.6,scale); - ctx.fillStyle = hot ? "rgba(45,212,191,.9)" : (dim ? "rgba(255,255,255,.04)" : "rgba(255,255,255,.22)"); + const ax=a.sx, ay=a.sy, bx=b.sx, by=b.sy; + const fog = Math.max(0.2, Math.min(1, 1 - (a.rz+b.rz)/1300)); // far edges recede + ctx.globalAlpha = al * (hot ? 1 : fog); + ctx.strokeStyle = hot ? "rgba(45,212,191,.85)" : (dim ? "rgba(255,255,255,.035)" : "rgba(255,255,255,.12)"); + ctx.lineWidth = hot ? 2 : 1; + ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke(); + // arrowhead at b, backed off by b's projected radius + const ang = Math.atan2(by-ay, bx-ax); + const off = b.r*b.pp*scale + 4; + const hx = bx - Math.cos(ang)*off, hy = by - Math.sin(ang)*off; + const ah = 7; + ctx.fillStyle = hot ? "rgba(45,212,191,.9)" : (dim ? "rgba(255,255,255,.035)" : "rgba(255,255,255,.2)"); ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(hx - Math.cos(ang-.4)*ah, hy - Math.sin(ang-.4)*ah); @@ -326,10 +356,10 @@ // animated flow particle along highlighted edges if(hot){ const k = (t*0.0006) % 1; - const fx = a.x + (b.x-a.x)*k, fy = a.y + (b.y-a.y)*k; + const fx = ax + (bx-ax)*k, fy = ay + (by-ay)*k; ctx.shadowColor = "rgba(45,212,191,.9)"; ctx.shadowBlur = 8; ctx.fillStyle = "rgba(170,255,240,.98)"; - ctx.beginPath(); ctx.arc(fx, fy, 2.6/Math.max(.6,scale), 0, 6.2832); ctx.fill(); + ctx.beginPath(); ctx.arc(fx, fy, 2.8, 0, 6.2832); ctx.fill(); ctx.shadowBlur = 0; } ctx.globalAlpha = 1; @@ -343,38 +373,40 @@ // idle breathing for hubs (when nothing is hovered) const breathe = (!hover && hubIds.has(n.id)) ? (1 + 0.10*Math.sin(t*0.0024)) : 1; const pulse = (n.id===hover) ? (1.32 + 0.10*Math.sin(t*0.006)) : 1; - const r = n.r * pulse * breathe; - ctx.globalAlpha = n.alpha; + const r = Math.max(2, n.r * pulse * breathe * n.pp * scale); // perspective: near = larger + const fog = Math.max(0.32, Math.min(1, 1 - n.rz/640)); // depth cue: far nodes recede + const x = n.sx, y = n.sy; + ctx.globalAlpha = n.alpha * (faded ? 0.9 : fog); // selected ring if(n.id === selected){ - ctx.beginPath(); ctx.arc(n.x, n.y, r+6, 0, 6.2832); - ctx.strokeStyle = `rgba(${col},.85)`; ctx.lineWidth = 2/scale; ctx.stroke(); + ctx.beginPath(); ctx.arc(x, y, r+6, 0, 6.2832); + ctx.strokeStyle = `rgba(${col},.85)`; ctx.lineWidth = 2; ctx.stroke(); } // hover halo if(n.id === hover){ - ctx.beginPath(); ctx.arc(n.x, n.y, r+11, 0, 6.2832); + ctx.beginPath(); ctx.arc(x, y, r+11, 0, 6.2832); ctx.fillStyle = `rgba(${col},.16)`; ctx.fill(); } // disc + glow - ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, 6.2832); + ctx.beginPath(); ctx.arc(x, y, r, 0, 6.2832); ctx.fillStyle = `rgba(${col},${faded ? .25 : 1})`; ctx.shadowColor = `rgba(${col},.85)`; - ctx.shadowBlur = (faded ? 0 : (n.id===hover ? 22 : 14)) / Math.max(.6, scale); + ctx.shadowBlur = (faded ? 0 : (n.id===hover ? 22 : 14)) * fog; ctx.fill(); ctx.shadowBlur = 0; // bright inner core if(!faded){ - ctx.beginPath(); ctx.arc(n.x-r*0.28, n.y-r*0.28, r*0.34, 0, 6.2832); + ctx.beginPath(); ctx.arc(x-r*0.28, y-r*0.28, r*0.34, 0, 6.2832); ctx.fillStyle = "rgba(255,255,255,.55)"; ctx.fill(); } - // label — declutter: hubs at rest; hovered node + neighbors; selection; all when zoomed in + // label — declutter: hubs at rest; hovered node + neighbors; selection; zoomed in; near depth only const showLabel = (scale > 1.4) || (n.id === selected) || (hover ? near : labelIds.has(n.id)); - if(showLabel){ + if(showLabel && fog > 0.55){ ctx.fillStyle = faded ? "rgba(170,184,199,.3)" : "rgba(238,242,247,.95)"; - ctx.font = `600 ${11/Math.max(.7,scale)}px ui-monospace,monospace`; + ctx.font = `600 11px ui-monospace,monospace`; ctx.textAlign = "center"; ctx.shadowColor = "rgba(4,6,10,.92)"; ctx.shadowBlur = 4; // legibility over the edge web - ctx.fillText(n.label, n.x, n.y + r + 13/Math.max(.7,scale)); + ctx.fillText(n.label, x, y + r + 12); ctx.shadowBlur = 0; } ctx.globalAlpha = 1; @@ -382,44 +414,43 @@ function frame(t){ if(alpha > 0.005) step(); // once cooled, stop integrating — the graph holds still + project(); // 3D → screen coords for this frame ctx.setTransform(DPR,0,0,DPR,0,0); ctx.clearRect(0,0,W(),H()); - applyView(); edges.forEach(e => drawEdge(e, t)); - nodes.forEach(n => drawNode(n, t)); + // draw nodes far → near so nearer discs occlude farther ones (painter's algorithm) + nodes.filter(visible).sort((a,b) => b.rz - a.rz).forEach(n => drawNode(n, t)); requestAnimationFrame(frame); } /* ===================== hover / drag / pan ===================== */ canvas.addEventListener("mousemove", e => { - const w = toWorld(e.offsetX, e.offsetY); if(drag){ - drag.x = w.x; drag.y = w.y; drag.vx = drag.vy = 0; didDrag = true; + const w = unproject(e.offsetX, e.offsetY, drag); // move the node within the current view plane + drag.x = w.x; drag.y = w.y; drag.z = w.z; drag.vx = drag.vy = drag.vz = 0; didDrag = true; alpha = Math.max(alpha, 0.5); // reheat so neighbors re-settle around the dragged node return; } - if(panning){ - panX = panOrigX + (e.offsetX - panStartX); - panY = panOrigY + (e.offsetY - panStartY); + if(orbiting){ + yaw = yawStart + (e.offsetX - oStartX) * 0.01; + pitch = Math.max(-1.45, Math.min(1.45, pitchStart - (e.offsetY - oStartY) * 0.01)); didDrag = true; return; } - const n = pick(w.x, w.y); + const n = pick(e.offsetX, e.offsetY); hover = n ? n.id : null; - canvas.style.cursor = n ? "grab" : "grab"; }); canvas.addEventListener("mousedown", e => { - const w = toWorld(e.offsetX, e.offsetY); - const n = pick(w.x, w.y); + const n = pick(e.offsetX, e.offsetY); didDrag = false; if(n){ drag = n; hover = n.id; canvas.style.cursor = "grabbing"; } - else { panning = true; panStartX = e.offsetX; panStartY = e.offsetY; panOrigX = panX; panOrigY = panY; } + else { orbiting = true; oStartX = e.offsetX; oStartY = e.offsetY; yawStart = yaw; pitchStart = pitch; canvas.style.cursor = "grabbing"; } }); window.addEventListener("mouseup", e => { if(drag && !didDrag){ openPanel(drag); } // click (no drag) on a node → inspect - drag = null; panning = false; + drag = null; orbiting = false; canvas.style.cursor = "grab"; }); -canvas.addEventListener("mouseleave", () => { if(!drag && !panning) hover = null; }); +canvas.addEventListener("mouseleave", () => { if(!drag && !orbiting) hover = null; }); /* ===================== inspector panel ===================== */ function esc(s){ return String(s == null ? "" : s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); } From 4e4d1f3bbdccfdda396cdf13e1df2f1be00dbde0 Mon Sep 17 00:00:00 2001 From: mountain Date: Thu, 18 Jun 2026 17:12:10 +0800 Subject: [PATCH 10/15] fix(visualize): gentler hover + slower orbit + deep zoom + pull-out pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address feedback that hovering flashed the scene and that exploring was hard: - smooth, eased highlight (per-node hl lerp) replaces the hard faded snap; non-neighbors dim gently (never to black) and the hover pulse/ glow are toned down — no more flicker - an open inspector now keeps its node's connections lit (focus = hover || selected), so you can study one node's links at leisure - orbit sensitivity halved (0.0045) — rotation is no longer twitchy - zoom range widened to 9x and anchored on the cursor, so you can drill into a local cluster - drag a node to pull it out; releasing pins it (dashed ring, won't spring back) so its links fan out for inspection; double-click releases - hint line updated to match --- openkb/templates/graph.html | 73 ++++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/openkb/templates/graph.html b/openkb/templates/graph.html index 492c837d..3bdc872e 100644 --- a/openkb/templates/graph.html +++ b/openkb/templates/graph.html @@ -158,8 +158,8 @@
- scroll zoom   drag bg rotate
- drag node move   click inspect + scroll zoom   drag bg rotate   click inspect
+ drag node pull out & pin   dbl-click release
@@ -190,7 +190,7 @@ ...n, x:0, y:0, z:0, vx:0, vy:0, vz:0, r: 8 + Math.min(10, (n.in || 0) + (n.out || 0)), // radius scales with degree alpha: 1, // per-node fade (legend toggle) - sx:0, sy:0, rz:0, pp:1 // per-frame 3D projection cache + sx:0, sy:0, rz:0, pp:1, hl:1, pinned:false // projection cache + smoothed highlight + pinned (drag to pull out) })); const byId = Object.fromEntries(nodes.map(n => [n.id, n])); const edges = GRAPH.edges.filter(e => byId[e.source] && byId[e.target]); @@ -271,7 +271,7 @@ // smoothly approach the legend-toggle target opacity const target = typeVisible(n.type) ? 1 : 0; n.alpha += (target - n.alpha) * 0.12; - if(n === drag){ n.vx = n.vy = n.vz = 0; return; } // pinned: position set by drag handler + if(n === drag || n.pinned){ n.vx = n.vy = n.vz = 0; return; } // dragged or pinned: held in place n.vx *= 0.84; n.vy *= 0.84; n.vz *= 0.84; // stronger damping dissipates energy fast if(n.vx > VMAX) n.vx = VMAX; else if(n.vx < -VMAX) n.vx = -VMAX; if(n.vy > VMAX) n.vy = VMAX; else if(n.vy < -VMAX) n.vy = -VMAX; @@ -313,7 +313,13 @@ canvas.addEventListener("wheel", e => { e.preventDefault(); const f = Math.exp(-e.deltaY * 0.0015); - scale = Math.max(0.3, Math.min(4.5, scale*f)); // zoom about the screen centre + const ns = Math.max(0.3, Math.min(9, scale*f)); // wider range so you can dive deep in + const k = ns/scale; + // anchor zoom on the cursor → drill into whatever cluster you point at + const dx = e.offsetX - W()/2, dy = e.offsetY - H()/2; + panX = dx*(1-k) + panX*k; + panY = dy*(1-k) + panY*k; + scale = ns; }, {passive:false}); /* ===================== picking ===================== */ @@ -335,11 +341,11 @@ const al = Math.min(a.alpha, b.alpha); if(al < 0.02) return; const hot = hover && (e.source===hover || e.target===hover); - const dim = hover && !hot; const ax=a.sx, ay=a.sy, bx=b.sx, by=b.sy; const fog = Math.max(0.2, Math.min(1, 1 - (a.rz+b.rz)/1300)); // far edges recede - ctx.globalAlpha = al * (hot ? 1 : fog); - ctx.strokeStyle = hot ? "rgba(45,212,191,.85)" : (dim ? "rgba(255,255,255,.035)" : "rgba(255,255,255,.12)"); + const ehl = Math.max(a.hl, b.hl); // edge eases with its endpoints (smooth dim) + ctx.globalAlpha = al * fog * (hot ? 1 : (0.18 + 0.82*ehl)); + ctx.strokeStyle = hot ? "rgba(45,212,191,.85)" : `rgba(255,255,255,${0.06 + 0.07*ehl})`; ctx.lineWidth = hot ? 2 : 1; ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke(); // arrowhead at b, backed off by b's projected radius @@ -347,7 +353,7 @@ const off = b.r*b.pp*scale + 4; const hx = bx - Math.cos(ang)*off, hy = by - Math.sin(ang)*off; const ah = 7; - ctx.fillStyle = hot ? "rgba(45,212,191,.9)" : (dim ? "rgba(255,255,255,.035)" : "rgba(255,255,255,.2)"); + ctx.fillStyle = hot ? "rgba(45,212,191,.9)" : `rgba(255,255,255,${0.08 + 0.12*ehl})`; ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(hx - Math.cos(ang-.4)*ah, hy - Math.sin(ang-.4)*ah); @@ -368,41 +374,45 @@ function drawNode(n, t){ if(n.alpha < 0.02) return; const col = colorOf(n.type); - const near = hover ? (n.id===hover || adj[hover].includes(n.id)) : true; - const faded = hover && !near; - // idle breathing for hubs (when nothing is hovered) - const breathe = (!hover && hubIds.has(n.id)) ? (1 + 0.10*Math.sin(t*0.0024)) : 1; - const pulse = (n.id===hover) ? (1.32 + 0.10*Math.sin(t*0.006)) : 1; + const hl = n.hl; // smoothed highlight: 1 = lit; eases down (not snaps) when another node is hovered + const breathe = (!hover && hubIds.has(n.id)) ? (1 + 0.05*Math.sin(t*0.0024)) : 1; + const pulse = (n.id===hover) ? (1.16 + 0.03*Math.sin(t*0.006)) : 1; const r = Math.max(2, n.r * pulse * breathe * n.pp * scale); // perspective: near = larger const fog = Math.max(0.32, Math.min(1, 1 - n.rz/640)); // depth cue: far nodes recede const x = n.sx, y = n.sy; - ctx.globalAlpha = n.alpha * (faded ? 0.9 : fog); + ctx.globalAlpha = n.alpha * fog * (0.34 + 0.66*hl); // dim non-neighbors gently, never to black // selected ring if(n.id === selected){ ctx.beginPath(); ctx.arc(x, y, r+6, 0, 6.2832); ctx.strokeStyle = `rgba(${col},.85)`; ctx.lineWidth = 2; ctx.stroke(); } + // pinned marker (dashed ring → "pulled out and held") + if(n.pinned){ + ctx.beginPath(); ctx.arc(x, y, r+4, 0, 6.2832); + ctx.strokeStyle = "rgba(255,255,255,.65)"; ctx.lineWidth = 1.5; + ctx.setLineDash([3,3]); ctx.stroke(); ctx.setLineDash([]); + } // hover halo if(n.id === hover){ ctx.beginPath(); ctx.arc(x, y, r+11, 0, 6.2832); ctx.fillStyle = `rgba(${col},.16)`; ctx.fill(); } - // disc + glow + // disc + glow (glow eases with highlight so hovering doesn't flash the whole scene) ctx.beginPath(); ctx.arc(x, y, r, 0, 6.2832); - ctx.fillStyle = `rgba(${col},${faded ? .25 : 1})`; - ctx.shadowColor = `rgba(${col},.85)`; - ctx.shadowBlur = (faded ? 0 : (n.id===hover ? 22 : 14)) * fog; + ctx.fillStyle = `rgba(${col},${0.5 + 0.5*hl})`; + ctx.shadowColor = `rgba(${col},.8)`; + ctx.shadowBlur = (n.id===hover ? 17 : 11) * fog * hl; ctx.fill(); ctx.shadowBlur = 0; // bright inner core - if(!faded){ + if(hl > 0.35){ ctx.beginPath(); ctx.arc(x-r*0.28, y-r*0.28, r*0.34, 0, 6.2832); - ctx.fillStyle = "rgba(255,255,255,.55)"; ctx.fill(); + ctx.fillStyle = `rgba(255,255,255,${0.5*hl})`; ctx.fill(); } // label — declutter: hubs at rest; hovered node + neighbors; selection; zoomed in; near depth only - const showLabel = (scale > 1.4) || (n.id === selected) || (hover ? near : labelIds.has(n.id)); + const showLabel = (scale > 1.4) || (n.id === selected) || (hover ? hl > 0.55 : labelIds.has(n.id)); if(showLabel && fog > 0.55){ - ctx.fillStyle = faded ? "rgba(170,184,199,.3)" : "rgba(238,242,247,.95)"; + ctx.fillStyle = `rgba(238,242,247,${0.4 + 0.55*hl})`; ctx.font = `600 11px ui-monospace,monospace`; ctx.textAlign = "center"; ctx.shadowColor = "rgba(4,6,10,.92)"; ctx.shadowBlur = 4; // legibility over the edge web @@ -415,6 +425,12 @@ function frame(t){ if(alpha > 0.005) step(); // once cooled, stop integrating — the graph holds still project(); // 3D → screen coords for this frame + // ease each node's highlight toward its target so hover brightens/dims smoothly (no flash) + const focus = hover || selected; // hovering OR an open inspector keeps that node's connections lit + for(const n of nodes){ + const near = !focus || n.id===focus || adj[focus].includes(n.id); + n.hl += ((near ? 1 : 0.22) - n.hl) * 0.12; + } ctx.setTransform(DPR,0,0,DPR,0,0); ctx.clearRect(0,0,W(),H()); edges.forEach(e => drawEdge(e, t)); @@ -432,8 +448,8 @@ return; } if(orbiting){ - yaw = yawStart + (e.offsetX - oStartX) * 0.01; - pitch = Math.max(-1.45, Math.min(1.45, pitchStart - (e.offsetY - oStartY) * 0.01)); + yaw = yawStart + (e.offsetX - oStartX) * 0.0045; + pitch = Math.max(-1.45, Math.min(1.45, pitchStart - (e.offsetY - oStartY) * 0.0045)); didDrag = true; return; } const n = pick(e.offsetX, e.offsetY); @@ -446,10 +462,15 @@ else { orbiting = true; oStartX = e.offsetX; oStartY = e.offsetY; yawStart = yaw; pitchStart = pitch; canvas.style.cursor = "grabbing"; } }); window.addEventListener("mouseup", e => { - if(drag && !didDrag){ openPanel(drag); } // click (no drag) on a node → inspect + if(drag && !didDrag){ openPanel(drag); } // click (no drag) → inspect + else if(drag && didDrag){ drag.pinned = true; alpha = Math.max(alpha, 0.5); } // pulled out → pin it drag = null; orbiting = false; canvas.style.cursor = "grab"; }); +canvas.addEventListener("dblclick", e => { + const n = pick(e.offsetX, e.offsetY); + if(n && n.pinned){ n.pinned = false; alpha = Math.max(alpha, 0.5); } // double-click a pinned node → release +}); canvas.addEventListener("mouseleave", () => { if(!drag && !orbiting) hover = null; }); /* ===================== inspector panel ===================== */ From a2376b9a6ea763bc5171c8cc2af90e43cd26a45d Mon Sep 17 00:00:00 2001 From: mountain Date: Thu, 18 Jun 2026 17:56:05 +0800 Subject: [PATCH 11/15] feat(visualize): open the graph in the browser by default Auto-open after generating so plain `openkb visualize` just shows the graph; pass --no-open for headless/CI use. The HTML is still written to output/visualize/graph.html every run (latest snapshot, shareable). If a browser can't be launched, print a hint instead of failing. --- openkb/cli.py | 11 ++++++++--- tests/test_visualize_cli.py | 20 +++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/openkb/cli.py b/openkb/cli.py index 0327e70c..a6236b7d 100644 --- a/openkb/cli.py +++ b/openkb/cli.py @@ -1495,8 +1495,8 @@ def lint(ctx, fix): @cli.command() -@click.option("--open", "open_browser", is_flag=True, default=False, - help="Open the generated graph in your browser.") +@click.option("--open/--no-open", "open_browser", default=True, + help="Open the graph in your browser after generating (default: on; --no-open for headless).") @click.pass_context @_with_kb_lock(exclusive=False) def visualize(ctx, open_browser): @@ -1516,7 +1516,12 @@ def visualize(ctx, open_browser): click.echo(f"Graph written to {out} ({len(graph['nodes'])} nodes, {len(graph['edges'])} edges)") if open_browser: import webbrowser - webbrowser.open(out.resolve().as_uri()) # resolve() so a relative --kb-dir still yields a valid file URI + try: + opened = webbrowser.open(out.resolve().as_uri()) # resolve() so a relative --kb-dir still yields a valid file URI + except Exception: + opened = False + if not opened: + click.echo("(couldn't launch a browser — open the file above manually, or use --no-open)") def print_list(kb_dir: Path) -> None: diff --git a/tests/test_visualize_cli.py b/tests/test_visualize_cli.py index cfb079a0..278da317 100644 --- a/tests/test_visualize_cli.py +++ b/tests/test_visualize_cli.py @@ -16,15 +16,27 @@ def _kb(tmp_path: Path) -> Path: return tmp_path -def test_visualize_writes_html(tmp_path): +def test_visualize_writes_html_and_opens_by_default(tmp_path): kb = _kb(tmp_path) - with patch("openkb.cli._find_kb_dir", return_value=kb): + with patch("openkb.cli._find_kb_dir", return_value=kb), \ + patch("webbrowser.open") as wb: result = CliRunner().invoke(cli, ["visualize"]) assert result.exit_code == 0, result.output out = kb / "output" / "visualize" / "graph.html" assert out.exists() html = out.read_text(encoding="utf-8") assert " Date: Thu, 18 Jun 2026 18:40:33 +0800 Subject: [PATCH 12/15] fix(visualize): address code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - graph.html project(): clamp the perspective denominator (FOCAL+z2) so a node crossing the camera plane can't divide by ~0 → ±Infinity/NaN, which previously flung the node off-screen and (via centerOn) poisoned panX/panY until reload. - graph.html: require >4px of motion before a press counts as a drag, so a normal click (with sub-pixel jitter) inspects the node instead of pinning it. - visualize.py: reuse schema.PAGE_CONTENT_DIRS instead of a local _NODE_DIRS copy, and derive the fallback type so a new content dir can't KeyError. - visualize.py: read each wiki file once (cache text for the edge pass) instead of twice. --- openkb/templates/graph.html | 10 ++++++--- openkb/visualize.py | 42 ++++++++++++++++++++----------------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/openkb/templates/graph.html b/openkb/templates/graph.html index 3bdc872e..b0daa5e6 100644 --- a/openkb/templates/graph.html +++ b/openkb/templates/graph.html @@ -291,7 +291,7 @@ const z1 = -n.x*syw + n.z*cyw; const y2 = n.y*cpt - z1*spt; // then about X (pitch) const z2 = n.y*spt + z1*cpt; - const pp = FOCAL/(FOCAL + z2); // perspective: near → larger + const pp = FOCAL/Math.max(FOCAL + z2, FOCAL*0.25); // perspective: near→larger; clamp denom so a node crossing the camera plane can't blow up to ±Infinity/NaN n.rz = z2; n.pp = pp; n.sx = ox + x1*pp*scale; n.sy = oy + y2*pp*scale; @@ -442,8 +442,11 @@ /* ===================== hover / drag / pan ===================== */ canvas.addEventListener("mousemove", e => { if(drag){ + // ignore sub-pixel jitter so a plain click still inspects instead of pinning + if(!didDrag && Math.hypot(e.offsetX - oStartX, e.offsetY - oStartY) < 4) return; + didDrag = true; const w = unproject(e.offsetX, e.offsetY, drag); // move the node within the current view plane - drag.x = w.x; drag.y = w.y; drag.z = w.z; drag.vx = drag.vy = drag.vz = 0; didDrag = true; + drag.x = w.x; drag.y = w.y; drag.z = w.z; drag.vx = drag.vy = drag.vz = 0; alpha = Math.max(alpha, 0.5); // reheat so neighbors re-settle around the dragged node return; } @@ -458,8 +461,9 @@ canvas.addEventListener("mousedown", e => { const n = pick(e.offsetX, e.offsetY); didDrag = false; + oStartX = e.offsetX; oStartY = e.offsetY; // press origin — for the drag threshold and for orbit if(n){ drag = n; hover = n.id; canvas.style.cursor = "grabbing"; } - else { orbiting = true; oStartX = e.offsetX; oStartY = e.offsetY; yawStart = yaw; pitchStart = pitch; canvas.style.cursor = "grabbing"; } + else { orbiting = true; yawStart = yaw; pitchStart = pitch; canvas.style.cursor = "grabbing"; } }); window.addEventListener("mouseup", e => { if(drag && !didDrag){ openPanel(drag); } // click (no drag) → inspect diff --git a/openkb/visualize.py b/openkb/visualize.py index 17bf1f7c..90634081 100644 --- a/openkb/visualize.py +++ b/openkb/visualize.py @@ -7,23 +7,32 @@ from openkb import frontmatter from openkb.lint import _extract_wikilinks, _normalize_target +from openkb.schema import PAGE_CONTENT_DIRS -_NODE_DIRS = ("summaries", "concepts", "entities") -_FALLBACK_TYPE = {"summaries": "Summary", "concepts": "Concept", "entities": "Entity"} +# Singular display type per content dir; falls back to a derived name for any +# dir not listed (so a new PAGE_CONTENT_DIRS entry never KeyErrors here). +_DIR_TYPE = {"summaries": "Summary", "concepts": "Concept", "entities": "Entity"} + + +def _type_for_dir(sub: str) -> str: + return _DIR_TYPE.get(sub) or sub.rstrip("s").capitalize() or sub def build_graph(wiki_dir: Path) -> dict: """Collect nodes (pages), directed edges (wikilinks), and the set of types.""" nodes: dict[str, dict] = {} - for sub in _NODE_DIRS: + texts: dict[str, str] = {} # nid -> file text, read once and reused for edges + for sub in PAGE_CONTENT_DIRS: d = wiki_dir / sub if not d.exists(): continue for p in sorted(d.glob("*.md")): nid = f"{sub}/{p.stem}" - fm = frontmatter.parse(p.read_text(encoding="utf-8")) + text = p.read_text(encoding="utf-8") + texts[nid] = text + fm = frontmatter.parse(text) t = fm.get("type") - t = t.strip() if isinstance(t, str) and t.strip() else _FALLBACK_TYPE[sub] + t = t.strip() if isinstance(t, str) and t.strip() else _type_for_dir(sub) desc = fm.get("description") desc = desc.strip() if isinstance(desc, str) else "" srcs = fm.get("sources") @@ -34,20 +43,15 @@ def build_graph(wiki_dir: Path) -> dict: norm = {_normalize_target(nid): nid for nid in nodes} edges: list[dict] = [] seen: set[tuple[str, str]] = set() - for sub in _NODE_DIRS: - d = wiki_dir / sub - if not d.exists(): - continue - for p in sorted(d.glob("*.md")): - src = f"{sub}/{p.stem}" - for raw in _extract_wikilinks(p.read_text(encoding="utf-8")): - tgt = norm.get(_normalize_target(raw)) - if not tgt or tgt == src or (src, tgt) in seen: - continue - seen.add((src, tgt)) - edges.append({"source": src, "target": tgt}) - nodes[src]["out"] += 1 - nodes[tgt]["in"] += 1 + for src, text in texts.items(): + for raw in _extract_wikilinks(text): + tgt = norm.get(_normalize_target(raw)) + if not tgt or tgt == src or (src, tgt) in seen: + continue + seen.add((src, tgt)) + edges.append({"source": src, "target": tgt}) + nodes[src]["out"] += 1 + nodes[tgt]["in"] += 1 types = sorted({n["type"] for n in nodes.values()}) return {"nodes": list(nodes.values()), "edges": edges, "types": types} From 508daee5ad77aa754b21370568b11ec1a3fdbcf0 Mon Sep 17 00:00:00 2001 From: mountain Date: Thu, 18 Jun 2026 19:02:26 +0800 Subject: [PATCH 13/15] fix(visualize): show summary sources + review cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- openkb/templates/graph.html | 42 ++++++++++++++++++------------------- openkb/visualize.py | 3 +++ tests/test_visualize.py | 5 ++++- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/openkb/templates/graph.html b/openkb/templates/graph.html index b0daa5e6..f7af1a52 100644 --- a/openkb/templates/graph.html +++ b/openkb/templates/graph.html @@ -174,6 +174,7 @@ Work:"168,160,255", Event:"94,234,212", Entity:"148,163,184" }; const colorOf = t => TYPE_COLORS[t] || "148,163,184"; +const TAU = Math.PI * 2; // full circle for ctx.arc /* ===================== canvas + state ===================== */ const canvas = document.getElementById("c"); @@ -189,23 +190,19 @@ const nodes = GRAPH.nodes.map(n => ({ ...n, x:0, y:0, z:0, vx:0, vy:0, vz:0, r: 8 + Math.min(10, (n.in || 0) + (n.out || 0)), // radius scales with degree + col: colorOf(n.type), // cached neon color → no per-frame lookup alpha: 1, // per-node fade (legend toggle) sx:0, sy:0, rz:0, pp:1, hl:1, pinned:false // projection cache + smoothed highlight + pinned (drag to pull out) })); const byId = Object.fromEntries(nodes.map(n => [n.id, n])); const edges = GRAPH.edges.filter(e => byId[e.source] && byId[e.target]); -const adj = {}; nodes.forEach(n => adj[n.id] = []); -edges.forEach(e => { adj[e.source].push(e.target); adj[e.target].push(e.source); }); - -// the few highest-degree hubs get an idle breathing glow -const hubIds = new Set( - [...nodes].sort((a,b) => ((b.in+b.out)-(a.in+a.out))).slice(0, Math.min(3, nodes.length)).map(n => n.id) -); -// labels shown at rest = the most-connected nodes; the rest reveal on hover/zoom -const labelIds = new Set( - [...nodes].sort((a,b) => ((b.in+b.out)-(a.in+a.out))) - .slice(0, Math.max(6, Math.round(nodes.length*0.18))).map(n => n.id) -); +const adj = {}; nodes.forEach(n => adj[n.id] = new Set()); +edges.forEach(e => { adj[e.source].add(e.target); adj[e.target].add(e.source); }); // Set → O(1) neighbour test + +// rank nodes by degree once; top 3 hubs breathe at rest, top ~18% keep labels at rest +const byDeg = [...nodes].sort((a,b) => (b.in+b.out)-(a.in+a.out)).map(n => n.id); +const hubIds = new Set(byDeg.slice(0, Math.min(3, nodes.length))); +const labelIds = new Set(byDeg.slice(0, Math.max(6, Math.round(nodes.length*0.18)))); const hiddenTypes = new Set(); // legend filter const typeVisible = t => !hiddenTypes.has(t); @@ -261,8 +258,9 @@ const a = byId[e.source], b = byId[e.target]; if(!visible(a) || !visible(b)) return; let dx = b.x-a.x, dy = b.y-a.y, dz = b.z-a.z, d = Math.hypot(dx,dy,dz) || 1; - // normalize the pull by degree: a hub with N springs shouldn't feel N× the force - const s = 1 / Math.min(adj[e.source].length || 1, adj[e.target].length || 1); + // soften by the lower-degree endpoint so dense hub↔hub edges don't dominate + // (a leaf↔hub edge stays full strength — that's intended) + const s = 1 / Math.min(adj[e.source].size || 1, adj[e.target].size || 1); const f = (d-118)*0.012 * s * alpha; a.vx += dx/d*f; a.vy += dy/d*f; a.vz += dz/d*f; b.vx -= dx/d*f; b.vy -= dy/d*f; b.vz -= dz/d*f; @@ -365,7 +363,7 @@ const fx = ax + (bx-ax)*k, fy = ay + (by-ay)*k; ctx.shadowColor = "rgba(45,212,191,.9)"; ctx.shadowBlur = 8; ctx.fillStyle = "rgba(170,255,240,.98)"; - ctx.beginPath(); ctx.arc(fx, fy, 2.8, 0, 6.2832); ctx.fill(); + ctx.beginPath(); ctx.arc(fx, fy, 2.8, 0, TAU); ctx.fill(); ctx.shadowBlur = 0; } ctx.globalAlpha = 1; @@ -373,7 +371,7 @@ function drawNode(n, t){ if(n.alpha < 0.02) return; - const col = colorOf(n.type); + const col = n.col; const hl = n.hl; // smoothed highlight: 1 = lit; eases down (not snaps) when another node is hovered const breathe = (!hover && hubIds.has(n.id)) ? (1 + 0.05*Math.sin(t*0.0024)) : 1; const pulse = (n.id===hover) ? (1.16 + 0.03*Math.sin(t*0.006)) : 1; @@ -383,22 +381,22 @@ ctx.globalAlpha = n.alpha * fog * (0.34 + 0.66*hl); // dim non-neighbors gently, never to black // selected ring if(n.id === selected){ - ctx.beginPath(); ctx.arc(x, y, r+6, 0, 6.2832); + ctx.beginPath(); ctx.arc(x, y, r+6, 0, TAU); ctx.strokeStyle = `rgba(${col},.85)`; ctx.lineWidth = 2; ctx.stroke(); } // pinned marker (dashed ring → "pulled out and held") if(n.pinned){ - ctx.beginPath(); ctx.arc(x, y, r+4, 0, 6.2832); + ctx.beginPath(); ctx.arc(x, y, r+4, 0, TAU); ctx.strokeStyle = "rgba(255,255,255,.65)"; ctx.lineWidth = 1.5; ctx.setLineDash([3,3]); ctx.stroke(); ctx.setLineDash([]); } // hover halo if(n.id === hover){ - ctx.beginPath(); ctx.arc(x, y, r+11, 0, 6.2832); + ctx.beginPath(); ctx.arc(x, y, r+11, 0, TAU); ctx.fillStyle = `rgba(${col},.16)`; ctx.fill(); } // disc + glow (glow eases with highlight so hovering doesn't flash the whole scene) - ctx.beginPath(); ctx.arc(x, y, r, 0, 6.2832); + ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fillStyle = `rgba(${col},${0.5 + 0.5*hl})`; ctx.shadowColor = `rgba(${col},.8)`; ctx.shadowBlur = (n.id===hover ? 17 : 11) * fog * hl; @@ -406,7 +404,7 @@ ctx.shadowBlur = 0; // bright inner core if(hl > 0.35){ - ctx.beginPath(); ctx.arc(x-r*0.28, y-r*0.28, r*0.34, 0, 6.2832); + ctx.beginPath(); ctx.arc(x-r*0.28, y-r*0.28, r*0.34, 0, TAU); ctx.fillStyle = `rgba(255,255,255,${0.5*hl})`; ctx.fill(); } // label — declutter: hubs at rest; hovered node + neighbors; selection; zoomed in; near depth only @@ -428,7 +426,7 @@ // ease each node's highlight toward its target so hover brightens/dims smoothly (no flash) const focus = hover || selected; // hovering OR an open inspector keeps that node's connections lit for(const n of nodes){ - const near = !focus || n.id===focus || adj[focus].includes(n.id); + const near = !focus || n.id===focus || adj[focus].has(n.id); n.hl += ((near ? 1 : 0.22) - n.hl) * 0.12; } ctx.setTransform(DPR,0,0,DPR,0,0); diff --git a/openkb/visualize.py b/openkb/visualize.py index 90634081..58c519d8 100644 --- a/openkb/visualize.py +++ b/openkb/visualize.py @@ -37,6 +37,9 @@ def build_graph(wiki_dir: Path) -> dict: desc = desc.strip() if isinstance(desc, str) else "" srcs = fm.get("sources") srcs = [str(s) for s in srcs] if isinstance(srcs, list) else [] + ft = fm.get("full_text") # summaries record their origin document here, not in `sources` + if isinstance(ft, str) and ft.strip(): + srcs.insert(0, ft.strip()) nodes[nid] = {"id": nid, "label": p.stem, "type": t, "description": desc, "sources": srcs, "out": 0, "in": 0} diff --git a/tests/test_visualize.py b/tests/test_visualize.py index 3233151f..3d7f2016 100644 --- a/tests/test_visualize.py +++ b/tests/test_visualize.py @@ -14,7 +14,7 @@ def _wiki(tmp_path: Path) -> Path: def test_build_graph_nodes_edges_types(tmp_path): wiki = _wiki(tmp_path) (wiki / "summaries" / "paper.md").write_text( - '---\ntype: "Summary"\ndescription: "A paper."\n---\n\n' + '---\ntype: "Summary"\ndescription: "A paper."\nfull_text: "sources/paper.json"\n---\n\n' "Discusses [[concepts/attention]] and [[entities/anthropic]].\n", encoding="utf-8") (wiki / "concepts" / "attention.md").write_text( '---\ntype: "Concept"\ndescription: "Focus."\nsources: ["summaries/paper"]\n---\n\n' @@ -37,6 +37,9 @@ def test_build_graph_nodes_edges_types(tmp_path): assert not any(e["source"] == e["target"] for e in g["edges"]) assert by["concepts/attention"]["in"] == 1 and by["summaries/paper"]["out"] == 2 assert g["types"] == ["Concept", "Organization", "Summary"] + # sources: concepts use the `sources` field; summaries fall back to `full_text` (the origin doc) + assert by["concepts/attention"]["sources"] == ["summaries/paper"] + assert by["summaries/paper"]["sources"] == ["sources/paper.json"] def test_build_graph_empty_wiki(tmp_path): From fcfdc9f2a9ba61e23a2a3b10295a3afb45921444 Mon Sep 17 00:00:00 2001 From: mountain Date: Fri, 19 Jun 2026 11:10:42 +0800 Subject: [PATCH 14/15] =?UTF-8?q?style(visualize):=20cleaner,=20calmer=20l?= =?UTF-8?q?ook=20=E2=80=94=20crisp=20point-and-line=20graph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per design feedback (the neon/candy and muddy palettes both looked unrefined), reworked the look from big glossy color blobs into a crisp point-and-line graph: - smaller solid nodes — no more large translucent discs overlapping into a blur - flat at rest; node glow only on hover, and no glossy core highlight - crisp modern palette (teal/green/sky/indigo/violet + amber accent), neither neon nor muddy - thinner, fainter edges and a more restrained background aurora → more whitespace and breathing room --- openkb/templates/graph.html | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/openkb/templates/graph.html b/openkb/templates/graph.html index f7af1a52..b83c25ed 100644 --- a/openkb/templates/graph.html +++ b/openkb/templates/graph.html @@ -20,10 +20,9 @@ .aurora{ position:fixed;inset:-10%;z-index:0;pointer-events:none;filter:blur(14px); background: - radial-gradient(48vw 48vw at 12% 8%,rgba(45,212,191,.16),transparent 60%), - radial-gradient(42vw 42vw at 88% 22%,rgba(232,121,249,.13),transparent 60%), - radial-gradient(46vw 46vw at 70% 92%,rgba(56,189,248,.12),transparent 62%), - radial-gradient(40vw 40vw at 30% 78%,rgba(246,185,75,.09),transparent 60%); + radial-gradient(52vw 52vw at 15% 10%,rgba(56,189,248,.07),transparent 62%), + radial-gradient(46vw 46vw at 85% 24%,rgba(129,140,248,.06),transparent 62%), + radial-gradient(48vw 48vw at 72% 90%,rgba(45,212,191,.05),transparent 64%); animation:drift 26s ease-in-out infinite alternate; } @keyframes drift{ @@ -168,10 +167,10 @@ /* ===================== data hook ===================== */ const GRAPH = __GRAPH_DATA__; // replaced by render_html → {nodes,edges,types} -const TYPE_COLORS = { // neon RGB per type; fallback for unknown types - Summary:"56,189,248", Concept:"45,212,191", Organization:"232,121,249", - Person:"246,185,75", Product:"123,220,143", Place:"244,114,182", - Work:"168,160,255", Event:"94,234,212", Entity:"148,163,184" +const TYPE_COLORS = { // clean modern palette — crisp, not neon, not muddy + Concept:"45,212,191", Product:"74,222,128", Summary:"56,189,248", + Work:"129,140,248", Organization:"192,132,252", Person:"250,204,21", + Place:"244,114,182", Event:"251,191,36", Entity:"148,163,184" }; const colorOf = t => TYPE_COLORS[t] || "148,163,184"; const TAU = Math.PI * 2; // full circle for ctx.arc @@ -189,7 +188,7 @@ // build working nodes/edges from GRAPH (object-form edges: e.source/e.target) const nodes = GRAPH.nodes.map(n => ({ ...n, x:0, y:0, z:0, vx:0, vy:0, vz:0, - r: 8 + Math.min(10, (n.in || 0) + (n.out || 0)), // radius scales with degree + r: 3.5 + Math.min(7, ((n.in || 0) + (n.out || 0)) * 0.42), // small crisp nodes, scaled by degree col: colorOf(n.type), // cached neon color → no per-frame lookup alpha: 1, // per-node fade (legend toggle) sx:0, sy:0, rz:0, pp:1, hl:1, pinned:false // projection cache + smoothed highlight + pinned (drag to pull out) @@ -342,9 +341,9 @@ const ax=a.sx, ay=a.sy, bx=b.sx, by=b.sy; const fog = Math.max(0.2, Math.min(1, 1 - (a.rz+b.rz)/1300)); // far edges recede const ehl = Math.max(a.hl, b.hl); // edge eases with its endpoints (smooth dim) - ctx.globalAlpha = al * fog * (hot ? 1 : (0.18 + 0.82*ehl)); - ctx.strokeStyle = hot ? "rgba(45,212,191,.85)" : `rgba(255,255,255,${0.06 + 0.07*ehl})`; - ctx.lineWidth = hot ? 2 : 1; + ctx.globalAlpha = al * fog * (hot ? 1 : (0.13 + 0.6*ehl)); + ctx.strokeStyle = hot ? "rgba(45,212,191,.9)" : `rgba(255,255,255,${0.04 + 0.05*ehl})`; + ctx.lineWidth = hot ? 1.5 : 0.7; ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke(); // arrowhead at b, backed off by b's projected radius const ang = Math.atan2(by-ay, bx-ax); @@ -378,7 +377,7 @@ const r = Math.max(2, n.r * pulse * breathe * n.pp * scale); // perspective: near = larger const fog = Math.max(0.32, Math.min(1, 1 - n.rz/640)); // depth cue: far nodes recede const x = n.sx, y = n.sy; - ctx.globalAlpha = n.alpha * fog * (0.34 + 0.66*hl); // dim non-neighbors gently, never to black + ctx.globalAlpha = n.alpha * (0.62 + 0.38*fog) * (0.45 + 0.55*hl); // mostly solid → no muddy overlap, fog only hints depth // selected ring if(n.id === selected){ ctx.beginPath(); ctx.arc(x, y, r+6, 0, TAU); @@ -398,15 +397,12 @@ // disc + glow (glow eases with highlight so hovering doesn't flash the whole scene) ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fillStyle = `rgba(${col},${0.5 + 0.5*hl})`; - ctx.shadowColor = `rgba(${col},.8)`; - ctx.shadowBlur = (n.id===hover ? 17 : 11) * fog * hl; + ctx.shadowColor = `rgba(${col},.7)`; + ctx.shadowBlur = (n.id===hover ? 9 : 0) * fog * hl; // flat crisp discs at rest; a touch of glow only on hover ctx.fill(); ctx.shadowBlur = 0; // bright inner core - if(hl > 0.35){ - ctx.beginPath(); ctx.arc(x-r*0.28, y-r*0.28, r*0.34, 0, TAU); - ctx.fillStyle = `rgba(255,255,255,${0.5*hl})`; ctx.fill(); - } + // clean flat discs — no glossy core highlight // label — declutter: hubs at rest; hovered node + neighbors; selection; zoomed in; near depth only const showLabel = (scale > 1.4) || (n.id === selected) || (hover ? hl > 0.55 : labelIds.has(n.id)); if(showLabel && fog > 0.55){ From 5f606e842fb4193546ca4bba3b31b66ee64de8f6 Mon Sep 17 00:00:00 2001 From: mountain Date: Fri, 19 Jun 2026 11:27:18 +0800 Subject: [PATCH 15/15] fix(visualize): polish from 2nd code-review pass - scale the edge arrowhead to the node's projected radius so it no longer engulfs the (now smaller) dots at low zoom - bump edge line width 0.7 -> 0.9 to avoid sub-pixel blur on 1x displays - restore the 4th aurora gradient (bottom-left corner was unlit/asymmetric) - _type_for_dir: strip only one trailing 's' (rstrip('s') stripped all), so a future irregular content-dir name isn't mangled - tidy stale palette-churn comments --- openkb/templates/graph.html | 17 +++++++++-------- openkb/visualize.py | 2 +- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/openkb/templates/graph.html b/openkb/templates/graph.html index b83c25ed..996b73dd 100644 --- a/openkb/templates/graph.html +++ b/openkb/templates/graph.html @@ -22,7 +22,8 @@ background: radial-gradient(52vw 52vw at 15% 10%,rgba(56,189,248,.07),transparent 62%), radial-gradient(46vw 46vw at 85% 24%,rgba(129,140,248,.06),transparent 62%), - radial-gradient(48vw 48vw at 72% 90%,rgba(45,212,191,.05),transparent 64%); + radial-gradient(48vw 48vw at 72% 90%,rgba(45,212,191,.05),transparent 64%), + radial-gradient(44vw 44vw at 24% 82%,rgba(118,138,200,.045),transparent 62%); animation:drift 26s ease-in-out infinite alternate; } @keyframes drift{ @@ -189,7 +190,7 @@ const nodes = GRAPH.nodes.map(n => ({ ...n, x:0, y:0, z:0, vx:0, vy:0, vz:0, r: 3.5 + Math.min(7, ((n.in || 0) + (n.out || 0)) * 0.42), // small crisp nodes, scaled by degree - col: colorOf(n.type), // cached neon color → no per-frame lookup + col: colorOf(n.type), // cached color → no per-frame lookup alpha: 1, // per-node fade (legend toggle) sx:0, sy:0, rz:0, pp:1, hl:1, pinned:false // projection cache + smoothed highlight + pinned (drag to pull out) })); @@ -343,13 +344,14 @@ const ehl = Math.max(a.hl, b.hl); // edge eases with its endpoints (smooth dim) ctx.globalAlpha = al * fog * (hot ? 1 : (0.13 + 0.6*ehl)); ctx.strokeStyle = hot ? "rgba(45,212,191,.9)" : `rgba(255,255,255,${0.04 + 0.05*ehl})`; - ctx.lineWidth = hot ? 1.5 : 0.7; + ctx.lineWidth = hot ? 1.5 : 0.9; ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke(); - // arrowhead at b, backed off by b's projected radius + // arrowhead at b, backed off by b's projected radius; sized to the node so it never engulfs a small dot const ang = Math.atan2(by-ay, bx-ax); - const off = b.r*b.pp*scale + 4; + const br = b.r*b.pp*scale; + const off = br + 3; const hx = bx - Math.cos(ang)*off, hy = by - Math.sin(ang)*off; - const ah = 7; + const ah = Math.max(3.5, Math.min(7, br*0.95)); ctx.fillStyle = hot ? "rgba(45,212,191,.9)" : `rgba(255,255,255,${0.08 + 0.12*ehl})`; ctx.beginPath(); ctx.moveTo(hx, hy); @@ -401,8 +403,7 @@ ctx.shadowBlur = (n.id===hover ? 9 : 0) * fog * hl; // flat crisp discs at rest; a touch of glow only on hover ctx.fill(); ctx.shadowBlur = 0; - // bright inner core - // clean flat discs — no glossy core highlight + // (flat discs — no glossy core highlight) // label — declutter: hubs at rest; hovered node + neighbors; selection; zoomed in; near depth only const showLabel = (scale > 1.4) || (n.id === selected) || (hover ? hl > 0.55 : labelIds.has(n.id)); if(showLabel && fog > 0.55){ diff --git a/openkb/visualize.py b/openkb/visualize.py index 58c519d8..a8748e3d 100644 --- a/openkb/visualize.py +++ b/openkb/visualize.py @@ -15,7 +15,7 @@ def _type_for_dir(sub: str) -> str: - return _DIR_TYPE.get(sub) or sub.rstrip("s").capitalize() or sub + return _DIR_TYPE.get(sub) or (sub[:-1] if sub.endswith("s") else sub).capitalize() or sub def build_graph(wiki_dir: Path) -> dict: