move settings to one icon mobile

This commit is contained in:
Vincent van der Wal
2026-08-01 12:39:19 +02:00
parent 8ad02ea3f9
commit 5cc9a5428c
23 changed files with 1511 additions and 189 deletions
+195
View File
@@ -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
};
}