Spaces:
Sleeping
Sleeping
| """SVG world map generator — lush fantasy garden aesthetic.""" | |
| from __future__ import annotations | |
| import math | |
| import random | |
| from ui import assets | |
| from world.entities import get_entities_by_location | |
| from world.events import get_active_world_event | |
| from world.locations import get_all_locations | |
| ENTITY_TYPE_COLORS = { | |
| "character": "#d4a84b", | |
| "creature": "#e07b4a", | |
| "object": "#a8c8e8", | |
| "place": "#60a060", | |
| } | |
| LOCATION_ICONS = { | |
| "library": "M-6,-4 L6,-4 L6,4 L-6,4 Z M-4,-6 L4,-6", | |
| "sea": "M-8,2 Q-4,-2 0,2 T8,2", | |
| "clock-forest": "M0,-8 L2,0 L0,8 L-2,0 Z", | |
| "moon-market": "M0,-6 L5,6 L-5,6 Z", | |
| "valley": "M-8,4 L0,-6 L8,4", | |
| "crossroads": "M0,-7 L2,0 L0,7 L-2,0 M-7,0 L7,0", | |
| "mirror-bogs": "M-6,-6 A6,6 0 1,1 6,-6", | |
| "hollow-mountain": "M-8,6 L0,-8 L8,6", | |
| } | |
| LOCATION_PATHS = [ | |
| "M 120,120 Q 220,60 400,80 Q 520,40 640,100", | |
| "M 120,120 Q 80,220 160,320 Q 200,420 200,500", | |
| "M 640,100 Q 620,220 640,340", | |
| "M 400,80 Q 400,200 400,300", | |
| "M 400,300 Q 280,380 200,500", | |
| "M 400,300 Q 520,380 600,500", | |
| "M 400,300 Q 520,280 640,340", | |
| "M 200,500 Q 400,460 600,500", | |
| ] | |
| def render_world_map(selected_location_id: int | None = None) -> str: | |
| locations = get_all_locations() | |
| active_event = get_active_world_event() | |
| affected_slugs = set() | |
| if active_event: | |
| from world.seed_data import LOCATION_NAME_TO_SLUG | |
| for name in active_event.get("affected_locations", []): | |
| slug = LOCATION_NAME_TO_SLUG.get(name, name) | |
| affected_slugs.add(slug) | |
| svg_parts = [ | |
| '<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg" class="realm-map">', | |
| "<defs>", | |
| '<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">', | |
| '<feGaussianBlur stdDeviation="5" result="blur"/>', | |
| '<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>', | |
| "</filter>", | |
| '<filter id="pulse-glow" x="-100%" y="-100%" width="300%" height="300%">', | |
| '<feGaussianBlur stdDeviation="10" result="blur"/>', | |
| '<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>', | |
| "</filter>", | |
| '<radialGradient id="gardenSky" cx="50%" cy="25%" r="75%">', | |
| '<stop offset="0%" stop-color="#1a3a2a"/>', | |
| '<stop offset="45%" stop-color="#142218"/>', | |
| '<stop offset="100%" stop-color="#0a120c"/>', | |
| "</radialGradient>", | |
| '<radialGradient id="gardenMist" cx="50%" cy="80%" r="50%">', | |
| '<stop offset="0%" stop-color="#2a5a3a" stop-opacity="0.15"/>', | |
| '<stop offset="100%" stop-color="#0a120c" stop-opacity="0"/>', | |
| "</radialGradient>", | |
| '<radialGradient id="mapVignette" cx="50%" cy="50%" r="75%">', | |
| '<stop offset="0%" stop-color="#0a120c" stop-opacity="0"/>', | |
| '<stop offset="78%" stop-color="#0a120c" stop-opacity="0.25"/>', | |
| '<stop offset="100%" stop-color="#0a120c" stop-opacity="0.85"/>', | |
| "</radialGradient>", | |
| "</defs>", | |
| '<rect width="800" height="600" fill="url(#gardenSky)"/>', | |
| f'<image href="{assets.file_url("map_backdrop.jpg")}" ' | |
| 'x="0" y="0" width="800" height="600" preserveAspectRatio="xMidYMid slice" opacity="0.62"/>', | |
| '<rect width="800" height="600" fill="url(#mapVignette)"/>', | |
| '<rect width="800" height="600" fill="url(#gardenMist)"/>', | |
| ] | |
| # Fireflies / stars | |
| for i in range(55): | |
| random.seed(i * 13) | |
| cx = random.randint(20, 780) | |
| cy = random.randint(20, 580) | |
| r = random.uniform(0.4, 2.0) | |
| opacity = random.uniform(0.04, 0.18) | |
| color = "#d4a84b" if i % 3 == 0 else "#e8f0d8" | |
| svg_parts.append( | |
| f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="{color}" opacity="{opacity:.2f}"/>' | |
| ) | |
| # Organic terrain blobs | |
| for i, (cx, cy, rx, ry, color) in enumerate([ | |
| (200, 450, 120, 60, "rgba(42,74,58,0.25)"), | |
| (550, 200, 90, 50, "rgba(42,74,58,0.2)"), | |
| (400, 350, 150, 80, "rgba(58,90,68,0.15)"), | |
| (650, 420, 80, 40, "rgba(42,74,58,0.18)"), | |
| ]): | |
| svg_parts.append( | |
| f'<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" fill="{color}"/>' | |
| ) | |
| for i, path in enumerate(LOCATION_PATHS): | |
| svg_parts.append( | |
| f'<path class="realm-path" d="{path}" fill="none" ' | |
| f'stroke="rgba(212,168,75,0.22)" stroke-width="2.5" ' | |
| f'stroke-dasharray="8,10" stroke-linecap="round" ' | |
| f'style="animation-delay:{i * 0.4:.1f}s"/>' | |
| ) | |
| for loc in locations: | |
| x = loc["map_x"] / 100 * 800 | |
| y = loc["map_y"] / 100 * 600 | |
| is_affected = loc["slug"] in affected_slugs | |
| is_selected = selected_location_id == loc["id"] | |
| pulse_class = " location-pulse" if is_affected else " location-glow" | |
| selected_class = " location-selected" if is_selected else "" | |
| entities = get_entities_by_location(loc["id"], limit=8) | |
| count = loc["entity_count"] | |
| icon_path = LOCATION_ICONS.get(loc["slug"], "M0,-6 A6,6 0 1,1 0,6") | |
| svg_parts.append( | |
| f'<g class="location-node{pulse_class}{selected_class}" ' | |
| f'data-location-id="{loc["id"]}" transform="translate({x},{y})">' | |
| ) | |
| filter_id = "pulse-glow" if is_affected else "glow" | |
| svg_parts.append( | |
| f'<circle r="44" fill="{loc["glow_color"]}" opacity="0.1" ' | |
| f'class="location-aura" filter="url(#{filter_id})"/>' | |
| ) | |
| svg_parts.append( | |
| f'<circle r="36" fill="{loc["glow_color"]}" opacity="0.2" ' | |
| f'stroke="{loc["glow_color"]}" stroke-width="2" class="location-circle"/>' | |
| ) | |
| svg_parts.append( | |
| f'<path d="{icon_path}" fill="none" stroke="#e8e0d0" stroke-width="1.2" ' | |
| f'opacity="0.6" transform="scale(0.9)"/>' | |
| ) | |
| svg_parts.append( | |
| f'<text y="6" text-anchor="middle" class="location-count" ' | |
| f'fill="#f0e8d0" font-size="13" font-weight="bold">{count}</text>' | |
| ) | |
| for i, entity in enumerate(entities[:6]): | |
| angle = (i / 6) * 2 * math.pi - math.pi / 2 | |
| dot_x = math.cos(angle) * 46 | |
| dot_y = math.sin(angle) * 46 | |
| color = ENTITY_TYPE_COLORS.get(entity["type"], "#d4a84b") | |
| svg_parts.append( | |
| f'<g class="entity-dot-group" transform="translate({dot_x:.1f},{dot_y:.1f})">' | |
| f'<circle r="7" fill="{color}" opacity="0.25"/>' | |
| f'<circle r="5" fill="{color}" opacity="0.95" class="entity-dot">' | |
| f'<title>{entity["name"]}</title></circle></g>' | |
| ) | |
| if count > 6: | |
| svg_parts.append( | |
| f'<text x="48" y="-32" class="entity-overflow" ' | |
| f'fill="#a09880" font-size="10">+{count - 6}</text>' | |
| ) | |
| short_name = loc["name"].replace("The ", "") | |
| if len(short_name) > 18: | |
| short_name = short_name[:16] + "…" | |
| svg_parts.append( | |
| f'<text y="58" text-anchor="middle" class="location-label" ' | |
| f'fill="#c8b890" font-size="8.5" font-family="Georgia, serif">{short_name}</text>' | |
| ) | |
| svg_parts.append("</g>") | |
| svg_parts.append("</svg>") | |
| return "\n".join(svg_parts) | |