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 location.admin1}{location.admin1},{/if}
- {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 @@
@@ -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;
+ }
+
+
+