TODO-11 Fuller Relationship Graph Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans. Steps use checkbox (
- [ ]) syntax.
Goal: A generator that reads the 113 typed entity pages and produces a relationship-colored static graph.svg (embedded on a buckyverse page) plus a self-contained interactive index.html.
Architecture: Python generator in .wiki/output/projects/relationship-graph/ (a wiki project with WHY.md); publishable page at repo-root relationship_graph/. sfdp for static layout; vis-network (CDN) for interactivity. Publish is a later ACC step.
Tech Stack: Python 3 stdlib + graphviz sfdp (both confirmed present); vis-network via CDN in the HTML.
File structure
- Create
.wiki/output/projects/relationship-graph/WHY.md— project rationale. - Create
.wiki/output/projects/relationship-graph/build_graph.py— the generator. - Generated (by the script, committed):
graph.json,graph.dot,graph.svg,index.htmlin that dir. - Create
relationship_graph/index.md— the buckyverse page. - Generated into
relationship_graph/graph.svg— the embedded image (copy of the project SVG).
Color map (single source of truth, used by dot + html + legend)
REL_COLORS = {
"collaborator": "#2e7d32", # green
"influence-on-fuller": "#1565c0", # blue
"influenced-by-fuller": "#42a5f5", # light blue
"friend": "#ec407a", # pink
"family": "#ad1457", # magenta
"contemporary": "#f9a825", # amber
"rival-critic": "#c62828", # red
"forebear": "#6a1b9a", # purple
"chronicler": "#00838f", # teal
}
ORG_COLOR = "#9e9e9e" # grey
FULLER_COLOR = "#212121" # near-black hub
SEEALSO_EDGE = "#cccccc" # light grey cross-links
Task 1: The generator script
Files: Create .wiki/output/projects/relationship-graph/build_graph.py
- Step 1: Write
build_graph.py. It must, run from the repo root or its own dir, resolve the wiki topics dir relatively (<script_dir>/../../../wiki/topics). Full script:
#!/usr/bin/env python3
"""Build the Fuller relationship graph (graph.json/.dot/.svg + interactive index.html)
from the typed entity pages under .wiki/wiki/topics/. Re-run after entity pages change."""
import os, re, json, subprocess, html
HERE = os.path.dirname(os.path.abspath(__file__))
TOPICS = os.path.normpath(os.path.join(HERE, "..", "..", "..", "wiki", "topics"))
PAGE_DIR = os.path.normpath(os.path.join(HERE, "..", "..", "..", "..", "relationship_graph"))
FULLER = "r-buckminster-fuller"
SITE = "https://www.buckyverse.org/en/" # entity pages live at <slug> on the wiki
REL_COLORS = {
"collaborator": "#2e7d32", "influence-on-fuller": "#1565c0",
"influenced-by-fuller": "#42a5f5", "friend": "#ec407a", "family": "#ad1457",
"contemporary": "#f9a825", "rival-critic": "#c62828", "forebear": "#6a1b9a",
"chronicler": "#00838f",
}
ORG_COLOR, FULLER_COLOR, SEEALSO_EDGE = "#9e9e9e", "#212121", "#cccccc"
def frontmatter_value(text, key):
m = re.search(rf'^{key}:\s*"?(.*?)"?\s*$', text, re.M)
return m.group(1) if m else None
def see_also_slugs(text):
parts = text.split("## See Also", 1)
if len(parts) < 2:
return []
body = parts[1].split("\n## ", 1)[0]
return re.findall(r'\[\[([a-z0-9-]+)\|', body)
def load():
nodes = {} # slug -> dict(id,label,kind,relationship,color)
rels = {} # slug -> relationship (people only)
raw = {} # slug -> text
for fn in os.listdir(TOPICS):
if not fn.endswith(".md") or fn == "_index.md":
continue
slug = fn[:-3]
text = open(os.path.join(TOPICS, fn), encoding="utf-8").read()
raw[slug] = text
if "entity-person" in text:
kind = "person"
elif "entity-organization" in text:
kind = "org"
else:
continue
rel = frontmatter_value(text, "relationship") if kind == "person" else None
label = frontmatter_value(text, "title") or slug
color = FULLER_COLOR if slug == FULLER else (REL_COLORS.get(rel, "#607d8b") if kind == "person" else ORG_COLOR)
nodes[slug] = {"id": slug, "label": label, "kind": kind, "relationship": rel, "color": color}
if rel:
rels[slug] = rel
# Fuller hub node
nodes[FULLER] = {"id": FULLER, "label": "R. Buckminster Fuller", "kind": "fuller", "relationship": None, "color": FULLER_COLOR}
# edges
edges = []
for slug, n in nodes.items():
if slug == FULLER:
continue
if n["kind"] == "person":
rel = n["relationship"]
edges.append({"source": slug, "target": FULLER, "type": rel or "untyped",
"color": REL_COLORS.get(rel, "#607d8b")})
elif n["kind"] == "org":
edges.append({"source": slug, "target": FULLER, "type": "organization", "color": ORG_COLOR})
seen = set()
for slug, text in raw.items():
if slug not in nodes:
continue
for tgt in see_also_slugs(text):
if tgt in nodes and tgt != slug and tgt != FULLER:
key = tuple(sorted((slug, tgt)))
if key in seen:
continue
seen.add(key)
edges.append({"source": key[0], "target": key[1], "type": "see-also", "color": SEEALSO_EDGE})
return nodes, edges
def write_json(nodes, edges):
with open(os.path.join(HERE, "graph.json"), "w", encoding="utf-8") as f:
json.dump({"nodes": list(nodes.values()), "edges": edges}, f, indent=2, ensure_ascii=False)
def write_dot(nodes, edges):
lines = ['graph G {', ' overlap=false; splines=true; bgcolor="white";',
' node [style=filled, fontname="Helvetica", fontsize=9, color="#00000022"];',
' edge [color="%s"];' % SEEALSO_EDGE]
for slug, n in nodes.items():
size = 1.4 if slug == FULLER else 0.5
fontcolor = "white" if slug == FULLER else "black"
url = SITE + slug
lines.append(' "%s" [label="%s", fillcolor="%s", fontcolor="%s", width=%.2f, height=%.2f, URL="%s"];'
% (slug, n["label"].replace('"', "'"), n["color"], fontcolor, size, size, url))
for e in edges:
pen = 2.0 if e["target"] == FULLER and e["type"] != "see-also" else 0.6
lines.append(' "%s" -- "%s" [color="%s", penwidth=%.1f];' % (e["source"], e["target"], e["color"], pen))
# legend as a separate cluster
lines.append(' subgraph cluster_legend {')
lines.append(' label="Relationship to Fuller"; fontsize=11; style="rounded"; color="#999999";')
for rel, col in list(REL_COLORS.items()) + [("organization", ORG_COLOR), ("R. B. Fuller (hub)", FULLER_COLOR)]:
nid = "legend_" + re.sub(r'[^a-z]', '', rel.lower())
lines.append(' "%s" [label="%s", fillcolor="%s", shape=box, fontsize=9, width=1.8, height=0.3, fontcolor="%s"];'
% (nid, rel, col, "white" if col == FULLER_COLOR else "black"))
lines.append(' }')
lines.append('}')
dot = "\n".join(lines)
with open(os.path.join(HERE, "graph.dot"), "w", encoding="utf-8") as f:
f.write(dot)
return dot
def render_svg():
src = os.path.join(HERE, "graph.dot")
out = os.path.join(HERE, "graph.svg")
subprocess.run(["sfdp", "-Tsvg", "-o", out, src], check=True)
# copy to the publishable page dir
os.makedirs(PAGE_DIR, exist_ok=True)
data = open(out, "rb").read()
with open(os.path.join(PAGE_DIR, "graph.svg"), "wb") as f:
f.write(data)
def write_html(nodes, edges):
data = json.dumps({"nodes": [
{"id": n["id"], "label": n["label"], "color": n["color"],
"group": (n["relationship"] or n["kind"]), "url": SITE + n["id"]}
for n in nodes.values()],
"edges": [{"from": e["source"], "to": e["target"], "color": {"color": e["color"]},
"group": e["type"]} for e in edges]}, ensure_ascii=False)
tmpl = """<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><title>Fuller Relationship Graph</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
<style>
body{margin:0;font-family:Helvetica,Arial,sans-serif}
#bar{padding:8px;background:#f5f5f5;border-bottom:1px solid #ddd}
#net{width:100vw;height:calc(100vh - 96px)}
.lg{display:inline-block;margin:2px 8px;font-size:12px;cursor:pointer;user-select:none}
.sw{display:inline-block;width:11px;height:11px;border-radius:2px;vertical-align:middle;margin-right:3px}
.off{opacity:.35;text-decoration:line-through}
input{padding:4px 6px;font-size:13px}
</style></head><body>
<div id="bar">
<strong>Fuller Relationship Graph</strong>
<input id="q" placeholder="search a name…" oninput="doSearch()">
<div id="legend"></div>
</div>
<div id="net"></div>
<script>
const DATA = __DATA__;
const REL_COLORS = __COLORS__;
const nodes = new vis.DataSet(DATA.nodes.map(n => ({id:n.id,label:n.label,color:n.color,url:n.url,group:n.group})));
const edges = new vis.DataSet(DATA.edges.map((e,i) => ({id:i,from:e.from,to:e.to,color:e.color,group:e.group})));
const net = new vis.Network(document.getElementById('net'), {nodes,edges}, {
physics:{solver:'forceAtlas2Based', stabilization:{iterations:250}},
nodes:{shape:'dot', size:10, font:{size:11}},
interaction:{hover:true, tooltipDelay:120}
});
net.on('click', p => { if(p.nodes.length){ const n=nodes.get(p.nodes[0]); if(n&&n.url) window.open(n.url,'_blank'); }});
const hidden = new Set();
function apply(){ nodes.update(DATA.nodes.map(n=>({id:n.id,hidden:hidden.has(n.group)})));
edges.update(DATA.edges.map((e,i)=>({id:i,hidden:hidden.has(e.group)}))); }
function toggle(g,el){ if(hidden.has(g)){hidden.delete(g);el.classList.remove('off');}else{hidden.add(g);el.classList.add('off');} apply(); }
function doSearch(){ const q=document.getElementById('q').value.toLowerCase();
if(!q) return; const hit=DATA.nodes.find(n=>n.label.toLowerCase().includes(q));
if(hit){ net.focus(hit.id,{scale:1.3,animation:true}); net.selectNodes([hit.id]); } }
const leg=document.getElementById('legend');
Object.entries(REL_COLORS).concat([['organization','#9e9e9e'],['fuller','#212121']]).forEach(([g,c])=>{
const s=document.createElement('span'); s.className='lg';
s.innerHTML='<span class="sw" style="background:'+c+'"></span>'+g;
s.onclick=()=>toggle(g,s); leg.appendChild(s); });
</script></body></html>"""
out = tmpl.replace("__DATA__", data).replace("__COLORS__", json.dumps(REL_COLORS))
with open(os.path.join(HERE, "index.html"), "w", encoding="utf-8") as f:
f.write(out)
def main():
nodes, edges = load()
write_json(nodes, edges)
write_dot(nodes, edges)
render_svg()
write_html(nodes, edges)
people = sum(1 for n in nodes.values() if n["kind"] == "person")
orgs = sum(1 for n in nodes.values() if n["kind"] == "org")
print(f"nodes: {len(nodes)} ({people} person + {orgs} org + 1 fuller) | edges: {len(edges)}")
if __name__ == "__main__":
main()
- Step 2: Run it. From the project dir:
python3 build_graph.py. Expected stdout:nodes: 114 (102 person + 11 org + 1 fuller) | edges: ~202. - Step 3: Sanity-check outputs.
graph.json,graph.dot,graph.svg,index.htmlexist in the project dir;relationship_graph/graph.svgexists;graph.svgstarts with<?xml/<svgand is non-trivial in size. Verify (Python): every edge endpoint is a node id; node count 114. - Step 4: Commit
feat: add relationship-graph generator (build_graph.py) + generated graph.json/.dot/.svg/index.html.
Task 2: WHY.md for the wiki project
Files: Create .wiki/output/projects/relationship-graph/WHY.md
- Step 1: Write
WHY.md— first#heading = the project title, first paragraph = the goal (why this exists: turn the typed relationship data from IMP-16..19 into a navigable map of Fuller's network). Note the two outputs (static SVG for the site, interactive HTML for exploration) and how to regenerate (python3 build_graph.py). - Step 2: Commit
docs: add WHY.md for relationship-graph project.
Task 3: The buckyverse.org page
Files: Create relationship_graph/index.md (SVG already copied to relationship_graph/graph.svg by Task 1).
- Step 1: Write
index.mdwith WikiJS frontmatter (title,description≤255 chars,published: true,date,tags,editor: markdown,dateCreated) and body: a short explainer; the embedded image; a legend table mapping each relationship type to its meaning + live count (pull counts fromgraph.json); a line pointing to the interactive artifact at.wiki/output/projects/relationship-graph/index.html; a note that layout is force-directed. Keep an H1 in the body. - Step 2: Verify counts in the legend table match
graph.jsontallies (Python: count nodes by relationship). - Step 3: Commit
feat: add relationship_graph/ buckyverse page (embedded SVG + legend).
Task 4: Register the project + lint
Files: Modify .wiki/output/_index.md (projects listing), .wiki/_index.md (Outputs count + Recent Changes), .wiki/log.md.
- Step 1: Add the
relationship-graphproject to.wiki/output/_index.md(title + goal from WHY.md). Bump the Outputs/project count in.wiki/_index.mdStatistics and add a Recent Changes line for TODO-11. Append alog.mdline. - Step 2: Verify the wiki project is well-formed:
WHY.mdpresent + non-empty;output/_index.mdlists it. (Full/wiki:lint --localis run in Task 5.) - Step 3: Commit
docs: register relationship-graph project + update indexes.
Task 5: Verify
- Step 1: Run the full check (Python): node count 114; every edge endpoint resolves to a node; every node id (except the Fuller hub) resolves to an existing
wiki/topics/<slug>.md; relationship colors cover exactly the 9-value vocab; legend counts match. Confirmgraph.svgparses as XML andindex.htmlcontains the vis-network script tag + the embedded data. - Step 2: Re-run
build_graph.pyonce more and confirm outputs are stable (deterministic — same node/edge counts). - Step 3: If clean, hand back to pluck completion (squash-merge), and note that publishing to buckyverse.org + live-verifying the SVG is queued as an ACC item.
Self-review
- Spec coverage: generator (Task 1), WHY.md (Task 2), live page + SVG + legend (Task 3), project registration (Task 4), verify (Task 5). Publish deferred to ACC per spec. ✓
- No placeholders: the full
build_graph.pyand color map are inline; page/WHY content is specified with exact required fields. - Consistency:
REL_COLORSkeys = the 9-value vocab; the same slugs/colors flow through dot, html, and legend;FULLER/PAGE_DIR/TOPICSpaths consistent across steps.