Spaces:
Running on Zero
Running on Zero
File size: 7,211 Bytes
c2544e0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 | """
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"]}<br>{a["publisher"]} | {a["start_planned"] or a["start_actual"] or "?"}'
f'<br>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)}<br>{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)
|