/** * Weather API service using Open-Meteo (free, no API key required) * https://open-meteo.com/ */ export interface WeatherData { temperature: number; // Celsius temperatureFahrenheit: number; // Fahrenheit feelsLike: number; // Celsius feelsLikeFahrenheit: number; // Fahrenheit weatherCode: number; // WMO Weather Code weatherDescription: string; weatherIcon: string; // Emoji precipitation: number; // mm precipitationProbability: number; // % humidity: number; // % windSpeed: number; // km/h windDirection: number; // degrees windDirectionLabel: string; // N, NE, E, etc. cloudCover: number; // % visibility: number; // meters uvIndex: number; isDay: boolean; timestamp: number; } export interface AirQualityData { aqi: number; // Air Quality Index (US EPA standard) aqiLabel: string; // Good, Moderate, Unhealthy, etc. aqiColor: string; // Color for UI pm25: number; // PM2.5 concentration pm10: number; // PM10 concentration timestamp: number; } export interface WeatherForecast { daily: { date: string; maxTemp: number; minTemp: number; weatherCode: number; weatherIcon: string; precipitationProbability: number; }[]; } export interface SkateabilityScore { score: number; // 0-100 label: string; // Perfect, Good, Okay, Poor, Unrideable color: string; // green, yellow, orange, red reasons: string[]; } // WMO Weather Codes to emoji and description const weatherCodes: Record = { 0: { icon: '☀️', description: 'Clear sky' }, 1: { icon: '🌤️', description: 'Mainly clear' }, 2: { icon: '⛅', description: 'Partly cloudy' }, 3: { icon: '☁️', description: 'Overcast' }, 45: { icon: '🌫️', description: 'Foggy' }, 48: { icon: '🌫️', description: 'Depositing rime fog' }, 51: { icon: '🌦️', description: 'Light drizzle' }, 53: { icon: '🌦️', description: 'Moderate drizzle' }, 55: { icon: '🌧️', description: 'Dense drizzle' }, 56: { icon: '🌧️', description: 'Light freezing drizzle' }, 57: { icon: '🌧️', description: 'Dense freezing drizzle' }, 61: { icon: '🌧️', description: 'Slight rain' }, 63: { icon: '🌧️', description: 'Moderate rain' }, 65: { icon: '🌧️', description: 'Heavy rain' }, 66: { icon: '🌧️', description: 'Light freezing rain' }, 67: { icon: '🌧️', description: 'Heavy freezing rain' }, 71: { icon: '🌨️', description: 'Slight snow' }, 73: { icon: '🌨️', description: 'Moderate snow' }, 75: { icon: '🌨️', description: 'Heavy snow' }, 77: { icon: '🌨️', description: 'Snow grains' }, 80: { icon: '🌦️', description: 'Slight rain showers' }, 81: { icon: '🌧️', description: 'Moderate rain showers' }, 82: { icon: '⛈️', description: 'Violent rain showers' }, 85: { icon: '🌨️', description: 'Slight snow showers' }, 86: { icon: '🌨️', description: 'Heavy snow showers' }, 95: { icon: '⛈️', description: 'Thunderstorm' }, 96: { icon: '⛈️', description: 'Thunderstorm with slight hail' }, 99: { icon: '⛈️', description: 'Thunderstorm with heavy hail' }, }; function getWeatherInfo(code: number): { icon: string; description: string } { return weatherCodes[code] || { icon: '❓', description: 'Unknown' }; } function getWindDirection(degrees: number): string { const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']; const index = Math.round(degrees / 22.5) % 16; return directions[index]; } function celsiusToFahrenheit(celsius: number): number { return Math.round((celsius * 9 / 5) + 32); } function getAQIInfo(aqi: number): { label: string; color: string } { if (aqi <= 50) return { label: 'Good', color: '#00e400' }; if (aqi <= 100) return { label: 'Moderate', color: '#ffff00' }; if (aqi <= 150) return { label: 'Unhealthy for Sensitive Groups', color: '#ff7e00' }; if (aqi <= 200) return { label: 'Unhealthy', color: '#ff0000' }; if (aqi <= 300) return { label: 'Very Unhealthy', color: '#8f3f97' }; return { label: 'Hazardous', color: '#7e0023' }; } /** * Fetch current weather for a location */ export async function fetchWeather(lat: number, lng: number): Promise { const params = new URLSearchParams({ latitude: lat.toFixed(6), longitude: lng.toFixed(6), current: 'temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,uv_index', temperature_unit: 'celsius', wind_speed_unit: 'kmh', precipitation_unit: 'mm', timezone: 'auto', }); const response = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`); if (!response.ok) throw new Error('Failed to fetch weather data'); const data = await response.json(); const current = data.current; const weatherInfo = getWeatherInfo(current.weather_code); return { temperature: Math.round(current.temperature_2m), temperatureFahrenheit: celsiusToFahrenheit(current.temperature_2m), feelsLike: Math.round(current.apparent_temperature), feelsLikeFahrenheit: celsiusToFahrenheit(current.apparent_temperature), weatherCode: current.weather_code, weatherDescription: weatherInfo.description, weatherIcon: weatherInfo.icon, precipitation: current.precipitation || 0, precipitationProbability: 0, // Not available in current weather humidity: current.relative_humidity_2m, windSpeed: Math.round(current.wind_speed_10m), windDirection: current.wind_direction_10m, windDirectionLabel: getWindDirection(current.wind_direction_10m), cloudCover: current.cloud_cover, visibility: 10000, // Open-Meteo doesn't provide visibility in free tier uvIndex: current.uv_index || 0, isDay: current.is_day === 1, timestamp: Date.now(), }; } /** * Fetch air quality data for a location */ export async function fetchAirQuality(lat: number, lng: number): Promise { const params = new URLSearchParams({ latitude: lat.toFixed(6), longitude: lng.toFixed(6), current: 'us_aqi,pm10,pm2_5', timezone: 'auto', }); const response = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`); if (!response.ok) throw new Error('Failed to fetch air quality data'); const data = await response.json(); const current = data.current; const aqi = current.us_aqi || 0; const aqiInfo = getAQIInfo(aqi); return { aqi, aqiLabel: aqiInfo.label, aqiColor: aqiInfo.color, pm25: current.pm2_5 || 0, pm10: current.pm10 || 0, timestamp: Date.now(), }; } /** * Fetch 3-day weather forecast for a location */ export async function fetchWeatherForecast(lat: number, lng: number): Promise { const params = new URLSearchParams({ latitude: lat.toFixed(6), longitude: lng.toFixed(6), daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max', temperature_unit: 'celsius', timezone: 'auto', forecast_days: '3', }); const response = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`); if (!response.ok) throw new Error('Failed to fetch forecast data'); const data = await response.json(); return { daily: data.daily.time.map((date: string, index: number) => { const weatherInfo = getWeatherInfo(data.daily.weather_code[index]); return { date, maxTemp: Math.round(data.daily.temperature_2m_max[index]), minTemp: Math.round(data.daily.temperature_2m_min[index]), weatherCode: data.daily.weather_code[index], weatherIcon: weatherInfo.icon, precipitationProbability: data.daily.precipitation_probability_max[index] || 0, }; }), }; } /** * Calculate skateability score based on weather conditions */ export function calculateSkateability( weather: WeatherData, airQuality?: AirQualityData ): SkateabilityScore { let score = 100; const reasons: string[] = []; // Precipitation (dealbreaker) if (weather.precipitation > 0) { score = 0; reasons.push('Currently raining'); return { score, label: 'Unrideable', color: '#ff0000', reasons, }; } // Check for rain/snow conditions const isRaining = [51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(weather.weatherCode); const isSnowing = [71, 73, 75, 77, 85, 86].includes(weather.weatherCode); const isThunderstorm = [95, 96, 99].includes(weather.weatherCode); if (isRaining || isSnowing || isThunderstorm) { score = 0; reasons.push(isSnowing ? 'Snowing' : isThunderstorm ? 'Thunderstorm' : 'Raining'); return { score, label: 'Unrideable', color: '#ff0000', reasons, }; } // Temperature if (weather.temperature < 0) { score -= 50; reasons.push('Freezing temperature'); } else if (weather.temperature < 5) { score -= 30; reasons.push('Very cold'); } else if (weather.temperature > 38) { score -= 40; reasons.push('Extremely hot'); } else if (weather.temperature > 32) { score -= 20; reasons.push('Very hot'); } // Wind if (weather.windSpeed > 40) { score -= 40; reasons.push('Very strong wind'); } else if (weather.windSpeed > 25) { score -= 25; reasons.push('Strong wind'); } else if (weather.windSpeed > 15) { score -= 10; reasons.push('Moderate wind'); } // Air quality if (airQuality) { if (airQuality.aqi > 200) { score -= 40; reasons.push('Very unhealthy air'); } else if (airQuality.aqi > 150) { score -= 25; reasons.push('Unhealthy air'); } else if (airQuality.aqi > 100) { score -= 10; reasons.push('Moderate air quality'); } } // Humidity (affects grip) if (weather.humidity > 90) { score -= 15; reasons.push('Very humid'); } else if (weather.humidity > 75) { score -= 5; reasons.push('Humid'); } // UV Index (safety concern) if (weather.uvIndex > 10) { score -= 10; reasons.push('Extreme UV - wear sunscreen'); } else if (weather.uvIndex > 7) { score -= 5; reasons.push('High UV - sun protection recommended'); } // Ensure score doesn't go below 0 score = Math.max(0, score); // If no negative reasons, add positive feedback if (reasons.length === 0) { reasons.push('Perfect skating conditions'); } let label: string; let color: string; if (score >= 85) { label = 'Perfect'; color = '#00e400'; } else if (score >= 70) { label = 'Good'; color = '#92d050'; } else if (score >= 50) { label = 'Okay'; color = '#ffff00'; } else if (score >= 25) { label = 'Poor'; color = '#ff7e00'; } else { label = 'Bad'; color = '#ff0000'; } return { score, label, color, reasons, }; }