Skip to content

Commit 3c54370

Browse files
committed
feat: Add interactive 3D visualization script for the alerts analysis using deck.gl
1 parent 4205efa commit 3c54370

2 files changed

Lines changed: 515 additions & 0 deletions

File tree

alert-analysis/visualizations/alert_map_3d.html

Lines changed: 217 additions & 0 deletions
Large diffs are not rendered by default.

alert-analysis/visualize_alerts.py

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
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 &bull; Oct 2023 – Sep 2025 &bull; {num_alerts:,} alerts</div>
162+
</div>
163+
164+
<div id="controls">
165+
Drag to rotate &bull; 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

Comments
 (0)