From 409096a55006f8d2b4e7f1157ac03fb27ae481cb Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Sat, 25 Jul 2026 09:48:34 +0200 Subject: [PATCH] revamp week view: unit selector, redesigned day cards, richer meteograms and extended forecast --- src/lib/charts/CanvasChart.svelte | 187 ++++++++++++++---- src/lib/charts/index.ts | 2 +- .../components/charts/ChartContainer.svelte | 34 +++- src/lib/components/navigation/header.svelte | 10 +- src/lib/stores/settings.ts | 17 +- src/lib/utils/location.ts | 5 +- .../weather/14-day/[location]/+page.svelte | 69 +++++-- .../weather/compare/[location]/+page.svelte | 20 +- .../weather/week/[location]/+page.svelte | 54 +++-- .../weather/week/[location]/DailyCards.svelte | 182 +++++++++++++---- .../week/[location]/HourlyTable.svelte | 127 +++++++++--- .../week/[location]/MeteogramCharts.svelte | 81 +++++++- .../week/[location]/UnitSelector.svelte | 95 +++++++++ .../weather/week/[location]/variables.ts | 55 ++++-- 14 files changed, 753 insertions(+), 185 deletions(-) create mode 100644 src/routes/weather/week/[location]/UnitSelector.svelte diff --git a/src/lib/charts/CanvasChart.svelte b/src/lib/charts/CanvasChart.svelte index 0962550..50aac1d 100644 --- a/src/lib/charts/CanvasChart.svelte +++ b/src/lib/charts/CanvasChart.svelte @@ -86,6 +86,21 @@ delete groups[name]; } } + + /** + * Drive the shared crosshair of a chart group from the outside (e.g. hovering + * the hourly table). `time` is epoch seconds, or null to clear. No-op if no + * chart in that group is currently mounted. + */ + export function setGroupHover(name: string, time: number | null): void { + const state = groups[name]; + if (state) state.hover = time; + } + + /** Current shared zoom range of a group (null = full range), reactive. */ + export function groupRange(name: string): { start: number; end: number } | null { + return groups[name]?.range ?? null; + } -
+
@@ -100,18 +103,41 @@ .chart-bleed { /* Bleed exactly into the page padding on mobile (main has p-5 = 1.25rem) for edge-to-edge charts, and a bit past the content - column on md+ (main has 2rem padding) for extra readability. - Charts narrower than their min-width scroll sideways. */ + column on md+ (main has 2rem padding) for extra readability. */ margin-left: -1.25rem; margin-right: -1.25rem; overflow-x: auto; } + .chart-bleed.no-bleed { + margin-left: 0; + margin-right: 0; + } + + .chart-container { + min-width: var(--chart-min-width); + } + + /* Mobile: fit the chart to the viewport instead of forcing a min-width + sideways scroll (which fights touch inspection). Pinch to zoom for detail. */ + @media (max-width: 767px) { + .chart-container { + min-width: 0; + } + .chart-bleed { + overflow-x: hidden; + } + } + @media (min-width: 768px) { .chart-bleed { margin-left: -1.5rem; margin-right: -1.5rem; } + .chart-bleed.no-bleed { + margin-left: 0; + margin-right: 0; + } } .chart-content { diff --git a/src/lib/components/navigation/header.svelte b/src/lib/components/navigation/header.svelte index 38e5279..7da3527 100644 --- a/src/lib/components/navigation/header.svelte +++ b/src/lib/components/navigation/header.svelte @@ -75,15 +75,11 @@ src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg" alt={location.country} /> + - {location.name} + {#if location.admin1}{location.admin1}, {location.country}{:else}{location.country ?? + location.name}{/if} - {#if location.admin1 || location.country} - - {/if}
{/if} diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index 283ba54..757ce63 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -90,7 +90,7 @@ export interface ChartPanel { } export const defaultChartLayout: ChartPanel[] = [ - { id: 'panel-1', variables: ['temperature', 'cloud_cover'] }, + { id: 'panel-1', variables: ['weather_icons', 'temperature', 'cloud_cover'] }, { id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] }, { id: 'panel-3', variables: ['wind', 'humidity'] } ]; @@ -99,3 +99,18 @@ export const storedChartLayout = persisted('chart_layout_v1', defa /** Selected ensemble model for the 14-day spread forecast. */ export const storedEnsembleModel = persisted('ensemble_model', 'ncep_gefs_seamless'); + +/** Measurement units, shared across every forecast page and persisted. */ +export interface UnitPrefs { + temperature_unit: 'celsius' | 'fahrenheit'; + wind_speed_unit: 'kmh' | 'ms' | 'mph' | 'kn'; + precipitation_unit: 'mm' | 'inch'; +} + +export const defaultUnits: UnitPrefs = { + temperature_unit: 'celsius', + wind_speed_unit: 'kmh', + precipitation_unit: 'mm' +}; + +export const storedUnits = persisted('units_v1', defaultUnits); diff --git a/src/lib/utils/location.ts b/src/lib/utils/location.ts index 795c98f..109092a 100644 --- a/src/lib/utils/location.ts +++ b/src/lib/utils/location.ts @@ -115,7 +115,10 @@ export async function resolveLocationFromRoute({ location = candidate; } - const canonicalPath = `${routePrefix}${buildLocationRoute(location)}`; + // trailingSlash is 'always' (see routes/+layout.ts), so the router serves + // every path with a trailing slash. Match that here or the equality check + // never holds and the redirect loops forever. + const canonicalPath = `${routePrefix}${buildLocationRoute(location)}/`; if (event.url.pathname !== canonicalPath) { throw redirect(303, canonicalPath); } diff --git a/src/routes/weather/14-day/[location]/+page.svelte b/src/routes/weather/14-day/[location]/+page.svelte index 9f25cef..d216ad6 100644 --- a/src/routes/weather/14-day/[location]/+page.svelte +++ b/src/routes/weather/14-day/[location]/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { get } from 'svelte/store'; - import { storedEnsembleModel, storedLocation } from '$lib/stores/settings'; + import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings'; import { ChartContainer, ChartToolbar } from '$lib/components/charts'; import { Label } from '$lib/components/ui/label'; @@ -17,6 +17,7 @@ import { defaultParameters, ensembleModelGroups } from '../../options'; import ModelSelector from '../../week/[location]/ModelSelector.svelte'; + import UnitSelector from '../../week/[location]/UnitSelector.svelte'; import type { PageData } from './$types'; @@ -46,10 +47,25 @@ let params = $state({ ...defaultParameters, - hourly: ['temperature_2m'], + hourly: [ + 'temperature_2m', + 'precipitation', + 'wind_speed_10m', + 'relative_humidity_2m', + 'cloud_cover', + 'pressure_msl' + ], models: ['ncep_gefs_seamless'] }); + // units live in a persisted store; mirror them into params so a change + // re-runs the fetch effect (which reads params.*_unit) + $effect(() => { + params.temperature_unit = $storedUnits.temperature_unit; + params.wind_speed_unit = $storedUnits.wind_speed_unit; + params.precipitation_unit = $storedUnits.precipitation_unit; + }); + // ─── Cached API Response ──────────────────────────────────────────────────── interface FetchedData { @@ -119,9 +135,24 @@ // ─── Chart Building (runs when fetchedData or the variable list changes) ──── + // Ensemble members stop at the model's horizon; past it the service collapses + // every value to 0 (min = max = mean = 0). Trim the axis to the last hour that + // actually has data so the charts cut off instead of flat-lining to zero. + let validLength = $derived.by((): number => { + if (!fetchedData) return 0; + const temp = fetchedData.ensembleResult.variables['temperature_2m']; + const n = fetchedData.timestamps.length; + if (!temp) return n; + let last = 0; + for (let i = 0; i < n; i++) { + if (!(temp.max[i] === 0 && temp.min[i] === 0 && temp.average[i] === 0)) last = i + 1; + } + return last || n; + }); + // Timestamps from the service are in milliseconds; CanvasChart uses seconds. let timestampsSec = $derived.by(() => - fetchedData ? fetchedData.timestamps.map((t) => t / 1000) : [] + fetchedData ? fetchedData.timestamps.slice(0, validLength).map((t) => t / 1000) : [] ); interface ChartDef { @@ -213,24 +244,22 @@

{location.name}

-

- {#if location.admin1}{location.admin1}, - {/if}{location.country ?? ''} - · - 14-day ensemble forecast -

+

14-day ensemble forecast

- { - params.models = [model]; - storedEnsembleModel.set(model); - }} - /> +
+ { + params.models = [model]; + storedEnsembleModel.set(model); + }} + /> + +
@@ -269,9 +298,9 @@
{#snippet controls()} -
+
- +
{/snippet} diff --git a/src/routes/weather/compare/[location]/+page.svelte b/src/routes/weather/compare/[location]/+page.svelte index 96a45a1..ca17200 100644 --- a/src/routes/weather/compare/[location]/+page.svelte +++ b/src/routes/weather/compare/[location]/+page.svelte @@ -3,7 +3,7 @@ import { get } from 'svelte/store'; import { fade } from 'svelte/transition'; - import { storedLocation, storedModel } from '$lib/stores/settings'; + import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings'; import { ChartContainer, ChartToolbar } from '$lib/components/charts'; import { Checkbox } from '$lib/components/ui/checkbox'; @@ -27,6 +27,7 @@ import { findModel, hourly, modelGroups } from '../../options'; import { defaultParameters } from '../../options'; + import UnitSelector from '../../week/[location]/UnitSelector.svelte'; import ModelPictogramTimeline from './ModelPictogramTimeline.svelte'; import type { PageData } from './$types'; @@ -63,6 +64,14 @@ models: ['ecmwf_ifs', 'meteofrance_seamless', 'ukmo_seamless', 'icon_seamless', 'gfs_seamless'] }); + // units live in a persisted store; mirror them into params so a change + // re-runs the fetch effect (which reads params.*_unit) + $effect(() => { + params.temperature_unit = $storedUnits.temperature_unit; + params.wind_speed_unit = $storedUnits.wind_speed_unit; + params.precipitation_unit = $storedUnits.precipitation_unit; + }); + // ─── Cached API Response ──────────────────────────────────────────────────── interface FetchedData { @@ -268,9 +277,12 @@
{#snippet controls()} -
- - +
+
+ + +
+
{/snippet} diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index 98b45e5..2a099bb 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -7,6 +7,7 @@ storedChartLayout, storedLocation, storedModel, + storedUnits, storedVariablePrefs } from '$lib/stores/settings'; @@ -19,6 +20,7 @@ import HourlyTable from './HourlyTable.svelte'; import MeteogramCharts from './MeteogramCharts.svelte'; import ModelSelector from './ModelSelector.svelte'; + import UnitSelector from './UnitSelector.svelte'; import VariableSidebar from './VariableSidebar.svelte'; import { neededHourlyApiVars } from './variables'; @@ -32,6 +34,14 @@ ...defaultParameters }); + // units live in a persisted store; mirror them into params so a change + // re-runs the fetch effect below (which reads params.*_unit) + $effect(() => { + params.temperature_unit = $storedUnits.temperature_unit; + params.wind_speed_unit = $storedUnits.wind_speed_unit; + params.precipitation_unit = $storedUnits.precipitation_unit; + }); + let variableSidebarOpen = $state(false); // Number of meteogram panels: reserves the chart area height before data @@ -60,6 +70,9 @@ let loadError = $state(null); let requestVersion = 0; + // 7 by default; the user can extend to the model's longer range (up to 16 days) + let forecastDays = $state(7); + const selectedDay = new SvelteDate(); let fetchedHourly: FetchedHourly | null = $state(null); @@ -97,7 +110,7 @@ temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit', wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn', precipitation_unit: params.precipitation_unit as 'mm' | 'inch', - forecast_days: 7, + forecast_days: forecastDays, past_days: 0, timezone: loc.timezone }) @@ -151,12 +164,7 @@

