Spaces:
Running
Running
#!/usr/bin/env python3 | |
""" | |
ECMWF Real Wind Particle Visualization with Dual Layers | |
Downloads current ECMWF 10m and 100m wind data and visualizes with particles | |
""" | |
import gradio as gr | |
import folium | |
from branca.element import Element | |
import json | |
import sys | |
import requests | |
import numpy as np | |
import xarray as xr | |
import tempfile | |
import os | |
from datetime import datetime, timedelta | |
import warnings | |
warnings.filterwarnings('ignore') | |
# Import ECMWF OpenData client | |
try: | |
from ecmwf.opendata import Client as OpenDataClient | |
OPENDATA_AVAILABLE = True | |
except ImportError: | |
OPENDATA_AVAILABLE = False | |
def log_step(step, message): | |
"""Log each step with clear formatting""" | |
print(f"🔄 STEP {step}: {message}") | |
sys.stdout.flush() | |
class ECMWFWindDataProcessor: | |
"""Process real ECMWF wind data for particle visualization""" | |
def __init__(self): | |
self.temp_dir = tempfile.mkdtemp() | |
self.client = None | |
if OPENDATA_AVAILABLE: | |
try: | |
self.client = OpenDataClient() | |
except: | |
self.client = None | |
# AWS S3 direct access URLs for ECMWF open data | |
self.aws_base_url = "https://ecmwf-forecasts.s3.eu-central-1.amazonaws.com" | |
def get_latest_forecast_info(self): | |
"""Get the latest available forecast run information""" | |
try: | |
# ECMWF runs at 00, 06, 12, 18 UTC | |
now = datetime.utcnow() | |
# Find the most recent model run (data available 7-9 hours after run time) | |
for hours_back in range(4, 24, 6): # Check recent runs | |
test_time = now - timedelta(hours=hours_back) | |
# Round to nearest 6-hour cycle | |
run_hour = (test_time.hour // 6) * 6 | |
run_time = test_time.replace(hour=run_hour, minute=0, second=0, microsecond=0) | |
date_str = run_time.strftime("%Y%m%d") | |
time_str = f"{run_hour:02d}" | |
return date_str, time_str, run_time | |
# Fallback | |
return now.strftime("%Y%m%d"), "12", now | |
except Exception as e: | |
# Emergency fallback | |
now = datetime.utcnow() | |
return now.strftime("%Y%m%d"), "12", now | |
def download_wind_component(self, parameter="10u", step=0, max_retries=3): | |
"""Download ECMWF wind component data (10u, 10v, 100u, 100v)""" | |
date_str, time_str, run_time = self.get_latest_forecast_info() | |
# Method 1: Try ecmwf-opendata client (most reliable) | |
if OPENDATA_AVAILABLE and self.client: | |
try: | |
filename = os.path.join(self.temp_dir, f'ecmwf_{parameter}_{step}h_{datetime.now().strftime("%Y%m%d_%H%M%S")}.grib') | |
log_step("DOWNLOAD", f"Downloading {parameter} component via ECMWF client...") | |
self.client.retrieve( | |
type="fc", # forecast | |
param=parameter, # 10u, 10v, 100u, 100v | |
step=step, # forecast hour | |
target=filename | |
) | |
if os.path.exists(filename) and os.path.getsize(filename) > 1000: | |
log_step("SUCCESS", f"Downloaded {parameter} component ({os.path.getsize(filename)} bytes)") | |
return filename, f"✅ ECMWF {parameter} data downloaded successfully!\\nRun: {date_str} {time_str}z, Step: +{step}h" | |
except Exception as e: | |
log_step("ERROR", f"Client method failed: {str(e)}") | |
return None, f"❌ Unable to download ECMWF {parameter} data" | |
def extract_wind_data_from_grib(self, filename, parameter): | |
"""Extract wind data from GRIB file and return as array""" | |
try: | |
log_step("EXTRACT", f"Processing GRIB file for {parameter}...") | |
# Open the GRIB file with xarray | |
try: | |
ds = xr.open_dataset(filename, engine='cfgrib', backend_kwargs={'indexpath': ''}) | |
except: | |
ds = xr.open_dataset(filename, engine='cfgrib') | |
# Find the right variable | |
data_vars = list(ds.data_vars.keys()) | |
if not data_vars: | |
return None, None, None, "No data variables found in file" | |
data_var = data_vars[0] | |
data = ds[data_var] | |
# Handle coordinates | |
if 'latitude' in ds.coords: | |
lats = ds.latitude.values | |
lons = ds.longitude.values | |
elif 'lat' in ds.coords: | |
lats = ds.lat.values | |
lons = ds.lon.values | |
else: | |
return None, None, None, "Could not find latitude/longitude coordinates" | |
# Get the data values (select first time step if multiple) | |
if 'time' in data.dims and len(data.time) > 1: | |
values = data.isel(time=0).values | |
elif 'valid_time' in data.dims: | |
values = data.isel(valid_time=0).values | |
else: | |
values = data.values | |
# Handle 3D data (select first level if needed) | |
if values.ndim > 2: | |
values = values[0] | |
log_step("SUCCESS", f"Extracted {parameter}: {values.shape} grid, lat range: {lats.min():.1f} to {lats.max():.1f}") | |
ds.close() | |
return lats, lons, values, "Success" | |
except Exception as e: | |
return None, None, None, f"Error extracting data: {str(e)}" | |
def convert_to_wind_json(self, u_lats, u_lons, u_values, v_lats, v_lons, v_values, wind_level="10m"): | |
"""Convert ECMWF wind components to leaflet-velocity JSON format""" | |
try: | |
log_step("CONVERT", f"Converting ECMWF data to wind visualization format for {wind_level}...") | |
# Ensure grids match | |
if not (np.array_equal(u_lats, v_lats) and np.array_equal(u_lons, v_lons)): | |
log_step("WARNING", "U and V grids don't match exactly, using U grid as reference") | |
# Use U component grid as reference | |
lats = u_lats | |
lons = u_lons | |
# Ensure lats are in descending order (North to South) for leaflet-velocity | |
if lats[0] < lats[-1]: | |
lats = lats[::-1] | |
u_values = u_values[::-1, :] | |
v_values = v_values[::-1, :] | |
# Downsample to reduce data size for better performance | |
# Skip every 4th point to reduce from 1M+ to ~65k points | |
downsample_factor = 4 | |
lats = lats[::downsample_factor] | |
lons = lons[::downsample_factor] | |
u_values = u_values[::downsample_factor, ::downsample_factor] | |
v_values = v_values[::downsample_factor, ::downsample_factor] | |
# Convert to lists and flatten in row-major order | |
u_data = u_values.flatten().tolist() | |
v_data = v_values.flatten().tolist() | |
# Replace any NaN values with 0 | |
u_data = [0.0 if np.isnan(x) else float(x) for x in u_data] | |
v_data = [0.0 if np.isnan(x) else float(x) for x in v_data] | |
# Create grid info | |
ny, nx = u_values.shape | |
lo1 = float(lons[0]) | |
lo2 = float(lons[-1]) | |
la1 = float(lats[0]) # North (highest) | |
la2 = float(lats[-1]) # South (lowest) | |
dx = float(lons[1] - lons[0]) | |
dy = float(lats[0] - lats[1]) # Should be positive since lats are descending | |
current_time = datetime.utcnow() | |
ref_time = current_time.strftime("%Y-%m-%d %H:00:00") | |
# Create leaflet-velocity compatible JSON structure | |
wind_data = [ | |
{ | |
"header": { | |
"discipline": 0, | |
"parameterCategory": 2, | |
"parameterNumber": 2, | |
"parameterName": "UGRD", | |
"parameterNumberName": "eastward_wind", | |
"nx": nx, | |
"ny": ny, | |
"lo1": lo1, | |
"la1": la1, | |
"lo2": lo2, | |
"la2": la2, | |
"dx": dx, | |
"dy": dy, | |
"refTime": ref_time | |
}, | |
"data": u_data | |
}, | |
{ | |
"header": { | |
"discipline": 0, | |
"parameterCategory": 2, | |
"parameterNumber": 3, | |
"parameterName": "VGRD", | |
"parameterNumberName": "northward_wind", | |
"nx": nx, | |
"ny": ny, | |
"lo1": lo1, | |
"la1": la1, | |
"lo2": lo2, | |
"la2": la2, | |
"dx": dx, | |
"dy": dy, | |
"refTime": ref_time | |
}, | |
"data": v_data | |
} | |
] | |
log_step("SUCCESS", f"Converted {wind_level} to wind JSON: {nx}x{ny} grid, {len(u_data)} points each") | |
return wind_data, f"Successfully converted ECMWF {wind_level} data to wind visualization format" | |
except Exception as e: | |
return None, f"Error converting data: {str(e)}" | |
def fetch_real_ecmwf_wind_data(): | |
"""Download and process real ECMWF 10m and 100m wind data""" | |
log_step("WIND-1", "🌍 Fetching REAL ECMWF wind data (10m and 100m)...") | |
processor = ECMWFWindDataProcessor() | |
try: | |
# Download 10m wind components | |
log_step("WIND-2", "Downloading 10m U wind component...") | |
u10_file, u10_msg = processor.download_wind_component("10u", step=0) | |
log_step("WIND-3", "Downloading 10m V wind component...") | |
v10_file, v10_msg = processor.download_wind_component("10v", step=0) | |
# Download 100m wind components | |
log_step("WIND-4", "Downloading 100m U wind component...") | |
u100_file, u100_msg = processor.download_wind_component("100u", step=0) | |
log_step("WIND-5", "Downloading 100m V wind component...") | |
v100_file, v100_msg = processor.download_wind_component("100v", step=0) | |
# Process 10m data | |
wind_data_10m = None | |
if u10_file and v10_file: | |
log_step("WIND-6", "Processing 10m wind data...") | |
u10_lats, u10_lons, u10_values, u10_status = processor.extract_wind_data_from_grib(u10_file, "10u") | |
v10_lats, v10_lons, v10_values, v10_status = processor.extract_wind_data_from_grib(v10_file, "10v") | |
if u10_values is not None and v10_values is not None: | |
wind_data_10m, convert_msg = processor.convert_to_wind_json( | |
u10_lats, u10_lons, u10_values, v10_lats, v10_lons, v10_values, "10m" | |
) | |
# Process 100m data | |
wind_data_100m = None | |
if u100_file and v100_file: | |
log_step("WIND-7", "Processing 100m wind data...") | |
u100_lats, u100_lons, u100_values, u100_status = processor.extract_wind_data_from_grib(u100_file, "100u") | |
v100_lats, v100_lons, v100_values, v100_status = processor.extract_wind_data_from_grib(v100_file, "100v") | |
if u100_values is not None and v100_values is not None: | |
wind_data_100m, convert_msg = processor.convert_to_wind_json( | |
u100_lats, u100_lons, u100_values, v100_lats, v100_lons, v100_values, "100m" | |
) | |
# Return real data if available, otherwise fallback | |
if wind_data_10m is None: | |
wind_data_10m = generate_synthetic_wind_data("10m") | |
if wind_data_100m is None: | |
wind_data_100m = generate_synthetic_wind_data("100m") | |
log_step("WIND-8", f"✅ SUCCESS: Wind data ready!") | |
return wind_data_10m, wind_data_100m | |
except Exception as e: | |
log_step("WIND-ERROR", f"Failed to fetch real wind data: {str(e)}") | |
log_step("WIND-FALLBACK", "Falling back to synthetic data...") | |
# Fallback to synthetic data | |
return generate_synthetic_wind_data("10m"), generate_synthetic_wind_data("100m") | |
def fetch_ecmwf_forecast_data(forecast_hours=[0, 3, 6, 12, 18, 24]): | |
"""Download and process ECMWF forecast data for multiple hours""" | |
log_step("FORECAST-1", f"🌍 Fetching ECMWF forecast data for hours: {forecast_hours}") | |
processor = ECMWFWindDataProcessor() | |
forecast_data = {} | |
try: | |
for hour in forecast_hours: | |
log_step("FORECAST-2", f"Downloading forecast data for {hour}h...") | |
# Download wind components for this forecast hour | |
u10_file, u10_msg = processor.download_wind_component("10u", step=hour) | |
v10_file, v10_msg = processor.download_wind_component("10v", step=hour) | |
u100_file, u100_msg = processor.download_wind_component("100u", step=hour) | |
v100_file, v100_msg = processor.download_wind_component("100v", step=hour) | |
# Process 10m forecast data | |
wind_data_10m = None | |
if u10_file and v10_file: | |
log_step("FORECAST-3", f"Processing {hour}h 10m forecast data...") | |
u10_lats, u10_lons, u10_values, u10_status = processor.extract_wind_data_from_grib(u10_file, "10u") | |
v10_lats, v10_lons, v10_values, v10_status = processor.extract_wind_data_from_grib(v10_file, "10v") | |
if u10_values is not None and v10_values is not None: | |
wind_data_10m, convert_msg = processor.convert_to_wind_json( | |
u10_lats, u10_lons, u10_values, v10_lats, v10_lons, v10_values, "10m" | |
) | |
# Process 100m forecast data | |
wind_data_100m = None | |
if u100_file and v100_file: | |
log_step("FORECAST-4", f"Processing {hour}h 100m forecast data...") | |
u100_lats, u100_lons, u100_values, u100_status = processor.extract_wind_data_from_grib(u100_file, "100u") | |
v100_lats, v100_lons, v100_values, v100_status = processor.extract_wind_data_from_grib(v100_file, "100v") | |
if u100_values is not None and v100_values is not None: | |
wind_data_100m, convert_msg = processor.convert_to_wind_json( | |
u100_lats, u100_lons, u100_values, v100_lats, v100_lons, v100_values, "100m" | |
) | |
# Use fallback if no real data available | |
if wind_data_10m is None: | |
wind_data_10m = generate_synthetic_wind_data("10m") | |
if wind_data_100m is None: | |
wind_data_100m = generate_synthetic_wind_data("100m") | |
# Store forecast data for this hour | |
forecast_data[hour] = { | |
"10m": wind_data_10m, | |
"100m": wind_data_100m, | |
"timestamp": hour | |
} | |
log_step("FORECAST-5", f"✅ {hour}h forecast data ready!") | |
log_step("FORECAST-6", f"✅ SUCCESS: All forecast data ready for hours {list(forecast_data.keys())}") | |
return forecast_data | |
except Exception as e: | |
log_step("FORECAST-ERROR", f"Failed to fetch forecast data: {str(e)}") | |
log_step("FORECAST-FALLBACK", "Falling back to synthetic forecast data...") | |
# Fallback: create synthetic forecast data for all hours | |
fallback_data = {} | |
for hour in forecast_hours: | |
fallback_data[hour] = { | |
"10m": generate_synthetic_wind_data("10m"), | |
"100m": generate_synthetic_wind_data("100m"), | |
"timestamp": hour | |
} | |
return fallback_data | |
def generate_synthetic_wind_data(wind_level="10m"): | |
"""Generate synthetic wind data as fallback""" | |
log_step("GEN-1", f"Generating synthetic {wind_level} wind data...") | |
# Basic global grid | |
nx, ny = 72, 36 | |
lon_min, lon_max = -180, 175 | |
lat_min, lat_max = -85, 85 | |
lons = np.linspace(lon_min, lon_max, nx) | |
lats = np.linspace(lat_max, lat_min, ny) | |
u_data = [] | |
v_data = [] | |
# Adjust wind strength based on level | |
strength_multiplier = 1.5 if wind_level == "100m" else 1.0 | |
for j, lat in enumerate(lats): | |
for i, lon in enumerate(lons): | |
# Simple wind pattern with different strength for different levels | |
u = (10 * np.sin(np.radians(lon/2)) + np.random.normal(0, 3)) * strength_multiplier | |
v = (5 * np.cos(np.radians(lat)) + np.random.normal(0, 2)) * strength_multiplier | |
u_data.append(round(u, 2)) | |
v_data.append(round(v, 2)) | |
current_time = datetime.utcnow() | |
ref_time = current_time.strftime("%Y-%m-%d %H:00:00") | |
wind_data = [ | |
{ | |
"header": { | |
"discipline": 0, | |
"parameterCategory": 2, | |
"parameterNumber": 2, | |
"parameterName": "UGRD", | |
"parameterNumberName": "eastward_wind", | |
"nx": nx, | |
"ny": ny, | |
"lo1": lon_min, | |
"la1": lat_max, | |
"lo2": lon_max, | |
"la2": lat_min, | |
"dx": 5.0, | |
"dy": 5.0, | |
"refTime": ref_time | |
}, | |
"data": u_data | |
}, | |
{ | |
"header": { | |
"discipline": 0, | |
"parameterCategory": 2, | |
"parameterNumber": 3, | |
"parameterName": "VGRD", | |
"parameterNumberName": "northward_wind", | |
"nx": nx, | |
"ny": ny, | |
"lo1": lon_min, | |
"la1": lat_max, | |
"lo2": lon_max, | |
"la2": lat_min, | |
"dx": 5.0, | |
"dy": 5.0, | |
"refTime": ref_time | |
}, | |
"data": v_data | |
} | |
] | |
log_step("GEN-2", f"Generated synthetic {wind_level} wind data: {len(u_data)} points") | |
return wind_data | |
def create_wind_map(region="global", forecast_mode=False): | |
"""Create Leaflet-Velocity wind map with real ECMWF data""" | |
# Set map parameters based on region | |
if region == "global": | |
center = [20, 0] | |
zoom = 2 | |
elif region == "north_america": | |
center = [40, -100] | |
zoom = 3 | |
elif region == "europe": | |
center = [50, 10] | |
zoom = 4 | |
else: | |
center = [20, 0] | |
zoom = 2 | |
# Create map with dark theme as default | |
m = folium.Map( | |
location=center, | |
tiles=None, # We'll add custom tiles without attribution | |
zoom_start=zoom, | |
control_scale=False, # Disable scale control | |
width='100%', | |
height='80vh' # Use viewport height for better mobile scaling | |
) | |
# Add CartoDB dark matter tiles with minimal attribution (hidden by CSS) | |
folium.TileLayer( | |
tiles='https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', | |
attr='© CartoDB', # Minimal required attribution | |
name='Dark Matter', | |
overlay=False, | |
control=True, | |
max_zoom=19 | |
).add_to(m) | |
# Add light theme option (but don't make it active by default) | |
folium.TileLayer( | |
tiles='https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', | |
attr='© CartoDB', # Minimal required attribution | |
name="Light Matter", | |
overlay=False, | |
control=False, # We'll control this with our custom button | |
max_zoom=19 | |
).add_to(m) | |
# Fetch ECMWF wind data based on mode | |
if forecast_mode: | |
log_step(5, "Fetching ECMWF forecast data...") | |
forecast_data = fetch_ecmwf_forecast_data() | |
# Start with 0h forecast data | |
wind_data_10m = forecast_data[0]["10m"] | |
wind_data_100m = forecast_data[0]["100m"] | |
log_step(6, f"Forecast data ready: {len(forecast_data)} hours, starting with 0h") | |
else: | |
log_step(5, "Fetching real ECMWF current data...") | |
wind_data_10m, wind_data_100m = fetch_real_ecmwf_wind_data() | |
forecast_data = None | |
log_step(6, f"Current data ready: 10m={len(wind_data_10m)} components, 100m={len(wind_data_100m)} components") | |
# Add Leaflet-Velocity from CDN with comprehensive mobile CSS | |
velocity_css = """ | |
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> | |
<style> | |
/* Comprehensive white bar elimination */ | |
.leaflet-control-attribution, | |
.leaflet-bottom, | |
.leaflet-control-container .leaflet-bottom, | |
.leaflet-bottom.leaflet-right, | |
.leaflet-bottom.leaflet-left, | |
.leaflet-control-scale, | |
.leaflet-control-scale-line { | |
display: none !important; | |
visibility: hidden !important; | |
height: 0 !important; | |
width: 0 !important; | |
opacity: 0 !important; | |
} | |
/* Remove any padding/margin that might create white space */ | |
.leaflet-container { | |
margin: 0 !important; | |
padding: 0 !important; | |
border: none !important; | |
outline: none !important; | |
background: transparent !important; | |
} | |
.leaflet-map-pane, | |
.leaflet-tile-pane, | |
.leaflet-overlay-pane { | |
margin: 0 !important; | |
padding: 0 !important; | |
background: transparent !important; | |
} | |
/* Mobile responsiveness for forecast controls */ | |
@media (max-width: 768px) { | |
.forecast-controls { | |
bottom: 5px !important; | |
left: 5px !important; | |
right: 5px !important; | |
transform: none !important; | |
width: auto !important; | |
padding: 15px !important; | |
font-size: 14px !important; | |
} | |
.forecast-timeline { | |
flex-wrap: wrap !important; | |
justify-content: center !important; | |
gap: 8px !important; | |
} | |
.forecast-hour-btn { | |
min-width: 35px !important; | |
padding: 8px 10px !important; | |
font-size: 12px !important; | |
margin: 2px !important; | |
} | |
.forecast-prev, .forecast-next { | |
width: 40px !important; | |
height: 40px !important; | |
font-size: 18px !important; | |
} | |
.forecast-hour-display { | |
font-size: 16px !important; | |
min-width: 35px !important; | |
} | |
.wind-controls { | |
top: 5px !important; | |
right: 5px !important; | |
padding: 8px !important; | |
} | |
.wind-controls button { | |
width: 140px !important; | |
padding: 8px !important; | |
font-size: 12px !important; | |
} | |
.leaflet-container { | |
height: 85vh !important; | |
} | |
} | |
/* Small mobile phones */ | |
@media (max-width: 480px) { | |
.forecast-controls { | |
padding: 10px 8px !important; | |
font-size: 12px !important; | |
} | |
.forecast-hour-btn { | |
min-width: 30px !important; | |
padding: 6px 8px !important; | |
font-size: 11px !important; | |
} | |
.forecast-prev, .forecast-next { | |
width: 35px !important; | |
height: 35px !important; | |
font-size: 16px !important; | |
} | |
.wind-controls { | |
padding: 6px !important; | |
} | |
.wind-controls button { | |
width: 120px !important; | |
padding: 6px !important; | |
font-size: 11px !important; | |
} | |
.leaflet-container { | |
height: 90vh !important; | |
} | |
/* Mobile Gradio interface improvements */ | |
.gradio-container { | |
max-width: 100% !important; | |
padding: 0 !important; | |
} | |
.gr-row { | |
flex-direction: column !important; | |
} | |
.gr-column { | |
width: 100% !important; | |
max-width: 100% !important; | |
} | |
} | |
/* Hide Gradio controls on very small screens to maximize map space */ | |
@media (max-width: 480px) and (orientation: portrait) { | |
.gradio-container .gr-row:first-child { | |
display: none !important; | |
} | |
.leaflet-container { | |
height: 95vh !important; | |
} | |
} | |
</style> | |
""" | |
m.get_root().html.add_child(Element(velocity_css)) | |
velocity_js = """ | |
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/leaflet-velocity@1.8.0/dist/leaflet-velocity.min.js"></script> | |
""" | |
m.get_root().html.add_child(Element(velocity_js)) | |
# Get map variable name | |
map_id = m.get_name() | |
# Add wind visualization with embedded data | |
js_code = f""" | |
<script> | |
setTimeout(function() {{ | |
console.log("Initializing wind particles..."); | |
var map = {map_id}; | |
var windData10m = {json.dumps(wind_data_10m)}; | |
var windData100m = {json.dumps(wind_data_100m)}; | |
var forecastMode = {json.dumps(forecast_mode)}; | |
var forecastData = {json.dumps(forecast_data) if forecast_data else 'null'}; | |
var currentForecastHour = 0; | |
var availableForecastHours = {json.dumps(list(forecast_data.keys()) if forecast_data else [])}; | |
console.log("Wind data loaded - Forecast mode:", forecastMode); | |
console.log("10m Wind data loaded:", windData10m); | |
console.log("100m Wind data loaded:", windData100m); | |
if (forecastData) {{ | |
console.log("Forecast data loaded for hours:", availableForecastHours); | |
}} | |
// Variables to track velocity layers | |
var current10mLayer = null; | |
var current100mLayer = null; | |
var show10m = true; | |
var show100m = false; | |
// Function to get theme-appropriate color scale | |
function getColorScale(windType) {{ | |
// Check if we're on light theme by looking at current tile layer | |
var currentTileLayer = 'dark'; // default to dark | |
map.eachLayer(function(layer) {{ | |
if (layer.options && layer.options.attribution) {{ | |
if (layer.options.attribution.includes('Light Matter') || | |
layer.options.attribution.includes('light_all')) {{ | |
currentTileLayer = 'light'; | |
}} | |
}} | |
// Also check tile URL patterns | |
if (layer._url && layer._url.includes('light_all')) {{ | |
currentTileLayer = 'light'; | |
}} | |
}}); | |
console.log("Current theme detected:", currentTileLayer); | |
// Different color schemes for 10m (blue) and 100m (red) winds | |
if (windType === '100m') {{ | |
// Red color scheme for 100m winds | |
if (currentTileLayer === 'light') {{ | |
// DARK red colors for light theme (maximum contrast on white) | |
return [ | |
"#4c0000", "#660000", "#800000", "#990000", "#b30000", | |
"#cc0000", "#e60000", "#ff0000", "#ff3333", "#ff6666", "#ff9999" | |
]; | |
}} else {{ | |
// LIGHT red colors for dark theme (maximum visibility on black) | |
return [ | |
"#ff9999", "#ff6666", "#ff3333", "#ff0000", "#e60000", | |
"#cc0000", "#b30000", "#990000", "#800000", "#660000", "#ffcccc" | |
]; | |
}} | |
}} else {{ | |
// Blue color scheme for 10m winds | |
if (currentTileLayer === 'light') {{ | |
// DARK blue colors for light theme (maximum contrast on white) | |
return [ | |
"#000066", "#000080", "#000099", "#0000b3", "#0000cc", | |
"#0000e6", "#0000ff", "#3333ff", "#6666ff", "#9999ff", "#ccccff" | |
]; | |
}} else {{ | |
// LIGHT blue colors for dark theme (maximum visibility on black) | |
return [ | |
"#ccccff", "#9999ff", "#6666ff", "#3333ff", "#0000ff", | |
"#0000e6", "#0000cc", "#0000b3", "#000099", "#000080", "#ffffff" | |
]; | |
}} | |
}} | |
}} | |
// Check if L.velocityLayer exists with retry logic | |
if (typeof L === 'undefined') {{ | |
console.error("❌ Leaflet library not loaded!"); | |
console.error("Retrying in 2 seconds..."); | |
setTimeout(arguments.callee, 2000); | |
return; | |
}} | |
if (typeof L.velocityLayer === 'undefined') {{ | |
console.error("❌ Leaflet-Velocity plugin not loaded!"); | |
console.error("Retrying in 2 seconds..."); | |
setTimeout(arguments.callee, 2000); | |
return; | |
}} | |
console.log("✅ Libraries loaded successfully"); | |
// Function to create 10m wind layer | |
function create10mLayer() {{ | |
return L.velocityLayer({{ | |
data: windData10m, | |
displayValues: true, | |
displayOptions: {{ | |
velocityType: "10m Wind", | |
position: "bottomright", | |
emptyString: "No wind data", | |
speedUnit: "m/s", | |
angleConvention: "bearingCW", | |
showCardinal: true | |
}}, | |
velocityScale: 0.01, // Further reduced to prevent jumping artifacts | |
opacity: 0.9, | |
maxVelocity: 20, | |
particleMultiplier: 0.002, // Further reduced: 0.004 → 0.002 (less frequent) | |
lineWidth: 1.2, // Even thinner lines | |
colorScale: getColorScale('10m'), | |
frameRate: 15, // Lower frame rate for less frequent updates | |
particleAge: 40, // Shorter particle life | |
fadeOpacity: 0, // No fade animation - instant clear | |
animationDuration: 0, // No animation delay | |
// Stricter bounds to prevent jumping particles at far zoom | |
bounds: [[-80, -170], [80, 170]], // Stricter lat/lon bounds | |
wrapX: false, // Disable longitude wrapping to prevent jumps | |
noWrap: true, // Additional no-wrap protection | |
minZoom: 2, // Don't render below zoom level 2 | |
// Much shorter tails, especially for slow winds | |
velocityAgeScale: [ | |
[0, 3], // Very slow winds = extremely short tails | |
[2, 6], // Low winds = very short tails | |
[5, 12], // Moderate winds = short tails | |
[10, 20], // Fast winds = medium tails | |
[20, 30] // Very fast winds = longer tails (much reduced) | |
] | |
}}); | |
}} | |
// Function to create 100m wind layer | |
function create100mLayer() {{ | |
return L.velocityLayer({{ | |
data: windData100m, | |
displayValues: true, | |
displayOptions: {{ | |
velocityType: "100m Wind", | |
position: "bottomleft", | |
emptyString: "No wind data", | |
speedUnit: "m/s", | |
angleConvention: "bearingCW", | |
showCardinal: true | |
}}, | |
velocityScale: 0.012, // Further reduced to prevent jumping artifacts | |
opacity: 0.8, | |
maxVelocity: 30, | |
particleMultiplier: 0.0015, // Further reduced: 0.003 → 0.0015 (less frequent) | |
lineWidth: 1.6, // Thinner lines | |
colorScale: getColorScale('100m'), | |
frameRate: 15, // Lower frame rate for less frequent updates | |
particleAge: 50, // Shorter particle life | |
fadeOpacity: 0, // No fade animation - instant clear | |
animationDuration: 0, // No animation delay | |
// Stricter bounds to prevent jumping particles at far zoom | |
bounds: [[-80, -170], [80, 170]], // Stricter lat/lon bounds | |
wrapX: false, // Disable longitude wrapping to prevent jumps | |
noWrap: true, // Additional no-wrap protection | |
minZoom: 2, // Don't render below zoom level 2 | |
// Much shorter tails, especially for slow winds | |
velocityAgeScale: [ | |
[0, 5], // Very slow winds = extremely short tails | |
[3, 10], // Low winds = very short tails | |
[8, 18], // Moderate winds = short tails | |
[15, 28], // Fast winds = medium tails | |
[25, 40] // Very fast winds = longer tails (much reduced) | |
] | |
}}); | |
}} | |
// Forecast management functions | |
function updateWindDataForForecastHour(hour) {{ | |
if (!forecastData || !forecastData[hour]) {{ | |
console.warn("No forecast data available for hour " + hour); | |
return false; | |
}} | |
console.log("📅 Updating wind data to forecast hour " + hour + "h"); | |
windData10m = forecastData[hour]["10m"]; | |
windData100m = forecastData[hour]["100m"]; | |
currentForecastHour = hour; | |
// Update forecast hour display | |
updateForecastDisplay(); | |
return true; | |
}} | |
function updateForecastDisplay() {{ | |
var forecastDisplay = document.getElementById('forecast-hour-display'); | |
if (forecastDisplay) {{ | |
forecastDisplay.innerHTML = currentForecastHour + "h"; | |
}} | |
}} | |
function switchToForecastHour(hour) {{ | |
if (!forecastMode) {{ | |
console.log("Not in forecast mode, switching ignored"); | |
return; | |
}} | |
console.log("🔄 Switching to forecast hour " + hour + "h"); | |
// Update wind data | |
if (updateWindDataForForecastHour(hour)) {{ | |
// Clear existing particles and reload with new forecast data | |
clearAllParticles(); | |
setTimeout(reloadParticlesAfterMove, 100); | |
console.log("✅ Switched to forecast hour " + hour + "h"); | |
}} | |
}} | |
try {{ | |
// Create initial 10m layer | |
if (show10m) {{ | |
current10mLayer = create10mLayer(); | |
current10mLayer.addTo(map); | |
console.log("✅ 10m wind particles added successfully!"); | |
}} | |
// Optionally create 100m layer | |
if (show100m) {{ | |
current100mLayer = create100mLayer(); | |
current100mLayer.addTo(map); | |
console.log("✅ 100m wind particles added successfully!"); | |
}} | |
// Function to toggle 10m wind layer | |
function toggle10mLayer() {{ | |
show10m = !show10m; | |
// Force clear any existing 10m particles from map | |
map.eachLayer(function(layer) {{ | |
if (layer.options && layer.options.displayOptions && | |
layer.options.displayOptions.velocityType === "10m Wind") {{ | |
try {{ | |
map.removeLayer(layer); | |
console.log("🗑️ Force removed 10m layer from map"); | |
}} catch(e) {{ | |
console.warn("Error force removing 10m layer:", e); | |
}} | |
}} | |
}}); | |
// Clear tracked layer reference | |
if (current10mLayer) {{ | |
try {{ | |
map.removeLayer(current10mLayer); | |
}} catch(e) {{ | |
console.warn("Error removing tracked 10m layer:", e); | |
}} | |
current10mLayer = null; | |
}} | |
if (show10m) {{ | |
current10mLayer = create10mLayer(); | |
current10mLayer.addTo(map); | |
console.log("✅ 10m wind layer enabled"); | |
}} else {{ | |
console.log("❌ 10m wind layer disabled - ALL particles cleared"); | |
}} | |
}} | |
// Function to toggle 100m wind layer | |
function toggle100mLayer() {{ | |
show100m = !show100m; | |
// Force clear any existing 100m particles from map | |
map.eachLayer(function(layer) {{ | |
if (layer.options && layer.options.displayOptions && | |
layer.options.displayOptions.velocityType === "100m Wind") {{ | |
try {{ | |
map.removeLayer(layer); | |
console.log("🗑️ Force removed 100m layer from map"); | |
}} catch(e) {{ | |
console.warn("Error force removing 100m layer:", e); | |
}} | |
}} | |
}}); | |
// Clear tracked layer reference | |
if (current100mLayer) {{ | |
try {{ | |
map.removeLayer(current100mLayer); | |
}} catch(e) {{ | |
console.warn("Error removing tracked 100m layer:", e); | |
}} | |
current100mLayer = null; | |
}} | |
if (show100m) {{ | |
current100mLayer = create100mLayer(); | |
current100mLayer.addTo(map); | |
console.log("✅ 100m wind layer enabled"); | |
}} else {{ | |
console.log("❌ 100m wind layer disabled - ALL particles cleared"); | |
}} | |
}} | |
// Function to reload particles with new theme colors | |
function reloadParticlesWithTheme() {{ | |
console.log("🎨 Theme changed, updating particle colors..."); | |
// Reload 10m layer if active | |
if (show10m && current10mLayer) {{ | |
map.removeLayer(current10mLayer); | |
current10mLayer = create10mLayer(); | |
current10mLayer.addTo(map); | |
}} | |
// Reload 100m layer if active | |
if (show100m && current100mLayer) {{ | |
map.removeLayer(current100mLayer); | |
current100mLayer = create100mLayer(); | |
current100mLayer.addTo(map); | |
}} | |
console.log("⚡ Particle colors updated for theme!"); | |
}} | |
// Function to clear all particles immediately without fade | |
function clearAllParticles() {{ | |
console.log("🚫 INSTANTLY clearing all particles..."); | |
// Force instant removal of 10m particles | |
if (current10mLayer) {{ | |
try {{ | |
// Stop any animations and remove immediately | |
if (current10mLayer._map) {{ | |
current10mLayer._map.removeLayer(current10mLayer); | |
}} | |
map.removeLayer(current10mLayer); | |
// Force clear the canvas if velocity layer has one | |
if (current10mLayer._canvas) {{ | |
var ctx = current10mLayer._canvas.getContext('2d'); | |
ctx.clearRect(0, 0, current10mLayer._canvas.width, current10mLayer._canvas.height); | |
}} | |
}} catch(e) {{ | |
console.warn("Error during 10m instant clear:", e); | |
}} | |
current10mLayer = null; | |
console.log("⚡ 10m particles INSTANTLY cleared"); | |
}} | |
// Force instant removal of 100m particles | |
if (current100mLayer) {{ | |
try {{ | |
// Stop any animations and remove immediately | |
if (current100mLayer._map) {{ | |
current100mLayer._map.removeLayer(current100mLayer); | |
}} | |
map.removeLayer(current100mLayer); | |
// Force clear the canvas if velocity layer has one | |
if (current100mLayer._canvas) {{ | |
var ctx = current100mLayer._canvas.getContext('2d'); | |
ctx.clearRect(0, 0, current100mLayer._canvas.width, current100mLayer._canvas.height); | |
}} | |
}} catch(e) {{ | |
console.warn("Error during 100m instant clear:", e); | |
}} | |
current100mLayer = null; | |
console.log("⚡ 100m particles INSTANTLY cleared"); | |
}} | |
// Force a map refresh to ensure clean state | |
setTimeout(function() {{ | |
map.invalidateSize(); | |
}}, 10); | |
}} | |
// Function to reload particles after pan/zoom with consistent behavior | |
function reloadParticlesAfterMove() {{ | |
console.log("⚡ RELOADING particles with consistent behavior..."); | |
// Get current zoom for logging | |
var currentZoom = map.getZoom(); | |
// Force map refresh to ensure proper positioning | |
map.invalidateSize(); | |
// Always respect current toggle states - recreate layers if toggles are ON | |
if (show10m) {{ | |
// Force clean creation of 10m layer | |
if (current10mLayer) {{ | |
try {{ map.removeLayer(current10mLayer); }} catch(e) {{}} | |
}} | |
current10mLayer = create10mLayer(); | |
current10mLayer.addTo(map); | |
console.log("⚡ 10m particles RECREATED at correct position (zoom " + currentZoom + ")"); | |
}} else {{ | |
console.log("⚠️ 10m particles NOT reloaded (toggle OFF)"); | |
}} | |
if (show100m) {{ | |
// Force clean creation of 100m layer | |
if (current100mLayer) {{ | |
try {{ map.removeLayer(current100mLayer); }} catch(e) {{}} | |
}} | |
current100mLayer = create100mLayer(); | |
current100mLayer.addTo(map); | |
console.log("⚡ 100m particles RECREATED at correct position (zoom " + currentZoom + ")"); | |
}} else {{ | |
console.log("⚠️ 100m particles NOT reloaded (toggle OFF)"); | |
}} | |
console.log("✅ Consistent particle reload complete - pan/zoom behavior now identical!"); | |
}} | |
// Add pan/zoom event handlers - different behavior for pan vs zoom | |
map.on('movestart', function() {{ | |
console.log("📌 PAN START: Particles staying in place during pan..."); | |
// Do NOT clear particles during pan - let them stay until pan ends | |
}}); | |
map.on('zoomstart', function() {{ | |
console.log("⚡ ZOOM START: Clearing particles INSTANTLY..."); | |
// INSTANT particle clearing on zoom start to prevent disorientation | |
clearAllParticles(); | |
}}); | |
// Handle movement end for both pan and zoom | |
function handleMovementEnd(eventType) {{ | |
console.log("⚡ " + eventType + " END: Clearing and reloading particles..."); | |
// Clear particles (for zoom this is redundant since already cleared on zoomstart) | |
clearAllParticles(); | |
// Check zoom level to prevent particle artifacts at far zoom | |
var currentZoom = map.getZoom(); | |
if (currentZoom < 2) {{ | |
console.log("⚠️ Zoom level " + currentZoom + " too far out, NOT reloading particles to prevent jumps"); | |
// Don't reload particles at far zoom to prevent jumpers | |
return; | |
}} | |
// Reload particles at correct position - same for both pan and zoom | |
setTimeout(reloadParticlesAfterMove, 200); | |
}} | |
map.on('moveend', function() {{ | |
handleMovementEnd("PAN"); | |
}}); | |
map.on('zoomend', function() {{ | |
handleMovementEnd("ZOOM"); | |
}}); | |
// Event handler for theme changes | |
map.on('baselayerchange', function() {{ | |
setTimeout(reloadParticlesWithTheme, 100); // Small delay to ensure theme change is complete | |
}}); | |
// Add custom controls for wind layer toggles | |
var windControlDiv = L.DomUtil.create('div', 'wind-controls'); | |
windControlDiv.style.position = 'absolute'; | |
windControlDiv.style.top = '10px'; | |
windControlDiv.style.right = '10px'; | |
windControlDiv.style.zIndex = '1000'; | |
windControlDiv.style.background = 'rgba(255,255,255,0.9)'; | |
windControlDiv.style.padding = '10px'; | |
windControlDiv.style.borderRadius = '5px'; | |
windControlDiv.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)'; | |
windControlDiv.style.fontFamily = 'Arial, sans-serif'; | |
windControlDiv.style.fontSize = '12px'; | |
var button10m = L.DomUtil.create('button', 'wind-toggle-10m', windControlDiv); | |
button10m.innerHTML = '🌪️ 10m Winds (ON)'; | |
button10m.style.display = 'block'; | |
button10m.style.width = '120px'; | |
button10m.style.margin = '2px 0'; | |
button10m.style.padding = '5px'; | |
button10m.style.border = 'none'; | |
button10m.style.borderRadius = '3px'; | |
button10m.style.background = '#3b82f6'; | |
button10m.style.color = 'white'; | |
button10m.style.cursor = 'pointer'; | |
button10m.style.fontSize = '11px'; | |
var button100m = L.DomUtil.create('button', 'wind-toggle-100m', windControlDiv); | |
button100m.innerHTML = '🚁 100m Winds (OFF)'; | |
button100m.style.display = 'block'; | |
button100m.style.width = '120px'; | |
button100m.style.margin = '2px 0'; | |
button100m.style.padding = '5px'; | |
button100m.style.border = 'none'; | |
button100m.style.borderRadius = '3px'; | |
button100m.style.background = '#6b7280'; | |
button100m.style.color = 'white'; | |
button100m.style.cursor = 'pointer'; | |
button100m.style.fontSize = '11px'; | |
// Add theme toggle button | |
var buttonTheme = L.DomUtil.create('button', 'theme-toggle', windControlDiv); | |
buttonTheme.innerHTML = '🌙 Dark Matter'; | |
buttonTheme.style.display = 'block'; | |
buttonTheme.style.width = '120px'; | |
buttonTheme.style.margin = '2px 0'; | |
buttonTheme.style.padding = '5px'; | |
buttonTheme.style.border = 'none'; | |
buttonTheme.style.borderRadius = '3px'; | |
buttonTheme.style.background = '#374151'; | |
buttonTheme.style.color = 'white'; | |
buttonTheme.style.cursor = 'pointer'; | |
buttonTheme.style.fontSize = '11px'; | |
// Event handlers for toggle buttons | |
L.DomEvent.on(button10m, 'click', function() {{ | |
toggle10mLayer(); | |
button10m.innerHTML = show10m ? '🌪️ 10m Winds (ON)' : '🌪️ 10m Winds (OFF)'; | |
button10m.style.background = show10m ? '#3b82f6' : '#6b7280'; | |
}}); | |
L.DomEvent.on(button100m, 'click', function() {{ | |
toggle100mLayer(); | |
button100m.innerHTML = show100m ? '🚁 100m Winds (ON)' : '🚁 100m Winds (OFF)'; | |
button100m.style.background = show100m ? '#dc2626' : '#6b7280'; | |
}}); | |
// Theme toggle functionality | |
var isLightTheme = false; // Start with dark theme | |
var currentTileLayer = null; | |
// Find and store the current base layer | |
map.eachLayer(function(layer) {{ | |
if (layer.options && layer.options.attribution && layer.options.attribution.includes('CartoDB')) {{ | |
currentTileLayer = layer; | |
}} | |
}}); | |
L.DomEvent.on(buttonTheme, 'click', function() {{ | |
isLightTheme = !isLightTheme; | |
// Remove current tile layer | |
if (currentTileLayer) {{ | |
map.removeLayer(currentTileLayer); | |
}} | |
if (isLightTheme) {{ | |
// Switch to light matter theme | |
console.log("🌞 Switching to LIGHT MATTER theme..."); | |
currentTileLayer = L.tileLayer('https://{{s}}.basemaps.cartocdn.com/light_all/{{z}}/{{x}}/{{y}}{{r}}.png', {{ | |
attribution: '© CartoDB', | |
subdomains: 'abcd', | |
maxZoom: 19 | |
}}); | |
currentTileLayer.addTo(map); | |
buttonTheme.innerHTML = '☀️ Light Matter'; | |
buttonTheme.style.background = '#f59e0b'; | |
}} else {{ | |
// Switch to dark matter theme | |
console.log("🌙 Switching to DARK MATTER theme..."); | |
currentTileLayer = L.tileLayer('https://{{s}}.basemaps.cartocdn.com/dark_all/{{z}}/{{x}}/{{y}}{{r}}.png', {{ | |
attribution: '© CartoDB', | |
subdomains: 'abcd', | |
maxZoom: 19 | |
}}); | |
currentTileLayer.addTo(map); | |
buttonTheme.innerHTML = '🌙 Dark Matter'; | |
buttonTheme.style.background = '#374151'; | |
}} | |
// Trigger particle color update after theme change | |
setTimeout(reloadParticlesWithTheme, 200); | |
console.log("✅ Theme switched successfully!"); | |
}}); | |
// Add forecast controls if in forecast mode | |
if (forecastMode && forecastData) {{ | |
console.log("📅 Adding forecast timeline controls..."); | |
// Create forecast control container at bottom of map | |
var forecastControlDiv = L.DomUtil.create('div', 'forecast-controls'); | |
forecastControlDiv.style.position = 'absolute'; | |
forecastControlDiv.style.bottom = '10px'; | |
forecastControlDiv.style.left = '50%'; | |
forecastControlDiv.style.transform = 'translateX(-50%)'; | |
forecastControlDiv.style.zIndex = '1000'; | |
forecastControlDiv.style.background = 'rgba(0,0,0,0.8)'; | |
forecastControlDiv.style.padding = '10px 15px'; | |
forecastControlDiv.style.borderRadius = '25px'; | |
forecastControlDiv.style.boxShadow = '0 2px 10px rgba(0,0,0,0.3)'; | |
forecastControlDiv.style.fontFamily = 'Arial, sans-serif'; | |
forecastControlDiv.style.fontSize = '12px'; | |
forecastControlDiv.style.color = 'white'; | |
forecastControlDiv.style.display = 'flex'; | |
forecastControlDiv.style.alignItems = 'center'; | |
forecastControlDiv.style.gap = '10px'; | |
// Previous button | |
var prevButton = L.DomUtil.create('button', 'forecast-prev', forecastControlDiv); | |
prevButton.innerHTML = '⏮'; | |
prevButton.style.background = 'rgba(255,255,255,0.2)'; | |
prevButton.style.border = 'none'; | |
prevButton.style.borderRadius = '50%'; | |
prevButton.style.width = '30px'; | |
prevButton.style.height = '30px'; | |
prevButton.style.color = 'white'; | |
prevButton.style.cursor = 'pointer'; | |
prevButton.style.fontSize = '14px'; | |
// Forecast timeline | |
var timelineContainer = L.DomUtil.create('div', 'forecast-timeline', forecastControlDiv); | |
timelineContainer.style.display = 'flex'; | |
timelineContainer.style.gap = '5px'; | |
timelineContainer.style.alignItems = 'center'; | |
// Current hour display | |
var hourDisplay = L.DomUtil.create('span', 'forecast-hour-display', timelineContainer); | |
hourDisplay.id = 'forecast-hour-display'; | |
hourDisplay.innerHTML = currentForecastHour + 'h'; | |
hourDisplay.style.fontWeight = 'bold'; | |
hourDisplay.style.minWidth = '25px'; | |
hourDisplay.style.textAlign = 'center'; | |
// Timeline separator | |
var separator = L.DomUtil.create('span', '', timelineContainer); | |
separator.innerHTML = ' | '; | |
separator.style.color = 'rgba(255,255,255,0.5)'; | |
// Hour buttons for each forecast step | |
availableForecastHours.forEach(function(hour) {{ | |
var hourButton = L.DomUtil.create('button', 'forecast-hour-btn', timelineContainer); | |
hourButton.innerHTML = hour + 'h'; | |
hourButton.style.background = hour === currentForecastHour ? 'rgba(59,130,246,0.8)' : 'rgba(255,255,255,0.2)'; | |
hourButton.style.border = 'none'; | |
hourButton.style.borderRadius = '12px'; | |
hourButton.style.padding = '4px 8px'; | |
hourButton.style.color = 'white'; | |
hourButton.style.cursor = 'pointer'; | |
hourButton.style.fontSize = '10px'; | |
hourButton.style.minWidth = '28px'; | |
L.DomEvent.on(hourButton, 'click', function() {{ | |
// Update all hour buttons | |
var allHourButtons = timelineContainer.querySelectorAll('.forecast-hour-btn'); | |
allHourButtons.forEach(function(btn) {{ | |
btn.style.background = 'rgba(255,255,255,0.2)'; | |
}}); | |
hourButton.style.background = 'rgba(59,130,246,0.8)'; | |
// Switch to this forecast hour | |
switchToForecastHour(hour); | |
}}); | |
}}); | |
// Next button | |
var nextButton = L.DomUtil.create('button', 'forecast-next', forecastControlDiv); | |
nextButton.innerHTML = '⏭'; | |
nextButton.style.background = 'rgba(255,255,255,0.2)'; | |
nextButton.style.border = 'none'; | |
nextButton.style.borderRadius = '50%'; | |
nextButton.style.width = '30px'; | |
nextButton.style.height = '30px'; | |
nextButton.style.color = 'white'; | |
nextButton.style.cursor = 'pointer'; | |
nextButton.style.fontSize = '14px'; | |
// Navigation button event handlers | |
L.DomEvent.on(prevButton, 'click', function() {{ | |
var currentIndex = availableForecastHours.indexOf(currentForecastHour); | |
if (currentIndex > 0) {{ | |
var prevHour = availableForecastHours[currentIndex - 1]; | |
// Update hour button highlights | |
var allHourButtons = timelineContainer.querySelectorAll('.forecast-hour-btn'); | |
allHourButtons.forEach(function(btn, index) {{ | |
btn.style.background = index === (currentIndex - 1) ? 'rgba(59,130,246,0.8)' : 'rgba(255,255,255,0.2)'; | |
}}); | |
switchToForecastHour(prevHour); | |
}} | |
}}); | |
L.DomEvent.on(nextButton, 'click', function() {{ | |
var currentIndex = availableForecastHours.indexOf(currentForecastHour); | |
if (currentIndex < availableForecastHours.length - 1) {{ | |
var nextHour = availableForecastHours[currentIndex + 1]; | |
// Update hour button highlights | |
var allHourButtons = timelineContainer.querySelectorAll('.forecast-hour-btn'); | |
allHourButtons.forEach(function(btn, index) {{ | |
btn.style.background = index === (currentIndex + 1) ? 'rgba(59,130,246,0.8)' : 'rgba(255,255,255,0.2)'; | |
}}); | |
switchToForecastHour(nextHour); | |
}} | |
}}); | |
// Add forecast controls to map | |
map.getContainer().appendChild(forecastControlDiv); | |
console.log("✅ Forecast timeline controls added!"); | |
}} | |
// Add controls to map | |
map.getContainer().appendChild(windControlDiv); | |
console.log("✅ Toggle controls added to map!"); | |
console.log("✅ Theme toggle added to map!"); | |
// Force a map update to trigger particle rendering | |
setTimeout(function() {{ | |
map.invalidateSize(); | |
console.log("Map size invalidated to trigger particles"); | |
}}, 1000); | |
}} catch (error) {{ | |
console.error("Error creating velocity layer:", error); | |
}} | |
}}, 2000); | |
</script> | |
""" | |
m.get_root().html.add_child(Element(js_code)) | |
# Add layer control | |
folium.LayerControl().add_to(m) | |
return m._repr_html_() | |
# Global state to prevent duplicate processing | |
_processing_state = {"is_processing": False, "last_request": None} | |
def update_visualization(region, forecast_mode=False): | |
"""Update wind visualization for selected region and mode""" | |
global _processing_state | |
# Create unique request identifier | |
request_id = f"{region}_{forecast_mode}" | |
# Prevent duplicate processing | |
if _processing_state["is_processing"] and _processing_state["last_request"] == request_id: | |
print(f"⚠️ Skipping duplicate request: {request_id}") | |
return "⏳ Processing previous request...", "⏳ Processing..." | |
_processing_state["is_processing"] = True | |
_processing_state["last_request"] = request_id | |
try: | |
mode_str = "forecast" if forecast_mode else "current" | |
print(f"🔄 Creating {mode_str} wind visualization for {region}") | |
map_html = create_wind_map(region, forecast_mode) | |
if forecast_mode: | |
success_msg = f"📅 Forecast visualization loaded for {region.replace('_', ' ').title()} (0h-24h)" | |
else: | |
success_msg = f"✅ Current wind data loaded for {region.replace('_', ' ').title()}" | |
print(success_msg) | |
return map_html, success_msg | |
except Exception as e: | |
error_msg = f"❌ Error: {str(e)}" | |
print(error_msg) | |
return f"<div style='padding: 20px; color: red;'>Error: {str(e)}</div>", error_msg | |
finally: | |
# Reset processing state | |
_processing_state["is_processing"] = False | |
# Create Gradio interface | |
with gr.Blocks(title="Wind Particle Visualization") as app: | |
gr.Markdown(""" | |
# 🌪️ ECMWF Wind Visualization with Forecast - v4.0 LIVE | |
**Real ECMWF wind data with forecast timeline** | |
🎛️ **TOP-RIGHT CORNER CONTROLS:** | |
- 🌪️ **10m Surface Winds** (blue particles) | |
- 🚁 **100m Altitude Winds** (red particles) | |
- 🌙 **Dark/Light Theme Toggle** (switches map & particle colors) | |
📅 **FORECAST MODE:** Timeline controls at bottom for 0h-24h forecasts | |
⚡ **FEATURES:** Real ECMWF data • Forecast timeline • Instant particle clearing | |
*Updated: August 15, 2025 - FORECAST VERSION* | |
""") | |
with gr.Row(): | |
with gr.Column(scale=1): | |
region = gr.Radio( | |
choices=["global", "north_america", "europe"], | |
value="global", | |
label="🗺️ Region" | |
) | |
forecast_toggle = gr.Checkbox( | |
label="📅 Forecast Mode", | |
value=False, | |
info="Enable to see 0h-24h forecast timeline" | |
) | |
update_btn = gr.Button("🌪️ Update Visualization", variant="primary") | |
status = gr.Textbox( | |
label="Status", | |
lines=3, | |
value="🔄 Loading ECMWF dual wind visualization v3.0...\n🎛️ Look for 3 buttons in top-right corner of map!\n⚡ All features: Theme toggle, particle clearing, dual winds" | |
) | |
gr.Markdown(""" | |
### ✨ Features: | |
- **Dual wind levels**: 10m (blue) and 100m (red) wind visualization | |
- **Toggle controls**: Independent on/off buttons for each wind level | |
- **Adaptive colors**: Lighter colors on dark theme, darker on light theme | |
- **Dynamic tails**: Smaller tails and slower speed for low winds | |
- **Theme switching**: Colors update instantly when changing themes | |
### 🌪️ Wind Levels: | |
- **10m winds** (surface): Blue particles, standard speed | |
- **100m winds** (altitude): Red particles, stronger/faster | |
### 🎯 Controls: | |
- **Top-right corner**: Toggle buttons for wind layers | |
- **Layer control**: Switch between dark/light map themes | |
- **Wind displays**: Bottom corners show current wind data | |
### 🎯 Troubleshooting: | |
- **Wait 2-3 seconds** for particles to appear | |
- **Use toggle buttons** to enable/disable wind layers | |
- **Check browser console** (F12) for error messages | |
- Particles appear as **flowing lines** with theme-appropriate colors | |
""") | |
with gr.Column(scale=3): | |
wind_map = gr.HTML( | |
label="Wind Particle Animation", | |
value="<div style='padding: 40px; text-align: center;'>🔄 Loading wind particle visualization...</div>" | |
) | |
# Event handlers | |
update_btn.click( | |
update_visualization, | |
inputs=[region, forecast_toggle], | |
outputs=[wind_map, status] | |
) | |
region.change( | |
update_visualization, | |
inputs=[region, forecast_toggle], | |
outputs=[wind_map, status] | |
) | |
forecast_toggle.change( | |
update_visualization, | |
inputs=[region, forecast_toggle], | |
outputs=[wind_map, status] | |
) | |
# Auto-load on startup | |
app.load( | |
lambda: update_visualization("global", False), | |
outputs=[wind_map, status] | |
) | |
if __name__ == "__main__": | |
print("🚀 Starting ECMWF Wind Visualization with Forecast v4.0...") | |
print("🌍 Real ECMWF wind data with forecast timeline (0h-24h)") | |
print("📅 Forecast mode available with interactive timeline controls") | |
print("🎛️ Features: Theme toggle, particle clearing, adaptive colors") | |
print("⏰ Build timestamp: August 15, 2025 - FORCE REBUILD") | |
print("========================================") | |
app.launch( | |
server_name="0.0.0.0", | |
server_port=7860, | |
share=False | |
) |