274 lines
9.7 KiB
JavaScript
274 lines
9.7 KiB
JavaScript
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 }
|
|
}
|