home
readme
diff
tree
note
docs
0commit e54ab9296884ecf785ca3d0af4b0a04876e41bec
1Author: lakefox <mason@lakefox.net>
2Date: Sat May 23 12:22:36 2026 -0600
3
4 Test
5
6diff --git a/.index.html.swp b/.index.html.swp
7new file mode 100644
8index 0000000..23ab6e2
9Binary files /dev/null and b/.index.html.swp differ
10diff --git a/index.html b/index.html
11new file mode 100644
12index 0000000..998472f
13--- /dev/null
14+++ b/index.html
15@@ -0,0 +1,2295 @@
16+<!DOCTYPE html>
17+<html lang="en">
18+<head>
19+ <meta charset="UTF-8">
20+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
21+ <title>NexStorm - Tactical Storm Intercept HUD & Radar Terminal</title>
22+
23+ <!-- Leaflet Map CSS & JS -->
24+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
25+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
26+
27+ <!-- Tailwind CSS -->
28+ <script src="https://cdn.tailwindcss.com"></script>
29+
30+ <!-- Font Awesome Icons -->
31+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
32+
33+ <!-- Google Fonts (Inter & Orbitron for Tech Dashboard) -->
34+ <link rel="preconnect" href="https://fonts.googleapis.com">
35+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
36+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@500;700;900&display=swap" rel="stylesheet">
37+
38+ <!-- Tone.js for client-side procedural alert sounds -->
39+ <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
40+
41+ <style>
42+ body {
43+ font-family: 'Inter', sans-serif;
44+ background-color: #030712;
45+ overflow-x: hidden;
46+ transition: filter 0.3s ease;
47+ }
48+
49+ .tech-font {
50+ font-family: 'Orbitron', sans-serif;
51+ }
52+
53+ /* Tactical HUD Night Vision Mode (Grayscale + Monochromatic Red/Orange preserve night adaptation) */
54+ .night-vision-mode {
55+ filter: grayscale(100%) brightness(55%) sepia(100%) hue-rotate(-50deg) saturate(600%) !important;
56+ }
57+
58+ /* Custom Scrollbar */
59+ ::-webkit-scrollbar {
60+ width: 6px;
61+ height: 6px;
62+ }
63+ ::-webkit-scrollbar-track {
64+ background: #0b0f19;
65+ }
66+ ::-webkit-scrollbar-thumb {
67+ background: #374151;
68+ border-radius: 4px;
69+ }
70+ ::-webkit-scrollbar-thumb:hover {
71+ background: #4b5563;
72+ }
73+
74+ /* Leaflet Dark Popup Customization */
75+ .leaflet-popup-content-wrapper {
76+ background: rgba(17, 24, 39, 0.95) !important;
77+ color: #f3f4f6 !important;
78+ border: 1px solid rgba(75, 85, 99, 0.4) !important;
79+ backdrop-filter: blur(8px);
80+ border-radius: 8px !important;
81+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5) !important;
82+ }
83+ .leaflet-popup-tip {
84+ background: rgba(17, 24, 39, 0.95) !important;
85+ border: 1px solid rgba(75, 85, 99, 0.4) !important;
86+ }
87+ .leaflet-container {
88+ background-color: #0c0f1d !important;
89+ }
90+
91+ /* Pulsing Alert Keyframes */
92+ @keyframes alert-pulse {
93+ 0%, 100% { border-color: rgba(239, 68, 68, 0.4); box-shadow: 0 0 0 0px rgba(239, 68, 68, 0.2); }
94+ 50% { border-color: rgba(239, 68, 68, 1); box-shadow: 0 0 12px 4px rgba(239, 68, 68, 0.4); }
95+ }
96+ .pulse-severe {
97+ animation: alert-pulse 1.8s infinite;
98+ }
99+
100+ @keyframes scanning {
101+ 0% { transform: rotate(0deg); }
102+ 100% { transform: rotate(360deg); }
103+ }
104+ .radar-scan {
105+ animation: scanning 4s linear infinite;
106+ }
107+
108+ .custom-gps-marker {
109+ background: none;
110+ border: none;
111+ }
112+
113+ .custom-metar-marker {
114+ background: none;
115+ border: none;
116+ }
117+ </style>
118+</head>
119+<body class="text-slate-100 flex flex-col h-screen overflow-hidden">
120+
121+ <!-- Toast Notifications Container -->
122+ <div id="toast-container" class="fixed top-20 right-4 z-[3000] flex flex-col gap-2 pointer-events-none"></div>
123+
124+ <header class="bg-slate-900/90 border-b border-slate-800 backdrop-blur-md px-6 py-3 flex items-center justify-between z-[1000] shrink-0">
125+ <div class="flex items-center space-x-3">
126+ <div class="relative w-10 h-10 flex items-center justify-center bg-sky-950/80 rounded-lg border border-sky-500/30">
127+ <i class="fa-solid fa-satellite-dish text-sky-400 text-lg z-10"></i>
128+ <div class="absolute inset-0 border-2 border-dashed border-sky-400/20 rounded-lg radar-scan"></div>
129+ </div>
130+ <div>
131+ <h1 class="text-md font-bold tracking-wider uppercase text-slate-100 flex items-center gap-2">
132+ NEXSTORM <span class="text-[10px] bg-red-600/30 text-red-400 px-2 py-0.5 rounded border border-red-500/20 uppercase font-mono tracking-widest animate-pulse">Tactical HUD</span>
133+ </h1>
134+ <p class="text-[10px] text-slate-400 uppercase tracking-widest font-mono">Storm Intercept Operations Console</p>
135+ </div>
136+ </div>
137+
138+ <!-- System Stats Bar -->
139+ <div class="hidden lg:flex items-center space-x-6 text-xs font-mono border-l border-r border-slate-800 px-6 py-1">
140+ <div class="flex items-center gap-2">
141+ <span class="w-2.5 h-2.5 rounded-full bg-red-500 animate-ping"></span>
142+ <span>TORNADO: <span id="stat-tor" class="text-red-400 font-bold">0</span></span>
143+ </div>
144+ <div class="flex items-center gap-2">
145+ <span class="w-2.5 h-2.5 rounded-full bg-yellow-500"></span>
146+ <span>SVR TSTM: <span id="stat-svr" class="text-yellow-400 font-bold">0</span></span>
147+ </div>
148+ <div class="flex items-center gap-2">
149+ <span class="w-2.5 h-2.5 rounded-full bg-emerald-500"></span>
150+ <span>FLASH FLOOD: <span id="stat-ff" class="text-emerald-400 font-bold">0</span></span>
151+ </div>
152+ </div>
153+
154+ <!-- Search & Advanced Tactical Controls -->
155+ <div class="flex items-center space-x-3">
156+ <!-- Tactical Night Vision Mode Trigger -->
157+ <button id="btn-night-vision" title="Toggle Red Light Tactical HUD Mode" class="bg-slate-800 hover:bg-slate-700 active:scale-95 border border-slate-700/50 p-2 rounded-lg text-rose-500 hover:text-rose-400 transition-all">
158+ <i class="fa-solid fa-eye-low-vision text-sm"></i>
159+ <span class="hidden xl:inline ml-1 font-mono text-xs">Night Ops</span>
160+ </button>
161+
162+ <div class="relative flex items-center">
163+ <input id="search-input" type="text" placeholder="Search Intercept Target..."
164+ class="bg-slate-950 border border-slate-800 hover:border-slate-700 focus:border-sky-500 rounded-lg px-3 py-1.5 text-xs w-40 md:w-56 transition-all focus:outline-none text-slate-200 pl-8 font-mono">
165+ <i class="fa-solid fa-magnifying-glass absolute left-3 text-[10px] text-slate-500"></i>
166+ <button id="search-btn" class="absolute right-1 text-[10px] bg-slate-800 hover:bg-slate-700 px-2 py-1 rounded text-slate-300">GO</button>
167+ </div>
168+ <button id="btn-gps" title="Toggle GPS Tracking Marker" class="bg-slate-800 hover:bg-slate-700 active:scale-95 border border-slate-700/50 p-2 rounded-lg text-slate-300 hover:text-sky-400 transition-all">
169+ <i class="fa-solid fa-location-crosshairs"></i>
170+ </button>
171+ <button id="btn-telemetry-toggle" title="Toggle Dashboard Telemetry Panel" class="bg-slate-800 hover:bg-slate-700 active:scale-95 border border-slate-700/50 px-3 py-2 rounded-lg text-slate-300 hover:text-orange-400 transition-all text-xs flex items-center gap-1.5">
172+ <i class="fa-solid fa-gauge"></i>
173+ <span class="hidden md:inline font-mono">Dashboard</span>
174+ </button>
175+ </div>
176+ </header>
177+
178+ <main class="flex flex-1 overflow-hidden relative">
179+
180+ <!-- Left Panel: Severe Alerts feed & Tactical Options -->
181+ <aside id="alert-sidebar" class="absolute md:relative inset-y-0 left-0 w-80 bg-slate-900/95 md:bg-slate-950/80 border-r border-slate-800 backdrop-blur-md flex flex-col z-[1000] transition-all duration-300 transform -translate-x-full md:translate-x-0">
182+ <!-- Sidebar Header -->
183+ <div class="p-4 border-b border-slate-800 flex items-center justify-between">
184+ <h2 class="text-xs font-bold tracking-widest text-slate-400 uppercase font-mono">Warning Stream</h2>
185+ <div class="flex items-center space-x-2">
186+ <button id="sound-toggle" class="bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-400 px-2 py-1 rounded text-[10px] flex items-center gap-1 transition-all" title="Toggle audio alarms">
187+ <i id="sound-icon" class="fa-solid fa-volume-mute text-slate-500"></i>
188+ <span id="sound-text">Mute Audio</span>
189+ </button>
190+ <!-- Close button for mobile -->
191+ <button class="md:hidden text-slate-400 hover:text-slate-100" onclick="toggleSidebar()">
192+ <i class="fa-solid fa-times"></i>
193+ </button>
194+ </div>
195+ </div>
196+
197+ <!-- Warning Feed Stream -->
198+ <div id="alerts-container" class="flex-1 overflow-y-auto p-3 space-y-2">
199+ <div class="flex flex-col items-center justify-center h-48 text-center px-4">
200+ <i class="fa-solid fa-triangle-exclamation text-slate-600 text-2xl mb-2 animate-pulse"></i>
201+ <p class="text-xs text-slate-400 font-mono">Acquiring NWS alert grids...</p>
202+ </div>
203+ </div>
204+
205+ <!-- Footer: Global Radar Palette Info -->
206+ <div class="p-4 border-t border-slate-800 bg-slate-950/50">
207+ <div class="flex justify-between text-[10px] text-slate-400 uppercase font-mono mb-2">
208+ <span>Quick Filter Streams</span>
209+ <span id="warning-total-lbl">0 Total</span>
210+ </div>
211+ <div class="grid grid-cols-3 gap-1">
212+ <button onclick="filterWarnings('Tornado')" class="p-1 rounded bg-red-950/40 border border-red-900/40 text-[9px] font-mono hover:bg-red-900/30 text-red-400 text-center">TOR</button>
213+ <button onclick="filterWarnings('Severe')" class="p-1 rounded bg-yellow-950/40 border border-yellow-900/40 text-[9px] font-mono hover:bg-yellow-900/30 text-yellow-400 text-center">SVR</button>
214+ <button onclick="filterWarnings('Flood')" class="p-1 rounded bg-emerald-950/40 border border-emerald-900/40 text-[9px] font-mono hover:bg-emerald-900/30 text-emerald-400 text-center">FFW</button>
215+ </div>
216+ </div>
217+ </aside>
218+
219+ <!-- Center Map & Overlays -->
220+ <section class="flex-1 flex flex-col relative h-full">
221+
222+ <!-- Floating Mobile Sidebar Toggles -->
223+ <div class="absolute top-4 left-4 z-[999] flex gap-2">
224+ <button onclick="toggleSidebar()" class="md:hidden bg-slate-900/90 border border-slate-800 px-3 py-2 rounded-lg text-slate-300">
225+ <i class="fa-solid fa-bars mr-1"></i> <span class="text-xs font-mono">Alerts</span>
226+ </button>
227+ <button onclick="toggleTelemetry()" class="md:hidden bg-slate-900/90 border border-slate-800 px-3 py-2 rounded-lg text-slate-300">
228+ <i class="fa-solid fa-gauge mr-1"></i> <span class="text-xs font-mono">HUD</span>
229+ </button>
230+ </div>
231+
232+ <!-- Map Coordinate Intercept Planning Overlay Tools -->
233+ <div class="absolute top-16 left-4 z-[999] bg-slate-900/90 border border-slate-800 p-2 rounded-lg flex flex-col gap-2 shadow-xl pointer-events-auto">
234+ <button id="btn-draw-planner" title="Initiate Cell Intercept Vector Plotter" class="bg-slate-950 hover:bg-slate-800 active:scale-95 border border-slate-800 p-2 rounded text-slate-300 hover:text-red-400 transition-all flex items-center gap-1.5 text-xs font-mono">
235+ <i class="fa-solid fa-drafting-compass"></i>
236+ <span>Plot Intercept Vector</span>
237+ </button>
238+ <div id="planner-status-bar" class="hidden text-[8px] font-mono text-red-400 uppercase animate-pulse text-center">
239+ [Drawing Path...]
240+ </div>
241+ </div>
242+
243+ <!-- Leaflet Map Element -->
244+ <div id="map" class="w-full flex-1 z-[1]"></div>
245+
246+ <!-- BOTTOM HUD INTERACTIVE COMMAND BLOCK -->
247+ <div class="absolute bottom-4 left-1/2 -translate-x-1/2 w-[92%] max-w-5xl bg-slate-900/95 border border-slate-800 backdrop-blur-md rounded-xl p-4 shadow-2xl z-[999] flex flex-col xl:flex-row gap-4 justify-between items-center">
248+
249+ <!-- Playback controls -->
250+ <div class="flex items-center space-x-3 w-full xl:w-auto justify-between xl:justify-start">
251+ <div class="flex items-center space-x-1">
252+ <button id="btn-loop-prev" class="bg-slate-800 hover:bg-slate-700 active:scale-95 text-slate-300 w-8 h-8 rounded-lg flex items-center justify-center transition-all">
253+ <i class="fa-solid fa-backward-step"></i>
254+ </button>
255+ <button id="btn-loop-play" class="bg-sky-600 hover:bg-sky-500 active:scale-95 text-white w-10 h-10 rounded-lg flex items-center justify-center shadow-lg shadow-sky-900/30 transition-all">
256+ <i id="play-icon" class="fa-solid fa-play"></i>
257+ </button>
258+ <button id="btn-loop-next" class="bg-slate-800 hover:bg-slate-700 active:scale-95 text-slate-300 w-8 h-8 rounded-lg flex items-center justify-center transition-all">
259+ <i class="fa-solid fa-forward-step"></i>
260+ </button>
261+ </div>
262+
263+ <div class="flex flex-col">
264+ <span id="timeline-time" class="text-xs font-mono font-bold tracking-widest text-sky-400">Loading Radar Loop...</span>
265+ <span id="timeline-status" class="text-[9px] uppercase tracking-widest text-slate-500 font-mono">NEXRAD Real-time feed</span>
266+ </div>
267+ </div>
268+
269+ <!-- Timeline Slider -->
270+ <div class="flex-1 w-full flex items-center space-x-3">
271+ <span class="text-[9px] font-mono text-slate-500">PAST</span>
272+ <input id="timeline-slider" type="range" min="0" max="7" value="7" class="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-sky-500 focus:outline-none">
273+ <span class="text-[9px] font-mono text-sky-400">LIVE</span>
274+ </div>
275+
276+ <!-- Multi-Column Command Suite (Radar, Clouds, SPC Convective, METAR Winds) -->
277+ <div class="grid grid-cols-2 md:grid-cols-4 gap-3 w-full xl:w-auto border-t xl:border-t-0 border-slate-800 pt-3 xl:pt-0">
278+ <!-- Column 1: Radar Layer -->
279+ <div class="flex flex-col space-y-1">
280+ <label class="text-[8px] uppercase font-mono text-slate-500">NEXRAD Layer</label>
281+ <select id="layer-select" class="bg-slate-950 text-slate-300 text-[10px] rounded border border-slate-800 py-1 px-1.5 focus:outline-none focus:border-sky-500 w-full font-mono">
282+ <option value="nexrad-n0r-900913" selected>Base Reflectivity (N0R)</option>
283+ <option value="nexrad-n0q-900913">Hi-Res Reflectivity (N0Q)</option>
284+ <option value="nexrad-n1p-900913">Storm Precip (N1P)</option>
285+ </select>
286+ <div class="flex justify-between text-[8px] uppercase font-mono text-slate-500">
287+ <span>Opacity:</span>
288+ <span id="opacity-val">80%</span>
289+ </div>
290+ <input id="radar-opacity" type="range" min="0" max="100" value="80" class="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-sky-500">
291+ </div>
292+
293+ <!-- Column 2: NASA Clouds -->
294+ <div class="flex flex-col space-y-1">
295+ <label class="text-[8px] uppercase font-mono text-slate-500">NASA Satellite</label>
296+ <select id="cloud-select" class="bg-slate-950 text-slate-300 text-[10px] rounded border border-slate-800 py-1 px-1.5 focus:outline-none focus:border-emerald-500 w-full font-mono">
297+ <option value="none">Disabled</option>
298+ <option value="goes-east" selected>GOES-East IR</option>
299+ <option value="goes-west">GOES-West IR</option>
300+ <option value="both">Both (East/West)</option>
301+ </select>
302+ <div class="flex justify-between text-[8px] uppercase font-mono text-slate-500">
303+ <span>Opacity:</span>
304+ <span id="cloud-opacity-val">45%</span>
305+ </div>
306+ <input id="cloud-opacity" type="range" min="0" max="100" value="45" class="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-emerald-500">
307+ </div>
308+
309+ <!-- Column 3: SPC Convective Outlook -->
310+ <div class="flex flex-col space-y-2 bg-slate-950/40 p-1.5 rounded border border-slate-800/40">
311+ <span class="text-[8px] uppercase font-mono text-slate-500">SPC Outlook</span>
312+ <div class="flex items-center gap-1.5">
313+ <input type="checkbox" id="chk-spc" class="accent-orange-500 rounded cursor-pointer">
314+ <label for="chk-spc" class="text-[10px] font-mono text-slate-300 cursor-pointer">Day 1 Convective</label>
315+ </div>
316+ <span id="spc-status-lbl" class="text-[7px] font-mono text-slate-500 uppercase">OFFLINE</span>
317+ </div>
318+
319+ <!-- Column 4: Local METAR Winds -->
320+ <div class="flex flex-col space-y-2 bg-slate-950/40 p-1.5 rounded border border-slate-800/40">
321+ <span class="text-[8px] uppercase font-mono text-slate-500">METAR winds</span>
322+ <div class="flex items-center gap-1.5">
323+ <input type="checkbox" id="chk-metar" class="accent-teal-500 rounded cursor-pointer">
324+ <label for="chk-metar" class="text-[10px] font-mono text-slate-300 cursor-pointer">Surface Stations</label>
325+ </div>
326+ <span id="metar-status-lbl" class="text-[7px] font-mono text-slate-500 uppercase">OFFLINE</span>
327+ </div>
328+ </div>
329+ </div>
330+
331+ <!-- Legend & Floating Overlays Container (Top Right) -->
332+ <div id="right-map-widgets-container" class="absolute top-4 right-4 z-[999] flex flex-col gap-3 w-80 max-h-[75vh] overflow-y-auto pointer-events-none">
333+ <!-- Legend Overlay -->
334+ <div class="bg-slate-900/90 border border-slate-800 backdrop-blur-md rounded-xl p-3 shadow-2xl w-full hidden sm:block pointer-events-auto">
335+ <div class="flex justify-between items-center text-[10px] font-mono text-slate-400 mb-1.5 uppercase tracking-widest">
336+ <span>Reflectivity Scale</span>
337+ <span>dBZ</span>
338+ </div>
339+ <!-- dBZ Gradient bar representing standard weather color scales -->
340+ <div class="h-3 w-full rounded flex overflow-hidden">
341+ <div style="background-color: #00ffff;" class="flex-1" title="5 dBZ"></div>
342+ <div style="background-color: #04bcfc;" class="flex-1" title="15 dBZ"></div>
343+ <div style="background-color: #0100f8;" class="flex-1" title="25 dBZ"></div>
344+ <div style="background-color: #00e000;" class="flex-1" title="30 dBZ"></div>
345+ <div style="background-color: #00a000;" class="flex-1" title="35 dBZ"></div>
346+ <div style="background-color: #006000;" class="flex-1" title="40 dBZ"></div>
347+ <div style="background-color: #ffff00;" class="flex-1" title="45 dBZ"></div>
348+ <div style="background-color: #e0a000;" class="flex-1" title="50 dBZ"></div>
349+ <div style="background-color: #ff0000;" class="flex-1" title="55 dBZ"></div>
350+ <div style="background-color: #c00000;" class="flex-1" title="60 dBZ"></div>
351+ <div style="background-color: #800000;" class="flex-1" title="65 dBZ"></div>
352+ <div style="background-color: #ff00ff;" class="flex-1" title="70 dBZ"></div>
353+ <div style="background-color: #990099;" class="flex-1" title="75 dBZ"></div>
354+ </div>
355+ <div class="flex justify-between text-[8px] font-mono text-slate-500 mt-1 uppercase">
356+ <span>Rain (5)</span>
357+ <span>Storm (35)</span>
358+ <span>Severe (50)</span>
359+ <span>Hail (75)</span>
360+ </div>
361+
362+ <!-- SPC Color scale overlay details (Visible if checked) -->
363+ <div id="spc-legend-hud" class="hidden border-t border-slate-800/60 mt-2.5 pt-2">
364+ <div class="text-[9px] font-mono text-slate-400 uppercase tracking-widest mb-1">SPC Risk Levels</div>
365+ <div class="grid grid-cols-6 gap-0.5 text-[8px] font-mono font-bold text-center text-slate-900">
366+ <div style="background-color: #c0dec0;" class="rounded-sm" title="Thunderstorm">TSTM</div>
367+ <div style="background-color: #79a579;" class="rounded-sm text-slate-100" title="Marginal">MRGL</div>
368+ <div style="background-color: #f7f779;" class="rounded-sm" title="Slight">SLGT</div>
369+ <div style="background-color: #f7b05b;" class="rounded-sm" title="Enhanced">ENH</div>
370+ <div style="background-color: #e55b5b;" class="rounded-sm text-slate-100" title="Moderate">MDT</div>
371+ <div style="background-color: #f75bf7;" class="rounded-sm" title="High">HIGH</div>
372+ </div>
373+ </div>
374+ </div>
375+
376+ <!-- Floating Telemetry Container (under Legend, visible when sidebar is collapsed) -->
377+ <div id="floating-telemetry-box" class="flex-col gap-3 hidden pointer-events-auto">
378+ <!-- Dynamic floating layout nodes -->
379+ </div>
380+ </div>
381+ </section>
382+
383+ <!-- Right Panel: Telemetry Dashboard, Barometer Graph, and Live Dashcam Feed -->
384+ <aside id="telemetry-sidebar" class="absolute inset-y-0 right-0 w-80 bg-slate-900/95 md:bg-slate-950/80 border-l border-slate-800 backdrop-blur-md flex flex-col z-[1000] transition-all duration-300 transform translate-x-full md:translate-x-0">
385+ <div class="p-4 border-b border-slate-800 flex items-center justify-between">
386+ <div class="flex items-center gap-2">
387+ <i class="fa-solid fa-gauge-high text-orange-400"></i>
388+ <h2 class="text-xs font-bold tracking-widest text-slate-400 uppercase font-mono">Telemetry & Sensors</h2>
389+ </div>
390+ <div class="flex items-center gap-2">
391+ <!-- Collapse Button for desktop/mobile -->
392+ <button id="btn-close-telemetry" title="Collapse Panel" class="text-slate-400 hover:text-slate-100 p-1">
393+ <i class="fa-solid fa-angles-right text-xs"></i>
394+ </button>
395+ <button class="md:hidden text-slate-400 hover:text-slate-100" onclick="toggleTelemetry()">
396+ <i class="fa-solid fa-times"></i>
397+ </button>
398+ </div>
399+ </div>
400+
401+ <!-- Dynamic widget placeholder elements -->
402+ <div id="telemetry-widgets-container" class="flex-1 overflow-y-auto p-4 space-y-4">
403+
404+ <!-- Time & Clock Monitor -->
405+ <div id="clock-widget-wrapper" class="bg-slate-950/70 border border-slate-800 rounded-lg p-3">
406+ <div class="flex justify-between items-center text-[10px] font-mono text-slate-500 uppercase mb-1">
407+ <span>Chaser Terminal Time</span>
408+ <span class="text-orange-400 animate-pulse font-bold">● Operations Active</span>
409+ </div>
410+ <div id="telemetry-clock" class="text-lg font-bold tech-font text-slate-100 tracking-wider">00:00:00 AM</div>
411+ <div id="telemetry-date" class="text-[10px] text-slate-400 font-mono mt-0.5">--- --, ----</div>
412+ </div>
413+
414+ <!-- Barometer Device Widget (Atmospheric Pressure Trends) -->
415+ <div id="barometer-widget-wrapper" class="bg-slate-950/70 border border-slate-800 rounded-lg p-3 space-y-2">
416+ <div class="flex justify-between items-center text-[10px] font-mono text-slate-500 uppercase border-b border-slate-800/60 pb-1.5">
417+ <span>Atmospheric Barometer</span>
418+ <div class="flex items-center gap-1">
419+ <span id="baro-sensor-badge" class="w-1.5 h-1.5 rounded-full bg-slate-600"></span>
420+ <span id="baro-sensor-lbl" class="text-[8px] font-mono text-slate-500 uppercase">Simulated</span>
421+ </div>
422+ </div>
423+
424+ <div class="flex justify-between items-end">
425+ <div class="flex flex-col">
426+ <span class="text-[8px] uppercase font-mono text-slate-500">Current Pressure</span>
427+ <span id="lbl-pressure-val" class="text-lg font-bold tech-font text-sky-400">29.25 <span class="text-[10px] text-slate-400">inHG</span></span>
428+ </div>
429+ <button id="btn-baro-drop" title="Simulate pressure drops within storm updrafts" class="bg-red-950/40 hover:bg-red-900/30 border border-red-900/40 text-[9px] font-mono text-red-400 px-2 py-1 rounded">
430+ <i class="fa-solid fa-wind animate-pulse mr-1"></i>Meso Drop
431+ </button>
432+ </div>
433+
434+ <!-- Live Trending Sparkline (Rendered using inline responsive SVGs) -->
435+ <div class="h-12 w-full bg-slate-950 border border-slate-900 rounded p-1 flex items-center justify-center relative overflow-hidden">
436+ <svg id="svg-baro-chart" class="absolute inset-0 w-full h-full" viewBox="0 0 100 40" preserveAspectRatio="none">
437+ <path id="path-baro-line" d="M0,20 L10,20 L20,20" fill="none" stroke="#38bdf8" stroke-width="1.5" />
438+ </svg>
439+ </div>
440+ </div>
441+
442+ <!-- Storm Intercept planner corridor path results -->
443+ <div id="corridor-widget-wrapper" class="bg-slate-950/70 border border-slate-800 rounded-lg p-3 space-y-2 hidden">
444+ <div class="flex justify-between items-center text-[10px] font-mono text-slate-500 uppercase border-b border-slate-800/60 pb-1.5">
445+ <span>Intercept Path Log</span>
446+ <button id="btn-clear-corridor" class="text-[8px] font-mono text-slate-500 hover:text-slate-100 uppercase underline">Clear</button>
447+ </div>
448+ <div class="grid grid-cols-2 gap-1 text-[9px] font-mono">
449+ <span class="text-slate-500">CORRIDOR DIR:</span>
450+ <span id="lbl-corridor-heading" class="text-slate-300 font-bold">---°</span>
451+ <span class="text-slate-500">CELL SPEED:</span>
452+ <div class="flex items-center gap-1">
453+ <input id="input-cell-speed" type="number" min="5" max="100" value="40" class="bg-slate-950 border border-slate-800 w-12 text-center rounded text-orange-400">
454+ <span class="text-slate-400">MPH</span>
455+ </div>
456+ </div>
457+ <!-- Computed Intercept Intersections list -->
458+ <div id="planner-eta-container" class="space-y-1 mt-2 max-h-32 overflow-y-auto border border-slate-900 bg-slate-950/40 p-1 rounded">
459+ <p class="text-[8px] font-mono text-slate-500 italic text-center">Plot vector to evaluate ETA targets</p>
460+ </div>
461+ </div>
462+
463+ <!-- Dashcam Viewport Wrapper -->
464+ <div id="dashcam-widget-wrapper" class="bg-slate-950/70 border border-slate-800 rounded-lg overflow-hidden flex flex-col">
465+ <div class="p-2 border-b border-slate-800 flex justify-between items-center bg-slate-900/40">
466+ <span class="text-[9px] uppercase font-mono text-slate-400 flex items-center gap-1">
467+ <i class="fa-solid fa-video text-slate-400"></i> Dashcam Input
468+ </span>
469+ <div class="flex items-center gap-1.5">
470+ <span id="dashcam-indicator" class="w-1.5 h-1.5 rounded-full bg-slate-600"></span>
471+ <span id="dashcam-status-lbl" class="text-[8px] font-mono text-slate-500 uppercase">Disconnected</span>
472+ </div>
473+ </div>
474+
475+ <div id="dashcam-container" class="relative bg-slate-950 flex items-center justify-center border-b border-slate-800 transition-all duration-200 mx-auto w-full overflow-hidden" style="height: 150px;">
476+ <video id="dashcam-video" class="absolute inset-0 w-full h-full object-cover hidden" autoplay muted playsinline></video>
477+ <div id="dashcam-placeholder" class="text-center p-4">
478+ <i class="fa-solid fa-camera-rotate text-slate-700 text-2xl mb-1.5"></i>
479+ <p class="text-[10px] text-slate-500 font-mono">No Active Feed</p>
480+ </div>
481+ </div>
482+
483+ <!-- Web Cam Resize Controller Controls -->
484+ <div class="p-2 border-b border-slate-800/60 bg-slate-950/40 space-y-2">
485+ <div class="flex items-center justify-between text-[8px] uppercase font-mono text-slate-500">
486+ <span>Dashcam Height</span>
487+ <span id="lbl-cam-height" class="text-sky-400 font-bold">150px</span>
488+ </div>
489+ <input id="slider-cam-height" type="range" min="100" max="400" value="150" class="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-sky-500 focus:outline-none">
490+
491+ <div class="flex items-center justify-between text-[8px] uppercase font-mono text-slate-500">
492+ <span>Dashcam Width</span>
493+ <span id="lbl-cam-width" class="text-sky-400 font-bold">100%</span>
494+ </div>
495+ <input id="slider-cam-width" type="range" min="150" max="320" value="320" class="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-sky-500 focus:outline-none">
496+
497+ <div class="flex gap-1 justify-between">
498+ <button id="btn-size-sm" class="flex-1 text-[8px] bg-slate-900 hover:bg-slate-800 border border-slate-800 py-1 rounded text-slate-400 hover:text-slate-100 font-mono">120px H</button>
499+ <button id="btn-size-md" class="flex-1 text-[8px] bg-slate-900 hover:bg-slate-800 border border-slate-800 py-1 rounded text-slate-400 hover:text-slate-100 font-mono">180px H</button>
500+ <button id="btn-size-lg" class="flex-1 text-[8px] bg-slate-900 hover:bg-slate-800 border border-slate-800 py-1 rounded text-slate-400 hover:text-slate-100 font-mono">300px H</button>
501+ <button id="btn-pip" class="px-2 text-[8px] bg-slate-900 hover:bg-slate-800 border border-slate-800 py-1 rounded text-slate-400 hover:text-sky-400 font-mono" title="Native Picture-in-Picture mode"><i class="fa-solid fa-window-restore"></i> PiP</button>
502+ </div>
503+ </div>
504+
505+ <div class="p-2 bg-slate-950/40 flex justify-between gap-2">
506+ <button id="btn-camera-power" class="w-full text-[10px] font-semibold bg-slate-800 hover:bg-slate-700 active:scale-95 text-slate-200 py-1.5 px-2 rounded-md transition-all font-mono flex items-center justify-center gap-1">
507+ <i class="fa-solid fa-power-off"></i> Connect Dashcam
508+ </button>
509+ </div>
510+ </div>
511+
512+ <!-- Live Computational Geolocation Details -->
513+ <div id="geolocation-widget-wrapper" class="bg-slate-950/70 border border-slate-800 rounded-lg p-3 space-y-3 flex flex-col">
514+ <div class="flex justify-between items-center text-[10px] font-mono text-slate-500 uppercase border-b border-slate-800/60 pb-1.5">
515+ <span>Tactical Intercept GPS</span>
516+ <span id="gps-lock-badge" class="text-[9px] text-red-500 uppercase font-bold">Unbound</span>
517+ </div>
518+
519+ <div class="grid grid-cols-2 gap-3">
520+ <div class="flex flex-col">
521+ <span class="text-[8px] uppercase font-mono text-slate-500">Latitude</span>
522+ <span id="tele-lat" class="text-xs font-bold tech-font text-slate-200">00.00000°</span>
523+ </div>
524+ <div class="flex flex-col">
525+ <span class="text-[8px] uppercase font-mono text-slate-500">Longitude</span>
526+ <span id="tele-lon" class="text-xs font-bold tech-font text-slate-200">00.00000°</span>
527+ </div>
528+ </div>
529+
530+ <div class="grid grid-cols-2 gap-3">
531+ <div class="flex flex-col">
532+ <span class="text-[8px] uppercase font-mono text-slate-500">Velocity (Speed)</span>
533+ <span id="tele-speed" class="text-xs font-bold tech-font text-sky-400">0.0 MPH</span>
534+ </div>
535+ <div class="flex flex-col">
536+ <span class="text-[8px] uppercase font-mono text-slate-500">Chaser Heading</span>
537+ <span id="tele-heading" class="text-xs font-bold tech-font text-emerald-400">---°</span>
538+ </div>
539+ </div>
540+
541+ <div class="grid grid-cols-2 gap-3">
542+ <div class="flex flex-col">
543+ <span class="text-[8px] uppercase font-mono text-slate-500">Altitude</span>
544+ <span id="tele-alt" class="text-xs font-bold tech-font text-purple-400">0 m</span>
545+ </div>
546+ <div class="flex flex-col flex-1">
547+ <span class="text-[8px] uppercase font-mono text-slate-500">Auto-Center Map</span>
548+ <div class="flex items-center mt-0.5">
549+ <label class="relative inline-flex items-center cursor-pointer">
550+ <input type="checkbox" id="chk-autocenter" class="sr-only peer" checked>
551+ <div class="w-7 h-4 bg-slate-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-slate-300 after:border-slate-300 after:border after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:bg-orange-500"></div>
552+ </label>
553+ </div>
554+ </div>
555+ </div>
556+ </div>
557+
558+ <!-- Recorder & Export Suite -->
559+ <div id="recorder-widget-wrapper" class="bg-slate-950/70 border border-slate-800 rounded-lg p-3 space-y-3">
560+ <div class="flex justify-between items-center text-[10px] font-mono text-slate-500 uppercase border-b border-slate-800/60 pb-1.5">
561+ <span>Tactical Logging Suite</span>
562+ <div class="flex items-center gap-1">
563+ <span id="rec-dot" class="w-1.5 h-1.5 rounded-full bg-slate-600"></span>
564+ <span id="rec-timer" class="text-[9px] font-mono text-slate-500 uppercase">OFFLINE</span>
565+ </div>
566+ </div>
567+
568+ <div class="flex flex-col gap-2">
569+ <button id="btn-rec-control" class="w-full text-xs font-bold bg-orange-600 hover:bg-orange-500 active:scale-95 text-white py-2 rounded-lg transition-all font-mono flex items-center justify-center gap-1.5 shadow-lg shadow-orange-900/20">
570+ <i class="fa-solid fa-circle text-[9px] animate-pulse"></i> Start Recording Session
571+ </button>
572+ <button id="btn-export-session" disabled class="w-full text-xs font-bold bg-slate-800 hover:bg-slate-700 active:scale-95 disabled:opacity-40 disabled:pointer-events-none text-slate-300 py-2 rounded-lg transition-all font-mono flex items-center justify-center gap-1.5">
573+ <i class="fa-solid fa-file-export"></i> Export Intercept logs
574+ </button>
575+ </div>
576+
577+ <div class="text-[9px] text-slate-500 font-mono leading-relaxed mt-1">
578+ Compiles and downloads synchronized high-resolution WebM dashcam feeds and structural CSV GPS/Barometric spatial data tables.
579+ </div>
580+ </div>
581+
582+ </div>
583+ </aside>
584+
585+ </main>
586+
587+ <!-- Detailed Severe Alert Modal -->
588+ <div id="modal-alert" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-[2000] hidden flex items-center justify-center p-4">
589+ <div class="bg-slate-900 border border-slate-800 rounded-xl max-w-2xl w-full p-6 shadow-2xl flex flex-col max-h-[85vh]">
590+ <div class="flex justify-between items-start border-b border-slate-800 pb-4 mb-4">
591+ <div>
592+ <span id="modal-badge" class="text-[10px] font-mono px-2 py-0.5 rounded border uppercase">Warning</span>
593+ <h3 id="modal-title" class="text-lg font-bold text-slate-100 mt-1">Severe Alert Details</h3>
594+ <p id="modal-sub" class="text-xs text-slate-400 font-mono mt-0.5">NWS Regional Warning Office</p>
595+ </div>
596+ <button onclick="closeModal()" class="text-slate-400 hover:text-slate-100 p-1">
597+ <i class="fa-solid fa-times text-lg"></i>
598+ </button>
599+ </div>
600+
601+ <div class="flex-1 overflow-y-auto space-y-4 pr-1 text-slate-300">
602+ <div>
603+ <h4 class="text-xs uppercase font-mono text-slate-500 mb-1">Headline</h4>
604+ <p id="modal-headline" class="text-sm font-semibold text-slate-200"></p>
605+ </div>
606+ <div>
607+ <h4 class="text-xs uppercase font-mono text-slate-500 mb-1">Areas Affected</h4>
608+ <p id="modal-areas" class="text-xs font-mono text-sky-400"></p>
609+ </div>
610+ <div class="grid grid-cols-2 gap-4 bg-slate-950/40 border border-slate-800/40 p-3 rounded-lg">
611+ <div>
612+ <h4 class="text-[10px] uppercase font-mono text-slate-500">Expires</h4>
613+ <p id="modal-expires" class="text-xs font-mono text-slate-300"></p>
614+ </div>
615+ <div>
616+ <h4 class="text-[10px] uppercase font-mono text-slate-500">Urgency & Severity</h4>
617+ <p id="modal-severity" class="text-xs font-mono text-slate-300"></p>
618+ </div>
619+ </div>
620+ <div>
621+ <h4 class="text-xs uppercase font-mono text-slate-500 mb-1">Detailed Description</h4>
622+ <p id="modal-desc" class="text-xs whitespace-pre-line bg-slate-950/50 p-3 rounded-lg border border-slate-800/50 leading-relaxed font-mono text-slate-300"></p>
623+ </div>
624+ <div>
625+ <h4 class="text-xs uppercase font-mono text-slate-500 mb-1">Instructions</h4>
626+ <p id="modal-instruction" class="text-xs bg-red-950/20 border border-red-900/30 text-red-300 p-3 rounded-lg leading-relaxed"></p>
627+ </div>
628+ </div>
629+ </div>
630+ </div>
631+
632+ <script>
633+ // --- State and Variables ---
634+ let map;
635+ let radarLayers = [];
636+ let activeRadarLayer = null;
637+ let warningGeoJsonLayer = null;
638+
639+ // Dynamic Satellite Cloud Layers
640+ let cloudLayerEast = null;
641+ let cloudLayerWest = null;
642+
643+ // SPC Day 1 Convective Outlook Layer
644+ let spcOutlookLayer = null;
645+
646+ // METAR Observations Layer
647+ let metarObservationsLayer = null;
648+ let metarRefreshInterval = null;
649+
650+ // Storm Path Intercept Planner Variables
651+ let isPlanningCorridor = false;
652+ let corridorClickCount = 0;
653+ let corridorStartLatLng = null;
654+ let corridorEndLatLng = null;
655+ let corridorLineLayer = null;
656+ let corridorConeLayer = null;
657+ let corridorMarkerLayers = [];
658+
659+ let playbackInterval = null;
660+ let playbackTimes = [];
661+ let timelineIndex = 7; // Start at latest
662+ let isPlaying = false;
663+
664+ let allNwsAlerts = [];
665+ let filteredAlerts = [];
666+ let warningCounters = { Tornado: 0, Severe: 0, Flood: 0 };
667+
668+ let isSoundEnabled = false;
669+ let synth = null;
670+
671+ // --- Live GPS and Telemetry System Variables ---
672+ let gpsWatchId = null;
673+ let mapUserMarker = null;
674+ let currentTelemetry = {
675+ latitude: null,
676+ longitude: null,
677+ speed: null,
678+ heading: null,
679+ altitude: null,
680+ timestamp: null
681+ };
682+ let isTrackingGps = false;
683+
684+ // --- Barometric Pressure Variables ---
685+ let pressureHistory = Array(20).fill(1013.25); // Baseline std pressure
686+ let currentPressure = 1013.25;
687+ let barometerSensor = null;
688+
689+ // --- Dashcam and Session Recorder System Variables ---
690+ let cameraStream = null;
691+ let mediaRecorder = null;
692+ let recordedVideoChunks = [];
693+ let sessionTelemetryHistory = [];
694+ let isRecordingSession = false;
695+ let sessionRecordStartTime = null;
696+ let sessionTimerInterval = null;
697+
698+ // Sidebar Collapse State tracking
699+ let isTelemetryCollapsed = false;
700+
701+ // --- Color mappings for NWS warning categories ---
702+ const WARNING_STYLES = {
703+ 'Tornado Warning': { color: '#ef4444', fillColor: '#ef4444', weight: 2.5, opacity: 0.9, fillOpacity: 0.25 },
704+ 'Severe Thunderstorm Warning': { color: '#eab308', fillColor: '#eab308', weight: 2, opacity: 0.85, fillOpacity: 0.18 },
705+ 'Flash Flood Warning': { color: '#10b981', fillColor: '#10b981', weight: 2, opacity: 0.85, fillOpacity: 0.15 },
706+ 'Special Marine Warning': { color: '#06b6d4', fillColor: '#06b6d4', weight: 2, opacity: 0.85, fillOpacity: 0.15 },
707+ 'Winter Storm Warning': { color: '#a5f3fc', fillColor: '#a5f3fc', weight: 1.5, opacity: 0.8, fillOpacity: 0.12 },
708+ 'default': { color: '#a855f7', fillColor: '#a855f7', weight: 1.5, opacity: 0.8, fillOpacity: 0.1 }
709+ };
710+
711+ // --- Main Initialization ---
712+ window.onload = function() {
713+ initMap();
714+ generateTimelineTimes();
715+ loadRadarLayers();
716+ updateCloudLayers();
717+ fetchSevereAlerts();
718+ setupEventListeners();
719+ startTelemetryClock();
720+ initBarometer(); // Connect physical sensor or fallback to simulated trends
721+
722+ // Adjust default collapse view on load depending on screensize
723+ if (window.innerWidth < 768) {
724+ setTelemetrySidebarCollapsed(true);
725+ } else {
726+ setTelemetrySidebarCollapsed(false);
727+ }
728+
729+ // Periodically refresh active warning data every 60 seconds
730+ setInterval(fetchSevereAlerts, 60000);
731+ };
732+
733+ // --- Toast Message System (Replacing native Alert Windows) ---
734+ function showNotification(message, type = 'info') {
735+ const container = document.getElementById('toast-container');
736+ if (!container) return;
737+ const toast = document.createElement('div');
738+
739+ let colorClasses = 'bg-slate-900 border-slate-800 text-slate-100';
740+ let iconClass = 'fa-info-circle text-sky-400';
741+
742+ if (type === 'success') {
743+ colorClasses = 'bg-slate-900 border-emerald-900/50 text-slate-100';
744+ iconClass = 'fa-circle-check text-emerald-400';
745+ } else if (type === 'warning') {
746+ colorClasses = 'bg-slate-900 border-yellow-900/50 text-slate-100';
747+ iconClass = 'fa-triangle-exclamation text-yellow-400';
748+ } else if (type === 'error') {
749+ colorClasses = 'bg-slate-900 border-red-900/50 text-slate-100';
750+ iconClass = 'fa-circle-exclamation text-red-400';
751+ }
752+
753+ toast.className = `p-3 rounded-lg border ${colorClasses} shadow-xl backdrop-blur-md flex items-center gap-2.5 max-w-xs pointer-events-auto transition-all duration-300 opacity-0 translate-x-4`;
754+ toast.innerHTML = `
755+ <i class="fa-solid ${iconClass}"></i>
756+ <div class="text-[11px] font-mono leading-relaxed">${message}</div>
757+ `;
758+
759+ container.appendChild(toast);
760+
761+ // Force reflow for transitions
762+ setTimeout(() => {
763+ toast.classList.remove('opacity-0', 'translate-x-4');
764+ }, 50);
765+
766+ // Remove Toast after delay
767+ setTimeout(() => {
768+ toast.classList.add('opacity-0', 'translate-x-4');
769+ setTimeout(() => {
770+ toast.remove();
771+ }, 300);
772+ }, 4500);
773+ }
774+
775+ // --- Live Time Clock ---
776+ function startTelemetryClock() {
777+ const updateClock = () => {
778+ const now = new Date();
779+ const clockEl = document.getElementById('telemetry-clock');
780+ const dateEl = document.getElementById('telemetry-date');
781+
782+ if (clockEl) {
783+ clockEl.innerText = now.toLocaleTimeString([], { hour12: true, hour: '2-digit', minute: '2-digit', second: '2-digit' });
784+ }
785+ if (dateEl) {
786+ dateEl.innerText = now.toLocaleDateString([], { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric' });
787+ }
788+ };
789+ updateClock();
790+ setInterval(updateClock, 1000);
791+ }
792+
793+ // --- Map Base Setup ---
794+ function initMap() {
795+ map = L.map('map', {
796+ zoomControl: false,
797+ minZoom: 3,
798+ maxZoom: 18
799+ }).setView([37.8, -96], 4);
800+
801+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
802+ attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
803+ subdomains: 'abcd',
804+ maxZoom: 20
805+ }).addTo(map);
806+
807+ L.control.zoom({
808+ position: 'topright'
809+ }).addTo(map);
810+
811+ // Register map click callbacks to intercept planning tools
812+ map.on('click', handleMapClickForCorridor);
813+ }
814+
815+ // --- STREAMING_CHUNK: Processing device atmospheric sensor inputs and graph drawings ---
816+ // --- Barometer and Pressure Sensor API Hook ---
817+ function initBarometer() {
818+ const badge = document.getElementById('baro-sensor-badge');
819+ const lbl = document.getElementById('baro-sensor-lbl');
820+
821+ // Attempt to bind standard Sensor API (supported on select hardware devices)
822+ if (typeof Barometer !== 'undefined') {
823+ try {
824+ barometerSensor = new Barometer({ frequency: 0.2 }); // Poll once every 5 seconds
825+ barometerSensor.addEventListener('reading', () => {
826+ const pressureHpa = barometerSensor.pressure / 100; // Convert Pa to hPa
827+ updatePressureValue(pressureHpa, true);
828+ });
829+ barometerSensor.addEventListener('error', (event) => {
830+ console.warn("Hardware barometer connection error:", event.error.name);
831+ });
832+ barometerSensor.start();
833+ badge.className = 'w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse';
834+ lbl.innerText = 'Device Sensor';
835+ } catch (e) {
836+ console.warn("Hardware Barometer start failed. Reverting to simulator.");
837+ }
838+ }
839+
840+ // Start simulated micro barometric fluctuations (adds tactical visual depth)
841+ setInterval(() => {
842+ if (barometerSensor) return; // Keep standard readings
843+ // Normal ambient pressure fluctuating around sea level
844+ const drift = (Math.random() - 0.5) * 0.15;
845+ const newPressure = Math.max(900, Math.min(1050, currentPressure + drift));
846+ updatePressureValue(newPressure, false);
847+ }, 5000);
848+
849+ // Draw initial flat sparkline
850+ drawPressureChart();
851+ }
852+
853+ function updatePressureValue(value, isRealSensor) {
854+ currentPressure = value;
855+ document.getElementById('lbl-pressure-val').innerHTML = `${(currentPressure*0.02953).toFixed(2)} <span class="text-[10px] text-slate-400">inHG</span>`;
856+
857+ pressureHistory.shift();
858+ pressureHistory.push(currentPressure);
859+ drawPressureChart();
860+
861+ // Sound tactical alarm chime if pressure crashes steeply
862+ if (currentPressure < 980) {
863+ document.getElementById('lbl-pressure-val').className = 'text-lg font-bold tech-font text-red-500 animate-pulse';
864+ if (isSoundEnabled) {
865+ triggerSirens();
866+ }
867+ } else {
868+ document.getElementById('lbl-pressure-val').className = 'text-lg font-bold tech-font text-sky-400';
869+ }
870+ }
871+
872+ function triggerMesoPressureCollapse() {
873+ showNotification("Entering storm core. Initiating simulated meso-updraft pressure plunge...", "warning");
874+ let steps = 0;
875+ const collapseInterval = setInterval(() => {
876+ steps++;
877+ // Crash atmospheric pressure rapidly to simulate severe tornado proximity (965 hPa)
878+ const fall = 1013.25 - (steps * 2.5);
879+ updatePressureValue(fall, false);
880+
881+ if (steps >= 20) {
882+ clearInterval(collapseInterval);
883+ showNotification("WARNING: Severe low pressure threshold reached (<970 hPa)!", "error");
884+ }
885+ }, 300);
886+ }
887+
888+ function drawPressureChart() {
889+ const svg = document.getElementById('svg-baro-chart');
890+ const path = document.getElementById('path-baro-line');
891+ if (!svg || !path) return;
892+
893+ const minP = Math.min(...pressureHistory) - 1;
894+ const maxP = Math.max(...pressureHistory) + 1;
895+ const pRange = maxP - minP || 1;
896+
897+ let points = [];
898+ for (let i = 0; i < pressureHistory.length; i++) {
899+ const x = (i / (pressureHistory.length - 1)) * 100;
900+ // Invert coordinates since Y=0 is top in SVG
901+ const y = 40 - ((pressureHistory[i] - minP) / pRange) * 32 - 4;
902+ points.push(`${x.toFixed(1)},${y.toFixed(1)}`);
903+ }
904+
905+ path.setAttribute('d', 'M' + points.join(' L'));
906+ }
907+
908+ // --- STREAMING_CHUNK: Fetching and parsing NOAA SPC Convective Outlook layers ---
909+ // --- Live SPC Day 1 Convective Outlook Layers ---
910+ async function toggleSpcOutlook(active) {
911+ const label = document.getElementById('spc-status-lbl');
912+ const legend = document.getElementById('spc-legend-hud');
913+
914+ if (!active) {
915+ if (spcOutlookLayer) {
916+ map.removeLayer(spcOutlookLayer);
917+ spcOutlookLayer = null;
918+ }
919+ label.innerText = 'OFFLINE';
920+ label.className = 'text-[7px] font-mono text-slate-500 uppercase';
921+ legend.classList.add('hidden');
922+ return;
923+ }
924+
925+ label.innerText = 'LOADING...';
926+ label.className = 'text-[7px] font-mono text-yellow-400 uppercase animate-pulse';
927+
928+ try {
929+ // Request live Day 1 Categorical Outlook boundary features directly from NOAA's Vector services
930+ const response = await fetch('https://mapservices.weather.noaa.gov/vector/rest/services/outlooks/SPC_wx_outlks/MapServer/1/query?where=1%3D1&outFields=*&f=geojson');
931+ if (!response.ok) throw new Error("SPC NOAA server unresponsive");
932+
933+ const data = await response.json();
934+
935+ if (spcOutlookLayer) {
936+ map.removeLayer(spcOutlookLayer);
937+ }
938+
939+ spcOutlookLayer = L.geoJSON(data, {
940+ style: function(feature) {
941+ return {
942+ fillColor: feature.properties.fill || '#555555',
943+ fillOpacity: 0.18,
944+ color: feature.properties.stroke || '#888888',
945+ weight: 1.5,
946+ opacity: 0.75
947+ };
948+ },
949+ onEachFeature: function(feature, layer) {
950+ const p = feature.properties;
951+ const labelText = p.label2 || 'Outlook';
952+ layer.bindTooltip(`SPC DAY 1: ${labelText} Risk`, { sticky: true, className: 'bg-slate-950 border border-slate-800 text-[9px] font-mono text-slate-100 p-1 rounded' });
953+ }
954+ }).addTo(map);
955+
956+ label.innerText = 'LIVE SYNCED';
957+ label.className = 'text-[7px] font-mono text-emerald-400 uppercase font-bold';
958+ legend.classList.remove('hidden');
959+ showNotification("Convective Risk boundaries rendered. Crosscheck radar cells with storm threat areas.", "success");
960+
961+ } catch (err) {
962+ console.error("SPC Outlook load failure:", err);
963+ label.innerText = 'ERR / LIMIT';
964+ label.className = 'text-[7px] font-mono text-red-500 uppercase font-bold';
965+ document.getElementById('chk-spc').checked = false;
966+ showNotification("Unable to parse SPC layers. Service limits might be choked.", "error");
967+ }
968+ }
969+
970+ // --- STREAMING_CHUNK: Gathering live airport surface observations from Aviation Weather ---
971+ // --- Live surface METAR Winds observations ---
972+ async function refreshSurfaceMetars() {
973+ const chk = document.getElementById('chk-metar');
974+ const label = document.getElementById('metar-status-lbl');
975+ if (!chk.checked) return;
976+
977+ label.innerText = 'SCANNING...';
978+ label.className = 'text-[7px] font-mono text-teal-400 uppercase animate-pulse';
979+
980+ const bounds = map.getBounds();
981+ const bbox = `${bounds.getWest().toFixed(4)},${bounds.getSouth().toFixed(4)},${bounds.getEast().toFixed(4)},${bounds.getNorth().toFixed(4)}`;
982+
983+ try {
984+ // Fetch live observations inside current map viewport using official AviationWeather GeoJSON endpoints
985+ const res = await fetch(`https://aviationweather.gov/api/data/metar?bbox=${bbox}&format=geojson`);
986+ if (!res.ok) throw new Error("AWC observations down");
987+
988+ const data = await res.json();
989+
990+ if (metarObservationsLayer) {
991+ map.removeLayer(metarObservationsLayer);
992+ }
993+
994+ metarObservationsLayer = L.geoJSON(data, {
995+ pointToLayer: function(feature, latlng) {
996+ const p = feature.properties;
997+ const tempF = p.temp ? (p.temp * 1.8 + 32).toFixed(0) : 'N/A';
998+ const dewF = p.dewp ? (p.dewp * 1.8 + 32).toFixed(0) : 'N/A';
999+ const speedKt = p.wspd || 0;
1000+ const dirDeg = p.wdir || 0;
1001+ const stationId = p.id || 'STN';
1002+
1003+ // Set custom colored markers to easily trace drylines and warm boundary positions
1004+ let windColor = 'text-sky-400';
1005+ if (speedKt > 25) windColor = 'text-red-500 animate-pulse';
1006+ else if (speedKt > 15) windColor = 'text-orange-400';
1007+
1008+ let markerHtml = `
1009+ <div class="flex flex-col items-center justify-center font-mono pointer-events-auto">
1010+ <div class="bg-slate-950/90 border border-slate-800 rounded-md px-1 py-0.5 text-[8px] flex items-center gap-1 shadow-lg leading-none">
1011+ <span class="text-orange-400 font-bold">${tempF}°</span>
1012+ <div style="transform: rotate(${dirDeg}deg);" class="${windColor} transition-all">
1013+ <i class="fa-solid fa-arrow-up text-[9px]"></i>
1014+ </div>
1015+ <span class="text-emerald-400 font-bold">${speedKt}k</span>
1016+ </div>
1017+ <span class="text-[7px] text-slate-500 mt-0.5 scale-90">${stationId}</span>
1018+ </div>
1019+ `;
1020+
1021+ const metarIcon = L.divIcon({
1022+ className: 'custom-metar-marker',
1023+ html: markerHtml,
1024+ iconSize: [45, 25]
1025+ });
1026+
1027+ const marker = L.marker(latlng, { icon: metarIcon });
1028+
1029+ // Setup detailed popup containing raw aviation METAR logs
1030+ const popupHtml = `
1031+ <div class="p-1 font-mono text-[10px] w-64 leading-relaxed">
1032+ <div class="flex justify-between border-b border-slate-800 pb-1 mb-1">
1033+ <span class="font-bold text-sky-400">METAR: ${stationId}</span>
1034+ <span class="text-slate-500">${p.obsTime || 'Recent'}</span>
1035+ </div>
1036+ <div class="grid grid-cols-2 gap-1 mb-1.5">
1037+ <span>Temp: <strong class="text-orange-400">${tempF}°F (${p.temp}°C)</strong></span>
1038+ <span>Dew Point: <strong class="text-emerald-400">${dewF}°F (${p.dewp}°C)</strong></span>
1039+ <span>Wind: <strong class="text-yellow-400">${p.wdir}° @ ${speedKt} kt</strong></span>
1040+ <span>Altimeter: <strong class="text-purple-400">${p.altim || '29.92'} inHg</strong></span>
1041+ </div>
1042+ <div class="bg-slate-950/80 p-1.5 border border-slate-800 rounded text-[9px] text-slate-400 whitespace-normal leading-normal select-all">
1043+ ${p.rawOb || 'No raw string records.'}
1044+ </div>
1045+ </div>
1046+ `;
1047+ marker.bindPopup(popupHtml);
1048+ return marker;
1049+ }
1050+ }).addTo(map);
1051+
1052+ label.innerText = 'CONNECTED';
1053+ label.className = 'text-[7px] font-mono text-teal-400 uppercase font-bold';
1054+
1055+ } catch (err) {
1056+ console.error("METAR Surface fetch error:", err);
1057+ label.innerText = 'ERR / SECURE';
1058+ label.className = 'text-[7px] font-mono text-red-500 uppercase font-bold';
1059+ }
1060+ }
1061+
1062+ function toggleMetarObservations(active) {
1063+ const chk = document.getElementById('chk-metar');
1064+ const label = document.getElementById('metar-status-lbl');
1065+
1066+ if (!active) {
1067+ clearInterval(metarRefreshInterval);
1068+ if (metarObservationsLayer) {
1069+ map.removeLayer(metarObservationsLayer);
1070+ metarObservationsLayer = null;
1071+ }
1072+ label.innerText = 'OFFLINE';
1073+ label.className = 'text-[7px] font-mono text-slate-500';
1074+ return;
1075+ }
1076+
1077+ refreshSurfaceMetars();
1078+ // Regularly refresh surface metrics every 3 minutes
1079+ metarRefreshInterval = setInterval(refreshSurfaceMetars, 180000);
1080+
1081+ // Re-fetch instantly when user drags map focus around
1082+ map.on('moveend', refreshSurfaceMetars);
1083+ }
1084+
1085+ // --- STREAMING_CHUNK: Plotting cell motion vectors and drawing mathematical cones ---
1086+ // --- Storm Intercept Path Planner (Cone of Uncertainty Vectoring) ---
1087+ function toggleCorridorPlanner() {
1088+ const btn = document.getElementById('btn-draw-planner');
1089+ const status = document.getElementById('planner-status-bar');
1090+
1091+ if (isPlanningCorridor) {
1092+ cancelPlanning();
1093+ } else {
1094+ isPlanningCorridor = true;
1095+ corridorClickCount = 0;
1096+ btn.className = 'bg-red-600 hover:bg-red-500 text-white font-bold border-red-500 p-2 rounded text-xs font-mono transition-all flex items-center gap-1.5';
1097+ status.classList.remove('hidden');
1098+ document.getElementById('map').style.cursor = 'crosshair';
1099+ showNotification("Vectoring Active: Left-click target storm core on map to set start point.", "info");
1100+ }
1101+ }
1102+
1103+ function cancelPlanning() {
1104+ const btn = document.getElementById('btn-draw-planner');
1105+ const status = document.getElementById('planner-status-bar');
1106+ isPlanningCorridor = false;
1107+ corridorClickCount = 0;
1108+ btn.className = 'bg-slate-950 hover:bg-slate-800 border border-slate-800 p-2 rounded text-slate-300 hover:text-red-400 transition-all flex items-center gap-1.5 text-xs font-mono';
1109+ status.classList.add('hidden');
1110+ document.getElementById('map').style.cursor = '';
1111+ }
1112+
1113+ function handleMapClickForCorridor(e) {
1114+ if (!isPlanningCorridor) return;
1115+
1116+ corridorClickCount++;
1117+
1118+ if (corridorClickCount === 1) {
1119+ corridorStartLatLng = e.latlng;
1120+ showNotification("Base-point established. Click second point downstream to indicate storm trajectory.", "info");
1121+ } else if (corridorClickCount === 2) {
1122+ corridorEndLatLng = e.latlng;
1123+ calculateStormCorridor();
1124+ cancelPlanning();
1125+ }
1126+ }
1127+
1128+ function calculateStormCorridor() {
1129+ if (!corridorStartLatLng || !corridorEndLatLng) return;
1130+
1131+ // 1. Clear previous layers
1132+ clearCorridorLayer();
1133+
1134+ const p1 = corridorStartLatLng;
1135+ const p2 = corridorEndLatLng;
1136+
1137+ // 2. Compute true geographic bearing (direction degrees clockwise from North)
1138+ const lat1 = p1.lat * Math.PI / 180;
1139+ const lon1 = p1.lng * Math.PI / 180;
1140+ const lat2 = p2.lat * Math.PI / 180;
1141+ const lon2 = p2.lng * Math.PI / 180;
1142+ const dLon = lon2 - lon1;
1143+
1144+ const y = Math.sin(dLon) * Math.cos(lat2);
1145+ const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon);
1146+ let bearing = Math.atan2(y, x) * 180 / Math.PI;
1147+ bearing = (bearing + 360) % 360;
1148+
1149+ document.getElementById('lbl-corridor-heading').innerText = `${bearing.toFixed(0)}° (${getCompassDirection(bearing).split(' ')[1] || '---'})`;
1150+
1151+ // Draw center vector line
1152+ corridorLineLayer = L.polyline([p1, p2], {
1153+ color: '#ef4444',
1154+ weight: 2,
1155+ dashArray: '5, 8',
1156+ opacity: 0.95
1157+ }).addTo(map);
1158+
1159+ // Project mathematical widening cone points (standard 20-degree sweep) out to 100km (62 miles)
1160+ const coneDistance = 100000; // Meters
1161+ const outerLeft = projectPointSpherical(p1, bearing - 20, coneDistance);
1162+ const outerRight = projectPointSpherical(p1, bearing + 20, coneDistance);
1163+
1164+ corridorConeLayer = L.polygon([p1, outerLeft, outerRight], {
1165+ color: '#f97316',
1166+ fillColor: '#f97316',
1167+ fillOpacity: 0.12,
1168+ weight: 1.5,
1169+ opacity: 0.6
1170+ }).addTo(map);
1171+
1172+ // Compute distance ETA intervals (15, 30, 45, 60 minutes) matching storm speed
1173+ const speedMph = parseFloat(document.getElementById('input-cell-speed').value) || 40;
1174+ const speedMps = speedMph * 0.44704; // Miles per hour to Meters per second
1175+ const intervals = [15, 30, 45, 60];
1176+ const etaListContainer = document.getElementById('planner-eta-container');
1177+ etaListContainer.innerHTML = '';
1178+
1179+ intervals.forEach(mins => {
1180+ const distMeters = speedMps * (mins * 60);
1181+ const projectedPoint = projectPointSpherical(p1, bearing, distMeters);
1182+
1183+ // Create checkpoint flag on map
1184+ const checkpointMarker = L.circleMarker(projectedPoint, {
1185+ radius: 5,
1186+ color: '#ef4444',
1187+ fillColor: '#ffffff',
1188+ fillOpacity: 0.9,
1189+ weight: 2
1190+ }).addTo(map);
1191+
1192+ checkpointMarker.bindTooltip(`${mins}m ETA Core`, { permanent: true, direction: 'right', className: 'bg-slate-950 border border-slate-800 text-[8px] font-mono text-red-400 rounded px-1' });
1193+ corridorMarkerLayers.push(checkpointMarker);
1194+
1195+ // Add to HUD log interface, attempting to fetch local municipal names via Nominatim reverse API
1196+ appendEtaLogItem(mins, projectedPoint);
1197+ });
1198+
1199+ document.getElementById('corridor-widget-wrapper').classList.remove('hidden');
1200+ showNotification("Intercept corridors drawn. Safe retreat vector points mapped.", "success");
1201+ }
1202+
1203+ function projectPointSpherical(start, bearing, distance) {
1204+ const R = 6378137; // Earth's radius in meters
1205+ const d = distance / R;
1206+ const brng = bearing * Math.PI / 180;
1207+ const lat1 = start.lat * Math.PI / 180;
1208+ const lon1 = start.lng * Math.PI / 180;
1209+
1210+ const lat2 = Math.asin(Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(brng));
1211+ const lon2 = lon1 + Math.atan2(Math.sin(brng) * Math.sin(d) * Math.cos(lat1), Math.cos(d) - Math.sin(lat1) * Math.sin(lat2));
1212+
1213+ return L.latLng(lat2 * 180 / Math.PI, lon2 * 180 / Math.PI);
1214+ }
1215+
1216+ async function appendEtaLogItem(mins, latlng) {
1217+ const container = document.getElementById('planner-eta-container');
1218+ const row = document.createElement('div');
1219+ row.className = 'flex justify-between border-b border-slate-900 pb-1 text-[9px] font-mono';
1220+
1221+ // Default placeholder string
1222+ row.innerHTML = `
1223+ <span class="text-slate-400">+${mins} MINS:</span>
1224+ <span class="text-sky-400 font-bold">${latlng.lat.toFixed(3)}, ${latlng.lng.toFixed(3)}</span>
1225+ `;
1226+ container.appendChild(row);
1227+
1228+ try {
1229+ // Fetch Nominatim reverse geocode data for real-world municipal target naming
1230+ const res = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${latlng.lat}&lon=${latlng.lng}&format=json&zoom=10`);
1231+ if (res.ok) {
1232+ const place = await res.json();
1233+ const name = place.address.city || place.address.town || place.address.county || 'Unincorporated Region';
1234+ row.innerHTML = `
1235+ <span class="text-red-400 font-bold">+${mins} MIN: ${name.toUpperCase()}</span>
1236+ <span class="text-slate-500">${latlng.lat.toFixed(2)}, ${latlng.lng.toFixed(2)}</span>
1237+ `;
1238+ }
1239+ } catch (err) {
1240+ // Graceful fallback
1241+ }
1242+ }
1243+
1244+ function clearCorridorLayer() {
1245+ if (corridorLineLayer && map.hasLayer(corridorLineLayer)) map.removeLayer(corridorLineLayer);
1246+ if (corridorConeLayer && map.hasLayer(corridorConeLayer)) map.removeLayer(corridorConeLayer);
1247+ corridorMarkerLayers.forEach(m => {
1248+ if (map.hasLayer(m)) map.removeLayer(m);
1249+ });
1250+ corridorMarkerLayers = [];
1251+ document.getElementById('planner-eta-container').innerHTML = `<p class="text-[8px] font-mono text-slate-500 italic text-center">Plot vector to evaluate ETA targets</p>`;
1252+ }
1253+
1254+ // --- Live Time Clock & Date ---
1255+ function generateTimelineTimes() {
1256+ playbackTimes = [];
1257+ const now = new Date();
1258+
1259+ const radarDelay = 8 * 60 * 1000;
1260+ const startTime = new Date(now.getTime() - radarDelay);
1261+
1262+ for (let i = 7; i >= 0; i--) {
1263+ const targetTime = new Date(startTime.getTime() - (i * 5 * 60 * 1000));
1264+
1265+ const m = targetTime.getUTCMinutes();
1266+ targetTime.setUTCMinutes(Math.floor(m / 5) * 5);
1267+ targetTime.setUTCSeconds(0);
1268+ targetTime.setUTCMilliseconds(0);
1269+
1270+ playbackTimes.push(targetTime);
1271+ }
1272+ }
1273+
1274+ // --- Load WMS Tile Radar Layers ---
1275+ function loadRadarLayers() {
1276+ radarLayers.forEach(l => {
1277+ if (map.hasLayer(l)) map.removeLayer(l);
1278+ });
1279+ radarLayers = [];
1280+
1281+ const opacity = parseFloat(document.getElementById('radar-opacity').value) / 100;
1282+
1283+ playbackTimes.forEach((time, index) => {
1284+ const formattedTime = time.toISOString().substring(0, 16) + 'Z';
1285+
1286+ const layer = L.tileLayer.wms('https://mesonet.agron.iastate.edu/cgi-bin/wms/nexrad/n0r-t.cgi', {
1287+ layers: 'nexrad-n0r-wmst',
1288+ time: formattedTime,
1289+ format: 'image/png',
1290+ transparent: true,
1291+ opacity: opacity,
1292+ zIndex: 10 + index,
1293+ attribution: 'Iowa State IEM NEXRAD'
1294+ });
1295+
1296+ radarLayers.push(layer);
1297+ });
1298+
1299+ activeRadarLayer = radarLayers[timelineIndex];
1300+ activeRadarLayer.addTo(map);
1301+ updateTimelineUI();
1302+ }
1303+
1304+ // --- Managing Cloud Overlay Layers ---
1305+ function updateCloudLayers() {
1306+ const cloudType = document.getElementById('cloud-select').value;
1307+ const opacity = parseFloat(document.getElementById('cloud-opacity').value) / 100;
1308+ const todayIso = new Date().toISOString().split('T')[0];
1309+
1310+ if (cloudLayerEast && map.hasLayer(cloudLayerEast)) {
1311+ map.removeLayer(cloudLayerEast);
1312+ }
1313+ if (cloudLayerWest && map.hasLayer(cloudLayerWest)) {
1314+ map.removeLayer(cloudLayerWest);
1315+ }
1316+
1317+ const opacityContainer = document.getElementById('cloud-opacity').parentElement;
1318+ if (cloudType === 'none') {
1319+ opacityContainer.style.opacity = '0.35';
1320+ opacityContainer.style.pointerEvents = 'none';
1321+ return;
1322+ } else {
1323+ opacityContainer.style.opacity = '1';
1324+ opacityContainer.style.pointerEvents = 'auto';
1325+ }
1326+
1327+ if (!cloudLayerEast) {
1328+ cloudLayerEast = L.tileLayer.wms('https://gibs.earthdata.nasa.gov/wms/epsg3857/best/wms.cgi', {
1329+ layers: 'GOES-East_ABI_Band13_Clean_Infrared',
1330+ format: 'image/png',
1331+ transparent: true,
1332+ opacity: opacity,
1333+ time: todayIso,
1334+ zIndex: 5,
1335+ attribution: 'NASA GIBS'
1336+ });
1337+ } else {
1338+ cloudLayerEast.setOpacity(opacity);
1339+ }
1340+
1341+ if (!cloudLayerWest) {
1342+ cloudLayerWest = L.tileLayer.wms('https://gibs.earthdata.nasa.gov/wms/epsg3857/best/wms.cgi', {
1343+ layers: 'GOES-West_ABI_Band13_Clean_Infrared',
1344+ format: 'image/png',
1345+ transparent: true,
1346+ opacity: opacity,
1347+ time: todayIso,
1348+ zIndex: 5,
1349+ attribution: 'NASA GIBS'
1350+ });
1351+ } else {
1352+ cloudLayerWest.setOpacity(opacity);
1353+ }
1354+
1355+ if (cloudType === 'goes-east' || cloudType === 'both') {
1356+ cloudLayerEast.addTo(map);
1357+ }
1358+ if (cloudType === 'goes-west' || cloudType === 'both') {
1359+ cloudLayerWest.addTo(map);
1360+ }
1361+ }
1362+
1363+ // --- Playback Animation Engine ---
1364+ function updateTimelineUI() {
1365+ const time = playbackTimes[timelineIndex];
1366+ if (!time) return;
1367+
1368+ const localString = time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' });
1369+ const utcString = time.toISOString().replace('T', ' ').substring(11, 16) + ' UTC';
1370+
1371+ document.getElementById('timeline-time').innerText = `${localString} (${utcString})`;
1372+ document.getElementById('timeline-slider').value = timelineIndex;
1373+
1374+ if (timelineIndex === 7) {
1375+ document.getElementById('timeline-status').innerText = "Live Radar Image";
1376+ document.getElementById('timeline-status').className = "text-[9px] uppercase tracking-widest text-emerald-400 font-bold font-mono";
1377+ } else {
1378+ const elapsedMin = (7 - timelineIndex) * 5;
1379+ document.getElementById('timeline-status').innerText = `${elapsedMin} minutes ago`;
1380+ document.getElementById('timeline-status').className = "text-[9px] uppercase tracking-widest text-sky-400 font-mono";
1381+ }
1382+ }
1383+
1384+ function switchFrame(newIndex) {
1385+ if (newIndex < 0 || newIndex >= radarLayers.length) return;
1386+
1387+ const prevLayer = activeRadarLayer;
1388+ timelineIndex = newIndex;
1389+ activeRadarLayer = radarLayers[timelineIndex];
1390+
1391+ activeRadarLayer.addTo(map);
1392+
1393+ if (prevLayer && prevLayer !== activeRadarLayer) {
1394+ map.removeLayer(prevLayer);
1395+ }
1396+
1397+ updateTimelineUI();
1398+ }
1399+
1400+ function togglePlayback() {
1401+ const playIcon = document.getElementById('play-icon');
1402+ if (isPlaying) {
1403+ clearInterval(playbackInterval);
1404+ playIcon.className = 'fa-solid fa-play';
1405+ isPlaying = false;
1406+ } else {
1407+ playIcon.className = 'fa-solid fa-pause';
1408+ isPlaying = true;
1409+
1410+ playbackInterval = setInterval(() => {
1411+ let nextIndex = timelineIndex + 1;
1412+ if (nextIndex >= radarLayers.length) {
1413+ nextIndex = 0;
1414+ }
1415+ switchFrame(nextIndex);
1416+ }, 1000);
1417+ }
1418+ }
1419+
1420+ // --- Fetch & Process Real-time Warning Polygons ---
1421+ async function fetchSevereAlerts() {
1422+ try {
1423+ const res = await fetch('https://api.weather.gov/alerts/active?status=actual');
1424+ if (!res.ok) throw new Error("API Limit exceeded");
1425+
1426+ const data = await res.json();
1427+
1428+ if (warningGeoJsonLayer) {
1429+ map.removeLayer(warningGeoJsonLayer);
1430+ }
1431+
1432+ allNwsAlerts = data.features || [];
1433+ filteredAlerts = [...allNwsAlerts];
1434+
1435+ processWarningStats();
1436+ renderSidebarAlerts();
1437+ drawWarningLayers();
1438+
1439+ } catch (err) {
1440+ console.error("Error drawing severe warnings:", err);
1441+ document.getElementById('alerts-container').innerHTML = `
1442+ <div class="text-center p-4">
1443+ <i class="fa-solid fa-circle-exclamation text-yellow-500 text-lg mb-1"></i>
1444+ <p class="text-xs text-slate-400 font-mono">Unable to connect to NWS Servers. Retrying shortly.</p>
1445+ </div>
1446+ `;
1447+ }
1448+ }
1449+
1450+ // --- Compute Warning Statistics & Update Header Badges ---
1451+ function processWarningStats() {
1452+ warningCounters = { Tornado: 0, Severe: 0, Flood: 0 };
1453+
1454+ allNwsAlerts.forEach(alert => {
1455+ const event = alert.properties.event || '';
1456+ if (event.includes('Tornado')) warningCounters.Tornado++;
1457+ else if (event.includes('Severe Thunderstorm')) warningCounters.Severe++;
1458+ else if (event.includes('Flood')) warningCounters.Flood++;
1459+ });
1460+
1461+ document.getElementById('stat-tor').innerText = warningCounters.Tornado;
1462+ document.getElementById('stat-svr').innerText = warningCounters.Severe;
1463+ document.getElementById('stat-ff').innerText = warningCounters.Flood;
1464+
1465+ const totalAlerts = allNwsAlerts.length;
1466+ document.getElementById('warning-total-lbl').innerText = `${totalAlerts} Total`;
1467+
1468+ if (warningCounters.Tornado > 0 && isSoundEnabled) {
1469+ triggerSirens();
1470+ }
1471+ }
1472+
1473+ // --- Draw Warning Shapes Layer onto Leaflet ---
1474+ function drawWarningLayers() {
1475+ warningGeoJsonLayer = L.geoJSON(filteredAlerts, {
1476+ filter: function(feature) {
1477+ return feature.geometry && feature.geometry.coordinates;
1478+ },
1479+ style: function(feature) {
1480+ const event = feature.properties.event || '';
1481+ let styleDef = WARNING_STYLES['default'];
1482+
1483+ if (event.includes('Tornado')) styleDef = WARNING_STYLES['Tornado Warning'];
1484+ else if (event.includes('Severe Thunderstorm')) styleDef = WARNING_STYLES['Severe Thunderstorm Warning'];
1485+ else if (event.includes('Flash Flood')) styleDef = WARNING_STYLES['Flash Flood Warning'];
1486+ else if (event.includes('Special Marine')) styleDef = WARNING_STYLES['Special Marine Warning'];
1487+ else if (event.includes('Winter Storm')) styleDef = WARNING_STYLES['Winter Storm Warning'];
1488+
1489+ return styleDef;
1490+ },
1491+ onEachFeature: function(feature, layer) {
1492+ const p = feature.properties;
1493+
1494+ let popupHtml = `
1495+ <div class="p-1 font-sans text-xs w-64">
1496+ <div class="flex items-center justify-between border-b border-slate-700/60 pb-1.5 mb-1.5">
1497+ <span class="font-bold tracking-wider text-[10px] uppercase font-mono px-2 py-0.5 rounded bg-red-950/40 text-red-400 border border-red-900/30">${p.event}</span>
1498+ <span class="text-[9px] font-mono text-slate-400">NWS Alert</span>
1499+ </div>
1500+ <p class="font-bold text-slate-100 text-xs mb-1">${p.headline || 'No Headline Provided'}</p>
1501+ <p class="text-[10px] text-slate-400 line-clamp-2 mb-2">${p.description || ''}</p>
1502+ <div class="flex justify-between items-center border-t border-slate-800 pt-1.5 mt-1">
1503+ <button onclick="viewFullAlert('${p.id}')" class="bg-sky-600 hover:bg-sky-500 text-white font-semibold font-mono text-[9px] px-2.5 py-1 rounded transition-all">READ MORE</button>
1504+ <span class="text-[9px] font-mono text-slate-500">Exp: ${new Date(p.expires).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
1505+ </div>
1506+ </div>
1507+ `;
1508+ layer.bindPopup(popupHtml);
1509+ layer.bindTooltip(p.event, { sticky: true, className: 'bg-slate-950 border border-slate-800 text-[9px] text-slate-200 rounded font-mono p-1' });
1510+ }
1511+ }).addTo(map);
1512+ }
1513+
1514+ // --- Sidebar Alerts Feed Renderer ---
1515+ function renderSidebarAlerts() {
1516+ const container = document.getElementById('alerts-container');
1517+ if (filteredAlerts.length === 0) {
1518+ container.innerHTML = `
1519+ <div class="flex flex-col items-center justify-center h-48 text-center px-4">
1520+ <i class="fa-solid fa-circle-check text-emerald-500 text-2xl mb-2 animate-bounce"></i>
1521+ <p class="text-xs text-emerald-400 font-bold font-mono">No Active Warnings Found</p>
1522+ <p class="text-[10px] text-slate-500 uppercase mt-0.5 font-mono">Atmospheric conditions nominal</p>
1523+ </div>
1524+ `;
1525+ return;
1526+ }
1527+
1528+ container.innerHTML = '';
1529+ filteredAlerts.forEach(alert => {
1530+ const p = alert.properties;
1531+ const event = p.event || 'Special Alert';
1532+
1533+ let accentColor = 'border-l-purple-500';
1534+ let iconClass = 'fa-triangle-exclamation';
1535+ let bgStyle = 'bg-slate-900/40';
1536+ let pulseClass = '';
1537+
1538+ if (event.includes('Tornado')) {
1539+ accentColor = 'border-l-red-500';
1540+ iconClass = 'fa-tornado text-red-500';
1541+ bgStyle = 'bg-red-950/10 hover:bg-red-950/20';
1542+ pulseClass = 'pulse-severe';
1543+ } else if (event.includes('Severe Thunderstorm')) {
1544+ accentColor = 'border-l-yellow-500';
1545+ iconClass = 'fa-cloud-bolt text-yellow-500';
1546+ bgStyle = 'bg-yellow-950/10 hover:bg-yellow-950/20';
1547+ } else if (event.includes('Flood')) {
1548+ accentColor = 'border-l-emerald-500';
1549+ iconClass = 'fa-cloud-showers-water text-emerald-500';
1550+ bgStyle = 'bg-emerald-950/10 hover:bg-emerald-950/20';
1551+ }
1552+
1553+ const card = document.createElement('div');
1554+ card.className = `p-3 rounded-lg border border-slate-800/80 ${accentColor} border-l-4 ${bgStyle} hover:border-slate-700 transition-all cursor-pointer ${pulseClass}`;
1555+ card.innerHTML = `
1556+ <div class="flex items-start justify-between">
1557+ <span class="text-[10px] font-bold tracking-wide font-mono text-slate-200 flex items-center gap-1.5">
1558+ <i class="fa-solid ${iconClass}"></i>
1559+ ${event}
1560+ </span>
1561+ <span class="text-[8px] font-mono text-slate-500">${new Date(p.sent).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
1562+ </div>
1563+ <p class="text-[10px] text-slate-400 mt-1 line-clamp-2">${p.headline || 'Active NWS warning coverage'}</p>
1564+ <div class="flex justify-between items-center mt-2 pt-2 border-t border-slate-800/60 text-[9px] font-mono">
1565+ <span class="text-sky-400 hover:underline">LOCATE</span>
1566+ <span class="text-slate-500">Exp: ${new Date(p.expires).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
1567+ </div>
1568+ `;
1569+
1570+ card.addEventListener('click', () => {
1571+ locateWarning(alert);
1572+ });
1573+
1574+ container.appendChild(card);
1575+ });
1576+ }
1577+
1578+ // --- Zoom & Focus Warning Polygon on Map ---
1579+ function locateWarning(alertFeature) {
1580+ if (!alertFeature.geometry || !alertFeature.geometry.coordinates) {
1581+ showNotification(`No geographic bounding box available for: ${alertFeature.properties.areaDesc}`, 'warning');
1582+ return;
1583+ }
1584+
1585+ const tempLayer = L.geoJSON(alertFeature);
1586+ map.fitBounds(tempLayer.getBounds(), { maxZoom: 10, padding: [20, 20] });
1587+
1588+ setTimeout(() => {
1589+ warningGeoJsonLayer.eachLayer(layer => {
1590+ if (layer.feature.properties.id === alertFeature.properties.id) {
1591+ layer.openPopup();
1592+ }
1593+ });
1594+ }, 350);
1595+
1596+ if (isSoundEnabled) {
1597+ playWarningChime(alertFeature.properties.event);
1598+ }
1599+ }
1600+
1601+ // --- Filtering Warnings from Feed ---
1602+ function filterWarnings(type) {
1603+ if (type === 'Tornado') {
1604+ filteredAlerts = allNwsAlerts.filter(a => a.properties.event.includes('Tornado'));
1605+ } else if (type === 'Severe') {
1606+ filteredAlerts = allNwsAlerts.filter(a => a.properties.event.includes('Severe'));
1607+ } else if (type === 'Flood') {
1608+ filteredAlerts = allNwsAlerts.filter(a => a.properties.event.includes('Flood'));
1609+ } else {
1610+ filteredAlerts = [...allNwsAlerts];
1611+ }
1612+ renderSidebarAlerts();
1613+ drawWarningLayers();
1614+ }
1615+
1616+ // --- Search City / Location via OpenStreetMap Nominatim Geocoder ---
1617+ async function performSearch() {
1618+ const query = document.getElementById('search-input').value.trim();
1619+ if (!query) return;
1620+
1621+ try {
1622+ const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1`);
1623+ const data = await res.json();
1624+
1625+ if (data && data.length > 0) {
1626+ const place = data[0];
1627+ const lat = parseFloat(place.lat);
1628+ const lon = parseFloat(place.lon);
1629+
1630+ map.setView([lat, lon], 8);
1631+
1632+ const marker = L.circleMarker([lat, lon], {
1633+ radius: 12,
1634+ color: '#0ea5e9',
1635+ fillColor: '#0ea5e9',
1636+ fillOpacity: 0.3,
1637+ weight: 2
1638+ }).addTo(map);
1639+
1640+ setTimeout(() => map.removeLayer(marker), 6000);
1641+ } else {
1642+ showNotification("Target intercept city not found.", "warning");
1643+ }
1644+ } catch (err) {
1645+ console.error("Geocoding failed:", err);
1646+ }
1647+ }
1648+
1649+ // --- STREAMING_CHUNK: Gathering live telemetry and high accuracy chaser positions ---
1650+ // --- High-Precision GPS System and Computations ---
1651+ function toggleGpsTracking() {
1652+ if (!navigator.geolocation) {
1653+ showNotification("Geolocation API is not supported by your browser.", "error");
1654+ return;
1655+ }
1656+
1657+ const gpsButton = document.getElementById('btn-gps');
1658+
1659+ if (isTrackingGps) {
1660+ navigator.geolocation.clearWatch(gpsWatchId);
1661+ gpsWatchId = null;
1662+ isTrackingGps = false;
1663+
1664+ gpsButton.classList.remove('bg-orange-600', 'text-white', 'border-orange-500');
1665+ gpsButton.classList.add('bg-slate-800', 'text-slate-300');
1666+
1667+ if (mapUserMarker) {
1668+ map.removeLayer(mapUserMarker);
1669+ mapUserMarker = null;
1670+ }
1671+
1672+ document.getElementById('gps-lock-badge').className = 'text-[9px] text-red-500 uppercase font-bold';
1673+ document.getElementById('gps-lock-badge').innerText = 'Unbound';
1674+ document.getElementById('tele-lat').innerText = '00.00000°';
1675+ document.getElementById('tele-lon').innerText = '00.00000°';
1676+ document.getElementById('tele-speed').innerText = '0.0 MPH';
1677+ document.getElementById('tele-heading').innerText = '---°';
1678+ document.getElementById('tele-alt').innerText = '0 m';
1679+
1680+ showNotification("Real-time GPS tracking deactivated.", "info");
1681+ } else {
1682+ showNotification("Requesting persistent geolocation tracking link...", "info");
1683+
1684+ gpsWatchId = navigator.geolocation.watchPosition(
1685+ updateLiveTelemetry,
1686+ handleGpsError,
1687+ { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
1688+ );
1689+
1690+ isTrackingGps = true;
1691+ gpsButton.classList.remove('bg-slate-800', 'text-slate-300');
1692+ gpsButton.classList.add('bg-orange-600', 'text-white', 'border-orange-500');
1693+ }
1694+ }
1695+
1696+ function handleGpsError(err) {
1697+ console.error("GPS connection failed:", err);
1698+ showNotification(`GPS Tracking Failure: ${err.message}`, "error");
1699+
1700+ isTrackingGps = false;
1701+ const gpsButton = document.getElementById('btn-gps');
1702+ gpsButton.classList.remove('bg-orange-600', 'text-white', 'border-orange-500');
1703+ gpsButton.classList.add('bg-slate-800', 'text-slate-300');
1704+ }
1705+
1706+ function updateLiveTelemetry(position) {
1707+ const coords = position.coords;
1708+
1709+ currentTelemetry.latitude = coords.latitude;
1710+ currentTelemetry.longitude = coords.longitude;
1711+ currentTelemetry.speed = coords.speed;
1712+ currentTelemetry.heading = coords.heading;
1713+ currentTelemetry.altitude = coords.altitude;
1714+ currentTelemetry.timestamp = new Date(position.timestamp);
1715+
1716+ document.getElementById('gps-lock-badge').className = 'text-[9px] text-emerald-400 uppercase font-bold animate-pulse';
1717+ document.getElementById('gps-lock-badge').innerText = 'Locked';
1718+
1719+ document.getElementById('tele-lat').innerText = `${coords.latitude.toFixed(5)}°`;
1720+ document.getElementById('tele-lon').innerText = `${coords.longitude.toFixed(5)}°`;
1721+
1722+ const speedMph = coords.speed ? (coords.speed * 2.23694).toFixed(1) : "0.0";
1723+ document.getElementById('tele-speed').innerText = `${speedMph} MPH`;
1724+
1725+ const headingText = getCompassDirection(coords.heading);
1726+ document.getElementById('tele-heading').innerText = headingText;
1727+
1728+ const altText = coords.altitude ? `${coords.altitude.toFixed(0)} m` : "N/A";
1729+ document.getElementById('tele-alt').innerText = altText;
1730+
1731+ const latlng = [coords.latitude, coords.longitude];
1732+
1733+ if (!mapUserMarker) {
1734+ const telemetryIcon = L.divIcon({
1735+ className: 'custom-gps-marker',
1736+ html: `
1737+ <div class="relative flex items-center justify-center">
1738+ <div class="absolute w-6 h-6 bg-orange-500/35 rounded-full animate-ping"></div>
1739+ <div class="relative w-4 h-4 bg-orange-500 border-2 border-slate-100 rounded-full shadow-lg flex items-center justify-center">
1740+ <div class="w-1.5 h-1.5 bg-white rounded-full"></div>
1741+ </div>
1742+ </div>
1743+ `,
1744+ iconSize: [24, 24]
1745+ });
1746+
1747+ mapUserMarker = L.marker(latlng, { icon: telemetryIcon }).addTo(map);
1748+ mapUserMarker.bindTooltip("Your Tracking Station", { permanent: false, className: 'bg-slate-950 border border-slate-800 text-[10px] text-slate-200 rounded font-mono p-1' });
1749+ } else {
1750+ mapUserMarker.setLatLng(latlng);
1751+ }
1752+
1753+ const autoCenterChecked = document.getElementById('chk-autocenter').checked;
1754+ if (autoCenterChecked) {
1755+ map.setView(latlng, map.getZoom() < 8 ? 10 : map.getZoom());
1756+ }
1757+
1758+ if (isRecordingSession) {
1759+ logSessionTelemetry(coords, speedMph, headingText);
1760+ }
1761+ }
1762+
1763+ function getCompassDirection(bearing) {
1764+ if (bearing === null || bearing === undefined || isNaN(bearing)) return "---°";
1765+ const compassSectors = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];
1766+ const index = Math.round(bearing / 22.5) % 16;
1767+ return `${bearing.toFixed(0)}° ${compassSectors[index]}`;
1768+ }
1769+
1770+ // --- Dashcam Stream Integrations ---
1771+ async function toggleCameraPower() {
1772+ const videoEl = document.getElementById('dashcam-video');
1773+ const placeholderEl = document.getElementById('dashcam-placeholder');
1774+ const indicatorEl = document.getElementById('dashcam-indicator');
1775+ const labelEl = document.getElementById('dashcam-status-lbl');
1776+ const btnEl = document.getElementById('btn-camera-power');
1777+
1778+ if (cameraStream) {
1779+ stopCameraStream();
1780+ videoEl.classList.add('hidden');
1781+ placeholderEl.classList.remove('hidden');
1782+
1783+ indicatorEl.className = 'w-1.5 h-1.5 rounded-full bg-slate-600';
1784+ labelEl.innerText = 'Disconnected';
1785+ labelEl.className = 'text-[8px] font-mono text-slate-500 uppercase';
1786+
1787+ btnEl.innerHTML = `<i class="fa-solid fa-power-off"></i> Connect Dashcam`;
1788+ btnEl.className = 'w-full text-[10px] font-semibold bg-slate-800 hover:bg-slate-700 active:scale-95 text-slate-200 py-1.5 px-2 rounded-md transition-all font-mono flex items-center justify-center gap-1';
1789+ showNotification("Dashcam stream terminated.", "info");
1790+ } else {
1791+ showNotification("Connecting to system camera inputs...", "info");
1792+ try {
1793+ cameraStream = await navigator.mediaDevices.getUserMedia({
1794+ video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } },
1795+ audio: true
1796+ });
1797+
1798+ videoEl.srcObject = cameraStream;
1799+ videoEl.classList.remove('hidden');
1800+ placeholderEl.classList.add('hidden');
1801+
1802+ indicatorEl.className = 'w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse';
1803+ labelEl.innerText = 'Connected';
1804+ labelEl.className = 'text-[8px] font-mono text-emerald-400 uppercase font-bold';
1805+
1806+ btnEl.innerHTML = `<i class="fa-solid fa-circle-stop text-red-500"></i> Disconnect Feed`;
1807+ btnEl.className = 'w-full text-[10px] font-semibold bg-red-950/20 hover:bg-red-950/40 border border-red-900/40 text-red-300 py-1.5 px-2 rounded-md transition-all font-mono flex items-center justify-center gap-1';
1808+
1809+ showNotification("Dashcam stream connected successfully.", "success");
1810+ } catch (err) {
1811+ console.error("Camera connection failed:", err);
1812+ showNotification(`Camera Hook Error: Secure HTTPS connection or localhost is required for browser device permissions.`, "error");
1813+ }
1814+ }
1815+ }
1816+
1817+ function stopCameraStream() {
1818+ if (cameraStream) {
1819+ cameraStream.getTracks().forEach(track => track.stop());
1820+ cameraStream = null;
1821+ }
1822+ document.getElementById('dashcam-video').srcObject = null;
1823+ }
1824+
1825+ // --- Session Recording & Synchronized Log Exporters ---
1826+ function toggleSessionRecording() {
1827+ const btn = document.getElementById('btn-rec-control');
1828+ const dot = document.getElementById('rec-dot');
1829+ const timerText = document.getElementById('rec-timer');
1830+ const exportBtn = document.getElementById('btn-export-session');
1831+
1832+ if (isRecordingSession) {
1833+ stopSessionRecording();
1834+ } else {
1835+ if (!cameraStream) {
1836+ showNotification("Cannot record. Connect camera feed first.", "warning");
1837+ return;
1838+ }
1839+
1840+ try {
1841+ recordedVideoChunks = [];
1842+ sessionTelemetryHistory = [];
1843+
1844+ let options = { mimeType: 'video/webm;codecs=vp9,opus' };
1845+ if (!MediaRecorder.isTypeSupported(options.mimeType)) {
1846+ options = { mimeType: 'video/webm;codecs=vp8,opus' };
1847+ if (!MediaRecorder.isTypeSupported(options.mimeType)) {
1848+ options = { mimeType: 'video/webm' };
1849+ }
1850+ }
1851+
1852+ mediaRecorder = new MediaRecorder(cameraStream, options);
1853+
1854+ mediaRecorder.ondataavailable = (event) => {
1855+ if (event.data && event.data.size > 0) {
1856+ recordedVideoChunks.push(event.data);
1857+ }
1858+ };
1859+
1860+ mediaRecorder.onstop = () => {
1861+ exportBtn.removeAttribute('disabled');
1862+ exportBtn.className = 'w-full text-xs font-bold bg-orange-600 hover:bg-orange-500 active:scale-95 text-white py-2 rounded-lg transition-all font-mono flex items-center justify-center gap-1.5 shadow-lg shadow-orange-900/25';
1863+ showNotification("Recording captured! Intercept suite package is ready for export.", "success");
1864+ };
1865+
1866+ mediaRecorder.start(1000);
1867+ isRecordingSession = true;
1868+ sessionRecordStartTime = Date.now();
1869+
1870+ btn.innerHTML = `<i class="fa-solid fa-circle-stop"></i> Stop & Save Session`;
1871+ btn.className = 'w-full text-xs font-bold bg-red-600 hover:bg-red-500 active:scale-95 text-white py-2 rounded-lg transition-all font-mono flex items-center justify-center gap-1.5 shadow-lg shadow-red-900/20';
1872+ dot.className = 'w-1.5 h-1.5 rounded-full bg-red-500 animate-ping';
1873+ exportBtn.setAttribute('disabled', 'true');
1874+ exportBtn.className = 'w-full text-xs font-bold bg-slate-800 hover:bg-slate-700 active:scale-95 disabled:opacity-40 disabled:pointer-events-none text-slate-300 py-2 rounded-lg transition-all font-mono flex items-center justify-center gap-1.5';
1875+
1876+ sessionTimerInterval = setInterval(() => {
1877+ const elapsedMs = Date.now() - sessionRecordStartTime;
1878+ const totalSeconds = Math.floor(elapsedMs / 1000);
1879+ const mins = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
1880+ const secs = (totalSeconds % 60).toString().padStart(2, '0');
1881+ timerText.innerText = `REC: ${mins}:${secs}`;
1882+ timerText.className = 'text-[9px] font-mono text-red-500 uppercase font-bold';
1883+
1884+ if (!isTrackingGps) {
1885+ logSessionTelemetry(null, "0.0", "---°");
1886+ }
1887+ }, 1000);
1888+
1889+ showNotification("Tactical Session Recording initialized.", "success");
1890+
1891+ } catch (err) {
1892+ console.error("Recording setup failed:", err);
1893+ showNotification(`Recorder Setup Error: ${err.message}`, "error");
1894+ }
1895+ }
1896+ }
1897+
1898+ function stopSessionRecording() {
1899+ if (mediaRecorder && mediaRecorder.state !== 'inactive') {
1900+ mediaRecorder.stop();
1901+ }
1902+
1903+ clearInterval(sessionTimerInterval);
1904+ isRecordingSession = false;
1905+
1906+ const btn = document.getElementById('btn-rec-control');
1907+ const dot = document.getElementById('rec-dot');
1908+ const timerText = document.getElementById('rec-timer');
1909+
1910+ btn.innerHTML = `<i class="fa-solid fa-circle text-[9px] animate-pulse"></i> Start Recording Session`;
1911+ btn.className = 'w-full text-xs font-bold bg-orange-600 hover:bg-orange-500 active:scale-95 text-white py-2 rounded-lg transition-all font-mono flex items-center justify-center gap-1.5 shadow-lg shadow-orange-900/20';
1912+ dot.className = 'w-1.5 h-1.5 rounded-full bg-slate-600';
1913+ timerText.innerText = 'OFFLINE';
1914+ timerText.className = 'text-[9px] font-mono text-slate-500 uppercase';
1915+ }
1916+
1917+ function logSessionTelemetry(coords, speedVal, headingVal) {
1918+ const timestamp = new Date();
1919+ const elapsedSecs = sessionRecordStartTime ? ((timestamp.getTime() - sessionRecordStartTime) / 1000).toFixed(1) : 0;
1920+
1921+ sessionTelemetryHistory.push({
1922+ elapsedSeconds: elapsedSecs,
1923+ utcTime: timestamp.toISOString(),
1924+ localTime: timestamp.toLocaleTimeString(),
1925+ latitude: coords ? coords.latitude : (currentTelemetry.latitude || "N/A"),
1926+ longitude: coords ? coords.longitude : (currentTelemetry.longitude || "N/A"),
1927+ pressureHpa: currentPressure.toFixed(2),
1928+ speedMph: speedVal,
1929+ heading: headingVal,
1930+ altitude: coords && coords.altitude ? coords.altitude.toFixed(1) : (currentTelemetry.altitude ? currentTelemetry.altitude.toFixed(1) : "N/A")
1931+ });
1932+ }
1933+
1934+ function exportRecordedSuite() {
1935+ if (recordedVideoChunks.length === 0) {
1936+ showNotification("No recorded files found to export.", "warning");
1937+ return;
1938+ }
1939+
1940+ const exportBtn = document.getElementById('btn-export-session');
1941+ exportBtn.setAttribute('disabled', 'true');
1942+ exportBtn.innerText = "Processing Suite...";
1943+
1944+ try {
1945+ const videoBlob = new Blob(recordedVideoChunks, { type: 'video/webm' });
1946+ const videoUrl = URL.createObjectURL(videoBlob);
1947+
1948+ const videoAnchor = document.createElement('a');
1949+ videoAnchor.href = videoUrl;
1950+ videoAnchor.download = `nexstorm_dashcam_${Date.now()}.webm`;
1951+ document.body.appendChild(videoAnchor);
1952+ videoAnchor.click();
1953+ document.body.removeChild(videoAnchor);
1954+
1955+ let csvRows = [
1956+ ["Elapsed Seconds", "Timestamp (UTC)", "Local Time", "Latitude", "Longitude", "Atmospheric Pressure (hPa)", "Velocity (MPH)", "Heading (Bearing)", "Altitude (Meters)"]
1957+ ];
1958+
1959+ sessionTelemetryHistory.forEach(log => {
1960+ csvRows.push([
1961+ log.elapsedSeconds,
1962+ log.utcTime,
1963+ `"${log.localTime}"`,
1964+ log.latitude,
1965+ log.longitude,
1966+ log.pressureHpa,
1967+ log.speedMph,
1968+ `"${log.heading}"`,
1969+ log.altitude
1970+ ]);
1971+ });
1972+
1973+ const csvContent = csvRows.map(e => e.join(",")).join("\n");
1974+ const csvBlob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
1975+ const csvUrl = URL.createObjectURL(csvBlob);
1976+
1977+ const csvAnchor = document.createElement('a');
1978+ csvAnchor.href = csvUrl;
1979+ csvAnchor.download = `nexstorm_telemetry_${Date.now()}.csv`;
1980+ document.body.appendChild(csvAnchor);
1981+ csvAnchor.click();
1982+ document.body.removeChild(csvAnchor);
1983+
1984+ showNotification("Export suite downloaded successfully!", "success");
1985+
1986+ } catch (err) {
1987+ console.error("Export process collapsed:", err);
1988+ showNotification(`Export collapsed: ${err.message}`, "error");
1989+ } finally {
1990+ exportBtn.removeAttribute('disabled');
1991+ exportBtn.className = 'w-full text-xs font-bold bg-orange-600 hover:bg-orange-500 active:scale-95 text-white py-2 rounded-lg transition-all font-mono flex items-center justify-center gap-1.5 shadow-lg shadow-orange-900/25';
1992+ exportBtn.innerHTML = `<i class="fa-solid fa-file-export"></i> Export Intercept logs`;
1993+ }
1994+ }
1995+
1996+ // --- Synthesized Procedural Audio System (Tone.js) ---
1997+ function toggleSound() {
1998+ if (!isSoundEnabled) {
1999+ Tone.start();
2000+ synth = new Tone.PolySynth(Tone.Synth).toDestination();
2001+ synth.set({
2002+ oscillator: { type: "triangle" },
2003+ envelope: { attack: 0.05, decay: 0.1, sustain: 0.3, release: 0.8 }
2004+ });
2005+
2006+ isSoundEnabled = true;
2007+ document.getElementById('sound-icon').className = 'fa-solid fa-volume-high text-emerald-400 animate-pulse';
2008+ document.getElementById('sound-text').innerText = 'Audio Enabled';
2009+
2010+ synth.triggerAttackRelease(["C4", "E4", "G4"], "0.3");
2011+ } else {
2012+ isSoundEnabled = false;
2013+ document.getElementById('sound-icon').className = 'fa-solid fa-volume-mute text-slate-500';
2014+ document.getElementById('sound-text').innerText = 'Mute Audio';
2015+ }
2016+ }
2017+
2018+ function playWarningChime(eventType) {
2019+ if (!isSoundEnabled || !synth) return;
2020+
2021+ if (eventType.includes('Tornado')) {
2022+ synth.triggerAttackRelease(["A5", "C6"], "0.2");
2023+ setTimeout(() => synth.triggerAttackRelease(["A5", "C6"], "0.2"), 300);
2024+ } else if (eventType.includes('Severe')) {
2025+ synth.triggerAttackRelease(["E5", "G5"], "0.15");
2026+ } else {
2027+ synth.triggerAttackRelease(["C4", "G4"], "0.3");
2028+ }
2029+ }
2030+
2031+ function triggerSirens() {
2032+ if (!isSoundEnabled || !synth) return;
2033+ const now = Tone.now();
2034+ synth.triggerAttackRelease("C5", "0.5", now);
2035+ synth.triggerAttackRelease("F5", "0.5", now + 0.6);
2036+ }
2037+
2038+ // --- Full Screen Detail Modals ---
2039+ function viewFullAlert(id) {
2040+ const alert = allNwsAlerts.find(a => a.properties.id === id);
2041+ if (!alert) return;
2042+
2043+ const p = alert.properties;
2044+
2045+ document.getElementById('modal-title').innerText = p.event || 'Severe Warning';
2046+ document.getElementById('modal-badge').innerText = p.severity || 'Moderate';
2047+
2048+ const badge = document.getElementById('modal-badge');
2049+ if (p.event.includes('Tornado')) {
2050+ badge.className = 'text-[10px] font-mono px-2 py-0.5 rounded border uppercase bg-red-950 text-red-400 border-red-800';
2051+ } else if (p.event.includes('Severe')) {
2052+ badge.className = 'text-[10px] font-mono px-2 py-0.5 rounded border uppercase bg-yellow-950 text-yellow-400 border-yellow-800';
2053+ } else {
2054+ badge.className = 'text-[10px] font-mono px-2 py-0.5 rounded border uppercase bg-slate-900 text-slate-300 border-slate-700';
2055+ }
2056+
2057+ document.getElementById('modal-sub').innerText = `ID: ${p.id || 'NWS'}`;
2058+ document.getElementById('modal-headline').innerText = p.headline || 'No specific headline provided.';
2059+ document.getElementById('modal-areas').innerText = p.areaDesc || 'US Regional Coordinates';
2060+ document.getElementById('modal-expires').innerText = new Date(p.expires).toLocaleString();
2061+ document.getElementById('modal-severity').innerText = `${p.severity || 'N/A'} Severity | ${p.urgency || 'N/A'} Urgency`;
2062+ document.getElementById('modal-desc').innerText = p.description || 'No extended description available.';
2063+ document.getElementById('modal-instruction').innerText = p.instruction || 'Monitor local weather broadcasts and instructions.';
2064+
2065+ document.getElementById('modal-alert').classList.remove('hidden');
2066+ }
2067+
2068+ function closeModal() {
2069+ document.getElementById('modal-alert').classList.add('hidden');
2070+ }
2071+
2072+ // --- Mobile Responsive Drawer Handler ---
2073+ function toggleSidebar() {
2074+ const sidebar = document.getElementById('alert-sidebar');
2075+ if (sidebar.classList.contains('-translate-x-full')) {
2076+ sidebar.classList.remove('-translate-x-full');
2077+ } else {
2078+ sidebar.classList.add('-translate-x-full');
2079+ }
2080+ }
2081+
2082+ // --- STREAMING_CHUNK: Enhancing camera size controls for both height and width ---
2083+ function setupWebcamSizer() {
2084+ const container = document.getElementById('dashcam-container');
2085+ const heightSlider = document.getElementById('slider-cam-height');
2086+ const heightLabel = document.getElementById('lbl-cam-height');
2087+ const widthSlider = document.getElementById('slider-cam-width');
2088+ const widthLabel = document.getElementById('lbl-cam-width');
2089+ const videoEl = document.getElementById('dashcam-video');
2090+ const btnPip = document.getElementById('btn-pip');
2091+
2092+ const setHeight = (val) => {
2093+ container.style.height = `${val}px`;
2094+ heightSlider.value = val;
2095+ heightLabel.innerText = `${val}px`;
2096+ };
2097+
2098+ const setWidth = (val) => {
2099+ if (val >= 320 && !isTelemetryCollapsed) {
2100+ container.style.width = '100%';
2101+ widthLabel.innerText = '100%';
2102+ } else {
2103+ container.style.width = `${val}px`;
2104+ widthLabel.innerText = `${val}px`;
2105+ }
2106+ widthSlider.value = val;
2107+ };
2108+
2109+ heightSlider.addEventListener('input', (e) => {
2110+ setHeight(e.target.value);
2111+ });
2112+
2113+ widthSlider.addEventListener('input', (e) => {
2114+ setWidth(e.target.value);
2115+ });
2116+
2117+ document.getElementById('btn-size-sm').addEventListener('click', () => setHeight(120));
2118+ document.getElementById('btn-size-md').addEventListener('click', () => setHeight(180));
2119+ document.getElementById('btn-size-lg').addEventListener('click', () => setHeight(300));
2120+
2121+ btnPip.addEventListener('click', async () => {
2122+ if (!document.pictureInPictureEnabled) {
2123+ showNotification("Picture-in-Picture mode is not supported in this browser.", "warning");
2124+ return;
2125+ }
2126+ if (!cameraStream) {
2127+ showNotification("Connect your webcam feed first to use Picture-in-Picture.", "info");
2128+ return;
2129+ }
2130+ try {
2131+ if (document.pictureInPictureElement) {
2132+ await document.exitPictureInPicture();
2133+ } else {
2134+ await videoEl.requestPictureInPicture();
2135+ }
2136+ } catch (err) {
2137+ console.error("Picture-in-Picture failure:", err);
2138+ }
2139+ });
2140+ }
2141+
2142+ // --- Handling DOM widget shifting on collapse ---
2143+ function setTelemetrySidebarCollapsed(collapsed) {
2144+ isTelemetryCollapsed = collapsed;
2145+ const sidebar = document.getElementById('telemetry-sidebar');
2146+ const icon = document.querySelector('#btn-close-telemetry i');
2147+
2148+ if (isTelemetryCollapsed) {
2149+ sidebar.classList.add('translate-x-full', 'md:translate-x-full');
2150+ sidebar.classList.remove('translate-x-0', 'md:translate-x-0');
2151+ if (icon) {
2152+ icon.className = 'fa-solid fa-angles-left text-xs';
2153+ }
2154+ } else {
2155+ sidebar.classList.remove('translate-x-full', 'md:translate-x-full');
2156+ sidebar.classList.add('translate-x-0', 'md:translate-x-0');
2157+ if (icon) {
2158+ icon.className = 'fa-solid fa-angles-right text-xs';
2159+ }
2160+ }
2161+
2162+ updateWidgetsPlacement();
2163+ }
2164+
2165+ function toggleTelemetry() {
2166+ setTelemetrySidebarCollapsed(!isTelemetryCollapsed);
2167+ }
2168+
2169+ // Move widgets between the Collapsible Sidebar and the Map's Floating Top-Right Box
2170+ function updateWidgetsPlacement() {
2171+ const camWidget = document.getElementById('dashcam-widget-wrapper');
2172+ const geoWidget = document.getElementById('geolocation-widget-wrapper');
2173+ const baroWidget = document.getElementById('barometer-widget-wrapper');
2174+ const corridorWidget = document.getElementById('corridor-widget-wrapper');
2175+
2176+ const floatingBox = document.getElementById('floating-telemetry-box');
2177+ const sidebarParent = document.getElementById('telemetry-widgets-container');
2178+ const recorderWidget = document.getElementById('recorder-widget-wrapper');
2179+
2180+ if (!camWidget || !geoWidget || !baroWidget || !corridorWidget || !floatingBox || !sidebarParent) return;
2181+
2182+ if (isTelemetryCollapsed) {
2183+ floatingBox.appendChild(camWidget);
2184+ floatingBox.appendChild(geoWidget);
2185+ floatingBox.appendChild(baroWidget);
2186+ floatingBox.appendChild(corridorWidget);
2187+
2188+ camWidget.style.maxWidth = '320px';
2189+ geoWidget.style.maxWidth = '320px';
2190+ baroWidget.style.maxWidth = '320px';
2191+ corridorWidget.style.maxWidth = '320px';
2192+
2193+ floatingBox.classList.remove('hidden');
2194+ floatingBox.classList.add('flex');
2195+ } else {
2196+ sidebarParent.insertBefore(baroWidget, recorderWidget);
2197+ sidebarParent.insertBefore(corridorWidget, recorderWidget);
2198+ sidebarParent.insertBefore(camWidget, recorderWidget);
2199+ sidebarParent.insertBefore(geoWidget, recorderWidget);
2200+
2201+ camWidget.style.maxWidth = '';
2202+ geoWidget.style.maxWidth = '';
2203+ baroWidget.style.maxWidth = '';
2204+ corridorWidget.style.maxWidth = '';
2205+
2206+ floatingBox.classList.add('hidden');
2207+ floatingBox.classList.remove('flex');
2208+ }
2209+ }
2210+
2211+ function toggleNightVision() {
2212+ const body = document.body;
2213+ const btn = document.getElementById('btn-night-vision');
2214+ if (body.classList.contains('night-vision-mode')) {
2215+ body.classList.remove('night-vision-mode');
2216+ btn.className = 'bg-slate-800 hover:bg-slate-700 active:scale-95 border border-slate-700/50 p-2 rounded-lg text-rose-500 hover:text-rose-400 transition-all';
2217+ showNotification("Tactical Night Vision deactivated.", "info");
2218+ } else {
2219+ body.classList.add('night-vision-mode');
2220+ btn.className = 'bg-rose-950/40 border border-rose-800 text-rose-400 p-2 rounded-lg transition-all animate-pulse';
2221+ showNotification("Tactical Night Vision activated. Monochromatic eyes protection adaptive matrix synced.", "success");
2222+ }
2223+ }
2224+
2225+ // --- Core App Interactions ---
2226+ function setupEventListeners() {
2227+ setupWebcamSizer();
2228+
2229+ // Opacity Handler
2230+ document.getElementById('radar-opacity').addEventListener('input', (e) => {
2231+ const val = e.target.value;
2232+ document.getElementById('opacity-val').innerText = `${val}%`;
2233+
2234+ radarLayers.forEach(layer => {
2235+ layer.setOpacity(parseFloat(val) / 100);
2236+ });
2237+ });
2238+
2239+ // Cloud Layer Action Listeners
2240+ document.getElementById('cloud-select').addEventListener('change', updateCloudLayers);
2241+ document.getElementById('cloud-opacity').addEventListener('input', (e) => {
2242+ const val = e.target.value;
2243+ document.getElementById('cloud-opacity-val').innerText = `${val}%`;
2244+ if (cloudLayerEast) cloudLayerEast.setOpacity(parseFloat(val) / 100);
2245+ if (cloudLayerWest) cloudLayerWest.setOpacity(parseFloat(val) / 100);
2246+ });
2247+
2248+ // Playback Slider Handler
2249+ document.getElementById('timeline-slider').addEventListener('input', (e) => {
2250+ const val = parseInt(e.target.value);
2251+ switchFrame(val);
2252+ });
2253+
2254+ // Loop Controls Buttons
2255+ document.getElementById('btn-loop-play').addEventListener('click', togglePlayback);
2256+ document.getElementById('btn-loop-prev').addEventListener('click', () => {
2257+ let prev = timelineIndex - 1;
2258+ if (prev < 0) prev = radarLayers.length - 1;
2259+ switchFrame(prev);
2260+ });
2261+ document.getElementById('btn-loop-next').addEventListener('click', () => {
2262+ let next = timelineIndex + 1;
2263+ if (next >= radarLayers.length) next = 0;
2264+ switchFrame(next);
2265+ });
2266+
2267+ // Switch Radar Layer type
2268+ document.getElementById('layer-select').addEventListener('change', loadRadarLayers);
2269+
2270+ // Sound Toggle Keybind
2271+ document.getElementById('sound-toggle').addEventListener('click', toggleSound);
2272+
2273+ // Night Vision Toggle
2274+ document.getElementById('btn-night-vision').addEventListener('click', toggleNightVision);
2275+
2276+ // Baro meso collapse trigger
2277+ document.getElementById('btn-baro-drop').addEventListener('click', triggerMesoPressureCollapse);
2278+
2279+ // Intercept Draw Planner controls
2280+ document.getElementById('btn-draw-planner').addEventListener('click', toggleCorridorPlanner);
2281+ document.getElementById('btn-clear-corridor').addEventListener('click', clearCorridorLayer);
2282+ document.getElementById('input-cell-speed').addEventListener('change', calculateStormCorridor);
2283+
2284+ // SPC Convective outlook checkbox
2285+ document.getElementById('chk-spc').addEventListener('change', (e) => {
2286+ toggleSpcOutlook(e.target.checked);
2287+ });
2288+
2289+ // METAR Weather checkbox
2290+ document.getElementById('chk-metar').addEventListener('change', (e) => {
2291+ toggleMetarObservations(e.target.checked);
2292+ });
2293+
2294+ // Geolocation and Telemetry Panel Controls
2295+ document.getElementById('btn-gps').addEventListener('click', toggleGpsTracking);
2296+ document.getElementById('btn-telemetry-toggle').addEventListener('click', toggleTelemetry);
2297+ document.getElementById('btn-close-telemetry').addEventListener('click', () => setTelemetrySidebarCollapsed(true));
2298+ document.getElementById('btn-camera-power').addEventListener('click', toggleCameraPower);
2299+ document.getElementById('btn-rec-control').addEventListener('click', toggleSessionRecording);
2300+ document.getElementById('btn-export-session').addEventListener('click', exportRecordedSuite);
2301+
2302+ // Search Handlers
2303+ document.getElementById('search-btn').addEventListener('click', performSearch);
2304+ document.getElementById('search-input').addEventListener('keypress', (e) => {
2305+ if (e.key === 'Enter') performSearch();
2306+ });
2307+ }
2308+ </script>
2309+</body>
2310+</html>