275 lines
11 KiB
JavaScript
275 lines
11 KiB
JavaScript
import { existsSync, mkdirSync } from 'fs'
|
|
import { resolve, dirname } from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
import { tzOffsetMinutes } from './config.mjs'
|
|
import { getWeatherCondition } from './forecast.mjs'
|
|
import { getCelestialForecast } from './celestial.mjs'
|
|
import {frostRisk} from './openweather.mjs';
|
|
|
|
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 })
|
|
|
|
const WMO = {
|
|
0: 'Clear Sky', 1: 'Mainly Clear', 2: 'Partly Cloudy',
|
|
3: 'Overcast', 45: 'Fog', 48: 'Icy Fog',
|
|
51: 'Light Drizzle', 53: 'Drizzle', 55: 'Heavy Drizzle',
|
|
61: 'Light Rain', 63: 'Rain', 65: 'Heavy Rain',
|
|
71: 'Light Snow', 73: 'Snow', 75: 'Heavy Snow',
|
|
77: 'Snow Grains', 80: 'Light Showers', 81: 'Showers',
|
|
82: 'Heavy Showers', 85: 'Snow Showers', 86: 'Heavy Snow Showers',
|
|
95: 'Thunderstorm', 96: 'Thunderstorm w/ Hail', 99: 'Thunderstorm w/ Heavy Hail',
|
|
}
|
|
|
|
const OWM_ICONS = {
|
|
0: '01d', 1: '01d', 2: '02d', 3: '04d',
|
|
45: '50d', 48: '50d',
|
|
51: '09d', 53: '09d', 55: '09d',
|
|
61: '10d', 63: '10d', 65: '10d',
|
|
71: '13d', 73: '13d', 75: '13d', 77: '13d',
|
|
80: '09d', 81: '09d', 82: '09d',
|
|
85: '13d', 86: '13d',
|
|
95: '11d', 96: '11d', 99: '11d',
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function dewPoint(tempC, humidity) {
|
|
const a = 17.27, b = 237.7
|
|
const gamma = (a * tempC) / (b + tempC) + Math.log(humidity / 100)
|
|
return Math.round((b * gamma) / (a - gamma) * 100) / 100
|
|
}
|
|
|
|
function heatIndex(tempC, humidity) {
|
|
const T = tempC * 9 / 5 + 32
|
|
if (T < 80) return Math.round(tempC * 100) / 100
|
|
const HI =
|
|
-42.379 + 2.04901523 * T + 10.14333127 * humidity
|
|
- 0.22475541 * T * humidity - 0.00683783 * T * T
|
|
- 0.05481717 * humidity * humidity + 0.00122874 * T * T * humidity
|
|
+ 0.00085282 * T * humidity * humidity - 0.00000199 * T * T * humidity * humidity
|
|
return Math.round((HI - 32) * 5 / 9 * 100) / 100
|
|
}
|
|
|
|
function vaporPressureDeficit(tempC, humidity) {
|
|
const svp = 0.6108 * Math.exp((17.27 * tempC) / (tempC + 237.3))
|
|
return Math.round(svp * (1 - humidity / 100) * 1000) / 1000
|
|
}
|
|
|
|
function absoluteHumidity(tempC, humidity) {
|
|
const svp = 0.6108 * Math.exp((17.27 * tempC) / (tempC + 237.3))
|
|
return Math.round((humidity / 100 * svp * 2165) / (tempC + 273.15) * 1000) / 1000
|
|
}
|
|
|
|
// ── Daily ─────────────────────────────────────────────────────────────────────
|
|
|
|
const DAILY_FIELDS = [
|
|
'temperature_2m_max', 'temperature_2m_min',
|
|
'precipitation_sum', 'precipitation_probability_max',
|
|
'windspeed_10m_max', 'windgusts_10m_max', 'winddirection_10m_dominant',
|
|
'weathercode', 'sunrise', 'sunset', 'relative_humidity_2m_mean',
|
|
'uv_index_max', 'shortwave_radiation_sum',
|
|
].join(',')
|
|
|
|
export async function dailyWeather(lat, lon, start, end) {
|
|
const startStr = start.toISOString().slice(0, 10)
|
|
const endStr = end.toISOString().slice(0, 10)
|
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
|
|
`&daily=${DAILY_FIELDS}&timezone=auto&start_date=${startStr}&end_date=${endStr}`
|
|
|
|
const data = await cachedFetch(`daily_${lat}_${lon}_${startStr}_${endStr}`, url)
|
|
if (!data?.daily?.time) return []
|
|
|
|
const rows = data.daily.time.map((time, i) => {
|
|
const code = data.daily.weathercode[i]
|
|
const icon = OWM_ICONS[code] || '01d'
|
|
const tempMax = data.daily.temperature_2m_max[i]
|
|
const tempMin = data.daily.temperature_2m_min[i]
|
|
const temp = tempMax
|
|
const humidity = data.daily.relative_humidity_2m_mean[i]
|
|
const precip = data.daily.precipitation_sum[i] ?? 0
|
|
const precipChance = data.daily.precipitation_probability_max[i] ?? 0
|
|
const windSpeed = data.daily.windspeed_10m_max[i] ?? 0
|
|
const windGusts = data.daily.windgusts_10m_max[i] ?? 0
|
|
|
|
const utcMidnight = new Date(`${time}T00:00:00Z`)
|
|
const offset = tzOffsetMinutes(utcMidnight)
|
|
const localMidnight = new Date(utcMidnight.getTime() - offset * 60_000)
|
|
|
|
const sunrise = data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null
|
|
const sunset = data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null
|
|
const daylight = sunrise && sunset
|
|
? Math.round((sunset - sunrise) / 3600_000 * 100) / 100
|
|
: null
|
|
|
|
const clouds = humidity / 100 * 0.8 // rough estimate — not provided by daily endpoint
|
|
|
|
const row = {
|
|
time: localMidnight,
|
|
daytime: true,
|
|
label: WMO[code] || 'Unknown',
|
|
icon,
|
|
temperature: temp,
|
|
temperature_max: tempMax,
|
|
temperature_min: tempMin,
|
|
humidity,
|
|
humidity_abs: absoluteHumidity(temp, humidity),
|
|
dew_point: dewPoint(temp, humidity),
|
|
heat_index: heatIndex(temp, humidity),
|
|
vapor_pressure_deficit: vaporPressureDeficit(temp, humidity),
|
|
pressure_hpa: null,
|
|
pressure_slp: null,
|
|
pressure_rate: null,
|
|
storm_trend: 0,
|
|
wind_speed: windSpeed,
|
|
wind_gusts: windGusts,
|
|
wind_direction: data.daily.winddirection_10m_dominant[i] ?? 0,
|
|
clouds,
|
|
raining: precipChance > 60 ? 'True' : 'False',
|
|
precipitation: precip,
|
|
precipitation_chance: precipChance,
|
|
accumulation: precip,
|
|
frost_risk: frostRisk(temp, dewPoint(temp, humidity), humidity),
|
|
uv_index: data.daily.uv_index_max[i] ?? 0,
|
|
uv_index_max: data.daily.uv_index_max[i] ?? 0,
|
|
uv_dose: 0,
|
|
solar_wm2: data.daily.shortwave_radiation_sum[i] ?? 0,
|
|
daily_light_integral: data.daily.shortwave_radiation_sum[i]
|
|
? Math.round(data.daily.shortwave_radiation_sum[i] * 3600 / 1_000_000 * 4.57 * 100) / 100
|
|
: 0,
|
|
lux: 0,
|
|
visibility: 50,
|
|
sunrise,
|
|
sunset,
|
|
daylight,
|
|
lightning_rate: 0,
|
|
lightning_distance: null,
|
|
seismic_magnitude: null,
|
|
air_quality: null,
|
|
ground_distance: null,
|
|
}
|
|
|
|
return { ...row, ...getWeatherCondition(row) }
|
|
})
|
|
|
|
return rows
|
|
}
|
|
|
|
// ── Hourly ────────────────────────────────────────────────────────────────────
|
|
|
|
const HOURLY_FIELDS = [
|
|
'temperature_2m', 'relative_humidity_2m', 'dewpoint_2m',
|
|
'apparent_temperature', 'precipitation_probability', 'precipitation',
|
|
'weathercode', 'surface_pressure', 'cloudcover',
|
|
'windspeed_10m', 'windgusts_10m', 'winddirection_10m',
|
|
'uv_index', 'shortwave_radiation', 'vapour_pressure_deficit',
|
|
'visibility',
|
|
].join(',')
|
|
|
|
export async function hourlyWeather(lat, lon, start, end) {
|
|
const startStr = start instanceof Date ? start.toISOString().slice(0, 10) : start.slice(0, 10)
|
|
const endStr = end instanceof Date ? end.toISOString().slice(0, 10) : end.slice(0, 10)
|
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
|
|
`&hourly=${HOURLY_FIELDS}&daily=sunrise,sunset&timezone=auto&start_date=${startStr}&end_date=${endStr}`
|
|
|
|
const data = await cachedFetch(`hourly_${lat}_${lon}_${startStr}_${endStr}`, url)
|
|
if (!data?.hourly?.time) return []
|
|
|
|
const sunMap = {}
|
|
if (data.daily?.time) {
|
|
data.daily.time.forEach((date, i) => {
|
|
sunMap[date] = {
|
|
sunrise: data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null,
|
|
sunset: data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null,
|
|
}
|
|
})
|
|
}
|
|
|
|
const rows = data.hourly.time.map((time, i) => {
|
|
const dateKey = time.slice(0, 10)
|
|
const sun = sunMap[dateKey] ?? {}
|
|
const t = new Date(time + 'Z').getTime()
|
|
const daytime = sun.sunrise && sun.sunset
|
|
? t >= sun.sunrise.getTime() && t < sun.sunset.getTime()
|
|
: false
|
|
|
|
const code = data.hourly.weathercode[i]
|
|
const temp = data.hourly.temperature_2m[i]
|
|
const humidity = data.hourly.relative_humidity_2m[i]
|
|
const clouds = Math.round(data.hourly.cloudcover[i]) / 100
|
|
const precip = data.hourly.precipitation[i] ?? 0
|
|
const precipChance = data.hourly.precipitation_probability[i] ?? 0
|
|
const windSpeed = data.hourly.windspeed_10m[i] ?? 0
|
|
const uv = data.hourly.uv_index[i] ?? 0
|
|
|
|
const sunrise = sun.sunrise ?? null
|
|
const sunset = sun.sunset ?? null
|
|
const daylight = sunrise && sunset
|
|
? Math.round((sunset - sunrise) / 3600_000 * 100) / 100
|
|
: null
|
|
|
|
const row = {
|
|
time: new Date(time + 'Z'),
|
|
daytime,
|
|
label: WMO[code] || 'Unknown',
|
|
icon: OWM_ICONS[code] || '01d',
|
|
temperature: temp,
|
|
temperature_max: temp,
|
|
temperature_min: temp,
|
|
humidity,
|
|
humidity_abs: absoluteHumidity(temp, humidity),
|
|
dew_point: data.hourly.dewpoint_2m[i] ?? dewPoint(temp, humidity),
|
|
heat_index: heatIndex(temp, humidity),
|
|
vapor_pressure_deficit: data.hourly.vapour_pressure_deficit[i] ?? vaporPressureDeficit(temp, humidity),
|
|
pressure_hpa: data.hourly.surface_pressure[i] ?? null,
|
|
pressure_slp: null,
|
|
pressure_rate: null,
|
|
storm_trend: 0,
|
|
wind_speed: windSpeed,
|
|
wind_gusts: data.hourly.windgusts_10m[i] ?? 0,
|
|
wind_direction: data.hourly.winddirection_10m[i] ?? 0,
|
|
clouds,
|
|
raining: precipChance > 60 ? 'True' : 'False',
|
|
precipitation: precip,
|
|
precipitation_chance: precipChance,
|
|
accumulation: precip,
|
|
frost_risk: frostRisk(temp, dewPoint(temp, humidity), humidity),
|
|
uv_index: uv,
|
|
uv_index_max: uv,
|
|
uv_dose: 0,
|
|
solar_wm2: data.hourly.shortwave_radiation[i] ?? 0,
|
|
daily_light_integral: data.hourly.shortwave_radiation[i]
|
|
? Math.round(data.hourly.shortwave_radiation[i] * 3600 / 1_000_000 * 4.57 * 100) / 100
|
|
: 0,
|
|
lux: data.hourly.shortwave_radiation[i]
|
|
? Math.round(data.hourly.shortwave_radiation[i] * 120)
|
|
: 0,
|
|
visibility: data.hourly.visibility[i] != null
|
|
? Math.round(data.hourly.visibility[i] / 1000 * 10) / 10
|
|
: 50,
|
|
sunrise,
|
|
sunset,
|
|
daylight,
|
|
lightning_rate: 0,
|
|
lightning_distance: null,
|
|
seismic_magnitude: null,
|
|
air_quality: null,
|
|
ground_distance: null,
|
|
}
|
|
|
|
return { ...row, ...getWeatherCondition(row) }
|
|
})
|
|
|
|
return rows
|
|
}
|