BUCKYVERSE

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

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

#!/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>
  &nbsp; <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()

Task 2: WHY.md for the wiki project

Files: Create .wiki/output/projects/relationship-graph/WHY.md

Task 3: The buckyverse.org page

Files: Create relationship_graph/index.md (SVG already copied to relationship_graph/graph.svg by Task 1).

Task 4: Register the project + lint

Files: Modify .wiki/output/_index.md (projects listing), .wiki/_index.md (Outputs count + Recent Changes), .wiki/log.md.

Task 5: Verify

Self-review