build-small-hackathon/oneiros
1
1"""JSON entities → SVG dream map (radial layout, 5 mood palettes)."""2from __future__ import annotations3 4import html5import math6from typing import Any7 8from map.themes import Palette, get_palette, terangkan9 10_SVG_W = 60011_SVG_H = 42012_CX = 30013_CY = 218 # slightly below midpoint → title strip clearance at top14_R1 = 115 # ring 1: secondary characters + places (safe: max node bottom = 375)15_R2 = 155 # ring 2: symbols (safe: max label bottom = 413)16_NODE_R = 28 # character circle radius17_RECT_W, _RECT_H = 62, 36 # place rect18_DIAMOND_R = 26 # symbol half-size19_LABEL_MAXLEN = 1420 21 22def _trunc(text: str, n: int = _LABEL_MAXLEN) -> str:23 s = str(text).strip()24 return s if len(s) <= n else s[:n - 1] + "…"25 26 27def _esc(text: str) -> str:28 return html.escape(str(text), quote=True)29 30 31def _radial_positions(count: int, radius: float, offset_angle: float = 0.0) -> list[tuple[float, float]]:32 if count == 0:33 return []34 step = 2 * math.pi / count35 return [36 (37 _CX + radius * math.cos(offset_angle + i * step - math.pi / 2),38 _CY + radius * math.sin(offset_angle + i * step - math.pi / 2),39 )40 for i in range(count)41 ]42 43 44def _draw_character(x: float, y: float, label: str, fill: str, text_color: str, is_self: bool) -> str:45 r = _NODE_R + (6 if is_self else 0)46 stroke = terangkan(fill, 0.25)47 sw = 2.5 if is_self else 1.548 txt_y = y + r + 1449 lines = [50 f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{r}" fill="{fill}" '51 f'stroke="{stroke}" stroke-width="{sw}" opacity="0.92"/>',52 f'<text x="{x:.1f}" y="{txt_y:.1f}" text-anchor="middle" '53 f'font-size="11" font-family="DM Sans,system-ui,sans-serif" '54 f'fill="{text_color}" opacity="0.9">{_esc(_trunc(label))}</text>',55 ]56 if is_self:57 lines.insert(0,58 f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{r + 7}" '59 f'fill="none" stroke="{stroke}" stroke-width="1" opacity="0.3"/>')60 return "\n".join(lines)61 62 63def _draw_place(x: float, y: float, label: str, fill: str, text_color: str) -> str:64 rx, ry = _RECT_W / 2, _RECT_H / 265 stroke = terangkan(fill, 0.2)66 txt_y = y + ry + 1467 return (68 f'<rect x="{x - rx:.1f}" y="{y - ry:.1f}" width="{_RECT_W}" height="{_RECT_H}" '69 f'rx="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5" opacity="0.88"/>\n'70 f'<text x="{x:.1f}" y="{txt_y:.1f}" text-anchor="middle" '71 f'font-size="11" font-family="DM Sans,system-ui,sans-serif" '72 f'fill="{text_color}" opacity="0.9">{_esc(_trunc(label))}</text>'73 )74 75 76def _draw_symbol(x: float, y: float, label: str, fill: str, text_color: str) -> str:77 r = _DIAMOND_R78 pts = f"{x:.1f},{y-r:.1f} {x+r:.1f},{y:.1f} {x:.1f},{y+r:.1f} {x-r:.1f},{y:.1f}"79 stroke = terangkan(fill, 0.2)80 txt_y = y + r + 1481 return (82 f'<polygon points="{pts}" fill="{fill}" '83 f'stroke="{stroke}" stroke-width="1.5" opacity="0.85"/>\n'84 f'<text x="{x:.1f}" y="{txt_y:.1f}" text-anchor="middle" '85 f'font-size="10" font-family="DM Sans,system-ui,sans-serif" '86 f'fill="{text_color}" opacity="0.85">{_esc(_trunc(label))}</text>'87 )88 89 90def _draw_edge(91 x1: float, y1: float, x2: float, y2: float,92 conn_type: str, palette: Palette,93) -> str:94 dashed = conn_type in ("tension", "pursuit")95 color = palette["edge_dash"] if dashed else palette["edge_solid"]96 dash_attr = ' stroke-dasharray="6,4"' if dashed else ""97 return (98 f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" '99 f'stroke="{color}" stroke-width="1.5"{dash_attr} opacity="0.7"/>'100 )101 102 103def _empty_map(palette: Palette, title: str = "") -> str:104 msg = _esc(title) if title else "Write a dream and tap the button…"105 return (106 f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {_SVG_W} {_SVG_H}" '107 f'width="100%" style="max-height:420px;display:block">'108 f'<rect width="{_SVG_W}" height="{_SVG_H}" fill="{palette["bg"]}"/>'109 f'<text x="{_CX}" y="{_CY}" text-anchor="middle" dominant-baseline="middle" '110 f'font-size="13" font-family="DM Sans,system-ui,sans-serif" '111 f'fill="{palette["text_dim"]}" opacity="0.6">{msg}</text>'112 f'</svg>'113 )114 115 116def generate_dream_map(entities: dict[str, Any]) -> str:117 """Return a complete SVG string from extracted entities."""118 mood = str(entities.get("mood") or "mysterious")119 palette = get_palette(mood)120 title = str(entities.get("title") or "")121 122 characters: list[dict] = [c for c in (entities.get("characters") or []) if isinstance(c, dict)]123 places: list[dict] = [p for p in (entities.get("places") or []) if isinstance(p, dict)]124 symbols: list[dict] = [s for s in (entities.get("symbols") or []) if isinstance(s, dict)]125 connections: list[dict] = [e for e in (entities.get("connections") or []) if isinstance(e, dict)]126 127 if not characters and not places and not symbols:128 return _empty_map(palette, title)129 130 # --- node registry: name → (x, y, type) ---131 node_pos: dict[str, tuple[float, float]] = {}132 133 # Center: first character (or first place/symbol if no characters)134 center_node = characters[0] if characters else (places[0] if places else symbols[0])135 center_name = str(center_node.get("name", "?"))136 node_pos[center_name] = (_CX, _CY)137 138 # Ring 1: remaining characters + places (exclude center if it came from places)139 if characters:140 ring1_nodes = characters[1:] + places141 elif places:142 ring1_nodes = places[1:]143 else:144 ring1_nodes = []145 146 r1_name_to_idx = {str(n.get("name", "?")): i for i, n in enumerate(ring1_nodes)}147 r1_pos = _radial_positions(len(ring1_nodes), _R1)148 for name, idx in r1_name_to_idx.items():149 if name != center_name: # jangan overwrite center jika nama sama150 node_pos[name] = r1_pos[idx]151 152 # Ring 2: symbols (exclude center if it came from symbols)153 symbols_r2 = symbols if (characters or places) else symbols[1:]154 r2_offset = math.pi / len(symbols_r2) if symbols_r2 else 0155 r2_pos = _radial_positions(len(symbols_r2), _R2, offset_angle=r2_offset)156 for i, sym in enumerate(symbols_r2):157 node_pos[str(sym.get("name", "?"))] = r2_pos[i]158 159 # --- Build SVG ---160 parts: list[str] = []161 162 # Background gradient163 grad_id = f"grad_{mood}"164 parts.append(165 f'<defs>'166 f'<radialGradient id="{grad_id}" cx="50%" cy="50%" r="70%">'167 f'<stop offset="0%" stop-color="{palette["bg2"]}"/>'168 f'<stop offset="100%" stop-color="{palette["bg"]}"/>'169 f'</radialGradient>'170 f'</defs>'171 f'<rect width="{_SVG_W}" height="{_SVG_H}" fill="url(#{grad_id})"/>'172 )173 174 # Subtle orbit rings — hanya tampil jika ada node di ring tersebut175 if ring1_nodes:176 parts.append(177 f'<circle cx="{_CX}" cy="{_CY}" r="{_R1}" fill="none" '178 f'stroke="{palette["orbit"]}" stroke-width="1" stroke-dasharray="3,6"/>'179 )180 if symbols_r2:181 parts.append(182 f'<circle cx="{_CX}" cy="{_CY}" r="{_R2}" fill="none" '183 f'stroke="{palette["orbit"]}" stroke-width="1" stroke-dasharray="2,8"/>'184 )185 186 # Edges (drawn first, below nodes)187 for conn in connections:188 fr = str(conn.get("from", ""))189 to = str(conn.get("to", ""))190 ctype = str(conn.get("type", "harmony"))191 if fr in node_pos and to in node_pos:192 x1, y1 = node_pos[fr]193 x2, y2 = node_pos[to]194 parts.append(_draw_edge(x1, y1, x2, y2, ctype, palette))195 196 # Nodes197 for char in characters:198 name = str(char.get("name", "?"))199 if name not in node_pos:200 continue201 x, y = node_pos[name]202 is_self = str(char.get("role", "")) == "self" or name == center_name203 fill = terangkan(palette["node_char"], 0.05) if is_self else palette["node_char"]204 parts.append(_draw_character(x, y, name, fill, palette["text"], is_self))205 206 for place in places:207 name = str(place.get("name", "?"))208 if name not in node_pos:209 continue210 x, y = node_pos[name]211 parts.append(_draw_place(x, y, name, palette["node_place"], palette["text"]))212 213 for sym in symbols:214 name = str(sym.get("name", "?"))215 if name not in node_pos:216 continue217 x, y = node_pos[name]218 parts.append(_draw_symbol(x, y, name, palette["node_symbol"], palette["text"]))219 220 # Title + mood badge (top strip)221 if title:222 parts.append(223 f'<text x="16" y="22" font-size="12" font-weight="600" '224 f'font-family="DM Sans,system-ui,sans-serif" fill="{palette["title"]}" opacity="0.85">'225 f'{_esc(_trunc(title, 32))}</text>'226 )227 mood_label = mood.capitalize()228 badge_w = len(mood_label) * 7 + 16229 badge_rx = _SVG_W - 8 - badge_w230 badge_cx = badge_rx + badge_w / 2231 parts.append(232 f'<rect x="{badge_rx:.0f}" y="8" width="{badge_w}" height="20" rx="10" '233 f'fill="{palette["badge_bg"]}"/>'234 f'<text x="{badge_cx:.1f}" y="21" text-anchor="middle" '235 f'font-size="10" font-weight="600" letter-spacing="0.08em" '236 f'font-family="DM Sans,system-ui,sans-serif" fill="{palette["title"]}" '237 f'text-transform="uppercase" opacity="0.9">{_esc(mood_label)}</text>'238 )239 240 inner = "\n".join(parts)241 return (242 f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {_SVG_W} {_SVG_H}" '243 f'width="100%" style="max-height:420px;display:block;border-radius:10px">'244 f'{inner}'245 f'</svg>'246 )247 