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