{location.name}

-

- {#if location.admin1}{location.admin1}, - {/if}{location.country ?? ''} - · - 7-day forecast -

+

7-day forecast

@@ -166,28 +174,10 @@ onModelChange={(model) => { params.models = [model]; storedModel.set(model); + forecastDays = 7; // a new model may not support the extended range }} /> - +
@@ -201,7 +191,14 @@ {/if} - + (forecastDays = 15)} + /> {#if fetchedHourly && fetchedDaily} (variableSidebarOpen = true)} /> {:else} diff --git a/src/routes/weather/week/[location]/DailyCards.svelte b/src/routes/weather/week/[location]/DailyCards.svelte index 9742154..9c1c5c2 100644 --- a/src/routes/weather/week/[location]/DailyCards.svelte +++ b/src/routes/weather/week/[location]/DailyCards.svelte @@ -4,7 +4,7 @@ import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date'; import { getTempStyle } from '../../utils/colors'; - import weatherCodes from '../../utils/weather-codes'; + import { getWeatherIconName } from '../../utils/weather-codes'; import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types'; interface Props { @@ -12,9 +12,12 @@ selectedDay: Date; units: WeatherUnits; onSelectDay: (date: Date, index: number) => void; + /** Offer a button after the last day to load the model's longer range */ + canExtend?: boolean; + onExtend?: () => void; } - let { daily, selectedDay, units, onSelectDay }: Props = $props(); + let { daily, selectedDay, units, onSelectDay, canExtend = false, onExtend }: Props = $props(); function getDaylightSeconds(index: number): number { if (!daily) return 0; @@ -34,13 +37,49 @@ const ratio = (sunshineSeconds ?? 0) / daylightSeconds; if (ratio >= 0.7) return '#f59e0b'; if (ratio >= 0.45) return '#fbbf24'; - if (ratio >= 0.2) return '#fcd34d'; + if (ratio >= 0.1) return '#fcd34d'; return '#d1d5db'; } + + // ─── "Is this metric worth highlighting?" thresholds ──────────────────────── + // Below these, the sun / precip / wind bits are greyed out so a card at a + // glance only emphasises what's actually notable that day. + + function sunIsSignificant(sunshineSeconds: number | null, daylightSeconds: number): boolean { + if (daylightSeconds <= 0) return false; + return (sunshineSeconds ?? 0) / daylightSeconds >= 0.1; + } + + function precipIsSignificant(sum: number | null, unit: string): boolean { + const min = unit === 'mm' ? 0.1 : 0.005; // anything above a trace + return (sum ?? 0) >= min; + } + + function windIsSignificant(speed: number | null, gust: number | null, unit: string): boolean { + // separate bars: sustained wind ~ a light breeze (~12 km/h), gusts a bit + // higher (~22 km/h). If EITHER is met, the whole wind readout is coloured. + const windMin = unit === 'ms' ? 3 : unit === 'mph' ? 7 : unit === 'kn' ? 6 : 12; + const gustMin = unit === 'ms' ? 6 : unit === 'mph' ? 14 : unit === 'kn' ? 12 : 22; + const s = speed != null && !isNaN(speed) ? speed : -Infinity; + const g = gust != null && !isNaN(gust) ? gust : -Infinity; + return s >= windMin || g >= gustMin; + } + + +
-
+ +
{#if daily} {#each daily.dailyDates as time, index (index)} {@const selected = isSameDayInZone(time, selectedDay, daily.timezone)} @@ -57,41 +96,62 @@ {@const windDir = daily.daily.winddirection_10m_dominant[index]} {@const unit = String(units.temperature_unit)} {@const maxStyle = getTempStyle(tempMax, unit)} - {#if tempMax != null && !isNaN(tempMax)} + {@const lowSun = !sunIsSignificant(sunDuration, daylightSec)} + {@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))} + {@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))} + {#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)} {/if} {/each} + + {#if canExtend && onExtend} + + {/if} {/if}
diff --git a/src/routes/weather/week/[location]/HourlyTable.svelte b/src/routes/weather/week/[location]/HourlyTable.svelte index 05060df..4e5b9b3 100644 --- a/src/routes/weather/week/[location]/HourlyTable.svelte +++ b/src/routes/weather/week/[location]/HourlyTable.svelte @@ -3,6 +3,8 @@ import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date'; + import { setGroupHover } from '$lib/charts'; + import { getTempStyle } from '../../utils/colors'; import { getWeatherIconName } from '../../utils/weather-codes'; import { @@ -21,9 +23,33 @@ selectedDay: Date; units: WeatherUnits; locationName: string; + /** Opens the variable-customization sidebar (button lives in the header). */ + onCustomize?: () => void; } - let { data, daily, selectedDay, units, locationName }: Props = $props(); + let { data, daily, selectedDay, units, locationName, onCustomize }: Props = $props(); + + // Must match MeteogramCharts' CHART_GROUP so hovering the time row drives the + // meteogram crosshairs. + const METEOGRAM_GROUP = 'week-meteogram'; + + // Scrubbing the time row moves the shared meteogram cursor to the hovered + // time (interpolated across the row so it feels continuous), and clears it on + // leave. + function hoverTimeRow(e: MouseEvent) { + const el = e.currentTarget as HTMLElement; + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || cellData.length === 0) return; + const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); + const stepMs = (is3h ? 3 : 1) * 3600 * 1000; + const first = cellData[0].date.getTime(); + const last = cellData[cellData.length - 1].date.getTime() + stepMs; + setGroupHover(METEOGRAM_GROUP, (first + frac * (last - first)) / 1000); + } + + function clearTimeRowHover() { + setGroupHover(METEOGRAM_GROUP, null); + } let hourlyInterval = $state<1 | 3>(3); @@ -209,7 +235,7 @@ {#if cellData.length > 0} {@const hourly = data.hourly} - {@const iconPx = is3h ? 38 : 26} + {@const iconPx = is3h ? 38 : 33}
-
- {#each [3, 1] as interval (interval)} +
+ {#if onCustomize} - {/each} + {/if} +
+ {#each [3, 1] as interval (interval)} + + {/each} +
@@ -269,7 +319,12 @@ Time - + {#if sunTimes && sunrisePercent != null && sunsetPercent != null}
{/if} + + {#if isTodaySelected && nowPercent != null} + + Now + + {/if} {#each cellData as cell, i (cell.idx)} {@const leftPct = (i / cellData.length) * 100} {@const widthPct = 100 / cellData.length} {#if is3h} - {formatZoned(cell.date, data.timezone, 'HH')} + + {formatZoned(cell.date, data.timezone, 'HH')} + 00 + {:else} {formatZoned(cell.date, data.timezone, 'HH')} 0000 {/if} @@ -495,16 +569,11 @@ style="left:{headerColWidth + nowIdx * colWidth}px;width:{colWidth}px" >
{/if} +
- - Now - -
+ > {/if} diff --git a/src/routes/weather/week/[location]/MeteogramCharts.svelte b/src/routes/weather/week/[location]/MeteogramCharts.svelte index 2307744..7d9a4b1 100644 --- a/src/routes/weather/week/[location]/MeteogramCharts.svelte +++ b/src/routes/weather/week/[location]/MeteogramCharts.svelte @@ -7,7 +7,7 @@ import { ChartContainer, ChartToolbar } from '$lib/components/charts'; - import { CanvasChart } from '$lib/charts'; + import { CanvasChart, groupRange } from '$lib/charts'; import { getWeatherIconName } from '../../utils/weather-codes'; import ChartCustomizer from './ChartCustomizer.svelte'; @@ -39,8 +39,22 @@ $storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k))) ); + // When an extended range runs past the model's horizon the service pads with + // zeros; trim the axis to the last hour that actually has data so the charts + // cut off instead of flat-lining to zero. + let validLength = $derived.by((): number => { + const temp = data.hourly.temperature_2m ?? []; + const n = data.timestamps.length; + if (temp.length === 0) return n; + let last = 0; + for (let i = 0; i < n; i++) { + if (temp[i] != null && !isNaN(temp[i]) && temp[i] !== 0) last = i + 1; + } + return last || n; + }); + // Timestamps from the service are in milliseconds; CanvasChart uses seconds. - let timestampsSec = $derived(data.timestamps.map((t) => t / 1000)); + let timestampsSec = $derived(data.timestamps.slice(0, validLength).map((t) => t / 1000)); function dayStartSec(day: Date): number | null { if (!data) return null; @@ -96,6 +110,21 @@ return out; }); + // Wind-direction arrows for panels showing wind (deg = direction from North). + let windArrowMarks = $derived.by((): { t: number; deg: number }[] => { + const dirs = data.hourly.winddirection_10m ?? []; + const out: { t: number; deg: number }[] = []; + for (let i = 0; i < timestampsSec.length; i++) { + const d = dirs[i]; + if (d == null || !isFinite(d)) continue; + out.push({ t: timestampsSec[i], deg: d }); + } + return out; + }); + + // True while the shared group is zoomed in (not the full range). + let zoomActive = $derived(groupRange(CHART_GROUP) != null); + // ─── Panel definitions ────────────────────────────────────────────────────── interface RenderPanel extends ChartPanel { @@ -112,6 +141,16 @@ return { ...p, def, title, titleShort }; }) ); + + // Uniform sizing across every panel: reserve the right-axis gutter and the + // tallest icon-row count so all meteograms share one plot rectangle. + let anyRightAxis = $derived(renderPanels.some((p) => p.def.unitRight != null)); + let maxTopRows = $derived( + Math.max( + 0, + ...renderPanels.map((p) => (p.def.hasPictograms ? 1 : 0) + (p.def.hasWindArrows ? 1 : 0)) + ) + );
@@ -129,11 +168,31 @@
+ {#if zoomActive} + + {/if}
{#each renderPanels as panel, i (panel.id)} -
-
+ +
+

{panel.titleShort}

- + + import { type UnitPrefs, storedUnits } from '$lib/stores/settings'; + + import * as Popover from '$lib/components/ui/popover'; + + // each group maps a stored unit key to its selectable options + const UNIT_GROUPS: { + key: keyof UnitPrefs; + label: string; + options: { value: string; label: string }[]; + }[] = [ + { + key: 'temperature_unit', + label: 'Temperature', + options: [ + { value: 'celsius', label: '°C' }, + { value: 'fahrenheit', label: '°F' } + ] + }, + { + key: 'wind_speed_unit', + label: 'Wind speed', + options: [ + { value: 'kmh', label: 'km/h' }, + { value: 'ms', label: 'm/s' }, + { value: 'mph', label: 'mph' }, + { value: 'kn', label: 'kn' } + ] + }, + { + key: 'precipitation_unit', + label: 'Precipitation', + options: [ + { value: 'mm', label: 'mm' }, + { value: 'inch', label: 'inch' } + ] + } + ]; + + function setUnit(key: keyof UnitPrefs, value: string) { + storedUnits.update((u) => ({ ...u, [key]: value })); + } + + + + + + + + + + + + + +
+ {#each UNIT_GROUPS as group (group.key)} +
+ + {group.label} + +
+ {#each group.options as opt (opt.value)} + {@const active = $storedUnits[group.key] === opt.value} + + {/each} +
+
+ {/each} +
+
+
diff --git a/src/routes/weather/week/[location]/variables.ts b/src/routes/weather/week/[location]/variables.ts index 0fd14e3..416a82c 100644 --- a/src/routes/weather/week/[location]/variables.ts +++ b/src/routes/weather/week/[location]/variables.ts @@ -45,6 +45,10 @@ export interface ChartVariableDef { 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) */ @@ -62,13 +66,21 @@ export const CHART_VARIABLES: ChartVariableDef[] = [ type: 'line', kind: 'temp', color: '#ef6c00', - width: 4, - fill: true, - fillOpacity: 0.12, + width: 9, colorScale: true, outline: true, - extrema: true, - pictograms: 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', @@ -189,7 +201,8 @@ export const CHART_VARIABLES: ChartVariableDef[] = [ color: '#26a69a', width: 2, fill: true, - fillOpacity: 0.15 + fillOpacity: 0.15, + windArrows: true }, { key: 'wind_gusts', @@ -347,7 +360,7 @@ export function isZeroBased(kind: UnitKind): boolean { return kind !== 'temp' && kind !== 'pressure'; } -/** Sensible decimal places for tooltip / label formatting. */ +/** Decimal places for on-chart extrema labels (kept coarse, like the cards). */ export function decimalsForKind(kind: UnitKind): number { switch (kind) { case 'precip': @@ -360,6 +373,20 @@ export function decimalsForKind(kind: UnitKind): number { } } +/** + * 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; @@ -370,6 +397,7 @@ export interface PanelDef { /** Whether the left axis should include zero (false for pressure) */ zeroBaseLeft: boolean; hasPictograms: boolean; + hasWindArrows: boolean; } /** @@ -382,10 +410,13 @@ export function buildPanelDef( hourly: WeekHourlyData, units: WeatherUnits ): PanelDef { - const defs = variableKeys + 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[] = []; @@ -400,6 +431,7 @@ export function buildPanelDef( : 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 { @@ -425,9 +457,9 @@ export function buildPanelDef( ? (v: number, i: number) => { const dir = hourly.winddirection_10m?.[i]; const dl = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : ''; - return `${v.toFixed(dec)} ${kindUnit}${dl}`; + return `${v.toFixed(tipDec)} ${kindUnit}${dl}`; } - : (v: number) => `${v.toFixed(dec)}${kindUnit ? ' ' + kindUnit : ''}` + : (v: number) => `${v.toFixed(tipDec)}${kindUnit ? ' ' + kindUnit : ''}` } satisfies ChartSeries; }); @@ -441,6 +473,7 @@ export function buildPanelDef( zeroBaseLeft: leftKind !== 'pressure', yMinRight: rightKind && rightZero ? 0 : undefined, yMaxRight: rightKind === 'percent' ? 100 : undefined, - hasPictograms: defs.some((d) => d.pictograms) + hasPictograms: allDefs.some((d) => d.pictograms), + hasWindArrows: allDefs.some((d) => d.windArrows) }; }