| """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> |
| """ |
|
|
|
|
|
|