/** * 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 = 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 | undefined, layoutKeys: string[] ): string[] { const on = (key: string): boolean => tablePrefs?.[key] ?? true; const s = new Set(); // 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) }; }