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.
381 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|