aether-garden / ui /map.py
kavyabhand's picture
Deploy Aether Garden application
e500ac8 verified
Raw
History Blame Contribute Delete
8.22 kB
"""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
# Keep hit areas and labels safely inside the frame on all aspect ratios.
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)"/>',
]
# 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:
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)