|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Interactive 3D map of rocket alerts using deck.gl. |
| 4 | +Features ESRI satellite basemap, extruded hexagons, and convex hull outline. |
| 5 | +""" |
| 6 | + |
| 7 | +import json |
| 8 | +import pandas as pd |
| 9 | + |
| 10 | +# ============================================================================= |
| 11 | +# LOAD DATA |
| 12 | +# ============================================================================= |
| 13 | +print("Loading data...") |
| 14 | + |
| 15 | +with open('./alerts-since-oct7-2023-filtered.geojson', 'r', encoding='utf-8') as f: |
| 16 | + alerts_data = json.load(f) |
| 17 | + |
| 18 | +with open('./convex_hull.geojson', 'r', encoding='utf-8') as f: |
| 19 | + hull_data = json.load(f) |
| 20 | + |
| 21 | +# Extract alert points |
| 22 | +points = [] |
| 23 | +for feature in alerts_data['features']: |
| 24 | + coords = feature['geometry']['coordinates'] |
| 25 | + props = feature['properties'] |
| 26 | + points.append({ |
| 27 | + 'lon': coords[0], |
| 28 | + 'lat': coords[1], |
| 29 | + 'city': props.get('city_name_en', ''), |
| 30 | + 'zone': props.get('zone', ''), |
| 31 | + 'date': props.get('date', '')[:10] if props.get('date') else '' |
| 32 | + }) |
| 33 | + |
| 34 | +df = pd.DataFrame(points) |
| 35 | +print(f"Loaded {len(df)} alerts") |
| 36 | + |
| 37 | +# Get convex hull coordinates |
| 38 | +hull_coords = hull_data['features'][0]['geometry']['coordinates'][0] |
| 39 | +print(f"Loaded convex hull with {len(hull_coords)} vertices") |
| 40 | + |
| 41 | +# Calculate center |
| 42 | +center_lon = df['lon'].mean() |
| 43 | +center_lat = df['lat'].mean() |
| 44 | + |
| 45 | +# Pre-calculate hexagon bin counts for legend |
| 46 | +import numpy as np |
| 47 | +from collections import Counter |
| 48 | + |
| 49 | +# Simple hex binning to get min/max counts |
| 50 | +hex_size = 0.015 # degrees, roughly matches 1500m radius |
| 51 | +df['hex_x'] = (df['lon'] / hex_size).astype(int) |
| 52 | +df['hex_y'] = (df['lat'] / hex_size).astype(int) |
| 53 | +df['hex_key'] = df['hex_x'].astype(str) + '_' + df['hex_y'].astype(str) |
| 54 | +hex_counts = df['hex_key'].value_counts() |
| 55 | +min_count = int(hex_counts.min()) |
| 56 | +max_count = int(hex_counts.max()) |
| 57 | +print(f"Hex bin range: {min_count} - {max_count} alerts per cell") |
| 58 | + |
| 59 | +# ============================================================================= |
| 60 | +# CUSTOM HTML WITH SATELLITE BASEMAP |
| 61 | +# ============================================================================= |
| 62 | +html_template = """ |
| 63 | +<!DOCTYPE html> |
| 64 | +<html> |
| 65 | +<head> |
| 66 | + <meta charset="utf-8"> |
| 67 | + <title>Rocket Alerts - 3D Visualization</title> |
| 68 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 69 | + <script src="https://unpkg.com/deck.gl@8.9.0/dist.min.js"></script> |
| 70 | + <script src="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.js"></script> |
| 71 | + <link href="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.css" rel="stylesheet" /> |
| 72 | + <style> |
| 73 | + body {{ margin: 0; padding: 0; font-family: 'Segoe UI', Arial, sans-serif; }} |
| 74 | + #map {{ position: absolute; top: 0; left: 0; right: 0; bottom: 0; }} |
| 75 | +
|
| 76 | + /* Legend */ |
| 77 | + #legend {{ |
| 78 | + position: absolute; |
| 79 | + bottom: 24px; |
| 80 | + right: 24px; |
| 81 | + background: rgba(10, 10, 20, 0.92); |
| 82 | + border-radius: 10px; |
| 83 | + padding: 18px 22px; |
| 84 | + color: white; |
| 85 | + font-size: 14px; |
| 86 | + box-shadow: 0 4px 20px rgba(0,0,0,0.6); |
| 87 | + border: 1px solid rgba(255,255,255,0.15); |
| 88 | + min-width: 200px; |
| 89 | + }} |
| 90 | + #legend h3 {{ |
| 91 | + margin: 0 0 12px 0; |
| 92 | + font-size: 16px; |
| 93 | + font-weight: 600; |
| 94 | + }} |
| 95 | + .gradient-bar {{ |
| 96 | + height: 16px; |
| 97 | + border-radius: 3px; |
| 98 | + background: linear-gradient(to right, #fee5d9, #fcbba1, #fc9272, #fb6a4a, #ef3b2c, #cb181d, #99000d); |
| 99 | + margin-bottom: 6px; |
| 100 | + }} |
| 101 | + .gradient-labels {{ |
| 102 | + display: flex; |
| 103 | + justify-content: space-between; |
| 104 | + font-size: 13px; |
| 105 | + color: #ccc; |
| 106 | + margin-bottom: 14px; |
| 107 | + font-weight: 500; |
| 108 | + }} |
| 109 | + .legend-item {{ |
| 110 | + display: flex; |
| 111 | + align-items: center; |
| 112 | + font-size: 13px; |
| 113 | + color: #bbb; |
| 114 | + margin-top: 8px; |
| 115 | + }} |
| 116 | + .hull-line {{ |
| 117 | + height: 3px; |
| 118 | + width: 20px; |
| 119 | + background: rgba(255,255,255,0.85); |
| 120 | + margin-right: 10px; |
| 121 | + border-radius: 1px; |
| 122 | + }} |
| 123 | +
|
| 124 | + /* Title */ |
| 125 | + #title {{ |
| 126 | + position: absolute; |
| 127 | + top: 24px; |
| 128 | + left: 24px; |
| 129 | + color: white; |
| 130 | + text-shadow: 0 2px 10px rgba(0,0,0,0.9); |
| 131 | + }} |
| 132 | + #title h1 {{ |
| 133 | + margin: 0; |
| 134 | + font-size: 32px; |
| 135 | + font-weight: 700; |
| 136 | + }} |
| 137 | + #title .meta {{ |
| 138 | + font-size: 14px; |
| 139 | + color: #ddd; |
| 140 | + margin-top: 6px; |
| 141 | + }} |
| 142 | +
|
| 143 | + /* Controls hint */ |
| 144 | + #controls {{ |
| 145 | + position: absolute; |
| 146 | + top: 24px; |
| 147 | + right: 24px; |
| 148 | + background: rgba(10, 10, 20, 0.85); |
| 149 | + border-radius: 6px; |
| 150 | + padding: 10px 14px; |
| 151 | + color: #aaa; |
| 152 | + font-size: 12px; |
| 153 | + }} |
| 154 | + </style> |
| 155 | +</head> |
| 156 | +<body> |
| 157 | + <div id="map"></div> |
| 158 | +
|
| 159 | + <div id="title"> |
| 160 | + <h1>ROCKET ALERTS</h1> |
| 161 | + <div class="meta">Study Area • Oct 2023 – Sep 2025 • {num_alerts:,} alerts</div> |
| 162 | + </div> |
| 163 | +
|
| 164 | + <div id="controls"> |
| 165 | + Drag to rotate • Scroll to zoom |
| 166 | + </div> |
| 167 | +
|
| 168 | + <div id="legend"> |
| 169 | + <h3>Alert Density</h3> |
| 170 | + <div class="gradient-bar"></div> |
| 171 | + <div class="gradient-labels"> |
| 172 | + <span>{min_count}</span> |
| 173 | + <span>{max_count}</span> |
| 174 | + </div> |
| 175 | + <div class="legend-item"> |
| 176 | + <div class="hull-line"></div> |
| 177 | + <span>Study area boundary</span> |
| 178 | + </div> |
| 179 | + <div class="legend-item" style="color: #999;"> |
| 180 | + Height = alert count |
| 181 | + </div> |
| 182 | + </div> |
| 183 | +
|
| 184 | + <script> |
| 185 | + const alertData = {alert_data_json}; |
| 186 | + const hullCoords = {hull_coords_json}; |
| 187 | +
|
| 188 | + // Satellite style using ESRI World Imagery |
| 189 | + const satelliteStyle = {{ |
| 190 | + version: 8, |
| 191 | + sources: {{ |
| 192 | + 'esri-satellite': {{ |
| 193 | + type: 'raster', |
| 194 | + tiles: [ |
| 195 | + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{{z}}/{{y}}/{{x}}' |
| 196 | + ], |
| 197 | + tileSize: 256, |
| 198 | + attribution: 'ESRI World Imagery' |
| 199 | + }} |
| 200 | + }}, |
| 201 | + layers: [{{ |
| 202 | + id: 'satellite-layer', |
| 203 | + type: 'raster', |
| 204 | + source: 'esri-satellite', |
| 205 | + minzoom: 0, |
| 206 | + maxzoom: 19 |
| 207 | + }}] |
| 208 | + }}; |
| 209 | +
|
| 210 | + // Create MapLibre map with satellite |
| 211 | + const map = new maplibregl.Map({{ |
| 212 | + container: 'map', |
| 213 | + style: satelliteStyle, |
| 214 | + center: [{center_lon}, {center_lat}], |
| 215 | + zoom: 9.5, |
| 216 | + pitch: 55, |
| 217 | + bearing: 20, |
| 218 | + antialias: true |
| 219 | + }}); |
| 220 | +
|
| 221 | + map.on('load', () => {{ |
| 222 | + // Create deck.gl overlay |
| 223 | + const deckOverlay = new deck.MapboxOverlay({{ |
| 224 | + layers: [ |
| 225 | + new deck.HexagonLayer({{ |
| 226 | + id: 'hexagon-layer', |
| 227 | + data: alertData, |
| 228 | + getPosition: d => [d.lon, d.lat], |
| 229 | + radius: 1500, |
| 230 | + elevationScale: 20, |
| 231 | + elevationRange: [0, 2500], |
| 232 | + extruded: true, |
| 233 | + coverage: 0.82, |
| 234 | + pickable: true, |
| 235 | + autoHighlight: true, |
| 236 | + colorRange: [ |
| 237 | + [254, 229, 217], |
| 238 | + [252, 174, 145], |
| 239 | + [251, 106, 74], |
| 240 | + [222, 45, 38], |
| 241 | + [165, 15, 21] |
| 242 | + ], |
| 243 | + material: {{ |
| 244 | + ambient: 0.64, |
| 245 | + diffuse: 0.6, |
| 246 | + shininess: 32 |
| 247 | + }} |
| 248 | + }}), |
| 249 | + new deck.PathLayer({{ |
| 250 | + id: 'hull-layer', |
| 251 | + data: [{{ path: hullCoords }}], |
| 252 | + getPath: d => d.path, |
| 253 | + getColor: [255, 255, 255, 200], |
| 254 | + getWidth: 100, |
| 255 | + widthMinPixels: 2, |
| 256 | + widthMaxPixels: 4, |
| 257 | + capRounded: true, |
| 258 | + jointRounded: true |
| 259 | + }}) |
| 260 | + ], |
| 261 | + getTooltip: ({{object}}) => object && object.points && {{ |
| 262 | + html: `<div style="padding:6px;font-family:Arial;font-size:12px;"> |
| 263 | + <b>${{object.points.length}} alerts</b> |
| 264 | + </div>`, |
| 265 | + style: {{ |
| 266 | + backgroundColor: 'rgba(20,20,30,0.9)', |
| 267 | + color: 'white', |
| 268 | + borderRadius: '4px' |
| 269 | + }} |
| 270 | + }} |
| 271 | + }}); |
| 272 | +
|
| 273 | + map.addControl(deckOverlay); |
| 274 | + map.addControl(new maplibregl.NavigationControl(), 'bottom-left'); |
| 275 | + }}); |
| 276 | + </script> |
| 277 | +</body> |
| 278 | +</html> |
| 279 | +""" |
| 280 | + |
| 281 | +# Format the HTML |
| 282 | +html_content = html_template.format( |
| 283 | + num_alerts=len(df), |
| 284 | + center_lon=center_lon, |
| 285 | + center_lat=center_lat, |
| 286 | + min_count=min_count, |
| 287 | + max_count=max_count, |
| 288 | + alert_data_json=df.to_json(orient='records'), |
| 289 | + hull_coords_json=json.dumps(hull_coords) |
| 290 | +) |
| 291 | + |
| 292 | +# Save HTML |
| 293 | +output_file = './visualizations/alert_map_3d.html' |
| 294 | +with open(output_file, 'w', encoding='utf-8') as f: |
| 295 | + f.write(html_content) |
| 296 | + |
| 297 | +print(f"\n✓ Map saved to: {output_file}") |
| 298 | +print(" Open in browser, adjust view, then screenshot") |
0 commit comments