modular meteograms
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* 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;
|
||||
/** 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: 4,
|
||||
fill: true,
|
||||
fillOpacity: 0.12,
|
||||
colorScale: true,
|
||||
outline: true,
|
||||
extrema: true,
|
||||
pictograms: 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
|
||||
},
|
||||
{
|
||||
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';
|
||||
}
|
||||
|
||||
/** Sensible decimal places for tooltip / label formatting. */
|
||||
export function decimalsForKind(kind: UnitKind): number {
|
||||
switch (kind) {
|
||||
case 'precip':
|
||||
case 'snow':
|
||||
case 'uv':
|
||||
case 'distance':
|
||||
return 1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 defs = variableKeys
|
||||
.map((k) => VARIABLE_BY_KEY.get(k))
|
||||
.filter((d): d is ChartVariableDef => d != null);
|
||||
|
||||
// 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 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(dec)} ${kindUnit}${dl}`;
|
||||
}
|
||||
: (v: number) => `${v.toFixed(dec)}${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: defs.some((d) => d.pictograms)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user