"""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 '
Bonds are forming…
No bonds woven yet.
{rel["entity_a_name"]} ↔ {rel["entity_b_name"]}
{desc}