feat: canvas to echarts (#5)

Co-authored-by: terraputix <terraputix@mailbox.org>
Reviewed-on: useweb/open-meteo-blue#5
This commit was merged in pull request #5.
This commit is contained in:
2026-02-16 00:33:58 +01:00
co-authored by terraputix
parent 2fe8cf92e1
commit 7a6cca5dad
19 changed files with 2449 additions and 1149 deletions
+744
View File
@@ -0,0 +1,744 @@
/**
* 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;
timezone?: string;
}
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[];
apparent_temperature: number[];
dew_point_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<string, number[]>;
}
export interface ModelCompareResult {
models: ModelSeriesData[];
timestamps: number[];
utcOffsetSeconds: number;
markAreas: MarkArea[];
units: Record<string, string>;
/** Flat record compatible with the existing chart utilities (keys like "temperature_2m_icon_seamless") */
hourlyFlat: Record<string, number[]>;
hourlyUnitsFlat: Record<string, string>;
}
// ─── 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<string, EnsembleVariableData>;
timestamps: number[];
utcOffsetSeconds: number;
markAreas: MarkArea[];
/** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */
hourlyFlat: Record<string, number[]>;
hourlyUnitsFlat: Record<string, string>;
}
// ─── 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',
'apparent_temperature',
'dew_point_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<WeekForecastResult> {
const forecastDays = params.forecast_days ?? 6;
const pastDays = params.past_days ?? 0;
const modelParam = params.model && params.model !== 'best_match' ? params.model : undefined;
const apiParams: Record<string, string | number | undefined> = {
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,
timezone: params.timezone
};
// Remove undefined values
const cleanParams: Record<string, string> = {};
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)!),
apparent_temperature: getValues(hourlyBlock.variables(8)!),
dew_point_2m: getValues(hourlyBlock.variables(9)!)
};
// 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<ModelCompareResult> {
const forecastApiParams: Record<string, string> = {
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<string, number[]> = {};
const hourlyUnitsFlat: Record<string, string> = {};
const units: Record<string, string> = {};
// 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<EnsembleForecastResult> {
const forecastDays = params.forecast_days ?? 14;
const ensembleParams: Record<string, string> = {
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<string, string> = {
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<string, EnsembleVariableData> = {};
const hourlyFlat: Record<string, number[]> = {};
const hourlyUnitsFlat: Record<string, string> = {};
// 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<number>(timeLength).fill(0);
const min = new Array<number>(timeLength).fill(Infinity);
const max = new Array<number>(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<number, string> = {
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}`;
}