""" Pydeck layer factories — shared across Subsystems K (3D terrain & surge), L (cyclone track & probability cone), and M (evacuation flow network). Each function returns a ``pydeck.Layer`` configured with Aegis Command colors (defined in ``src.config``). The module also exposes ``get_map_style()`` which picks between Mapbox dark-v11 (if a token is present in ``.streamlit/secrets.toml``) and the CartoDB dark-matter fallback. """ from __future__ import annotations import pydeck as pdk import pandas as pd import geopandas as gpd from src.config import ( DECK_SEVERITY_COLORS, DECK_COLOR_SHELTER, DECK_COLOR_SCHOOL, DECK_COLOR_SCHOOL_IN_ZONE, DECK_COLOR_TOWER_OK, DECK_COLOR_TOWER_DOWN, DECK_COLOR_ROAD_OK, DECK_COLOR_ROAD_CUT, get_mapbox_token, ) # ─── Basemap Style ──────────────────────────────────────────────────────────── def get_map_style() -> str: """Return a dark basemap style URI. Prefers Mapbox ``dark-v11`` when a token is configured; otherwise falls back to the free CartoDB *dark-matter* style which requires no auth. """ token = get_mapbox_token() if token: return "mapbox://styles/mapbox/dark-v11" return "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json" # ─── 3D Terrain Base ────────────────────────────────────────────────────────── def make_terrain_layer(bounds: list[float]) -> pdk.Layer: """3D terrain using the free Mapzen/AWS Terrarium elevation tiles. Parameters ---------- bounds : [west, south, east, north] Decimal-degree bounding box the terrain is rendered within. """ return pdk.Layer( "TerrainLayer", elevation_decoder={ "rScaler": 256, "gScaler": 1, "bScaler": 1 / 256, "offset": -32768, }, elevation_data="https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png", texture=( "https://server.arcgisonline.com/ArcGIS/rest/services/" "World_Imagery/MapServer/tile/{z}/{y}/{x}" ), bounds=bounds, material={ "ambient": 0.2, "diffuse": 0.6, "shininess": 32, "specularColor": [60, 64, 70], }, ) # ─── Severity Choropleth with Extrusion ────────────────────────────────────── def make_impact_extrusion( impact_geojson: dict, elevation_key: str = "surge_depth_m", elevation_scale: float = 800.0, ) -> pdk.Layer: """Extruded GeoJsonLayer — surge depth drives polygon height. The caller **must** have annotated each feature with numeric ``severity_rgb_r/g/b`` properties (see ``annotate_severity_rgb`` helper in the consuming page). Pydeck resolves the colour expression against those numeric properties. """ return pdk.Layer( "GeoJsonLayer", data=impact_geojson, pickable=True, stroked=True, filled=True, extruded=True, wireframe=True, get_fill_color=( "[properties.severity_rgb_r, properties.severity_rgb_g, " "properties.severity_rgb_b, 140]" ), get_line_color=[148, 163, 184, 200], get_elevation=f"properties.{elevation_key} * {elevation_scale}", line_width_min_pixels=1, ) # ─── Shelter Columns ────────────────────────────────────────────────────────── def make_shelter_columns(shelter_df: pd.DataFrame) -> pdk.Layer: """ColumnLayer — height proportional to shelter capacity. ``shelter_df`` must have columns: ``lon``, ``lat``, ``capacity``, ``name``. """ return pdk.Layer( "ColumnLayer", data=shelter_df, get_position=["lon", "lat"], get_elevation="capacity", elevation_scale=2.5, radius=150, get_fill_color=DECK_COLOR_SHELTER, pickable=True, auto_highlight=True, ) # ─── Schools — Scatterplot (red inside hazard zones) ───────────────────────── def make_school_scatter(school_df: pd.DataFrame) -> pdk.Layer: """ScatterplotLayer — schools coloured cyan (safe) or red (in hazard zone). ``school_df`` must have columns: ``lon``, ``lat``, ``name``, ``in_hazard_zone`` (bool), ``protocol_status`` (str, may be empty). """ school_df = school_df.copy() school_df["color"] = school_df["in_hazard_zone"].apply( lambda x: DECK_COLOR_SCHOOL_IN_ZONE if x else DECK_COLOR_SCHOOL ) return pdk.Layer( "ScatterplotLayer", data=school_df, get_position=["lon", "lat"], get_fill_color="color", get_radius=200, radius_min_pixels=3, radius_max_pixels=10, pickable=True, auto_highlight=True, ) # ─── Cyclone Track Arcs (Subsystem L) ──────────────────────────────────────── def make_track_arcs( track_df: pd.DataFrame, highlight_idx: int | None = None, ) -> list[pdk.Layer]: """Render a cyclone track as coloured arc segments + a pulsing current point. Parameters ---------- track_df : DataFrame with columns ``timestamp``, ``lat``, ``lon``, ``wind_knots``. highlight_idx : int or None — index of the "current" track point. Defaults to the last row. Returns ------- list[pdk.Layer] Two layers: (1) an ArcLayer for segments, (2) a ScatterplotLayer pulse for the current position. Caller spreads these into the deck's layers. """ segs: list[dict] = [] for i in range(len(track_df) - 1): w = track_df.iloc[i].get("wind_knots") or 0 color = _wind_color(float(w)) segs.append({ "from_lon": float(track_df.iloc[i]["lon"]), "from_lat": float(track_df.iloc[i]["lat"]), "to_lon": float(track_df.iloc[i + 1]["lon"]), "to_lat": float(track_df.iloc[i + 1]["lat"]), "color_r": color[0], "color_g": color[1], "color_b": color[2], "width": 2 + (float(w) / 20.0), }) arcs = pdk.Layer( "ArcLayer", data=segs, get_source_position=["from_lon", "from_lat"], get_target_position=["to_lon", "to_lat"], get_source_color="[color_r, color_g, color_b, 200]", get_target_color="[color_r, color_g, color_b, 200]", get_width="width", pickable=False, ) current_pos = ( track_df.iloc[highlight_idx] if highlight_idx is not None else track_df.iloc[-1] ) pulse = pdk.Layer( "ScatterplotLayer", data=[{ "lon": float(current_pos["lon"]), "lat": float(current_pos["lat"]), }], get_position=["lon", "lat"], get_fill_color=[255, 49, 49, 220], get_radius=8000, radius_min_pixels=8, radius_max_pixels=30, pickable=False, ) return [arcs, pulse] def _wind_color(wind_knots: float) -> list[int]: """Saffir-Simpson-style colour ramp: green → yellow → orange → red.""" if wind_knots < 34: return [0, 255, 157] # Tropical Depression if wind_knots < 64: return [255, 184, 0] # Tropical Storm if wind_knots < 83: return [255, 107, 53] # Category 1 if wind_knots < 96: return [255, 70, 30] # Category 2 if wind_knots < 113: return [255, 49, 49] # Category 3 return [200, 0, 60] # Category 4+ # ─── Probability Cone Heatmap (Subsystem L) ────────────────────────────────── def make_probability_heatmap( ensemble_points: pd.DataFrame, weight_key: str = "prob", ) -> pdk.Layer: """HeatmapLayer rendering the ensemble track probability cone. ``ensemble_points`` must have columns: ``lon``, ``lat``, and a numeric weight column (default ``prob``). Suitable input is the flattened 500-member ensemble produced by Part II Subsystem C. """ return pdk.Layer( "HeatmapLayer", data=ensemble_points, get_position=["lon", "lat"], get_weight=weight_key, radiusPixels=60, intensity=1.2, threshold=0.08, color_range=[ [ 0, 240, 255, 0], [ 0, 240, 255, 100], [255, 184, 0, 160], [255, 107, 53, 200], [255, 49, 49, 240], ], ) # ─── Road Network (Subsystem M) ────────────────────────────────────────────── def make_road_paths(road_gdf: gpd.GeoDataFrame) -> pdk.Layer: """PathLayer — roads coloured by passability. ``road_gdf`` must contain LineString geometries and a ``passable`` boolean column. An optional ``highway`` column controls line width (primary/trunk = thicker). """ road_records: list[dict] = [] for _, row in road_gdf.iterrows(): geom = row.geometry if geom is None or geom.is_empty: continue # Support MultiLineString by flattening to individual paths. if geom.geom_type == "MultiLineString": lines = list(geom.geoms) else: lines = [geom] for line in lines: coords = list(line.coords) if len(coords) < 2: continue road_records.append({ "path": [[c[0], c[1]] for c in coords], "color": ( DECK_COLOR_ROAD_OK if row.get("passable", True) else DECK_COLOR_ROAD_CUT ), "width": 2 if row.get("highway", "") in ("primary", "trunk") else 1, }) return pdk.Layer( "PathLayer", data=road_records, get_path="path", get_color="color", get_width="width", width_min_pixels=1, width_max_pixels=4, pickable=False, ) # ─── Evacuation Flow Lines (Subsystem M) ───────────────────────────────────── def make_evacuation_flows(assignments_df: pd.DataFrame) -> pdk.Layer: """LineLayer — population-weighted flows from origin centroids to shelters. ``assignments_df`` columns: ``origin_lon``, ``origin_lat``, ``dest_lon``, ``dest_lat``, ``population``, ``status`` — one of ``accessible`` / ``long`` / ``severed``. """ status_colors = { "accessible": [ 0, 255, 157, 200], "long": [255, 184, 0, 200], "severed": [255, 49, 49, 220], } assignments_df = assignments_df.copy() assignments_df["color"] = assignments_df["status"].apply( lambda s: status_colors.get(s, [148, 163, 184, 180]) ) pop_max = max(float(assignments_df["population"].max() or 1), 1.0) assignments_df["width"] = ( assignments_df["population"].astype(float) / pop_max * 8 + 1 ) return pdk.Layer( "LineLayer", data=assignments_df, get_source_position=["origin_lon", "origin_lat"], get_target_position=["dest_lon", "dest_lat"], get_color="color", get_width="width", pickable=True, ) # ─── Telecom Towers (Warning Dispatch page) ────────────────────────────────── def make_tower_layer(tower_df: pd.DataFrame) -> pdk.Layer: """ScatterplotLayer — telecom towers (green=operational, red=down). ``tower_df``: ``lon``, ``lat``, ``operational`` (bool), ``radio_type``. """ tower_df = tower_df.copy() tower_df["color"] = tower_df["operational"].apply( lambda x: DECK_COLOR_TOWER_OK if x else DECK_COLOR_TOWER_DOWN ) return pdk.Layer( "ScatterplotLayer", data=tower_df, get_position=["lon", "lat"], get_fill_color="color", get_radius=180, radius_min_pixels=2, radius_max_pixels=6, pickable=True, ) # ─── Severity RGB Annotation Helper ────────────────────────────────────────── def annotate_severity_rgb(geojson: dict) -> dict: """Inject numeric ``severity_rgb_{r,g,b}`` properties into each feature. Pydeck's JSON config evaluates ``get_fill_color`` expressions against numeric feature properties more reliably than string severity labels. This helper mutates and returns the same GeoJSON object. """ for feat in geojson.get("features", []): sev = feat.get("properties", {}).get("severity", "UNAFFECTED") r, g, b, _ = DECK_SEVERITY_COLORS.get(sev, DECK_SEVERITY_COLORS["UNAFFECTED"]) feat["properties"]["severity_rgb_r"] = r feat["properties"]["severity_rgb_g"] = g feat["properties"]["severity_rgb_b"] = b feat["properties"].setdefault("surge_depth_m", 0.0) return geojson