Spaces:
Sleeping
Sleeping
| #!/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 | |
| ) |