feat(okf): add minimal okf_viz Cytoscape generator with tests
Full suite green (20 tests). Smoke-tested on Google crypto_bitcoin bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
115
custom-skills/97-ourdigital-okf/code/scripts/okf_viz.py
Normal file
115
custom-skills/97-ourdigital-okf/code/scripts/okf_viz.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate a minimal, self-contained OKF bundle visualizer (stdlib only).
|
||||||
|
|
||||||
|
Emits one HTML file: a Cytoscape.js force-directed graph of the bundle's
|
||||||
|
concepts (nodes colored by `type`), with a side panel that renders the
|
||||||
|
selected concept's markdown body via marked. Both libraries load from a CDN;
|
||||||
|
the bundle is embedded as JSON, so no backend and no data leaves the page.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from okf_common import (concept_id, extract_links, iter_concepts,
|
||||||
|
parse_frontmatter)
|
||||||
|
|
||||||
|
|
||||||
|
def _target_id(bundle, path, target, ids):
|
||||||
|
t = target.split("#", 1)[0].strip()
|
||||||
|
if not t or "://" in t or not t.endswith(".md"):
|
||||||
|
return None
|
||||||
|
if t.startswith("/"):
|
||||||
|
cid = t.lstrip("/")[:-3]
|
||||||
|
else:
|
||||||
|
rel = (path.parent / t).resolve().relative_to(Path(bundle).resolve())
|
||||||
|
cid = rel.as_posix()[:-3]
|
||||||
|
return cid if cid in ids else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_graph(bundle_dir):
|
||||||
|
bundle = Path(bundle_dir)
|
||||||
|
concepts = list(iter_concepts(bundle))
|
||||||
|
ids = {concept_id(bundle, p) for p in concepts}
|
||||||
|
nodes, edges = [], []
|
||||||
|
for path in concepts:
|
||||||
|
cid = concept_id(bundle, path)
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
try:
|
||||||
|
meta, body = parse_frontmatter(text)
|
||||||
|
except Exception:
|
||||||
|
meta, body = None, text
|
||||||
|
meta = meta or {}
|
||||||
|
nodes.append({
|
||||||
|
"id": cid,
|
||||||
|
"type": str(meta.get("type", "Concept")),
|
||||||
|
"title": str(meta.get("title", Path(cid).name)),
|
||||||
|
"description": str(meta.get("description", "")),
|
||||||
|
"body": body,
|
||||||
|
})
|
||||||
|
for target in extract_links(body):
|
||||||
|
tid = _target_id(bundle, path, target, ids)
|
||||||
|
if tid and tid != cid:
|
||||||
|
edges.append({"source": cid, "target": tid})
|
||||||
|
return {"nodes": nodes, "edges": edges}
|
||||||
|
|
||||||
|
|
||||||
|
_HTML = """<!DOCTYPE html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
|
<title>__NAME__ — OKF Viewer</title>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.30.2/cytoscape.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<style>
|
||||||
|
body{margin:0;font-family:system-ui,sans-serif;display:flex;height:100vh}
|
||||||
|
#cy{flex:1;background:#fafafa}
|
||||||
|
#panel{width:380px;overflow:auto;padding:16px;border-left:1px solid #ddd;box-sizing:border-box}
|
||||||
|
#panel h2{margin:.2em 0;font-size:16px} table{border-collapse:collapse} td,th{border:1px solid #ddd;padding:2px 6px}
|
||||||
|
.type{display:inline-block;font-size:11px;background:#eef;color:#225;padding:2px 6px;border-radius:4px}
|
||||||
|
header{position:absolute;top:8px;left:12px;font-weight:600;color:#333}
|
||||||
|
</style></head><body>
|
||||||
|
<header>__NAME__</header><div id="cy"></div>
|
||||||
|
<div id="panel"><p>Click a concept node to view it.</p></div>
|
||||||
|
<script>
|
||||||
|
const DATA = __DATA__;
|
||||||
|
const colors=["#4e79a7","#f28e2b","#e15759","#76b7b2","#59a14f","#edc948","#b07aa1","#ff9da7","#9c755f","#bab0ac"];
|
||||||
|
const types=[...new Set(DATA.nodes.map(n=>n.type))];
|
||||||
|
const colorOf={}; types.forEach((t,i)=>colorOf[t]=colors[i%colors.length]);
|
||||||
|
const byId={}; DATA.nodes.forEach(n=>byId[n.id]=n);
|
||||||
|
const elements=[];
|
||||||
|
DATA.nodes.forEach(n=>elements.push({data:{id:n.id,label:n.title,color:colorOf[n.type]}}));
|
||||||
|
DATA.edges.forEach(e=>elements.push({data:{source:e.source,target:e.target}}));
|
||||||
|
const cy=cytoscape({container:document.getElementById('cy'),elements,
|
||||||
|
style:[{selector:'node',style:{'label':'data(label)','font-size':'9px','background-color':'data(color)','width':14,'height':14,'color':'#333'}},
|
||||||
|
{selector:'edge',style:{'width':1,'line-color':'#bbb','target-arrow-color':'#bbb','target-arrow-shape':'triangle','curve-style':'bezier','arrow-scale':0.7}}],
|
||||||
|
layout:{name:'cose',animate:false}});
|
||||||
|
cy.on('tap','node',evt=>{const n=byId[evt.target.id()];
|
||||||
|
document.getElementById('panel').innerHTML='<span class="type">'+n.type+'</span><h2>'+n.title+'</h2>'+
|
||||||
|
(n.description?'<p><em>'+n.description+'</em></p>':'')+'<hr>'+marked.parse(n.body||'');});
|
||||||
|
</script></body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_html(graph, name="OKF Bundle"):
|
||||||
|
data = json.dumps(graph, ensure_ascii=False).replace("</", "<\\/")
|
||||||
|
return _HTML.replace("__NAME__", html.escape(name)).replace("__DATA__", data)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(description="Visualize an OKF bundle as self-contained HTML.")
|
||||||
|
ap.add_argument("--bundle", required=True)
|
||||||
|
ap.add_argument("--out")
|
||||||
|
ap.add_argument("--name")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
bundle = Path(args.bundle)
|
||||||
|
graph = build_graph(bundle)
|
||||||
|
out = Path(args.out) if args.out else bundle / "viz.html"
|
||||||
|
out.write_text(render_html(graph, args.name or bundle.name), encoding="utf-8")
|
||||||
|
print("Wrote %s (%d nodes, %d edges)" % (out, len(graph["nodes"]), len(graph["edges"])))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import sys, tempfile, unittest
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
from okf_viz import build_graph, render_html, main
|
||||||
|
|
||||||
|
FIX = Path(__file__).resolve().parent / "fixtures" / "mini_bundle"
|
||||||
|
|
||||||
|
|
||||||
|
class TestViz(unittest.TestCase):
|
||||||
|
def test_build_graph_nodes_and_edges(self):
|
||||||
|
g = build_graph(FIX)
|
||||||
|
ids = {n["id"] for n in g["nodes"]}
|
||||||
|
self.assertIn("tables/orders", ids)
|
||||||
|
self.assertTrue(any(e["source"] == "tables/orders" and e["target"] == "tables/customers"
|
||||||
|
for e in g["edges"]))
|
||||||
|
|
||||||
|
def test_render_html_embeds_data_and_cdn(self):
|
||||||
|
html = render_html(build_graph(FIX), "Mini")
|
||||||
|
self.assertIn("cytoscape", html)
|
||||||
|
self.assertIn('"nodes"', html)
|
||||||
|
|
||||||
|
def test_render_html_escapes_script_close(self):
|
||||||
|
g = {"nodes": [{"id": "x", "type": "T", "title": "x",
|
||||||
|
"description": "", "body": "</script><b>hi</b>"}], "edges": []}
|
||||||
|
html = render_html(g, "X")
|
||||||
|
self.assertNotIn("</script><b>hi", html)
|
||||||
|
|
||||||
|
def test_main_writes_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
out = Path(d) / "v.html"
|
||||||
|
self.assertEqual(main(["--bundle", str(FIX), "--out", str(out)]), 0)
|
||||||
|
self.assertTrue(out.exists() and out.stat().st_size > 500)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user