| """SVG world map generator — lush fantasy garden aesthetic.""" |
|
|
| from __future__ import annotations |
|
|
| import html |
| 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() |
| map_w, map_h = 800, 600 |
| |
| margin_x = 62 |
| margin_top = 62 |
| margin_bottom = 90 |
| 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)"/>', |
| ] |
|
|
| |
| 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}"/>' |
| ) |
|
|
| |
| 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: |
| raw_x = loc["map_x"] / 100 * map_w |
| raw_y = loc["map_y"] / 100 * map_h |
| x = min(max(raw_x, margin_x), map_w - margin_x) |
| y = min(max(raw_y, margin_top), map_h - margin_bottom) |
| 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") |
|
|
| img = assets.location_image_url(loc.get("slug")) or "" |
| special = loc.get("special_property", "") or "" |
| desc = loc.get("short_description", "") or "" |
|
|
| svg_parts.append( |
| f'<g class="location-node{pulse_class}{selected_class}" ' |
| f'data-location-id="{loc["id"]}" ' |
| f'data-name="{html.escape(loc["name"], quote=True)}" ' |
| f'data-desc="{html.escape(desc, quote=True)}" ' |
| f'data-special="{html.escape(special, quote=True)}" ' |
| f'data-img="{html.escape(img, quote=True)}" ' |
| f'data-souls="{count}" ' |
| f'transform="translate({x},{y})">' |
| ) |
|
|
| svg_parts.append( |
| '<circle r="50" fill="transparent" class="location-hit" pointer-events="all"/>' |
| ) |
|
|
| 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) |
|
|