SkateSpots/src/lib/weather.ts
SkateSpots 54882285b6 feat(weather): 6-day forecast
FORECAST_DAYS=6 in lib/weather.ts is the single source for the fetch length and the UI labels (spot card + map popup). New count-interpolated labels spotUi.forecastDays / mapUi.forecastDays (en/es; other locales fall back to English). Legend colours moved to lib/skateabilityLevels.ts to keep the react-refresh lint at zero.
2026-10-12 13:51:29 +07:00

381 lines
14 KiB
TypeScript

/**
* 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;
weatherDescriptionKey: string; // `weatherUi.condition.*` i18n key for weatherDescription
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.
aqiLabelKey: string; // `weatherUi.aqi.*` i18n key for aqiLabel
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;
sunrise: string;
sunset: string;
}[];
}
export interface SkateabilityScore {
score: number; // 0-100
label: string; // Perfect, Good, Okay, Poor, Unrideable
labelKey: string; // `weatherUi.skateability.*` i18n key for label
color: string; // green, yellow, orange, red
reasons: string[];
reasonKeys: string[]; // `weatherUi.reason.*` i18n keys, parallel to `reasons`
}
// WMO Weather Codes to emoji and description. The English `description` stays on
// the object for non-UI (model/API) consumers; the UI renders `descriptionKey`
// through `t()` (see src/i18n/legacy/weather.ts → `weatherUi.condition.*`).
const weatherCodes: Record<number, { icon: string; description: string; descriptionKey: string }> = {
0: { icon: '☀️', description: 'Clear sky', descriptionKey: 'weatherUi.condition.clearSky' },
1: { icon: '🌤️', description: 'Mainly clear', descriptionKey: 'weatherUi.condition.mainlyClear' },
2: { icon: '⛅', description: 'Partly cloudy', descriptionKey: 'weatherUi.condition.partlyCloudy' },
3: { icon: '☁️', description: 'Overcast', descriptionKey: 'weatherUi.condition.overcast' },
45: { icon: '🌫️', description: 'Foggy', descriptionKey: 'weatherUi.condition.foggy' },
48: { icon: '🌫️', description: 'Depositing rime fog', descriptionKey: 'weatherUi.condition.depositingRimeFog' },
51: { icon: '🌦️', description: 'Light drizzle', descriptionKey: 'weatherUi.condition.lightDrizzle' },
53: { icon: '🌦️', description: 'Moderate drizzle', descriptionKey: 'weatherUi.condition.moderateDrizzle' },
55: { icon: '🌧️', description: 'Dense drizzle', descriptionKey: 'weatherUi.condition.denseDrizzle' },
56: { icon: '🌧️', description: 'Light freezing drizzle', descriptionKey: 'weatherUi.condition.lightFreezingDrizzle' },
57: { icon: '🌧️', description: 'Dense freezing drizzle', descriptionKey: 'weatherUi.condition.denseFreezingDrizzle' },
61: { icon: '🌧️', description: 'Slight rain', descriptionKey: 'weatherUi.condition.slightRain' },
63: { icon: '🌧️', description: 'Moderate rain', descriptionKey: 'weatherUi.condition.moderateRain' },
65: { icon: '🌧️', description: 'Heavy rain', descriptionKey: 'weatherUi.condition.heavyRain' },
66: { icon: '🌧️', description: 'Light freezing rain', descriptionKey: 'weatherUi.condition.lightFreezingRain' },
67: { icon: '🌧️', description: 'Heavy freezing rain', descriptionKey: 'weatherUi.condition.heavyFreezingRain' },
71: { icon: '🌨️', description: 'Slight snow', descriptionKey: 'weatherUi.condition.slightSnow' },
73: { icon: '🌨️', description: 'Moderate snow', descriptionKey: 'weatherUi.condition.moderateSnow' },
75: { icon: '🌨️', description: 'Heavy snow', descriptionKey: 'weatherUi.condition.heavySnow' },
77: { icon: '🌨️', description: 'Snow grains', descriptionKey: 'weatherUi.condition.snowGrains' },
80: { icon: '🌦️', description: 'Slight rain showers', descriptionKey: 'weatherUi.condition.slightRainShowers' },
81: { icon: '🌧️', description: 'Moderate rain showers', descriptionKey: 'weatherUi.condition.moderateRainShowers' },
82: { icon: '⛈️', description: 'Violent rain showers', descriptionKey: 'weatherUi.condition.violentRainShowers' },
85: { icon: '🌨️', description: 'Slight snow showers', descriptionKey: 'weatherUi.condition.slightSnowShowers' },
86: { icon: '🌨️', description: 'Heavy snow showers', descriptionKey: 'weatherUi.condition.heavySnowShowers' },
95: { icon: '⛈️', description: 'Thunderstorm', descriptionKey: 'weatherUi.condition.thunderstorm' },
96: { icon: '⛈️', description: 'Thunderstorm with slight hail', descriptionKey: 'weatherUi.condition.thunderstormSlightHail' },
99: { icon: '⛈️', description: 'Thunderstorm with heavy hail', descriptionKey: 'weatherUi.condition.thunderstormHeavyHail' },
};
function getWeatherInfo(code: number): { icon: string; description: string; descriptionKey: string } {
return weatherCodes[code] || { icon: '❓', description: 'Unknown', descriptionKey: 'weatherUi.condition.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; labelKey: string; color: string } {
if (aqi <= 50) return { label: 'Good', labelKey: 'weatherUi.aqi.good', color: '#00e400' };
if (aqi <= 100) return { label: 'Moderate', labelKey: 'weatherUi.aqi.moderate', color: '#ffff00' };
if (aqi <= 150) return { label: 'Unhealthy for Sensitive Groups', labelKey: 'weatherUi.aqi.unhealthySensitive', color: '#ff7e00' };
if (aqi <= 200) return { label: 'Unhealthy', labelKey: 'weatherUi.aqi.unhealthy', color: '#ff0000' };
if (aqi <= 300) return { label: 'Very Unhealthy', labelKey: 'weatherUi.aqi.veryUnhealthy', color: '#8f3f97' };
return { label: 'Hazardous', labelKey: 'weatherUi.aqi.hazardous', color: '#7e0023' };
}
/**
* Fetch current weather for a location
*/
export async function fetchWeather(lat: number, lng: number): Promise<WeatherData> {
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,
weatherDescriptionKey: weatherInfo.descriptionKey,
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<AirQualityData> {
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,
aqiLabelKey: aqiInfo.labelKey,
aqiColor: aqiInfo.color,
pm25: current.pm2_5 || 0,
pm10: current.pm10 || 0,
timestamp: Date.now(),
};
}
/** Forecast length requested and displayed (Open-Meteo supports up to 16 days). */
export const FORECAST_DAYS = 6;
/**
* Fetch a multi-day weather forecast for a location
*/
export async function fetchWeatherForecast(lat: number, lng: number): Promise<WeatherForecast> {
const params = new URLSearchParams({
latitude: lat.toFixed(6),
longitude: lng.toFixed(6),
daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,sunrise,sunset',
temperature_unit: 'celsius',
timezone: 'auto',
forecast_days: String(FORECAST_DAYS),
});
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,
sunrise: data.daily.sunrise?.[index] ?? '',
sunset: data.daily.sunset?.[index] ?? '',
};
}),
};
}
/**
* Calculate skateability score based on weather conditions
*/
export function calculateSkateability(
weather: WeatherData,
airQuality?: AirQualityData
): SkateabilityScore {
let score = 100;
const reasons: string[] = [];
const reasonKeys: string[] = [];
const addReason = (en: string, key: string) => {
reasons.push(en);
reasonKeys.push(key);
};
// Precipitation (dealbreaker)
if (weather.precipitation > 0) {
score = 0;
addReason('Currently raining', 'weatherUi.reason.currentlyRaining');
return {
score,
label: 'Unrideable',
labelKey: 'weatherUi.skateability.unrideable',
color: '#ff0000',
reasons,
reasonKeys,
};
}
// 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;
if (isSnowing) addReason('Snowing', 'weatherUi.reason.snowing');
else if (isThunderstorm) addReason('Thunderstorm', 'weatherUi.reason.thunderstorm');
else addReason('Raining', 'weatherUi.reason.raining');
return {
score,
label: 'Unrideable',
labelKey: 'weatherUi.skateability.unrideable',
color: '#ff0000',
reasons,
reasonKeys,
};
}
// Temperature
if (weather.temperature < 0) {
score -= 50;
addReason('Freezing temperature', 'weatherUi.reason.freezingTemperature');
} else if (weather.temperature < 5) {
score -= 30;
addReason('Very cold', 'weatherUi.reason.veryCold');
} else if (weather.temperature > 38) {
score -= 40;
addReason('Extremely hot', 'weatherUi.reason.extremelyHot');
} else if (weather.temperature > 32) {
score -= 20;
addReason('Very hot', 'weatherUi.reason.veryHot');
}
// Wind
if (weather.windSpeed > 40) {
score -= 40;
addReason('Very strong wind', 'weatherUi.reason.veryStrongWind');
} else if (weather.windSpeed > 25) {
score -= 25;
addReason('Strong wind', 'weatherUi.reason.strongWind');
} else if (weather.windSpeed > 15) {
score -= 10;
addReason('Moderate wind', 'weatherUi.reason.moderateWind');
}
// Air quality
if (airQuality) {
if (airQuality.aqi > 200) {
score -= 40;
addReason('Very unhealthy air', 'weatherUi.reason.veryUnhealthyAir');
} else if (airQuality.aqi > 150) {
score -= 25;
addReason('Unhealthy air', 'weatherUi.reason.unhealthyAir');
} else if (airQuality.aqi > 100) {
score -= 10;
addReason('Moderate air quality', 'weatherUi.reason.moderateAirQuality');
}
}
// Humidity (affects grip)
if (weather.humidity > 90) {
score -= 15;
addReason('Very humid', 'weatherUi.reason.veryHumid');
} else if (weather.humidity > 75) {
score -= 5;
addReason('Humid', 'weatherUi.reason.humid');
}
// UV Index (safety concern)
if (weather.uvIndex > 10) {
score -= 10;
addReason('Extreme UV - wear sunscreen', 'weatherUi.reason.extremeUv');
} else if (weather.uvIndex > 7) {
score -= 5;
addReason('High UV - sun protection recommended', 'weatherUi.reason.highUv');
}
// Ensure score doesn't go below 0
score = Math.max(0, score);
// If no negative reasons, add positive feedback
if (reasons.length === 0) {
addReason('Perfect skating conditions', 'weatherUi.reason.perfectConditions');
}
let label: string;
let labelKey: string;
let color: string;
if (score >= 85) {
label = 'Perfect';
labelKey = 'weatherUi.skateability.perfect';
color = '#00e400';
} else if (score >= 70) {
label = 'Good';
labelKey = 'weatherUi.skateability.good';
color = '#92d050';
} else if (score >= 50) {
label = 'Okay';
labelKey = 'weatherUi.skateability.okay';
color = '#ffff00';
} else if (score >= 25) {
label = 'Poor';
labelKey = 'weatherUi.skateability.poor';
color = '#ff7e00';
} else {
label = 'Bad';
labelKey = 'weatherUi.skateability.bad';
color = '#ff0000';
}
return {
score,
label,
labelKey,
color,
reasons,
reasonKeys,
};
}