move settings to one icon mobile
This commit is contained in:
@@ -23,6 +23,7 @@ import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
|
||||
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
|
||||
const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
|
||||
const SEASONAL_URL = 'https://seasonal-api.open-meteo.com/v1/seasonal';
|
||||
|
||||
// ─── Core Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1143,3 +1144,197 @@ export async function fetchClimateNormals(params: ClimateNormalsParams): Promise
|
||||
precipitation_unit: params.precipitation_unit ?? 'mm'
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Seasonal (Long-Range) Types ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One daily variable of the seasonal ensemble: every member plus the spread
|
||||
* statistics the outlook renders (percentile band, mean, extremes).
|
||||
*/
|
||||
export interface SeasonalVariableData {
|
||||
/** Raw members, `members[m][t]`. */
|
||||
members: number[][];
|
||||
mean: number[];
|
||||
min: number[];
|
||||
max: number[];
|
||||
p25: number[];
|
||||
p75: number[];
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface SeasonalForecastParams extends WeatherLocation, WeatherUnitParams {
|
||||
/** Daily API variables to request; defaults to SEASONAL_DAILY_VARS. */
|
||||
dailyVariables?: string[];
|
||||
/** Lead time in days; the API allows at most 216. */
|
||||
forecast_days?: number;
|
||||
}
|
||||
|
||||
export interface SeasonalForecastResult {
|
||||
variables: Record<string, SeasonalVariableData>;
|
||||
/** Milliseconds, one entry per day (already trimmed to the model's horizon). */
|
||||
timestamps: number[];
|
||||
/**
|
||||
* Local wall time (local midnight) expressed as a UTC instant - read these
|
||||
* with the UTC getters, never with the location's IANA zone. The seasonal API
|
||||
* keeps ONE offset for the whole series, so a half-year range that crosses a
|
||||
* DST change would otherwise land two days on the same local date.
|
||||
*/
|
||||
dailyDates: Date[];
|
||||
/** `YYYY-MM-DD` local calendar date per day, matching the API's own labels. */
|
||||
dateKeys: string[];
|
||||
memberCount: number;
|
||||
utcOffsetSeconds: number;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
/** The API caps the lead time here; the model itself usually stops earlier. */
|
||||
export const SEASONAL_MAX_DAYS = 216;
|
||||
|
||||
/** Requested in this order; the daily block returns variables positionally. */
|
||||
export const SEASONAL_DAILY_VARS = [
|
||||
'temperature_2m_max',
|
||||
'temperature_2m_min',
|
||||
'temperature_2m_mean',
|
||||
'precipitation_sum',
|
||||
'wind_speed_10m_mean',
|
||||
'cloud_cover_mean'
|
||||
] as const;
|
||||
|
||||
// ─── Seasonal (Long-Range) Fetch ────────────────────────────────────────────
|
||||
|
||||
/** Linear-interpolated percentile over an already ascending array. */
|
||||
function percentileSorted(sorted: number[], p: number): number {
|
||||
if (sorted.length === 0) return NaN;
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
const pos = (sorted.length - 1) * p;
|
||||
const lo = Math.floor(pos);
|
||||
const hi = Math.ceil(pos);
|
||||
if (lo === hi) return sorted[lo];
|
||||
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the seasonal (multi-month) ensemble outlook from Open-Meteo's
|
||||
* seasonal API. Unlike the medium-range ensemble this is daily data: each
|
||||
* requested variable comes back once per member, so the members are collapsed
|
||||
* into the spread statistics the outlook page plots.
|
||||
*
|
||||
* The requested lead time is only an upper bound - the model's own horizon is
|
||||
* shorter, and every day past it comes back empty. Those trailing days are
|
||||
* trimmed here so callers never plot a flat-lined tail.
|
||||
*/
|
||||
export async function fetchSeasonalForecast(
|
||||
params: SeasonalForecastParams
|
||||
): Promise<SeasonalForecastResult> {
|
||||
const dailyVars =
|
||||
params.dailyVariables && params.dailyVariables.length > 0
|
||||
? [...new Set(params.dailyVariables)]
|
||||
: [...SEASONAL_DAILY_VARS];
|
||||
|
||||
const apiParams: Record<string, string | number | undefined> = {
|
||||
latitude: params.latitude,
|
||||
longitude: params.longitude,
|
||||
daily: dailyVars.join(','),
|
||||
forecast_days: Math.min(params.forecast_days ?? SEASONAL_MAX_DAYS, SEASONAL_MAX_DAYS),
|
||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||
timezone: params.timezone
|
||||
};
|
||||
|
||||
const cleanParams: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(apiParams)) {
|
||||
if (value !== undefined) cleanParams[key] = String(value);
|
||||
}
|
||||
|
||||
const responses = await fetchWeatherApi(SEASONAL_URL, cleanParams);
|
||||
const response = responses[0];
|
||||
const utcOffsetSeconds = response.utcOffsetSeconds();
|
||||
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
|
||||
|
||||
const dailyBlock = response.daily()!;
|
||||
const allTimestamps = getTimestamps(dailyBlock);
|
||||
const timeLength = allTimestamps.length;
|
||||
|
||||
// Members are laid out like the ensemble API: var0_member0 … var0_memberM-1,
|
||||
// var1_member0 …, so the count follows from the totals instead of being
|
||||
// hard-coded (it differs per seasonal model).
|
||||
const totalVariables = dailyBlock.variablesLength();
|
||||
const memberCount = dailyVars.length > 0 ? Math.floor(totalVariables / dailyVars.length) : 0;
|
||||
|
||||
const variables: Record<string, SeasonalVariableData> = {};
|
||||
|
||||
for (let vi = 0; vi < dailyVars.length; vi++) {
|
||||
const members: number[][] = [];
|
||||
let unitStr = '';
|
||||
|
||||
for (let mi = 0; mi < memberCount; mi++) {
|
||||
const variable = dailyBlock.variables(vi * memberCount + mi);
|
||||
if (!variable) continue;
|
||||
members.push(getValues(variable));
|
||||
if (mi === 0) unitStr = unitToDisplayString(variable.unit());
|
||||
}
|
||||
|
||||
const mean = new Array<number>(timeLength).fill(NaN);
|
||||
const min = new Array<number>(timeLength).fill(NaN);
|
||||
const max = new Array<number>(timeLength).fill(NaN);
|
||||
const p25 = new Array<number>(timeLength).fill(NaN);
|
||||
const p75 = new Array<number>(timeLength).fill(NaN);
|
||||
|
||||
for (let t = 0; t < timeLength; t++) {
|
||||
const values: number[] = [];
|
||||
for (const memberValues of members) {
|
||||
const val = memberValues[t];
|
||||
if (val != null && Number.isFinite(val)) values.push(val);
|
||||
}
|
||||
if (values.length === 0) continue;
|
||||
values.sort((a, b) => a - b);
|
||||
mean[t] = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
min[t] = values[0];
|
||||
max[t] = values[values.length - 1];
|
||||
p25[t] = percentileSorted(values, 0.25);
|
||||
p75[t] = percentileSorted(values, 0.75);
|
||||
}
|
||||
|
||||
variables[dailyVars[vi]] = { members, mean, min, max, p25, p75, unit: unitStr };
|
||||
}
|
||||
|
||||
// Past the model's horizon every member is empty (or padded to a constant
|
||||
// zero); cut the axis at the last day that carries real spread.
|
||||
const sentinel = variables[dailyVars[0]];
|
||||
let validLength = timeLength;
|
||||
if (sentinel) {
|
||||
let last = 0;
|
||||
for (let t = 0; t < timeLength; t++) {
|
||||
const hasSpread = !(sentinel.min[t] === 0 && sentinel.max[t] === 0);
|
||||
if (Number.isFinite(sentinel.mean[t]) && hasSpread) last = t + 1;
|
||||
}
|
||||
validLength = last || timeLength;
|
||||
}
|
||||
|
||||
if (validLength < timeLength) {
|
||||
for (const data of Object.values(variables)) {
|
||||
data.members = data.members.map((m) => m.slice(0, validLength));
|
||||
data.mean = data.mean.slice(0, validLength);
|
||||
data.min = data.min.slice(0, validLength);
|
||||
data.max = data.max.slice(0, validLength);
|
||||
data.p25 = data.p25.slice(0, validLength);
|
||||
data.p75 = data.p75.slice(0, validLength);
|
||||
}
|
||||
}
|
||||
|
||||
const timestamps = allTimestamps.slice(0, validLength);
|
||||
// Shifted by the response's single offset (not the IANA zone) so each day
|
||||
// carries the exact local date the API labelled it with.
|
||||
const dailyDates = timestamps.map((t) => new Date(t + utcOffsetSeconds * 1000));
|
||||
|
||||
return {
|
||||
variables,
|
||||
timestamps,
|
||||
dailyDates,
|
||||
dateKeys: dailyDates.map((d) => d.toISOString().slice(0, 10)),
|
||||
memberCount,
|
||||
utcOffsetSeconds,
|
||||
timezone
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user