weather-app_v1 / src /geovisor /map_manager.py
chirfort's picture
Refactor popup creation in WeatherMapManager for compact layout and improved readability
5fb8978
"""
Intelligent Map Management System
Dynamic zoom, markers, and weather layer visualization
"""
import folium
import json
from typing import List, Dict, Tuple, Optional
import logging
import math
import re
logger = logging.getLogger(__name__)
class WeatherMapManager:
"""Advanced map management with intelligent features"""
def __init__(self):
self.default_center = (39.8283, -98.5795) # Geographic center of US
self.default_zoom = 4
# Enhanced Weather condition to icon mapping with more specific conditions
self.weather_icons = {
# Sunny/Clear conditions
'sunny': {'icon': 'sun', 'color': 'orange'},
'clear': {'icon': 'sun', 'color': 'orange'},
'fair': {'icon': 'sun', 'color': 'orange'},
'hot': {'icon': 'sun', 'color': 'red'},
'warm': {'icon': 'sun', 'color': 'orange'},
'bright': {'icon': 'sun', 'color': 'yellow'},
'clear skies': {'icon': 'sun', 'color': 'orange'},
# Cloudy conditions
'cloudy': {'icon': 'cloud', 'color': 'gray'},
'partly cloudy': {'icon': 'cloud-sun', 'color': 'lightblue'},
'mostly cloudy': {'icon': 'cloud', 'color': 'gray'},
'overcast': {'icon': 'cloud', 'color': 'darkgray'},
'partly sunny': {'icon': 'cloud-sun', 'color': 'lightblue'},
'mostly sunny': {'icon': 'cloud-sun', 'color': 'orange'},
'scattered clouds': {'icon': 'cloud-sun', 'color': 'lightblue'},
'broken clouds': {'icon': 'cloud', 'color': 'gray'},
'few clouds': {'icon': 'cloud-sun', 'color': 'lightblue'},
# Rainy conditions
'rainy': {'icon': 'cloud-rain', 'color': 'blue'},
'rain': {'icon': 'cloud-rain', 'color': 'blue'},
'light rain': {'icon': 'cloud-drizzle', 'color': 'lightblue'},
'heavy rain': {'icon': 'cloud-showers-heavy', 'color': 'darkblue'},
'drizzle': {'icon': 'cloud-drizzle', 'color': 'lightblue'},
'showers': {'icon': 'cloud-showers-heavy', 'color': 'blue'},
'scattered showers': {'icon': 'cloud-rain', 'color': 'blue'},
'isolated showers': {'icon': 'cloud-rain', 'color': 'lightblue'},
'occasional showers': {'icon': 'cloud-rain', 'color': 'blue'},
'intermittent rain': {'icon': 'cloud-drizzle', 'color': 'lightblue'},
# Stormy conditions
'thunderstorm': {'icon': 'bolt', 'color': 'purple'},
'thunderstorms': {'icon': 'bolt', 'color': 'purple'},
'severe thunderstorms': {'icon': 'bolt', 'color': 'darkred'},
'storm': {'icon': 'bolt', 'color': 'purple'},
'storms': {'icon': 'bolt', 'color': 'purple'},
'lightning': {'icon': 'bolt', 'color': 'purple'},
'severe weather': {'icon': 'bolt', 'color': 'darkred'},
'tornado': {'icon': 'tornado', 'color': 'darkred'},
'hurricane': {'icon': 'hurricane', 'color': 'darkred'},
# Snow conditions
'snow': {'icon': 'snowflake', 'color': 'white'},
'snowy': {'icon': 'snowflake', 'color': 'white'},
'light snow': {'icon': 'snowflake', 'color': 'lightgray'},
'heavy snow': {'icon': 'snowflake', 'color': 'gray'},
'snow showers': {'icon': 'snowflake', 'color': 'lightgray'},
'blizzard': {'icon': 'snowflake', 'color': 'darkgray'},
'flurries': {'icon': 'snowflake', 'color': 'lightgray'},
'snow storm': {'icon': 'snowflake', 'color': 'darkgray'},
'winter storm': {'icon': 'snowflake', 'color': 'darkgray'},
# Windy conditions
'windy': {'icon': 'wind', 'color': 'green'},
'breezy': {'icon': 'wind', 'color': 'lightgreen'},
'gusty': {'icon': 'wind', 'color': 'green'},
'strong winds': {'icon': 'wind', 'color': 'darkgreen'},
'gale': {'icon': 'wind', 'color': 'darkgreen'},
# Foggy/Misty conditions
'fog': {'icon': 'smog', 'color': 'gray'},
'foggy': {'icon': 'smog', 'color': 'gray'},
'mist': {'icon': 'smog', 'color': 'lightgray'},
'misty': {'icon': 'smog', 'color': 'lightgray'},
'haze': {'icon': 'smog', 'color': 'gray'},
'hazy': {'icon': 'smog', 'color': 'gray'},
'dense fog': {'icon': 'smog', 'color': 'darkgray'},
'patchy fog': {'icon': 'smog', 'color': 'lightgray'},
# Mixed conditions
'rain and snow': {'icon': 'cloud-rain', 'color': 'blue'},
'wintry mix': {'icon': 'snowflake', 'color': 'lightblue'},
'sleet': {'icon': 'snowflake', 'color': 'lightblue'},
'freezing rain': {'icon': 'icicles', 'color': 'lightblue'},
'ice': {'icon': 'icicles', 'color': 'lightblue'},
'icy': {'icon': 'icicles', 'color': 'lightblue'},
'freezing drizzle': {'icon': 'icicles', 'color': 'lightblue'},
'mixed precipitation': {'icon': 'cloud-rain', 'color': 'blue'},
# Temperature extremes
'cold': {'icon': 'thermometer-quarter', 'color': 'blue'},
'freezing': {'icon': 'thermometer-empty', 'color': 'lightblue'},
'cool': {'icon': 'thermometer-half', 'color': 'lightblue'},
'extreme heat': {'icon': 'thermometer-full', 'color': 'red'},
'heat wave': {'icon': 'thermometer-full', 'color': 'darkred'},
# Special conditions
'dust storm': {'icon': 'wind', 'color': 'brown'},
'sandstorm': {'icon': 'wind', 'color': 'brown'},
'smoke': {'icon': 'smog', 'color': 'gray'},
'volcanic ash': {'icon': 'smog', 'color': 'darkgray'},
'air quality alert': {'icon': 'exclamation-triangle', 'color': 'red'},
# Default fallback
'default': {'icon': 'cloud', 'color': 'blue'}
}
def _get_weather_icon(self, weather_condition: str) -> Dict[str, str]:
"""Determine appropriate icon and color based on weather condition with enhanced matching"""
if not weather_condition:
return self.weather_icons['default']
# Convert to lowercase for matching
condition_lower = weather_condition.lower().strip()
# Direct match first (exact match)
if condition_lower in self.weather_icons:
return self.weather_icons[condition_lower]
# Enhanced partial matching with priority order
# Priority 1: Severe weather conditions (highest priority)
severe_conditions = [
('tornado', 'tornado'), ('hurricane', 'hurricane'),
('severe thunderstorms', 'severe thunderstorms'),
('severe weather', 'severe weather'), ('blizzard', 'blizzard'),
('winter storm', 'winter storm'), ('heat wave', 'heat wave')
]
for keyword, icon_key in severe_conditions:
if keyword in condition_lower:
return self.weather_icons[icon_key]
# Priority 2: Precipitation types
precip_conditions = [
('freezing rain', 'freezing rain'), ('freezing drizzle', 'freezing drizzle'),
('heavy snow', 'heavy snow'), ('light snow', 'light snow'),
('snow showers', 'snow showers'), ('heavy rain', 'heavy rain'),
('light rain', 'light rain'), ('scattered showers', 'scattered showers'),
('thunderstorms', 'thunderstorms'), ('thunderstorm', 'thunderstorm'),
('wintry mix', 'wintry mix'), ('sleet', 'sleet')
]
for keyword, icon_key in precip_conditions:
if keyword in condition_lower:
return self.weather_icons[icon_key]
# Priority 3: Cloud conditions
cloud_conditions = [
('mostly cloudy', 'mostly cloudy'), ('partly cloudy', 'partly cloudy'),
('scattered clouds', 'scattered clouds'), ('broken clouds', 'broken clouds'),
('few clouds', 'few clouds'), ('mostly sunny', 'mostly sunny'),
('partly sunny', 'partly sunny'), ('overcast', 'overcast')
]
for keyword, icon_key in cloud_conditions:
if keyword in condition_lower:
return self.weather_icons[icon_key]
# Priority 4: Clear conditions
clear_conditions = [
('clear skies', 'clear skies'), ('sunny', 'sunny'), ('clear', 'clear'),
('fair', 'fair'), ('bright', 'bright')
]
for keyword, icon_key in clear_conditions:
if keyword in condition_lower:
return self.weather_icons[icon_key]
# Priority 5: General keyword matching with improved logic
if any(term in condition_lower for term in ['tornado', 'hurricane']):
return self.weather_icons.get('tornado', self.weather_icons['severe weather'])
elif any(term in condition_lower for term in ['thunder', 'lightning', 'storm']):
if 'severe' in condition_lower:
return self.weather_icons['severe thunderstorms']
return self.weather_icons['thunderstorms']
elif any(term in condition_lower for term in ['snow', 'blizzard', 'flurries', 'winter']):
if 'heavy' in condition_lower or 'blizzard' in condition_lower:
return self.weather_icons['heavy snow']
elif 'light' in condition_lower or 'flurries' in condition_lower:
return self.weather_icons['light snow']
return self.weather_icons['snow']
elif any(term in condition_lower for term in ['rain', 'shower', 'drizzle', 'precipitation']):
if 'heavy' in condition_lower:
return self.weather_icons['heavy rain']
elif 'light' in condition_lower or 'drizzle' in condition_lower:
return self.weather_icons['light rain']
elif 'freezing' in condition_lower:
return self.weather_icons['freezing rain']
return self.weather_icons['rain']
elif any(term in condition_lower for term in ['cloud', 'overcast']):
if 'partly' in condition_lower:
return self.weather_icons['partly cloudy']
elif 'mostly' in condition_lower:
return self.weather_icons['mostly cloudy']
return self.weather_icons['cloudy']
elif any(term in condition_lower for term in ['fog', 'mist', 'haze']):
if 'dense' in condition_lower:
return self.weather_icons.get('dense fog', self.weather_icons['fog'])
elif 'patchy' in condition_lower:
return self.weather_icons.get('patchy fog', self.weather_icons['mist'])
return self.weather_icons['fog']
elif any(term in condition_lower for term in ['wind', 'breezy', 'gusty', 'gale']):
if 'strong' in condition_lower or 'gale' in condition_lower:
return self.weather_icons.get('strong winds', self.weather_icons['windy'])
elif 'breezy' in condition_lower:
return self.weather_icons['breezy']
return self.weather_icons['windy']
elif any(term in condition_lower for term in ['hot', 'warm', 'heat']):
if 'extreme' in condition_lower or 'wave' in condition_lower:
return self.weather_icons.get('heat wave', self.weather_icons['hot'])
return self.weather_icons['hot']
elif any(term in condition_lower for term in ['cold', 'cool', 'freezing']):
if 'freezing' in condition_lower:
return self.weather_icons['freezing']
elif 'cold' in condition_lower:
return self.weather_icons['cold']
return self.weather_icons['cool']
elif any(term in condition_lower for term in ['dust', 'sand']):
return self.weather_icons.get('dust storm', self.weather_icons['windy'])
elif any(term in condition_lower for term in ['smoke', 'ash']):
return self.weather_icons.get('smoke', self.weather_icons['fog'])
# Final fallback
return self.weather_icons['default']
def _get_temperature_icon_enhancement(self, temperature: Optional[int]) -> Dict[str, str]:
"""Get additional icon styling based on temperature with enhanced granularity"""
if temperature is None:
return {}
# Enhanced temperature-based color adjustments with more granular ranges
if temperature >= 100:
return {'color': 'darkred'} # Extreme heat
elif temperature >= 95:
return {'color': 'red'} # Very hot
elif temperature >= 85:
return {'color': 'orange'} # Hot
elif temperature >= 75:
return {'color': 'yellow'} # Warm
elif temperature >= 65:
return {'color': 'green'} # Pleasant
elif temperature >= 55:
return {'color': 'lightgreen'} # Cool
elif temperature >= 45:
return {'color': 'lightblue'} # Cold
elif temperature >= 35:
return {'color': 'blue'} # Very cold
elif temperature >= 25:
return {'color': 'purple'} # Freezing
elif temperature >= 15:
return {'color': 'darkblue'} # Very freezing
else:
return {'color': 'black'} # Extreme cold
def _get_weather_icon_with_context(self, weather_condition: str, temperature: Optional[int] = None,
wind_speed: Optional[str] = None) -> Dict[str, str]:
"""Get weather icon with additional context like temperature and wind"""
# Get base icon from weather condition
base_icon = self._get_weather_icon(weather_condition)
# Apply temperature-based color enhancement
if temperature is not None:
temp_enhancement = self._get_temperature_icon_enhancement(temperature)
if temp_enhancement.get('color'):
base_icon = {**base_icon, 'color': temp_enhancement['color']}
# Consider wind speed for icon selection
if wind_speed and isinstance(wind_speed, str):
try:
# Extract wind speed number
import re
wind_match = re.search(r'(\d+)', wind_speed)
if wind_match:
wind_val = int(wind_match.group(1))
# High wind conditions override some icons
if wind_val >= 35: # Very strong winds
if base_icon['icon'] not in ['bolt', 'tornado', 'hurricane']:
# Override with wind icon for extreme wind
base_icon = {'icon': 'wind', 'color': 'darkred'}
elif wind_val >= 25: # Strong winds
if base_icon['icon'] in ['cloud', 'cloud-sun']:
# Modify cloudy conditions to show wind
base_icon['icon'] = 'wind'
base_icon['color'] = 'green'
except:
pass
return base_icon
def _get_weather_emoji(self, weather_condition: str) -> str:
"""Get appropriate emoji for weather condition"""
if not weather_condition:
return "🌤️"
condition_lower = weather_condition.lower()
# Direct emoji mapping
emoji_map = {
'sunny': '☀️', 'clear': '☀️', 'fair': '🌤️', 'hot': '🌡️',
'cloudy': '☁️', 'partly cloudy': '⛅', 'mostly cloudy': '☁️', 'overcast': '☁️',
'partly sunny': '⛅', 'mostly sunny': '🌤️',
'rainy': '🌧️', 'rain': '🌧️', 'light rain': '🌦️', 'heavy rain': '🌧️',
'drizzle': '🌦️', 'showers': '🌦️', 'scattered showers': '🌦️',
'thunderstorm': '⛈️', 'thunderstorms': '⛈️', 'storm': '⛈️', 'storms': '⛈️',
'snow': '❄️', 'snowy': '❄️', 'light snow': '🌨️', 'heavy snow': '❄️',
'snow showers': '🌨️', 'blizzard': '🌨️', 'flurries': '🌨️',
'windy': '💨', 'breezy': '🍃', 'gusty': '💨',
'fog': '🌫️', 'foggy': '🌫️', 'mist': '🌫️', 'misty': '🌫️', 'haze': '🌫️',
'freezing rain': '🧊', 'sleet': '🧊', 'ice': '🧊', 'icy': '🧊'
}
# Direct match
if condition_lower in emoji_map:
return emoji_map[condition_lower]
# Partial matching
for key, emoji in emoji_map.items():
if key in condition_lower:
return emoji
return "🌤️" # Default emoji
def calculate_optimal_bounds(self, coordinates: List[Tuple[float, float]]) -> Dict:
"""Calculate optimal map bounds for given coordinates"""
if not coordinates:
return {'center': self.default_center, 'zoom': self.default_zoom}
if len(coordinates) == 1:
return {'center': coordinates[0], 'zoom': 10}
# Calculate center point
lats = [coord[0] for coord in coordinates]
lons = [coord[1] for coord in coordinates]
center_lat = sum(lats) / len(lats)
center_lon = sum(lons) / len(lons)
# Calculate zoom based on coordinate spread
lat_range = max(lats) - min(lats)
lon_range = max(lons) - min(lons)
max_range = max(lat_range, lon_range)
# Determine appropriate zoom level
if max_range < 0.5:
zoom = 11
elif max_range < 1:
zoom = 9
elif max_range < 3:
zoom = 8
elif max_range < 5:
zoom = 7
elif max_range < 10:
zoom = 6
elif max_range < 20:
zoom = 5
else:
zoom = 4
return {'center': (center_lat, center_lon), 'zoom': zoom}
def create_weather_map(self, cities_data: List[Dict], comparison_mode: bool = False,
show_weather_layers: bool = True) -> str:
"""Create comprehensive weather map with all features"""
try:
# Calculate optimal view
if cities_data:
coordinates = [(city['lat'], city['lon']) for city in cities_data]
bounds = self.calculate_optimal_bounds(coordinates)
else:
bounds = {'center': self.default_center, 'zoom': self.default_zoom}
# Create base map with street map as default
m = folium.Map(
location=bounds['center'],
zoom_start=bounds['zoom'],
tiles='OpenStreetMap',
attr='OpenStreetMap'
)
# Add alternative tile layers
folium.TileLayer(
'CartoDB dark_matter',
name='Dark Theme',
overlay=False,
control=True,
attr='CartoDB & OpenStreetMap contributors'
).add_to(m)
folium.TileLayer(
'CartoDB positron',
name='Light Theme',
overlay=False,
control=True,
attr='CartoDB & OpenStreetMap contributors'
).add_to(m)
# Add weather layers if requested
if show_weather_layers:
self._add_weather_layers(m)
# Add city markers
if cities_data:
self._add_city_markers(m, cities_data)
# Add comparison features
if comparison_mode and len(cities_data) > 1:
self._add_comparison_features(m, cities_data)
# Add map controls
folium.LayerControl().add_to(m)
# Add fullscreen button
from folium.plugins import Fullscreen
Fullscreen().add_to(m)
return m._repr_html_()
except Exception as e:
logger.error(f"Error creating weather map: {e}")
return self._create_error_map(str(e))
def create_enhanced_weather_map(self, cities_data: List[Dict], mcp_data: Dict = None) -> str:
"""Create enhanced weather map with MCP data integration"""
try:
# Calculate optimal view
if cities_data:
coordinates = [(city['lat'], city['lon']) for city in cities_data]
bounds = self.calculate_optimal_bounds(coordinates)
else:
bounds = {'center': self.default_center, 'zoom': self.default_zoom}
# Create base map with enhanced styling
m = folium.Map(
location=bounds['center'],
zoom_start=bounds['zoom'],
tiles='OpenStreetMap',
attr='OpenStreetMap'
)
# Add enhanced tile layers
folium.TileLayer(
'CartoDB dark_matter',
name='Dark Theme',
overlay=False,
control=True,
attr='CartoDB & OpenStreetMap contributors'
).add_to(m)
folium.TileLayer(
'OpenTopoMap',
name='Topographic',
overlay=False,
control=True,
attr='OpenTopoMap contributors'
).add_to(m)
folium.TileLayer(
'CartoDB positron',
name='Light Theme',
overlay=False,
control=True,
attr='CartoDB & OpenStreetMap contributors'
).add_to(m)
# Add weather layers
self._add_weather_layers(m)
# Add enhanced markers with MCP data
for city in cities_data:
city_name = city.get('name', 'Unknown City')
# Enhanced popup with MCP integration
popup_content = self._create_enhanced_popup(city, mcp_data)
# Get weather condition for enhanced icon selection
forecast = city.get('forecast', [])
if forecast:
weather_condition = forecast[0].get('shortForecast', '')
temperature = forecast[0].get('temperature')
wind_speed = forecast[0].get('windSpeed')
# Use enhanced icon system with context
icon_info = self._get_weather_icon_with_context(
weather_condition, temperature, wind_speed
)
else:
icon_info = self.weather_icons['default']
# Enhanced marker with more data and better styling
folium.Marker(
location=[city['lat'], city['lon']],
popup=folium.Popup(popup_content, max_width=300),
tooltip=f"🌤️ {city_name} - Enhanced weather data available",
icon=folium.Icon(
icon=icon_info['icon'],
prefix='fa',
color=icon_info['color'],
icon_color='white'
)
).add_to(m)
# Add enhanced weather circle indicator
if forecast:
current_weather = forecast[0]
current_temp = current_weather.get('temperature', 70)
precip_prob = current_weather.get('precipitationProbability', 0) or 0
weather_condition = current_weather.get('shortForecast', '').lower()
# Enhanced color coding based on temperature and conditions
if 'snow' in weather_condition:
circle_color = '#87ceeb' # Sky blue for snow
elif 'rain' in weather_condition or precip_prob > 50:
circle_color = '#4682b4' # Steel blue for rain
elif current_temp >= 85:
circle_color = '#ff6b35' # Hot orange
elif current_temp >= 75:
circle_color = '#f7931e' # Warm orange
elif current_temp >= 60:
circle_color = '#ffdc00' # Yellow
elif current_temp >= 40:
circle_color = '#00bfff' # Deep sky blue
else:
circle_color = '#6495ed' # Cornflower blue
# Adjust circle size and opacity based on conditions
base_radius = 25000
radius_modifier = (precip_prob / 100) * 15000 # Up to 15km extra for high precipitation
final_radius = base_radius + radius_modifier
fill_opacity = 0.15 + (precip_prob / 100) * 0.25 # 0.15 to 0.4 opacity
folium.Circle(
location=[city['lat'], city['lon']],
radius=final_radius,
popup=f"🌡️ {current_temp}°F | 🌧️ {precip_prob}% chance | Enhanced with MCP data",
tooltip=f"Enhanced weather zone: {current_temp}°F, {precip_prob}% precipitation",
color=circle_color,
fill=True,
fillOpacity=fill_opacity,
weight=2
).add_to(m)
# Add layer control and fullscreen
folium.LayerControl().add_to(m)
try:
from folium.plugins import Fullscreen
Fullscreen().add_to(m)
except ImportError:
pass # Fullscreen plugin not available
return m._repr_html_()
except Exception as e:
logger.error(f"Error creating enhanced weather map: {e}")
return self._create_error_map(str(e))
def _add_weather_layers(self, map_obj: folium.Map):
"""Add weather overlay layers"""
try:
# Precipitation radar layer
precipitation_layer = folium.raster_layers.WmsTileLayer(
url='https://mesonet.agron.iastate.edu/cgi-bin/wms/nexrad/n0r.cgi',
layers='nexrad-n0r-900913',
name='Precipitation Radar',
overlay=True,
control=True,
transparent=True,
format='image/png',
opacity=0.6,
attr='Iowa Environmental Mesonet'
)
precipitation_layer.add_to(map_obj)
# Temperature layer (simplified)
# Note: In production, you'd use actual weather service WMS layers
except Exception as e:
logger.warning(f"Could not add weather layers: {e}")
def _add_city_markers(self, map_obj: folium.Map, cities_data: List[Dict]):
"""Add markers for cities with weather information using enhanced icon system"""
for i, city_data in enumerate(cities_data):
# Get weather condition and temperature from forecast
forecast = city_data.get('forecast', [])
current_weather = forecast[0] if forecast else {}
weather_condition = current_weather.get('shortForecast', '')
temperature = current_weather.get('temperature')
wind_speed = current_weather.get('windSpeed')
# Use enhanced weather icon selection with context
weather_icon_info = self._get_weather_icon_with_context(
weather_condition, temperature, wind_speed
)
icon_name = weather_icon_info['icon']
icon_color = weather_icon_info['color']
# Create enhanced weather condition tooltip with more details
weather_tooltip = f"📍 {city_data['name'].title()}"
if weather_condition:
weather_tooltip += f" - {weather_condition}"
if temperature:
weather_tooltip += f" ({temperature}°F)"
if wind_speed:
weather_tooltip += f" | Wind: {wind_speed}"
weather_tooltip += " - Click for details"
# Create detailed popup
popup_html = self._create_popup_html(city_data)
# Add main marker with enhanced weather-appropriate icon
folium.Marker(
location=[city_data['lat'], city_data['lon']],
popup=folium.Popup(popup_html, max_width=420),
tooltip=weather_tooltip,
icon=folium.Icon(
color=icon_color,
icon=icon_name,
prefix='fa',
icon_color='white' # Ensure icon is visible
)
).add_to(map_obj)
# Add wind flow arrow if wind data is available
if wind_speed and current_weather.get('windDirection'):
self._add_wind_arrow(map_obj, city_data['lat'], city_data['lon'],
wind_speed, current_weather['windDirection'])
# Add enhanced weather circle indicator with precipitation info
if forecast:
current_temp = current_weather.get('temperature', 0)
precip_prob = current_weather.get('precipitationProbability', 0) or 0
# Enhanced color coding based on temperature and precipitation
if current_temp >= 95:
circle_color = 'darkred'
elif current_temp >= 85:
circle_color = 'red'
elif current_temp >= 75:
circle_color = 'orange'
elif current_temp >= 65:
circle_color = 'yellow'
elif current_temp >= 55:
circle_color = 'green'
elif current_temp >= 45:
circle_color = 'lightblue'
elif current_temp >= 35:
circle_color = 'blue'
else:
circle_color = 'purple'
# Adjust circle opacity based on precipitation probability
fill_opacity = 0.2 + (precip_prob / 100) * 0.3 # 0.2 to 0.5 opacity
folium.Circle(
location=[city_data['lat'], city_data['lon']],
radius=25000 + (precip_prob * 200), # Size varies with precipitation
popup=f"🌡️ {current_temp}°F | 🌧️ {precip_prob}% chance",
tooltip=f"Temperature zone with {precip_prob}% rain chance",
color=circle_color,
fill=True,
fillOpacity=fill_opacity,
weight=2
).add_to(map_obj)
def _add_comparison_features(self, map_obj: folium.Map, cities_data: List[Dict]):
"""Add comparison lines and features between cities"""
if len(cities_data) < 2:
return
# Add connection lines between all cities
coordinates = [[city['lat'], city['lon']] for city in cities_data]
# Create comparison route
folium.PolyLine(
coordinates,
color='yellow',
weight=4,
opacity=0.8,
popup="Weather Comparison Route",
tooltip="Cities being compared"
).add_to(map_obj)
# Add midpoint marker for comparison summary
if len(cities_data) == 2:
city1, city2 = cities_data[0], cities_data[1]
mid_lat = (city1['lat'] + city2['lat']) / 2
mid_lon = (city1['lon'] + city2['lon']) / 2
comparison_summary = self._create_comparison_summary(city1, city2)
folium.Marker(
location=[mid_lat, mid_lon],
popup=folium.Popup(comparison_summary, max_width=400),
tooltip="Comparison Summary",
icon=folium.Icon(
color='lightblue',
icon='balance-scale',
prefix='fa'
)
).add_to(map_obj)
def _create_popup_html(self, city_data: Dict) -> str:
"""Create detailed HTML popup for city marker"""
forecast = city_data.get('forecast', [])
if not forecast:
return f"""
<div style="width: 300px; font-family: Arial, sans-serif;">
<h3 style="margin: 0; color: #2c3e50;">📍 {city_data['name'].title()}</h3>
<p>No weather data available</p>
</div>
"""
current = forecast[0]
next_period = forecast[1] if len(forecast) > 1 else {}
html = f"""
<div style="width: 320px; font-family: Arial, sans-serif; line-height: 1.4;">
<h3 style="margin: 0 0 10px 0; color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 5px;">
📍 {city_data['name'].title()}
</h3>
<div style="background: #f8f9fa; padding: 10px; border-radius: 5px; margin-bottom: 10px;">
<h4 style="margin: 0 0 8px 0; color: #e74c3c;">🌡️ Current Conditions</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 5px; font-size: 13px;">
<div><strong>Temperature:</strong></div>
<div>{current.get('temperature', 'N/A')}°{current.get('temperatureUnit', 'F')}</div>
<div><strong>Conditions:</strong></div>
<div>{current.get('shortForecast', 'N/A')}</div>
<div><strong>Wind:</strong></div>
<div>{current.get('windSpeed', 'N/A')} {current.get('windDirection', '')}</div>
<div><strong>Rain Chance:</strong></div>
<div>{current.get('precipitationProbability', 0)}%</div>
</div>
</div>
<div style="background: #e8f4f8; padding: 10px; border-radius: 5px; margin-bottom: 10px;">
<h4 style="margin: 0 0 8px 0; color: #2980b9;">📅 Next Period</h4>
<div style="font-size: 13px;">
<strong>{next_period.get('name', 'N/A')}:</strong><br>
{next_period.get('temperature', 'N/A')}°{next_period.get('temperatureUnit', 'F')} -
{next_period.get('shortForecast', 'N/A')}
</div>
</div>
<div style="background: #fff3cd; padding: 8px; border-radius: 5px; font-size: 12px;">
<strong>📝 Details:</strong><br>
{current.get('detailedForecast', 'No detailed forecast available')[:150]}...
</div>
<div style="text-align: center; margin-top: 10px; font-size: 11px; color: #6c757d;">
📊 Coordinates: {city_data['lat']:.3f}°, {city_data['lon']:.3f}°
</div>
</div>
"""
return html
def _create_comparison_summary(self, city1: Dict, city2: Dict) -> str:
"""Create comparison summary popup"""
name1, name2 = city1['name'].title(), city2['name'].title()
forecast1 = city1.get('forecast', [{}])[0]
forecast2 = city2.get('forecast', [{}])[0]
temp1 = forecast1.get('temperature', 0)
temp2 = forecast2.get('temperature', 0)
temp_diff = abs(temp1 - temp2)
warmer_city = name1 if temp1 > temp2 else name2
rain1 = forecast1.get('precipitationProbability', 0)
rain2 = forecast2.get('precipitationProbability', 0)
rain_diff = abs(rain1 - rain2)
rainier_city = name1 if rain1 > rain2 else name2
html = f"""
<div style="width: 350px; font-family: Arial, sans-serif;">
<h3 style="margin: 0 0 15px 0; color: #2c3e50; text-align: center; border-bottom: 2px solid #f39c12; padding-bottom: 8px;">
⚖️ Weather Comparison
</h3>
<div style="background: linear-gradient(135deg, #667eea, #764ba2); color: white; padding: 12px; border-radius: 8px; margin-bottom: 15px;">
<h4 style="margin: 0 0 10px 0; text-align: center;">🌡️ Temperature Comparison</h4>
<div style="display: grid; grid-template-columns: 1fr auto 1fr; gap: 10px; align-items: center;">
<div style="text-align: center;">
<div style="font-weight: bold;">{name1}</div>
<div style="font-size: 18px;">{temp1}°F</div>
</div>
<div style="text-align: center; font-size: 20px;">VS</div>
<div style="text-align: center;">
<div style="font-weight: bold;">{name2}</div>
<div style="font-size: 18px;">{temp2}°F</div>
</div>
</div>
<div style="text-align: center; margin-top: 10px; font-size: 14px;">
<strong>{warmer_city}</strong> is {temp_diff}°F warmer
</div>
</div>
<div style="background: linear-gradient(135deg, #4ecdc4, #44a08d); color: white; padding: 12px; border-radius: 8px; margin-bottom: 15px;">
<h4 style="margin: 0 0 10px 0; text-align: center;">🌧️ Precipitation Comparison</h4>
<div style="display: grid; grid-template-columns: 1fr auto 1fr; gap: 10px; align-items: center;">
<div style="text-align: center;">
<div style="font-weight: bold;">{name1}</div>
<div style="font-size: 18px;">{rain1}%</div>
</div>
<div style="text-align: center; font-size: 20px;">VS</div>
<div style="text-align: center;">
<div style="font-weight: bold;">{name2}</div>
<div style="font-size: 18px;">{rain2}%</div>
</div>
</div>
<div style="text-align: center; margin-top: 10px; font-size: 14px;">
<strong>{rainier_city}</strong> has {rain_diff}% higher chance
</div>
</div>
<div style="background: #f8f9fa; padding: 10px; border-radius: 5px; font-size: 13px; text-align: center;">
<strong>📏 Distance:</strong> {self._calculate_distance(city1['lat'], city1['lon'], city2['lat'], city2['lon']):.0f} miles
</div>
</div>
"""
return html
def _calculate_distance(self, lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Calculate distance between two points using Haversine formula"""
R = 3959 # Earth's radius in miles
lat1_rad = math.radians(lat1)
lat2_rad = math.radians(lat2)
delta_lat = math.radians(lat2 - lat1)
delta_lon = math.radians(lon2 - lon1)
a = (math.sin(delta_lat / 2) ** 2 +
math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon / 2) ** 2)
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return R * c
def _create_error_map(self, error_message: str) -> str:
"""Create error map when main map creation fails"""
try:
m = folium.Map(
location=self.default_center,
zoom_start=self.default_zoom,
tiles='CartoDB dark_matter'
)
folium.Marker(
location=self.default_center,
popup=f"Error: {error_message}",
tooltip="Map Error",
icon=folium.Icon(color='red', icon='exclamation-triangle', prefix='fa')
).add_to(m)
return m._repr_html_()
except:
return f"""
<div style="width: 100%; height: 400px; background: #2c3e50; color: white;
display: flex; align-items: center; justify-content: center;
font-family: Arial, sans-serif; border-radius: 10px;">
<div style="text-align: center;">
<h3>🗺️ Map Error</h3>
<p>Unable to load map: {error_message}</p>
</div>
</div>
"""
def _add_wind_arrow(self, map_obj: folium.Map, lat: float, lon: float,
wind_speed: str, wind_direction: str) -> None:
"""Add wind flow arrow to the map"""
try:
# Parse wind speed and direction
if not wind_speed or not wind_direction or wind_speed == 'N/A':
return
# Extract numeric wind speed
speed_match = re.search(r'(\d+)', str(wind_speed))
if not speed_match:
return
speed = int(speed_match.group(1))
# Convert wind direction to degrees
direction_map = {
'N': 0, 'NNE': 22.5, 'NE': 45, 'ENE': 67.5,
'E': 90, 'ESE': 112.5, 'SE': 135, 'SSE': 157.5,
'S': 180, 'SSW': 202.5, 'SW': 225, 'WSW': 247.5,
'W': 270, 'WNW': 292.5, 'NW': 315, 'NNW': 337.5
}
direction_deg = direction_map.get(wind_direction.upper(), 0)
# Calculate wind arrow properties
arrow_length = min(max(speed * 0.001, 0.01), 0.05) # Scale arrow length
arrow_color = self._get_wind_arrow_color(speed)
# Calculate arrow endpoint
import math
# Convert to radians and adjust for map orientation (wind direction is "from")
rad = math.radians(direction_deg + 180) # +180 because wind direction is "from"
end_lat = lat + arrow_length * math.cos(rad)
end_lon = lon + arrow_length * math.sin(rad)
# Create wind arrow as a polyline with arrowhead
folium.PolyLine(
locations=[(lat, lon), (end_lat, end_lon)],
color=arrow_color,
weight=4,
opacity=0.8,
popup=f"Wind: {wind_speed} from {wind_direction}",
tooltip=f"💨 {wind_speed} {wind_direction}"
).add_to(map_obj)
# Add arrowhead marker
folium.Marker(
location=[end_lat, end_lon],
icon=folium.Icon(
icon='arrow-up',
prefix='fa',
color=arrow_color,
icon_size=(10, 10)
),
popup=f"Wind: {wind_speed} from {wind_direction}"
).add_to(map_obj)
except Exception as e:
logger.warning(f"Could not add wind arrow: {e}")
def _get_wind_arrow_color(self, speed: int) -> str:
"""Get color for wind arrow based on speed"""
if speed >= 30:
return 'red' # Strong winds
elif speed >= 20:
return 'orange' # Moderate winds
elif speed >= 10:
return 'yellow' # Light winds
else:
return 'green' # Calm winds
def _create_enhanced_popup(self, city: Dict, mcp_data: Dict = None) -> str:
"""Create compact horizontal-layout popup for better map integration"""
try:
city_name = city.get('name', 'Unknown City').title()
forecast = city.get('forecast', [])
if not forecast:
return f"""
<div style="width: 320px; font-family: Arial, sans-serif;">
<h3 style="color: #2c3e50; margin-bottom: 10px; text-align: center;">📍 {city_name}</h3>
<p style="text-align: center; color: #777;">No weather data available</p>
</div>
"""
current = forecast[0]
next_period = forecast[1] if len(forecast) > 1 else {}
# Extract essential weather data
temperature = current.get('temperature', 'N/A')
temp_unit = current.get('temperatureUnit', 'F')
feels_like = current.get('apparentTemperature', {})
feels_like_val = feels_like.get('value', 'N/A') if isinstance(feels_like, dict) else 'N/A'
conditions = current.get('shortForecast', 'N/A')
# Compact weather details
wind_speed = current.get('windSpeed', 'N/A')
wind_direction = current.get('windDirection', '')
precip_prob = current.get('precipitationProbability', 0) or 0
humidity = current.get('relativeHumidity', {})
humidity_val = humidity.get('value', 'N/A') if isinstance(humidity, dict) else 'N/A'
# Get weather emoji
weather_emoji = self._get_weather_emoji(conditions)
# Create compact horizontal popup content
popup_content = f"""
<div style="width: 320px; font-family: Arial, sans-serif; line-height: 1.2;">
<!-- Header with gradient background -->
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 8px; border-radius: 8px 8px 0 0; text-align: center; margin-bottom: 8px;">
<h3 style="margin: 0; font-size: 16px; font-weight: bold;">
{weather_emoji} {city_name}
</h3>
<p style="margin: 2px 0 0 0; font-size: 11px; opacity: 0.9;">
📍 {city.get('lat', 'N/A'):.3f}°, {city.get('lon', 'N/A'):.3f}°
</p>
</div>
<!-- Main temperature and conditions in horizontal layout -->
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
<div style="flex: 1; background: #f8f9fa; padding: 8px; border-radius: 6px; border-left: 4px solid #e74c3c;">
<div style="font-size: 20px; font-weight: bold; color: #e74c3c; margin-bottom: 2px;">
{temperature}°{temp_unit}
</div>
<div style="font-size: 10px; color: #666;">
Feels {feels_like_val}°{temp_unit}
</div>
</div>
<div style="flex: 2; background: #e8f4f8; padding: 8px; border-radius: 6px; border-left: 4px solid #2980b9;">
<div style="font-size: 12px; font-weight: bold; color: #2980b9; margin-bottom: 2px;">
{conditions}
</div>
<div style="font-size: 10px; color: #666;">
💧 {humidity_val}% • 🌧️ {precip_prob}%
</div>
</div>
</div>
<!-- Wind and additional data in compact grid -->
<div style="display: flex; gap: 6px; margin-bottom: 8px;">
<div style="flex: 1; background: #fff3cd; padding: 6px; border-radius: 4px; text-align: center;">
<div style="font-size: 11px; font-weight: bold; color: #856404;">💨 Wind</div>
<div style="font-size: 10px; color: #666;">{wind_speed} {wind_direction}</div>
</div>
"""
# Add next period in compact format
if next_period:
next_temp = next_period.get('temperature', 'N/A')
next_conditions = next_period.get('shortForecast', 'N/A')[:15] + ('...' if len(next_period.get('shortForecast', '')) > 15 else '')
next_name = next_period.get('name', 'Next')[:8] # Truncate long period names
popup_content += f"""
<div style="flex: 2; background: #e6f3ff; padding: 6px; border-radius: 4px; text-align: center;">
<div style="font-size: 11px; font-weight: bold; color: #0056b3;">📅 {next_name}</div>
<div style="font-size: 10px; color: #666;">{next_temp}°F • {next_conditions}</div>
</div>
</div>
"""
else:
popup_content += "</div>"
# Add compact 3-day outlook if available
if len(forecast) > 2:
popup_content += f"""
<div style="background: #f0f8ff; padding: 6px; border-radius: 6px; margin-bottom: 8px;">
<div style="font-size: 11px; font-weight: bold; color: #4169e1; margin-bottom: 4px; text-align: center;">
📊 3-Day Outlook
</div>
<div style="display: flex; gap: 4px;">
"""
for i, period in enumerate(forecast[2:5]): # Next 3 periods only
period_name = period.get('name', f'D{i+3}')[:3] # Very short names
period_temp = period.get('temperature', 'N/A')
period_emoji = self._get_weather_emoji(period.get('shortForecast', ''))
popup_content += f"""
<div style="flex: 1; text-align: center; background: rgba(255,255,255,0.7); padding: 4px; border-radius: 3px;">
<div style="font-size: 9px; font-weight: bold;">{period_name}</div>
<div style="font-size: 12px;">{period_emoji}</div>
<div style="font-size: 9px;">{period_temp}°F</div>
</div>
"""
popup_content += "</div></div>"
# Add compact MCP data if available
if mcp_data:
mcp_section = self._create_compact_mcp_section(city_name, mcp_data)
if mcp_section:
popup_content += mcp_section
# Compact footer
popup_content += f"""
<div style="background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); color: white; padding: 4px; border-radius: 0 0 8px 8px; text-align: center;">
<div style="font-size: 9px; opacity: 0.9;">
🤖 Enhanced Weather Intelligence
</div>
</div>
</div>
"""
return popup_content
except Exception as e:
logger.error(f"Error creating enhanced popup: {e}")
return f"""
<div style="width: 300px;">
<h3 style="color: #d32f2f;">❌ Error Loading Weather Data</h3>
<p>Unable to load weather data for {city.get('name', 'Unknown City')}</p>
<p style="font-size: 12px; color: #666;">Error: {str(e)}</p>
</div>
"""
def _create_compact_mcp_section(self, city_name: str, mcp_data: Dict) -> str:
"""Create compact MCP section for horizontal layout popup"""
if not mcp_data:
return ""
# Find matching city data (case insensitive)
city_mcp = None
for key, value in mcp_data.items():
if key.lower() == city_name.lower():
city_mcp = value
break
if not city_mcp:
return ""
mcp_html = """
<div style="background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); color: white; padding: 6px; border-radius: 6px; margin-bottom: 6px;">
<div style="font-size: 11px; font-weight: bold; margin-bottom: 4px; text-align: center;">
🔬 Enhanced Intelligence
</div>
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
"""
# Compact data items in horizontal pills
compact_items = []
# Air Quality Data
if 'air_quality' in city_mcp or any('air' in str(k).lower() for k in city_mcp.keys()):
air_quality = str(city_mcp.get('air_quality', 'Good'))[:10]
compact_items.append(f'<span style="background: rgba(255,255,255,0.3); padding: 2px 6px; border-radius: 10px; font-size: 9px; white-space: nowrap;">🌬️ {air_quality}</span>')
# Historical Data
if 'historical_avg' in city_mcp or any('historical' in str(k).lower() for k in city_mcp.keys()):
historical = str(city_mcp.get('historical_avg', 'N/A'))[:8]
compact_items.append(f'<span style="background: rgba(255,255,255,0.3); padding: 2px 6px; border-radius: 10px; font-size: 9px; white-space: nowrap;">📊 {historical}</span>')
# UV Index
if 'uv_index' in city_mcp or any('uv' in str(k).lower() for k in city_mcp.keys()):
uv = str(city_mcp.get('uv_index', 'Mod'))[:8]
compact_items.append(f'<span style="background: rgba(255,255,255,0.3); padding: 2px 6px; border-radius: 10px; font-size: 9px; white-space: nowrap;">☀️ {uv}</span>')
# Marine/Travel (prioritize most relevant)
if 'marine_conditions' in city_mcp:
marine = str(city_mcp.get('marine_conditions', ''))[:8]
compact_items.append(f'<span style="background: rgba(255,255,255,0.3); padding: 2px 6px; border-radius: 10px; font-size: 9px; white-space: nowrap;">🌊 {marine}</span>')
elif 'travel_advice' in city_mcp:
travel = str(city_mcp.get('travel_advice', ''))[:8]
compact_items.append(f'<span style="background: rgba(255,255,255,0.3); padding: 2px 6px; border-radius: 10px; font-size: 9px; white-space: nowrap;">✈️ {travel}</span>')
# Severe Weather Alerts (high priority)
if any('severe' in str(k).lower() or 'alert' in str(k).lower() for k in city_mcp.keys()):
compact_items.append(f'<span style="background: rgba(255,100,100,0.6); padding: 2px 6px; border-radius: 10px; font-size: 9px; white-space: nowrap;">⚠️ Alert</span>')
# Add up to 4 most important items
for item in compact_items[:4]:
mcp_html += item
mcp_html += """
</div>
</div>
"""
return mcp_html
def create_map_manager() -> WeatherMapManager:
"""Factory function to create map manager"""
return WeatherMapManager()