"""JSON entities → SVG dream map (radial layout, 5 mood palettes).""" from __future__ import annotations import html import math from typing import Any from map.themes import Palette, get_palette, terangkan _SVG_W = 600 _SVG_H = 420 _CX = 300 _CY = 218 # slightly below midpoint → title strip clearance at top _R1 = 115 # ring 1: secondary characters + places (safe: max node bottom = 375) _R2 = 155 # ring 2: symbols (safe: max label bottom = 413) _NODE_R = 28 # character circle radius _RECT_W, _RECT_H = 62, 36 # place rect _DIAMOND_R = 26 # symbol half-size _LABEL_MAXLEN = 14 def _trunc(text: str, n: int = _LABEL_MAXLEN) -> str: s = str(text).strip() return s if len(s) <= n else s[:n - 1] + "…" def _esc(text: str) -> str: return html.escape(str(text), quote=True) def _radial_positions(count: int, radius: float, offset_angle: float = 0.0) -> list[tuple[float, float]]: if count == 0: return [] step = 2 * math.pi / count return [ ( _CX + radius * math.cos(offset_angle + i * step - math.pi / 2), _CY + radius * math.sin(offset_angle + i * step - math.pi / 2), ) for i in range(count) ] def _draw_character(x: float, y: float, label: str, fill: str, text_color: str, is_self: bool) -> str: r = _NODE_R + (6 if is_self else 0) stroke = terangkan(fill, 0.25) sw = 2.5 if is_self else 1.5 txt_y = y + r + 14 lines = [ f'', f'{_esc(_trunc(label))}', ] if is_self: lines.insert(0, f'') return "\n".join(lines) def _draw_place(x: float, y: float, label: str, fill: str, text_color: str) -> str: rx, ry = _RECT_W / 2, _RECT_H / 2 stroke = terangkan(fill, 0.2) txt_y = y + ry + 14 return ( f'\n' f'{_esc(_trunc(label))}' ) def _draw_symbol(x: float, y: float, label: str, fill: str, text_color: str) -> str: r = _DIAMOND_R pts = f"{x:.1f},{y-r:.1f} {x+r:.1f},{y:.1f} {x:.1f},{y+r:.1f} {x-r:.1f},{y:.1f}" stroke = terangkan(fill, 0.2) txt_y = y + r + 14 return ( f'\n' f'{_esc(_trunc(label))}' ) def _draw_edge( x1: float, y1: float, x2: float, y2: float, conn_type: str, palette: Palette, ) -> str: dashed = conn_type in ("tension", "pursuit") color = palette["edge_dash"] if dashed else palette["edge_solid"] dash_attr = ' stroke-dasharray="6,4"' if dashed else "" return ( f'' ) def _empty_map(palette: Palette, title: str = "") -> str: msg = _esc(title) if title else "Write a dream and tap the button…" return ( f'' f'' f'{msg}' f'' ) def generate_dream_map(entities: dict[str, Any]) -> str: """Return a complete SVG string from extracted entities.""" mood = str(entities.get("mood") or "mysterious") palette = get_palette(mood) title = str(entities.get("title") or "") characters: list[dict] = [c for c in (entities.get("characters") or []) if isinstance(c, dict)] places: list[dict] = [p for p in (entities.get("places") or []) if isinstance(p, dict)] symbols: list[dict] = [s for s in (entities.get("symbols") or []) if isinstance(s, dict)] connections: list[dict] = [e for e in (entities.get("connections") or []) if isinstance(e, dict)] if not characters and not places and not symbols: return _empty_map(palette, title) # --- node registry: name → (x, y, type) --- node_pos: dict[str, tuple[float, float]] = {} # Center: first character (or first place/symbol if no characters) center_node = characters[0] if characters else (places[0] if places else symbols[0]) center_name = str(center_node.get("name", "?")) node_pos[center_name] = (_CX, _CY) # Ring 1: remaining characters + places (exclude center if it came from places) if characters: ring1_nodes = characters[1:] + places elif places: ring1_nodes = places[1:] else: ring1_nodes = [] r1_name_to_idx = {str(n.get("name", "?")): i for i, n in enumerate(ring1_nodes)} r1_pos = _radial_positions(len(ring1_nodes), _R1) for name, idx in r1_name_to_idx.items(): if name != center_name: # jangan overwrite center jika nama sama node_pos[name] = r1_pos[idx] # Ring 2: symbols (exclude center if it came from symbols) symbols_r2 = symbols if (characters or places) else symbols[1:] r2_offset = math.pi / len(symbols_r2) if symbols_r2 else 0 r2_pos = _radial_positions(len(symbols_r2), _R2, offset_angle=r2_offset) for i, sym in enumerate(symbols_r2): node_pos[str(sym.get("name", "?"))] = r2_pos[i] # --- Build SVG --- parts: list[str] = [] # Background gradient grad_id = f"grad_{mood}" parts.append( f'' f'' f'' f'' f'' f'' f'' ) # Subtle orbit rings — hanya tampil jika ada node di ring tersebut if ring1_nodes: parts.append( f'' ) if symbols_r2: parts.append( f'' ) # Edges (drawn first, below nodes) for conn in connections: fr = str(conn.get("from", "")) to = str(conn.get("to", "")) ctype = str(conn.get("type", "harmony")) if fr in node_pos and to in node_pos: x1, y1 = node_pos[fr] x2, y2 = node_pos[to] parts.append(_draw_edge(x1, y1, x2, y2, ctype, palette)) # Nodes for char in characters: name = str(char.get("name", "?")) if name not in node_pos: continue x, y = node_pos[name] is_self = str(char.get("role", "")) == "self" or name == center_name fill = terangkan(palette["node_char"], 0.05) if is_self else palette["node_char"] parts.append(_draw_character(x, y, name, fill, palette["text"], is_self)) for place in places: name = str(place.get("name", "?")) if name not in node_pos: continue x, y = node_pos[name] parts.append(_draw_place(x, y, name, palette["node_place"], palette["text"])) for sym in symbols: name = str(sym.get("name", "?")) if name not in node_pos: continue x, y = node_pos[name] parts.append(_draw_symbol(x, y, name, palette["node_symbol"], palette["text"])) # Title + mood badge (top strip) if title: parts.append( f'' f'{_esc(_trunc(title, 32))}' ) mood_label = mood.capitalize() badge_w = len(mood_label) * 7 + 16 badge_rx = _SVG_W - 8 - badge_w badge_cx = badge_rx + badge_w / 2 parts.append( f'' f'{_esc(mood_label)}' ) inner = "\n".join(parts) return ( f'' f'{inner}' f'' )