aether-garden / ui /relationship_graph.py
kavyabhand's picture
Deploy Aether Garden application
e270790 verified
Raw
History Blame Contribute Delete
9.92 kB
"""3D relationship web — who met whom, and how they feel about it."""
from __future__ import annotations
import base64
import hashlib
import json
import math
import random
from collections import defaultdict
from ui import assets
from world.entities import get_all_entities
from world.locations import get_location_by_id
from world.relationships import get_all_relationships
REL_COLORS = {
"allied": "#5fd07a",
"friends": "#7eb8da",
"rivals": "#e0a040",
"enemies": "#c05050",
"mentor": "#c8a040",
"owes_debt": "#9070b8",
"curious": "#a09880",
}
TYPE_SHORT = {
"allied": "Allies",
"friends": "Friends",
"rivals": "Rivals",
"enemies": "Enemies",
"mentor": "Mentor",
"owes_debt": "Debt",
}
def _node_color(entity: dict) -> str:
loc = get_location_by_id(entity["location_id"])
if loc:
return loc.get("glow_color", "#d4a84b")
return "#d4a84b"
def _seed_pos(entity_id: str, width: float, height: float) -> tuple[float, float]:
h = int(hashlib.md5(entity_id.encode()).hexdigest()[:8], 16)
rng = random.Random(h)
return width * 0.2 + rng.random() * width * 0.6, height * 0.2 + rng.random() * height * 0.6
def _force_layout(
entities_by_id: dict[str, dict],
node_ids: list[str],
edge_pairs: list[tuple[str, str]],
width: float = 1160,
height: float = 760,
iterations: int = 180,
) -> dict[str, tuple[float, float]]:
if not node_ids:
return {}
margin = 84
cx, cy = width / 2, height / 2
location_groups: dict[int, list[str]] = defaultdict(list)
for nid in node_ids:
location_groups[entities_by_id[nid]["location_id"]].append(nid)
locations = sorted(location_groups)
ring_r = min(width, height) * 0.37
cluster_centers: dict[int, tuple[float, float]] = {}
for i, loc_id in enumerate(locations):
ang = (i / max(len(locations), 1)) * math.pi * 2
cluster_centers[loc_id] = (
cx + math.cos(ang) * ring_r,
cy + math.sin(ang) * ring_r,
)
positions: dict[str, list[float]] = {}
for nid in node_ids:
lx, ly = cluster_centers[entities_by_id[nid]["location_id"]]
sx, sy = _seed_pos(nid, width * 0.18, height * 0.18)
positions[nid] = [lx + sx - width * 0.09, ly + sy - height * 0.09]
degree: dict[str, int] = defaultdict(int)
for a, b in edge_pairs:
degree[a] += 1
degree[b] += 1
for _ in range(iterations):
for i, a in enumerate(node_ids):
for b in node_ids[i + 1 :]:
dx = positions[b][0] - positions[a][0]
dy = positions[b][1] - positions[a][1]
dist = max(math.hypot(dx, dy), 10.0)
force = 22000 / (dist * dist)
fx, fy = -dx / dist * force, -dy / dist * force
positions[a][0] += fx * 0.012
positions[a][1] += fy * 0.012
positions[b][0] -= fx * 0.012
positions[b][1] -= fy * 0.012
pad = 24 + min(16, degree[a] + degree[b])
if dist < pad:
push = (pad - dist) * 0.09
positions[a][0] -= dx / dist * push
positions[a][1] -= dy / dist * push
positions[b][0] += dx / dist * push
positions[b][1] += dy / dist * push
for a, b in edge_pairs:
if a not in positions or b not in positions:
continue
dx = positions[b][0] - positions[a][0]
dy = positions[b][1] - positions[a][1]
dist = max(math.hypot(dx, dy), 1.0)
target = 154
force = (dist - target) * 0.06
fx, fy = dx / dist * force, dy / dist * force
positions[a][0] += fx
positions[a][1] += fy
positions[b][0] -= fx
positions[b][1] -= fy
for nid in node_ids:
loc_id = entities_by_id[nid]["location_id"]
anchor_x, anchor_y = cluster_centers[loc_id]
positions[nid][0] += (anchor_x - positions[nid][0]) * 0.012
positions[nid][1] += (anchor_y - positions[nid][1]) * 0.012
positions[nid][0] += (cx - positions[nid][0]) * 0.005
positions[nid][1] += (cy - positions[nid][1]) * 0.005
positions[nid][0] = max(margin, min(width - margin, positions[nid][0]))
positions[nid][1] = max(margin, min(height - margin, positions[nid][1]))
return {nid: (positions[nid][0], positions[nid][1]) for nid in node_ids}
def _to_3d_positions(
positions_2d: dict[str, tuple[float, float]],
node_ids: list[str],
) -> dict[str, tuple[float, float, float]]:
"""Lift 2D layout into 3D with a gentle spiral."""
cx = sum(positions_2d[n][0] for n in node_ids) / max(len(node_ids), 1)
cy = sum(positions_2d[n][1] for n in node_ids) / max(len(node_ids), 1)
scale = 0.045
out: dict[str, tuple[float, float, float]] = {}
for i, nid in enumerate(node_ids):
x, y = positions_2d[nid]
h = int(hashlib.md5(nid.encode()).hexdigest()[:6], 16)
z = math.sin(i * 0.7 + h * 0.001) * 4.5
out[nid] = ((x - cx) * scale, (y - cy) * scale * 0.6, z)
return out
def _encode_scene(data: dict) -> str:
raw = json.dumps(data, separators=(",", ":")).encode()
return "data=" + base64.b64encode(raw).decode()
def _bonds_graph_src(selected_entity_id: str | None = None) -> str | None:
entities = get_all_entities(limit=80)
relationships = get_all_relationships()
if not entities or not relationships:
return None
entity_map = {e["id"]: e for e in entities}
connected_ids: set[str] = set()
edge_pairs: list[tuple[str, str]] = []
for rel in relationships:
a, b = rel["entity_a_id"], rel["entity_b_id"]
if a in entity_map and b in entity_map:
connected_ids.add(a)
connected_ids.add(b)
edge_pairs.append((a, b))
if not connected_ids:
return None
node_ids = sorted(connected_ids)
positions_2d = _force_layout(entity_map, node_ids, edge_pairs)
positions_3d = _to_3d_positions(positions_2d, node_ids)
degree: dict[str, int] = defaultdict(int)
for a, b in edge_pairs:
degree[a] += 1
degree[b] += 1
scene_nodes = []
for eid in node_ids:
ent = entity_map[eid]
x, y, z = positions_3d[eid]
label = ent["display_name"].replace("The ", "")
if len(label) > 16:
label = label[:14] + "…"
scene_nodes.append({
"id": eid,
"name": label,
"color": _node_color(ent),
"x": x,
"y": y,
"z": z,
"degree": degree[eid],
"showLabel": selected_entity_id == eid or degree[eid] >= 3,
})
scene_edges = []
for rel in relationships:
a, b = rel["entity_a_id"], rel["entity_b_id"]
if a not in positions_3d or b not in positions_3d:
continue
scene_edges.append({
"a": a,
"b": b,
"color": REL_COLORS.get(rel["relationship_type"], "#605848"),
"strength": rel.get("strength", 1),
"type": rel["relationship_type"],
})
scene = {"nodes": scene_nodes, "edges": scene_edges}
hash_data = _encode_scene(scene)
return f"{assets.file_url('bonds_graph.html')}#{hash_data}"
def render_bonds_graph_frame(scene_src: str | None = None) -> str:
if not scene_src:
return '<div class="rel-graph-stage rel-graph-empty"><p>Bonds are forming…</p></div>'
return f"""
<div class="rel-graph-stage">
<iframe
class="rel-graph-frame"
src="{scene_src}"
title="3D web of bonds in the Realm"
loading="lazy"
tabindex="-1"
></iframe>
</div>
"""
def render_relationship_graph(selected_entity_id: str | None = None) -> str:
src = _bonds_graph_src(selected_entity_id)
if not src:
return '<div class="rel-graph-stage rel-graph-empty"><p>No bonds woven yet.</p></div>'
return render_bonds_graph_frame(src)
def render_bonds_sidebar(rel_type: str = "all") -> str:
relationships = get_all_relationships()
if rel_type and rel_type != "all":
relationships = [r for r in relationships if r["relationship_type"] == rel_type]
if not relationships:
empty = (
"No bonds of this kind yet — try another thread type."
if rel_type and rel_type != "all"
else "No alliances forged yet — wait for the next realm tick."
)
return f"""
<div class="bonds-sidebar">
<p class="tome-printed-kicker">The Web of Bonds</p>
<p class="bonds-sidebar-empty">{empty}</p>
</div>
"""
legend = "".join(
f'<span class="rel-legend-item"><i style="background:{c}"></i>{k}</span>'
for k, c in REL_COLORS.items()
)
cards = []
for rel in relationships[:8]:
t = TYPE_SHORT.get(rel["relationship_type"], rel["relationship_type"])
color = REL_COLORS.get(rel["relationship_type"], "#605848")
desc = (rel.get("description") or "A bond still forming…")[:56]
cards.append(f"""
<div class="bond-card" style="--bond-color:{color}">
<span class="bond-card-type">{t}</span>
<p class="bond-card-names">{rel["entity_a_name"]}{rel["entity_b_name"]}</p>
<p class="bond-card-desc">{desc}</p>
</div>
""")
total = len(get_all_relationships())
return f"""
<div class="bonds-sidebar">
<p class="tome-printed-kicker">The Web of Bonds</p>
<p class="bonds-sidebar-desc">{len(relationships)} of {total} threads</p>
<div class="rel-legend bonds-legend-compact">{legend}</div>
<div class="bond-cards">{"".join(cards)}</div>
</div>
"""