import { createWriteStream, existsSync, mkdirSync } from 'fs' import { resolve, dirname } from 'path' import { fileURLToPath } from 'url' import { pipeline } from 'stream/promises' const DIR = dirname(fileURLToPath(import.meta.url)) const ICON_DIR = resolve(DIR, 'public', 'icons') const CACHE = {} const CACHE_MS = 15 * 60 * 1000 if (!existsSync(ICON_DIR)) mkdirSync(ICON_DIR, { recursive: true }) // WMO weather code map const WMO = { 0: { label: 'Clear Sky' }, 1: { label: 'Mainly Clear' }, 2: { label: 'Partly Cloudy' }, 3: { label: 'Overcast' }, 45: { label: 'Fog' }, 48: { label: 'Icy Fog' }, 51: { label: 'Light Drizzle' }, 53: { label: 'Drizzle' }, 55: { label: 'Heavy Drizzle' }, 61: { label: 'Light Rain' }, 63: { label: 'Rain' }, 65: { label: 'Heavy Rain' }, 71: { label: 'Light Snow' }, 73: { label: 'Snow' }, 75: { label: 'Heavy Snow' }, 77: { label: 'Snow Grains' }, 80: { label: 'Light Showers' }, 81: { label: 'Showers' }, 82: { label: 'Heavy Showers' }, 85: { label: 'Snow Showers' }, 86: { label: 'Heavy Snow Showers' }, 95: { label: 'Thunderstorm' }, 96: { label: 'Thunderstorm w/ Hail' }, 99: { label: 'Thunderstorm w/ Heavy Hail' }, } // OWM icon mapping by WMO code + is_day const OWM_ICONS = { 0: { day: '01d', night: '01n' }, 1: { day: '01d', night: '01n' }, 2: { day: '02d', night: '02n' }, 3: { day: '04d', night: '04n' }, 45: { day: '50d', night: '50n' }, 48: { day: '50d', night: '50n' }, 51: { day: '09d', night: '09n' }, 53: { day: '09d', night: '09n' }, 55: { day: '09d', night: '09n' }, 61: { day: '10d', night: '10n' }, 63: { day: '10d', night: '10n' }, 65: { day: '10d', night: '10n' }, 71: { day: '13d', night: '13n' }, 73: { day: '13d', night: '13n' }, 75: { day: '13d', night: '13n' }, 77: { day: '13d', night: '13n' }, 80: { day: '09d', night: '09n' }, 81: { day: '09d', night: '09n' }, 82: { day: '09d', night: '09n' }, 85: { day: '13d', night: '13n' }, 86: { day: '13d', night: '13n' }, 95: { day: '11d', night: '11n' }, 96: { day: '11d', night: '11n' }, 99: { day: '11d', night: '11n' }, } async function ensureIcon(code, isDay) { const variant = isDay ? 'day' : 'night' const owmCode = OWM_ICONS[code]?.[variant] || '01d' const filename = `${owmCode}.png` const filepath = resolve(ICON_DIR, filename) if (!existsSync(filepath)) { const url = `https://openweathermap.org/img/wn/${owmCode}@2x.png` const res = await fetch(url) await pipeline(res.body, createWriteStream(filepath)) } return `/icons/${filename}` } export async function resolveWeather(code, isDay = 1) { const label = WMO[code]?.label || 'Unknown' const icon = await ensureIcon(code, isDay).catch(() => null) return { forecast_weather_label: label, forecast_weather_icon: icon, forecast_weathercode: code } } async function cachedFetch(key, url) { const now = Date.now() if (CACHE[key] && now - CACHE[key].ts < CACHE_MS) return CACHE[key].data const res = await fetch(url) const data = await res.json() CACHE[key] = { ts: now, data } return data } const HOURLY_WEATHER = [ 'temperature_2m', 'relative_humidity_2m', 'precipitation', 'precipitation_probability', 'weathercode', 'windspeed_10m', 'winddirection_10m', 'windgusts_10m', 'pressure_msl', 'cloudcover', 'visibility', 'cape', 'uv_index', 'is_day', 'et0_fao_evapotranspiration', 'dewpoint_2m', 'apparent_temperature', 'snowfall', 'snow_depth', 'freezinglevel_height', ].join(',') const DAILY_WEATHER = [ 'temperature_2m_max', 'temperature_2m_min', 'precipitation_sum', 'precipitation_probability_max', 'windspeed_10m_max', 'windgusts_10m_max', 'winddirection_10m_dominant', 'weathercode', 'sunrise', 'sunset', 'uv_index_max', 'snowfall_sum', 'precipitation_hours', 'shortwave_radiation_sum', ].join(',') const HOURLY_AQ = [ 'pm10', 'pm2_5', 'carbon_monoxide', 'nitrogen_dioxide', 'sulphur_dioxide', 'ozone', 'aerosol_optical_depth', 'dust', 'uv_index', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen', ].join(',') const HOURLY_MARINE = [ 'wave_height', 'wave_direction', 'wave_period', 'wind_wave_height', 'swell_wave_height', 'swell_wave_direction', 'swell_wave_period', ].join(',') function zipHourly(data, prefix = '') { if (!data?.hourly) return [] return data.hourly.time.map((time, i) => { const row = { time } for (const [k, v] of Object.entries(data.hourly)) { if (k !== 'time') row[prefix + k] = v[i] } return row }) } function zipDaily(data, prefix = '') { if (!data?.daily) return [] return data.daily.time.map((time, i) => { const row = { time } for (const [k, v] of Object.entries(data.daily)) { if (k !== 'time') row[prefix + k] = v[i] } return row }) } // Remap Open-Meteo field names to our prefixed convention function remapWeatherFields(row) { const map = { temperature_2m: 'env_temp_c', apparent_temperature: 'env_heat_index_c', dewpoint_2m: 'env_dew_point_c', relative_humidity_2m: 'env_humidity', pressure_msl: 'env_pressure_slp', cloudcover: 'light_cloud_pct', uv_index: 'light_uvi', visibility: 'light_visibility_km', windspeed_10m: 'wind_speed_kmh', winddirection_10m: 'wind_direction_deg', windgusts_10m: 'wind_gusts_kmh', weathercode: 'forecast_weathercode', precipitation: 'forecast_precipitation_mm', precipitation_probability: 'forecast_precipitation_probability', snowfall: 'forecast_snowfall_mm', snow_depth: 'forecast_snow_depth_m', cape: 'forecast_cape', is_day: 'sun_is_day', freezinglevel_height: 'forecast_freezing_level_m', et0_fao_evapotranspiration: 'forecast_evapotranspiration_mm', // daily temperature_2m_max: 'env_temp_max_c', temperature_2m_min: 'env_temp_min_c', precipitation_sum: 'forecast_precipitation_mm', precipitation_probability_max: 'forecast_precipitation_probability', windspeed_10m_max: 'wind_speed_max_kmh', windgusts_10m_max: 'wind_gusts_max_kmh', winddirection_10m_dominant: 'wind_direction_deg', uv_index_max: 'light_uvi_max', snowfall_sum: 'forecast_snowfall_mm', shortwave_radiation_sum: 'light_solar_wm2_sum', sunrise: 'sun_sunrise', sunset: 'sun_sunset', } const out = {} for (const [k, v] of Object.entries(row)) { out[map[k] || k] = v } return out } export async function getOpenMeteo(lat, lon, start, end) { const base = start && end ? `&start_date=${start.slice(0,10)}&end_date=${end.slice(0,10)}&past_days=0` : `&forecast_days=7` const [weather, aq, marine] = await Promise.allSettled([ cachedFetch(`weather_${lat}_${lon}_${start}_${end}`, `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&hourly=${HOURLY_WEATHER}&daily=${DAILY_WEATHER}¤t_weather=true&timezone=auto${base}`), cachedFetch(`aq_${lat}_${lon}_${start}_${end}`, `https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${lat}&longitude=${lon}&hourly=${HOURLY_AQ}&timezone=auto${base}`), cachedFetch(`marine_${lat}_${lon}_${start}_${end}`, `https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lon}&hourly=${HOURLY_MARINE}&daily=wave_height_max,wave_direction_dominant,wave_period_max&timezone=auto${base}`), ]) const w = weather.status === 'fulfilled' ? weather.value : {} const a = aq.status === 'fulfilled' ? aq.value : {} const m = marine.status === 'fulfilled' ? marine.value : {} // Build hourly merged map const hourlyMap = {} for (const row of zipHourly(w)) { const remapped = remapWeatherFields(row) hourlyMap[row.time] = remapped } for (const row of zipHourly(a)) { const { time, ...rest } = row hourlyMap[time] = { ...hourlyMap[time], ...rest } } for (const row of zipHourly(m)) { const { time, ...rest } = row hourlyMap[time] = { ...hourlyMap[time], ...rest } } // Build daily merged map const dailyMap = {} for (const row of zipDaily(w)) { const remapped = remapWeatherFields(row) dailyMap[row.time] = remapped } for (const row of zipDaily(m)) { const { time, ...rest } = row dailyMap[time] = { ...dailyMap[time], ...rest } } // Resolve current weather let current = null if (w.current_weather) { const cw = w.current_weather const wInfo = await resolveWeather(cw.weathercode, cw.is_day).catch(() => ({})) current = { wind_speed_kmh: cw.windspeed, wind_direction_deg: cw.winddirection, sun_is_day: cw.is_day, ...wInfo, } } // Resolve weather labels + icons for all hourly/daily rows const hourlyArr = await Promise.all( Object.values(hourlyMap) .sort((a, b) => a.time.localeCompare(b.time)) .map(async row => { if (row.forecast_weathercode != null) { const wInfo = await resolveWeather(row.forecast_weathercode, row.sun_is_day ?? 1).catch(() => ({})) return { ...row, ...wInfo } } return row }) ) const dailyArr = await Promise.all( Object.values(dailyMap) .sort((a, b) => a.time.localeCompare(b.time)) .map(async row => { if (row.forecast_weathercode != null) { const wInfo = await resolveWeather(row.forecast_weathercode, 1).catch(() => ({})) return { ...row, ...wInfo } } return row }) ) return { current, hourly: hourlyArr, daily: dailyArr } }