"""Folium map rendering: plain route, discovery route, and POI markers.
Returns an HTML string suitable for a Gradio ``gr.HTML`` component. Styled per
the design handoff: cobalt plain route, grass discovery route (with an
in-iframe draw-on animation), coral POI markers that pop in, and a legend.
"""
from __future__ import annotations
import folium
from branca.element import Element
from discoverroute import config
from discoverroute.routing.graph import Route
from discoverroute.ui import design, markers
PLAIN_COLOR = "#2F5DF4" # cobalt — the plain/fastest route
DISCOVERY_COLOR = "#2FA463" # grass — the discovery route
# Warmer, livelier basemap than the pale Positron — CARTO Voyager (keyless).
_TILE_URL = "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
_TILE_ATTR = ('© OpenStreetMap '
'contributors © CARTO')
# POI marker color by category family (design palette).
_CATEGORY_COLORS = {
# nature & calm — grass
"park_garden": "#2FA463", "water_feature": "#2FA463", "viewpoint": "#2FA463",
# culture & history — cobalt
"monument_historic": "#2F5DF4", "museum_gallery": "#2F5DF4",
"place_of_worship": "#2F5DF4", "library": "#2F5DF4", "theatre_cinema": "#2F5DF4",
"attraction": "#2F5DF4",
# food & drink — sun
"cafe": "#E89E1C", "bakery_food_shop": "#E89E1C", "restaurant": "#E89E1C",
"bar_pub": "#E89E1C", "market": "#E89E1C",
# art & finds — coral
"artwork": "#FF6A52", "bookshop": "#FF6A52", "specialty_shop": "#FF6A52",
}
_DEFAULT_POI_COLOR = "#FF6A52"
_LEGEND_HTML = """
Discovery route
Fastest route
Green space
Water & wayfinding
Culture
Cozy stops
"""
# Gentle warm grade on the tiles so the basemap sits inside the cream design
# instead of fighting it (applies inside the folium iframe).
_TILE_WARMTH_CSS = """
"""
def _fit_bounds(fmap: folium.Map, coords: list[tuple[float, float]]) -> None:
if not coords:
return
lats = [c[0] for c in coords]
lons = [c[1] for c in coords]
fmap.fit_bounds([[min(lats), min(lons)], [max(lats), max(lons)]], padding=(28, 28))
def render_routes(
plain: Route | None = None,
discovery: Route | None = None,
pois=None,
start: tuple[float, float] | None = None,
end: tuple[float, float] | None = None,
) -> str:
"""Render routes + markers and return the map as standalone HTML."""
center = start or config.PARIS_CENTER
fmap = folium.Map(location=list(center), zoom_start=14,
tiles=_TILE_URL, attr=_TILE_ATTR)
all_coords: list[tuple[float, float]] = []
if plain is not None and plain.coords:
folium.PolyLine(
plain.coords, color=PLAIN_COLOR, weight=4, opacity=0.55,
dash_array="7 9",
tooltip=f"Fastest route · {plain.distance_m/1000:.2f} km · {plain.time_min:.0f} min",
).add_to(fmap)
all_coords.extend(plain.coords)
if discovery is not None and discovery.coords:
# under-glow + main stroke; class_name lets the iframe script draw it on
folium.PolyLine(
discovery.coords, color=DISCOVERY_COLOR, weight=10, opacity=0.18,
).add_to(fmap)
folium.PolyLine(
discovery.coords, color=DISCOVERY_COLOR, weight=5, opacity=0.95,
class_name="route-disc",
tooltip=f"Discovery route · {discovery.distance_m/1000:.2f} km · {discovery.time_min:.0f} min",
).add_to(fmap)
all_coords.extend(discovery.coords)
if pois:
from discoverroute.data import taxonomy
for i, poi in enumerate(pois):
name = getattr(poi, "name", None)
cat = getattr(poi, "category", "")
icon = markers.poi_icon(cat, index=i)
if name and str(name).strip():
tooltip = f"{name} · {taxonomy.pretty_category(cat)}" if cat else str(name)
else: # unnamed → a single natural label, never raw snake_case
tooltip = taxonomy.display_label(poi)
if icon is not None:
folium.Marker([poi.lat, poi.lon], icon=icon,
tooltip=tooltip).add_to(fmap)
else: # icon file missing — fall back to a colored dot
folium.CircleMarker(
location=[poi.lat, poi.lon], radius=7, color="#FFFCF5",
weight=2, fill=True, fill_opacity=1.0,
fill_color=_CATEGORY_COLORS.get(cat, _DEFAULT_POI_COLOR),
class_name="dr-poi", tooltip=tooltip,
).add_to(fmap)
if start is not None:
icon = markers.endpoint_icon("start")
folium.Marker(list(start), tooltip="Start",
icon=icon or folium.Icon(color="blue", icon="play")).add_to(fmap)
if end is not None:
icon = markers.endpoint_icon("dest")
folium.Marker(list(end), tooltip="Destination",
icon=icon or folium.Icon(color="red", icon="flag")).add_to(fmap)
_fit_bounds(fmap, all_coords or [c for c in (start, end) if c])
root = fmap.get_root()
root.html.add_child(Element(_LEGEND_HTML))
root.html.add_child(Element(_TILE_WARMTH_CSS))
root.html.add_child(Element(markers.MARKER_CSS))
root.html.add_child(Element(design.MAP_ANIMATION_JS))
return fmap._repr_html_()
def empty_map(message: str = design.EMPTY_STATE_LABEL) -> str:
"""A blank Paris map with a friendly sticker overlay (empty/error state)."""
fmap = folium.Map(location=list(config.PARIS_CENTER), zoom_start=12,
tiles=_TILE_URL, attr=_TILE_ATTR)
overlay = f"""
"""
fmap.get_root().html.add_child(Element(_TILE_WARMTH_CSS))
fmap.get_root().html.add_child(Element(overlay))
return fmap._repr_html_()