oneiros / map /generator.py
Adinda Panca Mochamad
Fix Day 2: orbit ring, duplicate name guard, format_panel cleanup, 4 test baru
925c1a7
Raw
History Blame Contribute Delete
9.64 kB
"""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'<circle cx="{x:.1f}" cy="{y:.1f}" r="{r}" fill="{fill}" '
f'stroke="{stroke}" stroke-width="{sw}" opacity="0.92"/>',
f'<text x="{x:.1f}" y="{txt_y:.1f}" text-anchor="middle" '
f'font-size="11" font-family="DM Sans,system-ui,sans-serif" '
f'fill="{text_color}" opacity="0.9">{_esc(_trunc(label))}</text>',
]
if is_self:
lines.insert(0,
f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{r + 7}" '
f'fill="none" stroke="{stroke}" stroke-width="1" opacity="0.3"/>')
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'<rect x="{x - rx:.1f}" y="{y - ry:.1f}" width="{_RECT_W}" height="{_RECT_H}" '
f'rx="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5" opacity="0.88"/>\n'
f'<text x="{x:.1f}" y="{txt_y:.1f}" text-anchor="middle" '
f'font-size="11" font-family="DM Sans,system-ui,sans-serif" '
f'fill="{text_color}" opacity="0.9">{_esc(_trunc(label))}</text>'
)
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'<polygon points="{pts}" fill="{fill}" '
f'stroke="{stroke}" stroke-width="1.5" opacity="0.85"/>\n'
f'<text x="{x:.1f}" y="{txt_y:.1f}" text-anchor="middle" '
f'font-size="10" font-family="DM Sans,system-ui,sans-serif" '
f'fill="{text_color}" opacity="0.85">{_esc(_trunc(label))}</text>'
)
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'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" '
f'stroke="{color}" stroke-width="1.5"{dash_attr} opacity="0.7"/>'
)
def _empty_map(palette: Palette, title: str = "") -> str:
msg = _esc(title) if title else "Write a dream and tap the button…"
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {_SVG_W} {_SVG_H}" '
f'width="100%" style="max-height:420px;display:block">'
f'<rect width="{_SVG_W}" height="{_SVG_H}" fill="{palette["bg"]}"/>'
f'<text x="{_CX}" y="{_CY}" text-anchor="middle" dominant-baseline="middle" '
f'font-size="13" font-family="DM Sans,system-ui,sans-serif" '
f'fill="{palette["text_dim"]}" opacity="0.6">{msg}</text>'
f'</svg>'
)
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'<defs>'
f'<radialGradient id="{grad_id}" cx="50%" cy="50%" r="70%">'
f'<stop offset="0%" stop-color="{palette["bg2"]}"/>'
f'<stop offset="100%" stop-color="{palette["bg"]}"/>'
f'</radialGradient>'
f'</defs>'
f'<rect width="{_SVG_W}" height="{_SVG_H}" fill="url(#{grad_id})"/>'
)
# Subtle orbit rings — hanya tampil jika ada node di ring tersebut
if ring1_nodes:
parts.append(
f'<circle cx="{_CX}" cy="{_CY}" r="{_R1}" fill="none" '
f'stroke="{palette["orbit"]}" stroke-width="1" stroke-dasharray="3,6"/>'
)
if symbols_r2:
parts.append(
f'<circle cx="{_CX}" cy="{_CY}" r="{_R2}" fill="none" '
f'stroke="{palette["orbit"]}" stroke-width="1" stroke-dasharray="2,8"/>'
)
# 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'<text x="16" y="22" font-size="12" font-weight="600" '
f'font-family="DM Sans,system-ui,sans-serif" fill="{palette["title"]}" opacity="0.85">'
f'{_esc(_trunc(title, 32))}</text>'
)
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'<rect x="{badge_rx:.0f}" y="8" width="{badge_w}" height="20" rx="10" '
f'fill="{palette["badge_bg"]}"/>'
f'<text x="{badge_cx:.1f}" y="21" text-anchor="middle" '
f'font-size="10" font-weight="600" letter-spacing="0.08em" '
f'font-family="DM Sans,system-ui,sans-serif" fill="{palette["title"]}" '
f'text-transform="uppercase" opacity="0.9">{_esc(mood_label)}</text>'
)
inner = "\n".join(parts)
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {_SVG_W} {_SVG_H}" '
f'width="100%" style="max-height:420px;display:block;border-radius:10px">'
f'{inner}'
f'</svg>'
)