""" 04_visualize.py -- Build a standalone interactive HTML graph from data/graph/. Reads the pipeline's CSVs directly (no Neo4j needed) and renders an interactive force-directed graph with pyvis. The output is a single HTML file you can email or host -- viewers just open it in a browser. Transactions are aggregated rather than drawn: 22k transaction nodes would swamp the layout, so funding is shown as weighted FUNDS edges (provider organisation -> activity, sized by transaction count, totals in the tooltip). Setup: pip install pyvis Usage: python 04_visualize.py # full graph python 04_visualize.py --country YE # one hotspot's neighborhood python 04_visualize.py --country YE --country SS python 04_visualize.py --min-links 2 # hide one-off partner orgs python 04_visualize.py --out demo.html """ import argparse import csv from collections import defaultdict from config import GRAPH_DIR, HOTSPOTS COLORS = { "Activity": "#7c5cbf", "Organisation": "#2a9d8f", "Country": "#4a8c5c", "Sector": "#8a8a8a", "Emergency": "#e07856", "SDG": "#4a8c5c", } def rows(name): path = GRAPH_DIR / name if not path.exists(): return [] with open(path, newline="", encoding="utf-8") as f: return list(csv.DictReader(f)) def build(countries_filter, min_links, out): from pyvis.network import Network acts = rows("nodes_activities.csv") if countries_filter: wanted = set(countries_filter) acts = [a for a in acts if wanted & set(a["hotspot_countries"].split("|"))] act_ids = {a["iati_identifier"] for a in acts} print(f"{len(acts)} activities selected") net = Network(height="97vh", width="100%", directed=True, bgcolor="#ffffff", font_color="#333333", cdn_resources="in_line", select_menu=True, filter_menu=True) net.barnes_hut(gravity=-8000, spring_length=120) def add(node_id, label, kind, title): net.add_node(node_id, label=label, color=COLORS[kind], title=title, group=kind, shape="dot" if kind != "Country" else "diamond", size=28 if kind == "Country" else 12) # Countries (only hotspots the selection touches) touched = set() for a in acts: touched |= set(a["hotspot_countries"].split("|")) for c in rows("nodes_countries.csv"): if c["code"] in touched: add(f'C:{c["code"]}', c["name"], "Country", f'{c["name"]} — hotspot tier {c["hotspot_tier"]}') # Activities for a in acts: title = (f'{a["title"]}
{a["publisher"]} | {a["start_planned"] or a["start_actual"] or "?"}' f'
match: {a["match_basis"]}') add(f'A:{a["iati_identifier"]}', a["title"][:34] or a["iati_identifier"], "Activity", title) for cc in a["hotspot_countries"].split("|"): if cc: net.add_edge(f'A:{a["iati_identifier"]}', f"C:{cc}", color="#4a8c5c", width=1) # Organisations via participation (trim one-off partners with --min-links) org_names = {o["ref"]: o["name"] or o["ref"] for o in rows("nodes_organisations.csv")} org_links = defaultdict(list) for r in rows("rel_participates.csv"): if r["iati_identifier"] in act_ids: org_links[r["org_ref"]].append(r) for ref, links in org_links.items(): if len(links) < min_links: continue add(f"O:{ref}", org_names.get(ref, ref)[:30], "Organisation", f"{org_names.get(ref, ref)}
{len(links)} participations") for r in links: net.add_edge(f"O:{ref}", f'A:{r["iati_identifier"]}', color="#2a9d8f", width=1, title=f'participates ({r["role"]})') # Aggregated funding edges: provider org -> activity tx_act = {t["tx_id"]: (t["iati_identifier"], t["value"], t["currency"]) for t in rows("nodes_transactions.csv") if t["iati_identifier"] in act_ids} funds = defaultdict(lambda: {"n": 0, "totals": defaultdict(float)}) for e in rows("rel_transaction_edges.csv"): if e["edge"] != "PROVIDED_BY" or e["tx_id"] not in tx_act: continue iid, value, currency = tx_act[e["tx_id"]] f = funds[(e["org_ref"], iid)] f["n"] += 1 try: f["totals"][currency or "?"] += float(value) except ValueError: pass for (ref, iid), f in funds.items(): if len(org_links.get(ref, [])) < min_links and f"O:{ref}" not in [n["id"] for n in net.nodes]: add(f"O:{ref}", org_names.get(ref, ref)[:30], "Organisation", org_names.get(ref, ref)) totals = ", ".join(f"{v:,.0f} {c}" for c, v in f["totals"].items()) net.add_edge(f"O:{ref}", f"A:{iid}", color="#e07856", width=min(1 + f["n"] / 5, 6), title=f'funds: {f["n"]} transactions ({totals})') # Sectors, emergencies, SDGs sec = {s["sector_key"]: s for s in rows("nodes_sectors.csv")} for r in rows("rel_classified_as.csv"): if r["iati_identifier"] not in act_ids: continue s = sec.get(r["sector_key"]) if not s: continue sid = f'S:{s["sector_key"]}' if sid not in [n["id"] for n in net.nodes]: add(sid, (s["name"] or s["code"])[:28], "Sector", f'{s["name"]} ({s["vocabulary"]}:{s["code"]}) hunger={s["is_hunger"]}') net.add_edge(f'A:{r["iati_identifier"]}', sid, color="#bbbbbb", width=1) for name, node_csv, rel_csv, key, kind in ( ("emergency", "nodes_emergencies.csv", "rel_responds_to.csv", "emergency_key", "Emergency"), ("sdg", "nodes_sdgs.csv", "rel_contributes_to.csv", "sdg_key", "SDG")): meta = {m[key]: m for m in rows(node_csv)} for r in rows(rel_csv): if r["iati_identifier"] not in act_ids or r[key] not in meta: continue m = meta[r[key]] nid = f'{kind[0]}#{r[key]}' if nid not in [n["id"] for n in net.nodes]: add(nid, (m.get("name") or m.get("code", ""))[:28], kind, str(dict(m))) net.add_edge(f'A:{r["iati_identifier"]}', nid, color=COLORS[kind], width=1) print(f"{len(net.nodes)} nodes, {len(net.edges)} edges -> {out}") net.save_graph(out) if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument("--country", action="append", default=[], help="hotspot ISO2 code; repeatable (default: all)") ap.add_argument("--min-links", type=int, default=1, help="hide partner orgs with fewer participations") ap.add_argument("--out", default="iati_graph.html") args = ap.parse_args() bad = [c for c in args.country if c.upper() not in HOTSPOTS] if bad: raise SystemExit(f"not hotspot codes: {bad}; valid: {sorted(HOTSPOTS)}") build([c.upper() for c in args.country], args.min_links, args.out)