From 71c8a5751538fde315a78012a4804c4767e9dc0d Mon Sep 17 00:00:00 2001 From: terraputix Date: Sun, 15 Feb 2026 20:29:39 +0100 Subject: [PATCH] unify weather data fetching --- src/lib/services/index.ts | 28 + src/lib/services/weather.ts | 736 ++++++++++++++++++ src/routes/weather/14-day/+page.svelte | 79 +- src/routes/weather/compare/+page.svelte | 56 +- .../weather/week/[location]/+page.svelte | 127 +-- 5 files changed, 845 insertions(+), 181 deletions(-) create mode 100644 src/lib/services/index.ts create mode 100644 src/lib/services/weather.ts diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts new file mode 100644 index 0000000..1d5b913 --- /dev/null +++ b/src/lib/services/index.ts @@ -0,0 +1,28 @@ +export { + fetchWeekForecast, + fetchModelComparison, + fetchEnsembleForecast, + range, + getTimestamps, + getDates, + getValues, + getInt64Values, + extractValues, + unitToDisplayString +} from './weather'; + +export type { + WeatherLocation, + WeatherUnitParams, + MarkArea, + WeekForecastParams, + WeekHourlyData, + WeekDailyData, + WeekForecastResult, + ModelCompareParams, + ModelSeriesData, + ModelCompareResult, + EnsembleForecastParams, + EnsembleVariableData, + EnsembleForecastResult +} from './weather'; diff --git a/src/lib/services/weather.ts b/src/lib/services/weather.ts new file mode 100644 index 0000000..8c09492 --- /dev/null +++ b/src/lib/services/weather.ts @@ -0,0 +1,736 @@ +/** + * Weather Data Service + * + * Centralized, type-safe weather data fetching using the Open-Meteo SDK + * with protobuf (FlatBuffers) transport for efficient data transfer. + * + * All weather data fetching flows through this service, providing: + * - Type-safe request parameters and response structures + * - Automatic retries with exponential backoff (via the SDK) + * - Efficient binary protobuf transport instead of JSON + * - Consistent timestamp and unit handling + */ +import { Unit } from '@openmeteo/sdk/unit'; +import { fetchWeatherApi } from 'openmeteo'; + +import { buildDaylightMarkAreas } from '$lib/utils/echarts'; + +import type { VariableWithValues } from '@openmeteo/sdk/variable-with-values'; +import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time'; + +// ─── Constants ────────────────────────────────────────────────────────────────── + +const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast'; +const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble'; + +// ─── Core Helpers ─────────────────────────────────────────────────────────────── + +/** + * Generates an array of numbers from start (inclusive) to stop (exclusive) with the given step. + * Used to reconstruct timestamp arrays from the protobuf time/timeEnd/interval fields. + */ +export function range(start: number, stop: number, step: number): number[] { + return Array.from( + { length: Math.max(0, Math.ceil((stop - start) / step)) }, + (_, i) => start + i * step + ); +} + +/** + * Extracts timestamp array (in milliseconds, with UTC offset applied) from a VariablesWithTime block. + */ +export function getTimestamps(timeBlock: VariablesWithTime, utcOffsetSeconds: number): number[] { + const start = Number(timeBlock.time()); + const end = Number(timeBlock.timeEnd()); + const interval = timeBlock.interval(); + return range(start, end, interval).map((t) => (t + utcOffsetSeconds) * 1000); +} + +/** + * Extracts Date array (with UTC offset applied) from a VariablesWithTime block. + */ +export function getDates(timeBlock: VariablesWithTime, utcOffsetSeconds: number): Date[] { + return getTimestamps(timeBlock, utcOffsetSeconds).map((t) => new Date(t)); +} + +/** + * Extracts a Float32Array of values from a VariableWithValues, returning a regular number[]. + * Falls back to an empty array if no values are present. + */ +export function getValues(variable: VariableWithValues): number[] { + const arr = variable.valuesArray(); + if (!arr) return []; + return Array.from(arr); +} + +/** + * Extracts Int64 (BigInt) values from a VariableWithValues, converting to number[]. + * Used for variables stored as unix timestamps (e.g. sunrise, sunset). + */ +export function getInt64Values(variable: VariableWithValues): number[] { + const len = variable.valuesInt64Length(); + const result: number[] = []; + for (let i = 0; i < len; i++) { + const val = variable.valuesInt64(i); + result.push(val !== null ? Number(val) : 0); + } + return result; +} + +/** + * Converts the SDK Unit enum to a human-readable display string. + */ +export function unitToDisplayString(unit: Unit): string { + switch (unit) { + case Unit.celsius: + return '°C'; + case Unit.fahrenheit: + return '°F'; + case Unit.millimetre: + return 'mm'; + case Unit.inch: + return 'in'; + case Unit.kilometres_per_hour: + return 'km/h'; + case Unit.metre_per_second: + return 'm/s'; + case Unit.miles_per_hour: + return 'mph'; + case Unit.knots: + return 'kn'; + case Unit.percentage: + return '%'; + case Unit.hectopascal: + return 'hPa'; + case Unit.degree_direction: + return '°'; + case Unit.wmo_code: + return 'wmo code'; + case Unit.seconds: + return 's'; + case Unit.hours: + return 'h'; + case Unit.watt_per_square_metre: + return 'W/m²'; + case Unit.megajoule_per_square_metre: + return 'MJ/m²'; + case Unit.joule_per_kilogram: + return 'J/kg'; + case Unit.metre: + return 'm'; + case Unit.centimetre: + return 'cm'; + case Unit.kilogram_per_square_metre: + return 'kg/m²'; + case Unit.kilopascal: + return 'kPa'; + case Unit.pascal: + return 'Pa'; + case Unit.fraction: + return ''; + case Unit.dimensionless: + return ''; + case Unit.dimensionless_integer: + return ''; + case Unit.unix_time: + return 'unixtime'; + case Unit.grains_per_cubic_metre: + return 'grains/m³'; + case Unit.micrograms_per_cubic_metre: + return 'µg/m³'; + default: + return ''; + } +} + +// ─── Shared Types ─────────────────────────────────────────────────────────────── + +export interface WeatherLocation { + latitude: number; + longitude: number; +} + +export interface WeatherUnitParams { + temperature_unit?: 'celsius' | 'fahrenheit'; + wind_speed_unit?: 'kmh' | 'ms' | 'mph' | 'kn'; + precipitation_unit?: 'mm' | 'inch'; +} + +export type MarkArea = [{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]; + +// ─── Week Forecast Types ──────────────────────────────────────────────────────── + +export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams { + model?: string; + forecast_days?: number; + past_days?: number; +} + +export interface WeekHourlyData { + temperature_2m: number[]; + precipitation: number[]; + precipitation_probability: number[]; + weather_code: number[]; + windspeed_10m: number[]; + winddirection_10m: number[]; + cloud_cover: number[]; + relative_humidity_2m: number[]; +} + +export interface WeekDailyData { + weather_code: number[]; + temperature_2m_max: number[]; + temperature_2m_min: number[]; + sunrise: number[]; + sunset: number[]; + sunshine_duration: number[]; + precipitation_sum: number[]; + windspeed_10m_max: number[]; + windgusts_10m_max: number[]; + winddirection_10m_dominant: number[]; +} + +export interface WeekForecastResult { + hourly: WeekHourlyData; + daily: WeekDailyData; + utcOffsetSeconds: number; + hourlyTimestamps: number[]; + hourlyDates: Date[]; + dailyDates: Date[]; + markAreas: MarkArea[]; +} + +// ─── Model Comparison Types ───────────────────────────────────────────────────── + +export interface ModelCompareParams extends WeatherLocation, WeatherUnitParams { + hourlyVariables: string[]; + models: string[]; +} + +export interface ModelSeriesData { + modelName: string; + variables: Record; +} + +export interface ModelCompareResult { + models: ModelSeriesData[]; + timestamps: number[]; + utcOffsetSeconds: number; + markAreas: MarkArea[]; + units: Record; + /** Flat record compatible with the existing chart utilities (keys like "temperature_2m_icon_seamless") */ + hourlyFlat: Record; + hourlyUnitsFlat: Record; +} + +// ─── Ensemble Forecast Types ──────────────────────────────────────────────────── + +export interface EnsembleForecastParams extends WeatherLocation, WeatherUnitParams { + hourlyVariables: string[]; + models: string[]; + forecast_days?: number; +} + +export interface EnsembleVariableData { + members: number[][]; + average: number[]; + min: number[]; + max: number[]; + unit: string; +} + +export interface EnsembleForecastResult { + variables: Record; + timestamps: number[]; + utcOffsetSeconds: number; + markAreas: MarkArea[]; + /** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */ + hourlyFlat: Record; + hourlyUnitsFlat: Record; +} + +// ─── Week Forecast Fetch ──────────────────────────────────────────────────────── + +const WEEK_HOURLY_VARS = [ + 'temperature_2m', + 'precipitation', + 'precipitation_probability', + 'weather_code', + 'wind_speed_10m', + 'wind_direction_10m', + 'cloud_cover', + 'relative_humidity_2m' +] as const; + +const WEEK_DAILY_VARS = [ + 'weather_code', + 'temperature_2m_max', + 'temperature_2m_min', + 'sunrise', + 'sunset', + 'sunshine_duration', + 'precipitation_sum', + 'wind_speed_10m_max', + 'wind_gusts_10m_max', + 'wind_direction_10m_dominant' +] as const; + +/** + * Fetches the 7-day (week) weather forecast for a single location and model. + * Returns typed hourly and daily data structures. + */ +export async function fetchWeekForecast(params: WeekForecastParams): Promise { + const forecastDays = params.forecast_days ?? 6; + const pastDays = params.past_days ?? 1; + const modelParam = params.model && params.model !== 'best_match' ? params.model : undefined; + + const apiParams: Record = { + latitude: params.latitude, + longitude: params.longitude, + hourly: WEEK_HOURLY_VARS.join(','), + daily: WEEK_DAILY_VARS.join(','), + temperature_unit: params.temperature_unit ?? 'celsius', + wind_speed_unit: params.wind_speed_unit ?? 'kmh', + precipitation_unit: params.precipitation_unit ?? 'mm', + forecast_days: forecastDays, + past_days: pastDays, + models: modelParam + }; + + // Remove undefined values + const cleanParams: Record = {}; + for (const [key, value] of Object.entries(apiParams)) { + if (value !== undefined) { + cleanParams[key] = String(value); + } + } + + const responses = await fetchWeatherApi(FORECAST_URL, cleanParams); + const response = responses[0]; + const utcOffsetSeconds = response.utcOffsetSeconds(); + + const hourlyBlock = response.hourly()!; + const dailyBlock = response.daily()!; + + // Hourly: variables are in the same order as WEEK_HOURLY_VARS + const hourlyTimestamps = getTimestamps(hourlyBlock, utcOffsetSeconds); + const hourlyDates = hourlyTimestamps.map((t) => new Date(t)); + + const hourly: WeekHourlyData = { + temperature_2m: getValues(hourlyBlock.variables(0)!), + precipitation: getValues(hourlyBlock.variables(1)!), + precipitation_probability: getValues(hourlyBlock.variables(2)!), + weather_code: getValues(hourlyBlock.variables(3)!), + windspeed_10m: getValues(hourlyBlock.variables(4)!), + winddirection_10m: getValues(hourlyBlock.variables(5)!), + cloud_cover: getValues(hourlyBlock.variables(6)!), + relative_humidity_2m: getValues(hourlyBlock.variables(7)!) + }; + + // Daily: variables are in the same order as WEEK_DAILY_VARS + const dailyDates = getDates(dailyBlock, utcOffsetSeconds); + + const sunriseVar = dailyBlock.variables(3)!; + const sunsetVar = dailyBlock.variables(4)!; + + const daily: WeekDailyData = { + weather_code: getValues(dailyBlock.variables(0)!), + temperature_2m_max: getValues(dailyBlock.variables(1)!), + temperature_2m_min: getValues(dailyBlock.variables(2)!), + sunrise: getInt64Values(sunriseVar), + sunset: getInt64Values(sunsetVar), + sunshine_duration: getValues(dailyBlock.variables(5)!), + precipitation_sum: getValues(dailyBlock.variables(6)!), + windspeed_10m_max: getValues(dailyBlock.variables(7)!), + windgusts_10m_max: getValues(dailyBlock.variables(8)!), + winddirection_10m_dominant: getValues(dailyBlock.variables(9)!) + }; + + const markAreas = buildDaylightMarkAreas(daily.sunrise, daily.sunset, utcOffsetSeconds); + + return { + hourly, + daily, + utcOffsetSeconds, + hourlyTimestamps, + hourlyDates, + dailyDates, + markAreas + }; +} + +// ─── Model Comparison Fetch ───────────────────────────────────────────────────── + +/** + * Fetches forecast data for multiple models for comparison. + * Also fetches daily sunrise/sunset for daylight mark areas. + * + * Returns both a typed model array structure and a flat record structure + * compatible with existing chart utilities. + */ +export async function fetchModelComparison( + params: ModelCompareParams +): Promise { + const forecastApiParams: Record = { + latitude: String(params.latitude), + longitude: String(params.longitude), + hourly: params.hourlyVariables.join(','), + models: params.models.join(','), + daily: 'sunrise,sunset', + temperature_unit: params.temperature_unit ?? 'celsius', + wind_speed_unit: params.wind_speed_unit ?? 'kmh', + precipitation_unit: params.precipitation_unit ?? 'mm' + }; + + const responses = await fetchWeatherApi(FORECAST_URL, forecastApiParams); + + // With multiple models, we get one response per model + const firstResponse = responses[0]; + const utcOffsetSeconds = firstResponse.utcOffsetSeconds(); + + // Extract timestamps from the first response's hourly block + const hourlyBlock = firstResponse.hourly()!; + const timestamps = getTimestamps(hourlyBlock, utcOffsetSeconds); + + // Extract sunrise/sunset from the first response's daily block + let markAreas: MarkArea[] = []; + const dailyBlock = firstResponse.daily(); + if (dailyBlock) { + const sunriseVar = dailyBlock.variables(0)!; + const sunsetVar = dailyBlock.variables(1)!; + const sunrise = getInt64Values(sunriseVar); + const sunset = getInt64Values(sunsetVar); + markAreas = buildDaylightMarkAreas(sunrise, sunset, utcOffsetSeconds); + } + + // Process each model's response + const models: ModelSeriesData[] = []; + const hourlyFlat: Record = {}; + const hourlyUnitsFlat: Record = {}; + const units: Record = {}; + + // Add time to flat record + const timeInUnixSeconds = range( + Number(hourlyBlock.time()), + Number(hourlyBlock.timeEnd()), + hourlyBlock.interval() + ); + hourlyFlat['time'] = timeInUnixSeconds; + + for (const response of responses) { + const modelHourly = response.hourly(); + if (!modelHourly) continue; + + // Determine model name from the response + const modelEnum = response.model(); + const modelName = modelEnumToString(modelEnum); + + const modelData: ModelSeriesData = { + modelName, + variables: {} + }; + + for (let vi = 0; vi < params.hourlyVariables.length; vi++) { + const varName = params.hourlyVariables[vi]; + const variable = modelHourly.variables(vi); + if (!variable) continue; + + const values = getValues(variable); + modelData.variables[varName] = values; + + // Build flat key like "temperature_2m_icon_seamless" + const flatKey = `${varName}_${modelName}`; + hourlyFlat[flatKey] = values; + + // Record unit + const unitStr = unitToDisplayString(variable.unit()); + units[varName] = unitStr; + hourlyUnitsFlat[flatKey] = unitStr; + } + + models.push(modelData); + } + + return { + models, + timestamps, + utcOffsetSeconds, + markAreas, + units, + hourlyFlat, + hourlyUnitsFlat + }; +} + +// ─── Ensemble Forecast Fetch ──────────────────────────────────────────────────── + +/** + * Fetches ensemble forecast data from the ensemble API. + * Separately fetches daily sunrise/sunset from the standard forecast API. + * + * Returns typed ensemble data with per-variable member arrays, averages, and spreads, + * plus a flat record structure for compatibility with existing chart utilities. + */ +export async function fetchEnsembleForecast( + params: EnsembleForecastParams +): Promise { + const forecastDays = params.forecast_days ?? 14; + + const ensembleParams: Record = { + latitude: String(params.latitude), + longitude: String(params.longitude), + hourly: params.hourlyVariables.join(','), + models: params.models.join(','), + forecast_days: String(forecastDays), + temperature_unit: params.temperature_unit ?? 'celsius', + wind_speed_unit: params.wind_speed_unit ?? 'kmh', + precipitation_unit: params.precipitation_unit ?? 'mm' + }; + + const dailyParams: Record = { + latitude: String(params.latitude), + longitude: String(params.longitude), + daily: 'sunrise,sunset', + forecast_days: String(forecastDays), + temperature_unit: params.temperature_unit ?? 'celsius' + }; + + // Fetch ensemble and daily data in parallel + const [ensembleResponses, dailyResponses] = await Promise.all([ + fetchWeatherApi(ENSEMBLE_URL, ensembleParams), + fetchWeatherApi(FORECAST_URL, dailyParams) + ]); + + const ensembleResponse = ensembleResponses[0]; + const utcOffsetSeconds = ensembleResponse.utcOffsetSeconds(); + + const hourlyBlock = ensembleResponse.hourly()!; + const timestamps = getTimestamps(hourlyBlock, utcOffsetSeconds); + const timeLength = timestamps.length; + + // Extract sunrise/sunset for mark areas + let markAreas: MarkArea[] = []; + if (dailyResponses.length > 0) { + const dailyResponse = dailyResponses[0]; + const dailyBlock = dailyResponse.daily(); + if (dailyBlock) { + const sunrise = getInt64Values(dailyBlock.variables(0)!); + const sunset = getInt64Values(dailyBlock.variables(1)!); + markAreas = buildDaylightMarkAreas(sunrise, sunset, dailyResponse.utcOffsetSeconds()); + } + } + + // Process ensemble variables + // Each requested variable will have multiple entries in the variables list (one per ensemble member) + const variables: Record = {}; + const hourlyFlat: Record = {}; + const hourlyUnitsFlat: Record = {}; + + // Add time to flat record + const timeInUnixSeconds = range( + Number(hourlyBlock.time()), + Number(hourlyBlock.timeEnd()), + hourlyBlock.interval() + ); + hourlyFlat['time'] = timeInUnixSeconds; + + // Group variables by their requested variable name + // The SDK provides variables indexed sequentially: + // For N requested variables and M ensemble members, we get N*M variables + // ordered as: var0_member0, var0_member1, ..., var0_memberM-1, var1_member0, ... + const totalVariables = hourlyBlock.variablesLength(); + const numRequestedVars = params.hourlyVariables.length; + + if (totalVariables > 0 && numRequestedVars > 0) { + const membersPerVar = Math.floor(totalVariables / numRequestedVars); + + for (let vi = 0; vi < numRequestedVars; vi++) { + const varName = params.hourlyVariables[vi]; + const members: number[][] = []; + let unitStr = ''; + + for (let mi = 0; mi < membersPerVar; mi++) { + const varIdx = vi * membersPerVar + mi; + const variable = hourlyBlock.variables(varIdx); + if (!variable) continue; + + const values = getValues(variable); + members.push(values); + + if (mi === 0) { + unitStr = unitToDisplayString(variable.unit()); + } + + // Build flat key compatible with JSON API format + const memberStr = String(mi).padStart(2, '0'); + const flatKey = `${varName}_member${memberStr}`; + hourlyFlat[flatKey] = values; + hourlyUnitsFlat[flatKey] = unitStr; + } + + // Calculate average, min, max across members + const average = new Array(timeLength).fill(0); + const min = new Array(timeLength).fill(Infinity); + const max = new Array(timeLength).fill(-Infinity); + + for (let t = 0; t < timeLength; t++) { + let count = 0; + for (const memberValues of members) { + const val = memberValues[t]; + if (val !== null && val !== undefined && !isNaN(val)) { + average[t] += val; + count++; + if (val < min[t]) min[t] = val; + if (val > max[t]) max[t] = val; + } + } + if (count > 0) { + average[t] = Math.round((average[t] / count) * 10) / 10; + } + if (min[t] === Infinity) min[t] = 0; + if (max[t] === -Infinity) max[t] = 0; + } + + variables[varName] = { + members, + average, + min, + max, + unit: unitStr + }; + } + } + + return { + variables, + timestamps, + utcOffsetSeconds, + markAreas, + hourlyFlat, + hourlyUnitsFlat + }; +} + +// ─── Model Enum Mapping ───────────────────────────────────────────────────────── + +/** + * Maps the SDK Model enum integer to a string model name. + * This table must stay in sync with the @openmeteo/sdk Model enum. + */ +function modelEnumToString(modelEnum: number): string { + const modelMap: Record = { + 0: 'undefined', + 1: 'best_match', + 2: 'gfs_seamless', + 3: 'gfs_global', + 4: 'gfs_hrrr', + 5: 'meteofrance_seamless', + 6: 'meteofrance_arpege_seamless', + 7: 'meteofrance_arpege_world', + 8: 'meteofrance_arpege_europe', + 9: 'meteofrance_arome_seamless', + 10: 'meteofrance_arome_france', + 11: 'meteofrance_arome_france_hd', + 12: 'jma_seamless', + 13: 'jma_msm', + 14: 'jms_gsm', + 15: 'jma_gsm', + 16: 'gem_seamless', + 17: 'gem_global', + 18: 'gem_regional', + 19: 'gem_hrdps_continental', + 20: 'icon_seamless', + 21: 'icon_global', + 22: 'icon_eu', + 23: 'icon_d2', + 24: 'ecmwf_ifs04', + 25: 'metno_nordic', + 26: 'era5_seamless', + 27: 'era5', + 28: 'cerra', + 29: 'era5_land', + 30: 'ecmwf_ifs', + 31: 'gwam', + 32: 'ewam', + 33: 'glofas_seamless_v3', + 34: 'glofas_forecast_v3', + 35: 'glofas_consolidated_v3', + 36: 'glofas_seamless_v4', + 37: 'glofas_forecast_v4', + 38: 'glofas_consolidated_v4', + 39: 'gfs025', + 40: 'gfs05', + 41: 'CMCC_CM2_VHR4', + 42: 'FGOALS_f3_H_highresSST', + 43: 'FGOALS_f3_H', + 44: 'HiRAM_SIT_HR', + 45: 'MRI_AGCM3_2_S', + 46: 'EC_Earth3P_HR', + 47: 'MPI_ESM1_2_XR', + 48: 'NICAM16_8S', + 49: 'cams_europe', + 50: 'cams_global', + 51: 'cfsv2', + 52: 'era5_ocean', + 53: 'cma_grapes_global', + 54: 'bom_access_global', + 55: 'bom_access_global_ensemble', + 56: 'arpae_cosmo_seamless', + 57: 'arpae_cosmo_2i', + 58: 'arpae_cosmo_2i_ruc', + 59: 'arpae_cosmo_5m', + 60: 'ecmwf_ifs025', + 61: 'ecmwf_aifs025', + 62: 'gfs013', + 63: 'gfs_graphcast025', + 64: 'ecmwf_wam025', + 65: 'meteofrance_wave', + 66: 'meteofrance_currents', + 67: 'ecmwf_wam025_ensemble', + 68: 'ncep_gfswave025', + 69: 'ncep_gefswave025', + 70: 'knmi_seamless', + 71: 'knmi_harmonie_arome_europe', + 72: 'knmi_harmonie_arome_netherlands', + 73: 'dmi_seamless', + 74: 'dmi_harmonie_arome_europe', + 75: 'metno_seamless', + 76: 'era5_ensemble', + 77: 'ecmwf_ifs_analysis', + 78: 'ecmwf_ifs_long_window', + 79: 'ecmwf_ifs_analysis_long_window', + 80: 'ukmo_global_deterministic_10km', + 81: 'ukmo_uk_deterministic_2km', + 82: 'ukmo_seamless', + 83: 'ncep_gfswave016', + 84: 'ncep_nbm_conus', + 85: 'ukmo_global_ensemble_20km', + 86: 'ecmwf_aifs025_single', + 87: 'jma_jaxa_himawari', + 88: 'eumetsat_sarah3', + 89: 'eumetsat_lsa_saf_msg', + 90: 'eumetsat_lsa_saf_iodc', + 91: 'satellite_radiation_seamless', + 92: 'kma_gdps', + 93: 'kma_ldps', + 94: 'kma_seamless', + 95: 'italia_meteo_arpae_icon_2i', + 96: 'ukmo_uk_ensemble_2km', + 97: 'meteofrance_arome_france_hd_15min', + 98: 'meteofrance_arome_france_15min', + 99: 'meteoswiss_icon_ch1', + 100: 'meteoswiss_icon_ch2', + 101: 'meteoswiss_icon_ch1_ensemble', + 102: 'meteoswiss_icon_ch2_ensemble', + 103: 'meteoswiss_icon_seamless', + 104: 'ncep_nam_conus', + 105: 'icon_d2_ruc', + 106: 'ecmwf_seas5', + 107: 'ecmwf_ec46', + 108: 'ecmwf_seasonal_seamless', + 109: 'ecmwf_ifs_seamless', + 110: 'jma_jaxa_mtg_fci', + 111: 'gem_hrdps_west' + }; + return modelMap[modelEnum] ?? `model_${modelEnum}`; +} diff --git a/src/routes/weather/14-day/+page.svelte b/src/routes/weather/14-day/+page.svelte index 60b6772..1500798 100644 --- a/src/routes/weather/14-day/+page.svelte +++ b/src/routes/weather/14-day/+page.svelte @@ -7,14 +7,9 @@ import { buildAverageSeries, buildCurrentTimeSeries, - buildDaylightMarkAreas, buildDaylightSeries, buildSpreadSeries, - calculateAverage, - calculateSpread, composeChartOption, - convertTimestamps, - findUnit, getThemeColors } from '$lib/utils/echarts'; @@ -23,6 +18,12 @@ import { Label } from '$lib/components/ui/label'; import { Switch } from '$lib/components/ui/switch'; + import { + type EnsembleForecastResult, + type MarkArea, + fetchEnsembleForecast + } from '$lib/services/weather'; + import { defaultParameters } from '../options'; import type * as echarts from 'echarts'; @@ -52,11 +53,10 @@ // ─── Cached API Response ──────────────────────────────────────────────────── interface FetchedData { - hourly: Record; - hourly_units: Record; - utc_offset_seconds: number; - markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>; + ensembleResult: EnsembleForecastResult; timestamps: number[]; + utc_offset_seconds: number; + markAreas: MarkArea[]; } let fetchedData: FetchedData | null = $state(null); @@ -92,35 +92,22 @@ chartInstances = []; chartComponents = []; - const [dataDaily, dataReq] = await Promise.all([ - fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14` - ), - fetch( - `https://ensemble-api.open-meteo.com/v1/ensemble?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&forecast_days=14` - ) - ]); - - const [wd, data] = await Promise.all([dataDaily.json(), dataReq.json()]); - - let markAreas: FetchedData['markAreas'] = []; - - if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) { - markAreas = buildDaylightMarkAreas( - wd.daily.sunrise, - wd.daily.sunset, - data.utc_offset_seconds - ); - } - - const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds); + const result: EnsembleForecastResult = await fetchEnsembleForecast({ + latitude: location.latitude!, + longitude: location.longitude!, + hourlyVariables: hourlyVars, + models: modelList, + forecast_days: 14, + temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit', + wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn', + precipitation_unit: params.precipitation_unit as 'mm' | 'inch' + }); fetchedData = { - hourly: data.hourly, - hourly_units: data.hourly_units, - utc_offset_seconds: data.utc_offset_seconds, - markAreas, - timestamps + ensembleResult: result, + timestamps: result.timestamps, + utc_offset_seconds: result.utcOffsetSeconds, + markAreas: result.markAreas }; loading = false; @@ -134,32 +121,26 @@ $effect(() => { if (!fetchedData) return; - const { - hourly: hourlyData, - hourly_units, - utc_offset_seconds, - markAreas, - timestamps - } = fetchedData; + const { ensembleResult, timestamps, utc_offset_seconds, markAreas } = fetchedData; const _showLegend = showLegend; const colors = getThemeColors(); const variableCount = params.hourly?.length || 0; - const timeLength = (hourlyData.time as number[]).length; const newOptions: Array> = []; for (let vi = 0; vi < variableCount; vi++) { const variable = params.hourly![vi]; - const unit = findUnit(hourly_units, hourlyData, variable); + const varData = ensembleResult.variables[variable]; + if (!varData) continue; - const { average } = calculateAverage(hourlyData, variable, timeLength); - const { minValues, maxValues } = calculateSpread(hourlyData, variable, timeLength); + const unit = varData.unit; + const { average, min: minValues, max: maxValues } = varData; const series: Array> = []; const spreadData: Array<[number, number, number]> = minValues.map( - (min, index) => - [timestamps[index], min ?? 0, maxValues[index] ?? 0] as [number, number, number] + (minVal, index) => + [timestamps[index], minVal ?? 0, maxValues[index] ?? 0] as [number, number, number] ); series.push(...buildSpreadSeries({ variable, spreadData })); diff --git a/src/routes/weather/compare/+page.svelte b/src/routes/weather/compare/+page.svelte index 6a1172d..9d39cd8 100644 --- a/src/routes/weather/compare/+page.svelte +++ b/src/routes/weather/compare/+page.svelte @@ -8,12 +8,10 @@ import { buildAverageSeries, buildCurrentTimeSeries, - buildDaylightMarkAreas, buildDaylightSeries, buildModelSeries, calculateAverage, composeChartOption, - convertTimestamps, findUnit, getThemeColors } from '$lib/utils/echarts'; @@ -24,6 +22,12 @@ import { Label } from '$lib/components/ui/label'; import { Switch } from '$lib/components/ui/switch'; + import { + type MarkArea, + type ModelCompareResult, + fetchModelComparison + } from '$lib/services/weather'; + import { hourly, models as modelsFlat } from '../options'; import { defaultParameters } from '../options'; @@ -65,7 +69,7 @@ hourly: Record; hourly_units: Record; utc_offset_seconds: number; - markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>; + markAreas: MarkArea[]; timestamps: number[]; } @@ -102,38 +106,22 @@ chartInstances = []; chartComponents = []; - const dataReq = await fetch( - `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&daily=sunset,sunrise` - ); - const data = await dataReq.json(); - - let markAreas: FetchedData['markAreas'] = []; - - if ('daily' in data) { - let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_'); - dailyFirstModelKey.shift(); - dailyFirstModelKey = dailyFirstModelKey.join('_'); - - const sunriseKey = 'sunrise_' + dailyFirstModelKey; - const sunsetKey = 'sunset_' + dailyFirstModelKey; - - if (sunriseKey in data.daily && sunsetKey in data.daily) { - markAreas = buildDaylightMarkAreas( - data.daily[sunriseKey], - data.daily[sunsetKey], - data.utc_offset_seconds - ); - } - } - - const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds); + const result: ModelCompareResult = await fetchModelComparison({ + latitude: location.latitude!, + longitude: location.longitude!, + hourlyVariables: hourlyVars, + models: modelList, + temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit', + wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn', + precipitation_unit: params.precipitation_unit as 'mm' | 'inch' + }); fetchedData = { - hourly: data.hourly, - hourly_units: data.hourly_units, - utc_offset_seconds: data.utc_offset_seconds, - markAreas, - timestamps + hourly: result.hourlyFlat, + hourly_units: result.hourlyUnitsFlat, + utc_offset_seconds: result.utcOffsetSeconds, + markAreas: result.markAreas, + timestamps: result.timestamps }; loading = false; @@ -158,7 +146,7 @@ const colors = getThemeColors(); const variableCount = params.hourly?.length || 0; - const timeLength = (hourlyData.time as number[]).length; + const timeLength = timestamps.length; const newOptions: Array> = []; for (let vi = 0; vi < variableCount; vi++) { diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index 3bb4081..515ef06 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -7,13 +7,7 @@ import { storedLocation } from '$lib/stores/settings'; - import { - buildCurrentTimeSeries, - buildDaylightMarkAreas, - buildDaylightSeries, - convertTimestamps, - getThemeColors - } from '$lib/utils/echarts'; + import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts'; import { pad } from '$lib/utils/index'; import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts'; @@ -21,6 +15,14 @@ import { Label } from '$lib/components/ui/label'; import * as Select from '$lib/components/ui/select'; + import { + type MarkArea, + type WeekDailyData, + type WeekForecastResult, + type WeekHourlyData, + fetchWeekForecast + } from '$lib/services/weather'; + import { defaultParameters, models } from '../../options'; import { getColor } from '../../utils/colors'; import weatherCodes from '../../utils/weather-codes'; @@ -61,42 +63,16 @@ // ─── Fetched Data ─────────────────────────────────────────────────────────── - interface HourlyData { - time: number[]; - temperature_2m: number[]; - precipitation: number[]; - precipitation_probability: number[]; - weather_code: number[]; - windspeed_10m: number[]; - winddirection_10m: number[]; - cloud_cover: number[]; - relative_humidity_2m: number[]; - } - - interface DailyData { - time: string[]; - weather_code: number[]; - temperature_2m_max: number[]; - temperature_2m_min: number[]; - sunrise: number[]; - sunset: number[]; - sunshine_duration: number[]; - precipitation_sum: number[]; - windspeed_10m_max: number[]; - windgusts_10m_max: number[]; - winddirection_10m_dominant: number[]; - } - interface FetchedHourly { - hourly: HourlyData; + hourly: WeekHourlyData; utc_offset_seconds: number; timestamps: number[]; hourlyDates: Date[]; - markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>; + markAreas: MarkArea[]; } interface FetchedDaily { - daily: DailyData; + daily: WeekDailyData; dailyDates: Date[]; } @@ -276,73 +252,28 @@ chartInstances = []; chartComponents = []; - const model = modelList[0]; - const hourlyVars = [ - 'temperature_2m', - 'precipitation', - 'precipitation_probability', - 'weather_code', - 'windspeed_10m', - 'winddirection_10m', - 'cloud_cover', - 'relative_humidity_2m' - ].join(','); - - const dailyVars = [ - 'weather_code', - 'temperature_2m_max', - 'temperature_2m_min', - 'sunrise', - 'sunset', - 'sunshine_duration', - 'precipitation_sum', - 'windspeed_10m_max', - 'windgusts_10m_max', - 'winddirection_10m_dominant' - ].join(','); - - const baseParams = `latitude=${loc.latitude}&longitude=${loc.longitude}&temperature_unit=${params.temperature_unit}&wind_speed_unit=${params.wind_speed_unit}&precipitation_unit=${params.precipitation_unit}`; - const modelParam = model === 'best_match' ? '' : `&models=${model}`; - - const [hourlyResp, dailyResp] = await Promise.all([ - fetch( - `https://api.open-meteo.com/v1/forecast?${baseParams}&hourly=${hourlyVars}${modelParam}&timeformat=unixtime&forecast_days=6&past_days=1&daily=sunrise,sunset` - ), - fetch( - `https://api.open-meteo.com/v1/forecast?${baseParams}&daily=${dailyVars}${modelParam}&timeformat=unixtime&forecast_days=6&past_days=1` - ) - ]); - - const [hourlyJson, dailyJson] = await Promise.all([hourlyResp.json(), dailyResp.json()]); - - const utcOffset = hourlyJson.utc_offset_seconds ?? 0; - const timestamps = convertTimestamps(hourlyJson.hourly.time, utcOffset); - const hourlyDates = timestamps.map((t: number) => new Date(t)); - - let markAreas: FetchedHourly['markAreas'] = []; - if (hourlyJson.daily?.sunrise && hourlyJson.daily?.sunset) { - markAreas = buildDaylightMarkAreas( - hourlyJson.daily.sunrise, - hourlyJson.daily.sunset, - utcOffset - ); - } + const result: WeekForecastResult = await fetchWeekForecast({ + latitude: loc.latitude!, + longitude: loc.longitude!, + model: modelList[0], + temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit', + wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn', + precipitation_unit: params.precipitation_unit as 'mm' | 'inch', + forecast_days: 6, + past_days: 1 + }); fetchedHourly = { - hourly: hourlyJson.hourly, - utc_offset_seconds: utcOffset, - timestamps, - hourlyDates, - markAreas + hourly: result.hourly, + utc_offset_seconds: result.utcOffsetSeconds, + timestamps: result.hourlyTimestamps, + hourlyDates: result.hourlyDates, + markAreas: result.markAreas }; - const dailyDates = (dailyJson.daily.time as number[]).map( - (t: number) => new Date((t + (dailyJson.utc_offset_seconds ?? 0)) * 1000) - ); - fetchedDaily = { - daily: dailyJson.daily, - dailyDates + daily: result.daily, + dailyDates: result.dailyDates }; loading = false;