480 lines
11 KiB
TypeScript
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)
|
|
};
|
|
}
|