This repository has been archived on 2026-08-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
drizzli/src/routes/weather/week/[location]/variables.ts
T
2026-07-25 09:53:40 +02:00

480 lines
11 KiB
TypeScript

/**
* Registry of every variable that can be plotted on the customizable
* meteograms. Each entry carries the metadata needed to build a chart series
* (data field, render style, colour, unit family) so the panels can be
* assembled dynamically from a user-defined layout.
*/
import { getColor } from '../../utils/colors';
import {
type WeatherUnits,
getPrecipUnit,
getTempUnit,
getWindDirectionLabel,
getWindUnit
} from './types';
import type { ChartSeries } from '$lib/charts';
import type { WeekHourlyData } from '$lib/services/weather';
/** Families of variables that share a y-axis and unit. */
export type UnitKind =
'temp' | 'precip' | 'snow' | 'wind' | 'percent' | 'pressure' | 'uv' | 'distance' | 'energy';
export interface ChartVariableDef {
/** Stable id used in the persisted layout */
key: string;
label: string;
/** Short label for the tooltip / legend */
short: string;
/** Data array on the hourly response */
field: keyof WeekHourlyData;
/** Open-Meteo API variable name (defaults to `field` when identical) */
api?: string;
type: 'line' | 'bar';
kind: UnitKind;
color: string;
dashed?: boolean;
fill?: boolean;
fillOpacity?: number;
width?: number;
/** Stroke the line coloured by the temperature scale */
colorScale?: boolean;
/** Draw a contrasting halo under the line */
outline?: boolean;
/** Annotate local minima / maxima with their value */
extrema?: boolean;
/** Draw weather-code pictograms across the top of the chart */
pictograms?: boolean;
/** Draw wind-direction arrows across the top of the chart */
windArrows?: boolean;
/** Marker-only variable (icons / arrows): contributes no plotted series */
marker?: boolean;
/** Render as a puffy cloud band hanging from the top (0-100 → 0-40px) */
cloudBand?: boolean;
/** Transform raw values before plotting (e.g. m → km) */
transform?: (v: number) => number;
/** Preset range to use when this variable lands on a shared right axis */
rightPreset?: { min: number; max: number; invert?: boolean };
}
export const CHART_VARIABLES: ChartVariableDef[] = [
{
key: 'temperature',
label: 'Temperature',
short: 'Temp',
field: 'temperature_2m',
type: 'line',
kind: 'temp',
color: '#ef6c00',
width: 9,
colorScale: true,
outline: true,
extrema: true
},
{
key: 'weather_icons',
label: 'Weather icons',
short: 'Icons',
field: 'weather_code',
type: 'line',
kind: 'temp',
color: '#94a3b8',
pictograms: true,
marker: true
},
{
key: 'apparent_temperature',
label: 'Apparent Temp',
short: 'Feels',
field: 'apparent_temperature',
type: 'line',
kind: 'temp',
color: '#c2410c',
width: 2,
dashed: true
},
{
key: 'dew_point',
label: 'Dew Point',
short: 'Dew',
field: 'dew_point_2m',
type: 'line',
kind: 'temp',
color: '#0e7490',
width: 2
},
{
key: 'cloud_cover',
label: 'Cloud Cover',
short: 'Cloud',
field: 'cloud_cover',
type: 'line',
kind: 'percent',
color: 'rgb(150, 155, 165)',
cloudBand: true
},
{
key: 'cloud_cover_low',
label: 'Cloud Cover Low',
short: 'Low',
field: 'cloud_cover_low',
type: 'line',
kind: 'percent',
color: '#94a3b8',
width: 2
},
{
key: 'cloud_cover_mid',
label: 'Cloud Cover Mid',
short: 'Mid',
field: 'cloud_cover_mid',
type: 'line',
kind: 'percent',
color: '#64748b',
width: 2
},
{
key: 'cloud_cover_high',
label: 'Cloud Cover High',
short: 'High',
field: 'cloud_cover_high',
type: 'line',
kind: 'percent',
color: '#cbd5e1',
width: 2
},
{
key: 'precipitation',
label: 'Precipitation',
short: 'Precip',
field: 'precipitation',
type: 'bar',
kind: 'precip',
color: 'rgba(30, 136, 229, 0.8)'
},
{
key: 'precipitation_probability',
label: 'Precip. Probability',
short: 'PoP',
field: 'precipitation_probability',
type: 'line',
kind: 'percent',
color: '#5c6bc0',
width: 2,
dashed: true
},
{
key: 'rain',
label: 'Rain',
short: 'Rain',
field: 'rain',
type: 'bar',
kind: 'precip',
color: 'rgba(37, 99, 235, 0.75)'
},
{
key: 'showers',
label: 'Showers',
short: 'Shwr',
field: 'showers',
type: 'bar',
kind: 'precip',
color: 'rgba(6, 182, 212, 0.75)'
},
{
key: 'snowfall',
label: 'Snowfall',
short: 'Snow',
field: 'snowfall',
type: 'bar',
kind: 'snow',
color: 'rgba(147, 197, 253, 0.9)'
},
{
key: 'wind',
label: 'Wind Speed',
short: 'Wind',
field: 'windspeed_10m',
api: 'wind_speed_10m',
type: 'line',
kind: 'wind',
color: '#26a69a',
width: 2,
fill: true,
fillOpacity: 0.15,
windArrows: true
},
{
key: 'wind_gusts',
label: 'Wind Gusts',
short: 'Gusts',
field: 'wind_gusts_10m',
type: 'line',
kind: 'wind',
color: '#0d9488',
width: 2,
dashed: true
},
{
key: 'humidity',
label: 'Humidity',
short: 'RH',
field: 'relative_humidity_2m',
type: 'line',
kind: 'percent',
color: '#8d6e63',
width: 2,
dashed: true
},
{
key: 'pressure_msl',
label: 'Pressure (MSL)',
short: 'MSLP',
field: 'pressure_msl',
type: 'line',
kind: 'pressure',
color: '#7c3aed',
width: 2
},
{
key: 'surface_pressure',
label: 'Surface Pressure',
short: 'Psfc',
field: 'surface_pressure',
type: 'line',
kind: 'pressure',
color: '#a855f7',
width: 2,
dashed: true
},
{
key: 'uv_index',
label: 'UV Index',
short: 'UV',
field: 'uv_index',
type: 'line',
kind: 'uv',
color: '#eab308',
width: 2,
fill: true,
fillOpacity: 0.15
},
{
key: 'visibility',
label: 'Visibility',
short: 'Vis',
field: 'visibility',
type: 'line',
kind: 'distance',
color: '#0891b2',
width: 2,
transform: (v) => v / 1000
},
{
key: 'cape',
label: 'CAPE',
short: 'CAPE',
field: 'cape',
type: 'line',
kind: 'energy',
color: '#dc2626',
width: 2,
fill: true,
fillOpacity: 0.12
}
];
export const VARIABLE_BY_KEY: Map<string, ChartVariableDef> = new Map(
CHART_VARIABLES.map((v) => [v.key, v])
);
/** Open-Meteo API variable name for a registry entry. */
export function apiNameOf(def: ChartVariableDef): string {
return def.api ?? def.field;
}
/**
* The set of API hourly variables needed to render the current table rows and
* chart layout, so the fetch requests only what is actually shown.
*/
export function neededHourlyApiVars(
tablePrefs: Record<string, boolean> | undefined,
layoutKeys: string[]
): string[] {
const on = (key: string): boolean => tablePrefs?.[key] ?? true;
const s = new Set<string>();
// Hourly table rows
if (on('icons')) s.add('weather_code');
if (on('temperature')) s.add('temperature_2m');
if (on('feels')) s.add('apparent_temperature');
if (on('wind')) {
s.add('wind_speed_10m');
s.add('wind_direction_10m');
}
if (on('humidity')) s.add('relative_humidity_2m');
if (on('clouds')) s.add('cloud_cover');
if (on('precipitation')) {
s.add('precipitation');
s.add('precipitation_probability');
}
// Meteogram variables
for (const key of layoutKeys) {
const def = VARIABLE_BY_KEY.get(key);
if (!def) continue;
s.add(apiNameOf(def));
if (def.pictograms) s.add('weather_code');
if (def.key === 'wind') s.add('wind_direction_10m');
}
return [...s];
}
/** Unit label for a variable family, honouring the user's unit settings. */
export function unitForKind(kind: UnitKind, units: WeatherUnits): string {
switch (kind) {
case 'temp':
return getTempUnit(units);
case 'precip':
return getPrecipUnit(units);
case 'snow':
return 'cm';
case 'wind':
return getWindUnit(units);
case 'percent':
return '%';
case 'pressure':
return 'hPa';
case 'uv':
return '';
case 'distance':
return 'km';
case 'energy':
return 'J/kg';
}
}
/** Families whose axis should always start at zero. */
export function isZeroBased(kind: UnitKind): boolean {
return kind !== 'temp' && kind !== 'pressure';
}
/** Decimal places for on-chart extrema labels (kept coarse, like the cards). */
export function decimalsForKind(kind: UnitKind): number {
switch (kind) {
case 'precip':
case 'snow':
case 'uv':
case 'distance':
return 1;
default:
return 0;
}
}
/**
* Decimal places for the hover tooltip — finer than the cards / extrema labels
* so the meteogram reveals more detail. Percentages stay whole numbers.
*/
export function tooltipDecimalsForKind(kind: UnitKind): number {
switch (kind) {
case 'percent':
case 'energy':
return 0;
default:
return 1;
}
}
export interface PanelDef {
series: ChartSeries[];
unit: string;
unitRight?: string;
yMin?: number;
yMinRight?: number;
yMaxRight?: number;
/** Whether the left axis should include zero (false for pressure) */
zeroBaseLeft: boolean;
hasPictograms: boolean;
hasWindArrows: boolean;
}
/**
* Builds a chart definition for one panel: turns its ordered variable keys into
* series and works out the left / right axis units. The first variable's family
* owns the left axis; the first differing family gets the right axis.
*/
export function buildPanelDef(
variableKeys: string[],
hourly: WeekHourlyData,
units: WeatherUnits
): PanelDef {
const allDefs = variableKeys
.map((k) => VARIABLE_BY_KEY.get(k))
.filter((d): d is ChartVariableDef => d != null);
// Marker-only variables (weather icons) render no series, just a top row.
const defs = allDefs.filter((d) => !d.marker);
// Cloud-band variables float above the plot and don't claim an axis.
const axisDefs = defs.filter((d) => !d.cloudBand);
const kinds: UnitKind[] = [];
for (const d of axisDefs) if (!kinds.includes(d.kind)) kinds.push(d.kind);
const leftKind = kinds[0];
const rightKind = kinds.find((k) => k !== leftKind);
const series: ChartSeries[] = defs.map((d) => {
const raw = (hourly[d.field] as number[]) ?? [];
const data: (number | null)[] = d.transform
? raw.map((v) => (v == null || !isFinite(v) ? null : d.transform!(v)))
: raw;
const kindUnit = unitForKind(d.kind, units);
const dec = decimalsForKind(d.kind);
const tipDec = tooltipDecimalsForKind(d.kind);
const axis: 'left' | 'right' = d.cloudBand || d.kind === leftKind ? 'left' : 'right';
return {
name: d.label,
shortName: d.short,
type: d.type,
color: d.color,
data,
width: d.width,
fill: d.fill,
fillOpacity: d.fillOpacity,
dashed: d.dashed,
axis,
cloudBand: d.cloudBand,
segmentColor: d.colorScale ? (v: number) => getColor(v, units.temperature_unit) : undefined,
outline: d.outline,
labelExtrema: d.extrema,
labelFormat: d.extrema
? (v: number) => (d.kind === 'temp' ? `${v.toFixed(0)}°` : `${v.toFixed(dec)}${kindUnit}`)
: undefined,
format:
d.key === 'wind'
? (v: number, i: number) => {
const dir = hourly.winddirection_10m?.[i];
const dl = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : '';
return `${v.toFixed(tipDec)} ${kindUnit}${dl}`;
}
: (v: number) => `${v.toFixed(tipDec)}${kindUnit ? ' ' + kindUnit : ''}`
} satisfies ChartSeries;
});
const rightZero = rightKind ? isZeroBased(rightKind) : false;
return {
series,
unit: leftKind ? unitForKind(leftKind, units) : '',
unitRight: rightKind ? unitForKind(rightKind, units) : undefined,
yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined,
// pressure sits far from zero, so its axis is derived from the data
zeroBaseLeft: leftKind !== 'pressure',
yMinRight: rightKind && rightZero ? 0 : undefined,
yMaxRight: rightKind === 'percent' ? 100 : undefined,
hasPictograms: allDefs.some((d) => d.pictograms),
hasWindArrows: allDefs.some((d) => d.windArrows)
};
}