Spaces:
Running
Running
Adinda Panca Mochamad
Fix Day 2: orbit ring, duplicate name guard, format_panel cleanup, 4 test baru
925c1a7 | """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>' | |
| ) | |