fallback and 404

This commit is contained in:
Vincent van der Wal
2026-07-19 22:47:05 +02:00
parent baa4840b38
commit cd1a47b6c3
11 changed files with 580 additions and 212 deletions
@@ -1,8 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings';
import { storedLocation, storedModel, storedVariablePrefs } from '$lib/stores/settings';
import { ChartContainer } from '$lib/components/charts';
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather';
@@ -11,6 +14,7 @@
import HourlyTable from './HourlyTable.svelte';
import MeteogramCharts from './MeteogramCharts.svelte';
import ModelSelector from './ModelSelector.svelte';
import VariableSidebar from './VariableSidebar.svelte';
import type { PageData } from './$types';
import type { FetchedDaily, FetchedHourly } from './types';
@@ -22,6 +26,19 @@
...defaultParameters
});
let variableSidebarOpen = $state(false);
// Number of meteogram chart panels currently enabled: used to reserve the
// exact chart area height before data arrives (no layout shift)
let enabledChartCount = $derived.by(() => {
const on = (key: string) => $storedVariablePrefs.charts?.[key] ?? true;
return (
(on('temperature') || on('cloud_cover') ? 1 : 0) +
(on('precipitation') || on('precipitation_probability') ? 1 : 0) +
(on('wind') || on('humidity') ? 1 : 0)
);
});
// the URL is the source of truth: location comes from the load function,
// which is also correct on hydrated prerendered pages. The persisted store
// only mirrors it so the header and bare /weather/* redirects follow along.
@@ -47,6 +64,8 @@
};
onMount(() => {
// preselect the persisted model (client-only so prerendered HTML stays stable)
params.models = [get(storedModel)];
mounted = true;
});
@@ -131,14 +150,39 @@
</div>
</div>
<ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
params.models = [model];
}}
/>
<div class="flex items-center gap-3">
<ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
params.models = [model];
storedModel.set(model);
}}
/>
<button
class="flex h-11 cursor-pointer items-center gap-2 rounded-xl border-2 border-border bg-card px-3.5 text-sm font-semibold text-muted-foreground shadow-sm transition-colors hover:border-primary/50 hover:text-foreground"
onclick={() => (variableSidebarOpen = true)}
aria-label="Choose visible variables"
>
<!-- sliders icon -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
d="M4 6h9m5 0h2M4 12h2m5 0h9M4 18h9m5 0h2M13 4.5v3M6 10.5v3M18 16.5v3"
/>
</svg>
<span class="hidden md:inline">Variables</span>
</button>
</div>
</div>
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
{#if loadError}
<div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
@@ -157,10 +201,18 @@
units={params}
locationName={location.name ?? ''}
/>
{:else}
<!-- placeholder with the table's approximate height: no layout shift -->
<div class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"></div>
{/if}
{#if fetchedHourly}
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
{:else}
<!-- reserve the exact chart area height before the first fetch resolves -->
<section class="mt-8">
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
</section>
{/if}
</div>
</div>
@@ -1,4 +1,6 @@
<script lang="ts">
import { storedVariablePrefs } from '$lib/stores/settings';
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
import { getTempStyle } from '../../utils/colors';
@@ -25,6 +27,10 @@
let hourlyInterval = $state<1 | 3>(3);
// Row visibility, controlled from the Variables sidebar (missing keys
// from older stored prefs default to visible)
let showRow = $derived((key: string): boolean => $storedVariablePrefs.table?.[key] ?? true);
const today = new Date();
const tempUnit = $derived(getTempUnit(units));
const windUnit = $derived(getWindUnit(units));
@@ -333,115 +339,129 @@
</tr>
<!-- Weather Icons -->
<tr class="row">
{@render rowHeader('wi-day-cloudy')}
{#each cellData as cell, i (cell.idx)}
{@const wCode = hourly.weather_code[cell.idx]}
<td
class="cell leading-0 {is3h ? 'h-14' : 'h-11'}"
class:icon-day={cell.isDaytime}
class:icon-night={!cell.isDaytime}
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
class:icon-dusk={cell.isDaytime && cellData[i + 1] && !cellData[i + 1].isDaytime}
>
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
</td>
{/each}
</tr>
{#if showRow('icons')}
<tr class="row">
{@render rowHeader('wi-day-cloudy')}
{#each cellData as cell, i (cell.idx)}
{@const wCode = hourly.weather_code[cell.idx]}
<td
class="cell leading-0 {is3h ? 'h-14' : 'h-11'}"
class:icon-day={cell.isDaytime}
class:icon-night={!cell.isDaytime}
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
class:icon-dusk={cell.isDaytime && cellData[i + 1] && !cellData[i + 1].isDaytime}
>
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
</td>
{/each}
</tr>
{/if}
<!-- Temperature -->
<tr class="row">
{@render rowHeader('wi-thermometer', tempUnit, 'Temp')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.temperature_2m[cell.idx]}
{@const style = getTempStyle(temp, String(units.temperature_unit))}
<td
class="cell h-10 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
style="background-color:{style.bg};color:{style.fg}"
>
{formatTemp(temp)}
</td>
{/each}
</tr>
{#if showRow('temperature')}
<tr class="row">
{@render rowHeader('wi-thermometer', tempUnit, 'Temp')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.temperature_2m[cell.idx]}
{@const style = getTempStyle(temp, String(units.temperature_unit))}
<td
class="cell h-10 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
style="background-color:{style.bg};color:{style.fg}"
>
{formatTemp(temp)}
</td>
{/each}
</tr>
{/if}
<!-- Feels Like -->
<tr class="row">
{@render rowHeader(undefined, tempUnit, 'Feels')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.apparent_temperature[cell.idx]}
<td class="cell h-8 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}">
{formatTemp(temp)}
</td>
{/each}
</tr>
{#if showRow('feels')}
<tr class="row">
{@render rowHeader(undefined, tempUnit, 'Feels')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.apparent_temperature[cell.idx]}
<td class="cell h-8 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}">
{formatTemp(temp)}
</td>
{/each}
</tr>
{/if}
<!-- Wind -->
<tr class="row">
{@render rowHeader('wi-strong-wind', windUnit, 'Wind')}
{#each cellData as cell (cell.idx)}
{@const wind = hourly.windspeed_10m[cell.idx]}
{@const windDir = hourly.winddirection_10m[cell.idx]}
<td class="cell h-13 align-middle leading-tight">
{#if windDir != null && !isNaN(windDir)}
<span
class="inline-block leading-0"
style="transform:{getWindArrowRotation(windDir)}"
>
{@render weatherIcon('wi-direction-down', 22)}
{#if showRow('wind')}
<tr class="row">
{@render rowHeader('wi-strong-wind', windUnit, 'Wind')}
{#each cellData as cell (cell.idx)}
{@const wind = hourly.windspeed_10m[cell.idx]}
{@const windDir = hourly.winddirection_10m[cell.idx]}
<td class="cell h-13 align-middle leading-tight">
{#if windDir != null && !isNaN(windDir)}
<span
class="inline-block leading-0"
style="transform:{getWindArrowRotation(windDir)}"
>
{@render weatherIcon('wi-direction-down', 22)}
</span>
{/if}
<span class="block font-semibold {is3h ? 'text-sm' : 'text-xs'}">
{formatValue(wind)}
</span>
{/if}
<span class="block font-semibold {is3h ? 'text-sm' : 'text-xs'}">
{formatValue(wind)}
</span>
</td>
{/each}
</tr>
</td>
{/each}
</tr>
{/if}
<!-- Humidity -->
<tr class="row">
{@render rowHeader('wi-humidity', '%', 'Humidity')}
{#each cellData as cell (cell.idx)}
{@const hum = hourly.relative_humidity_2m[cell.idx]}
<td class="cell h-8" style="background:{getHumidityBg(hum ?? 0)}">
{formatValue(hum)}
</td>
{/each}
</tr>
{#if showRow('humidity')}
<tr class="row">
{@render rowHeader('wi-humidity', '%', 'Humidity')}
{#each cellData as cell (cell.idx)}
{@const hum = hourly.relative_humidity_2m[cell.idx]}
<td class="cell h-8" style="background:{getHumidityBg(hum ?? 0)}">
{formatValue(hum)}
</td>
{/each}
</tr>
{/if}
<!-- Cloud Cover -->
<tr class="row">
{@render rowHeader('wi-cloud', '%', 'Clouds')}
{#each cellData as cell (cell.idx)}
{@const cloud = hourly.cloud_cover[cell.idx]}
<td
class="cell h-8"
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
>
{formatValue(cloud)}
</td>
{/each}
</tr>
{#if showRow('clouds')}
<tr class="row">
{@render rowHeader('wi-cloud', '%', 'Clouds')}
{#each cellData as cell (cell.idx)}
{@const cloud = hourly.cloud_cover[cell.idx]}
<td
class="cell h-8"
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
>
{formatValue(cloud)}
</td>
{/each}
</tr>
{/if}
<!-- Precipitation -->
<tr class="row">
{@render rowHeader('wi-raindrop', precipUnit, 'Precip')}
{#each cellData as cell (cell.idx)}
{@const precip = hourly.precipitation[cell.idx]}
{@const prob = hourly.precipitation_probability[cell.idx]}
<td
class="cell precip-cell {is3h ? 'h-14' : 'h-11'}"
style="background:{getPrecipProbBg(prob ?? 0)}"
title={formatPrecipTooltip(precip, prob)}
>
{#if precip > 0}
<div class="precip-bar" style="height:{getPrecipBarHeight(precip)}%"></div>
<span class="precip-label {is3h ? 'text-[13px]' : 'text-[10px]'}">
{precip.toFixed(1)}
</span>
{/if}
</td>
{/each}
</tr>
{#if showRow('precipitation')}
<tr class="row">
{@render rowHeader('wi-raindrop', precipUnit, 'Precip')}
{#each cellData as cell (cell.idx)}
{@const precip = hourly.precipitation[cell.idx]}
{@const prob = hourly.precipitation_probability[cell.idx]}
<td
class="cell precip-cell {is3h ? 'h-14' : 'h-11'}"
style="background:{getPrecipProbBg(prob ?? 0)}"
title={formatPrecipTooltip(precip, prob)}
>
{#if precip > 0}
<div class="precip-bar" style="height:{getPrecipBarHeight(precip)}%"></div>
<span class="precip-label {is3h ? 'text-[13px]' : 'text-[10px]'}">
{precip.toFixed(1)}
</span>
{/if}
</td>
{/each}
</tr>
{/if}
</tbody>
</table>
@@ -1,6 +1,8 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { storedVariablePrefs } from '$lib/stores/settings';
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
@@ -68,6 +70,12 @@
{ label: 'All', apply: () => resetZoom() }
];
// Soft band drawn on every chart marking the currently selected day
let selectedDayHighlight = $derived.by(() => {
const start = dayStartSec(selectedDay);
return start == null ? undefined : { start, end: start + SECONDS_PER_DAY };
});
// ─── Series Building ────────────────────────────────────────────────────────
let tempUnit = $derived(getTempUnit(units));
@@ -90,17 +98,14 @@
if (!data) return [];
const { hourly } = data;
// Variables the user disabled in the sidebar are left out entirely
const on = (key: string): boolean => $storedVariablePrefs.charts?.[key] ?? true;
const defs: ChartDef[] = [];
const tempChart: ChartDef = {
title: 'Temperature & Cloud Cover',
unit: tempUnit,
// Hidden inverted right axis (0-250) so cloud cover hangs from the top,
// occupying at most the upper 40% of the plot
yMinRight: 0,
yMaxRight: 250,
invertRight: true,
series: [
{
if (on('temperature') || on('cloud_cover')) {
const series: ChartSeries[] = [];
if (on('cloud_cover')) {
series.push({
name: 'Cloud Cover',
type: 'line',
color: 'rgb(150, 150, 150)',
@@ -110,8 +115,10 @@
fillOpacity: 0.25,
axis: 'right',
format: (v) => `${v.toFixed(0)}%`
},
{
});
}
if (on('temperature')) {
series.push({
name: 'Temperature',
type: 'line',
color: '#ef6c00',
@@ -120,26 +127,35 @@
fill: true,
fillOpacity: 0.2,
format: (v) => `${v.toFixed(1)} ${tempUnit}`
}
]
};
});
}
defs.push({
title: [on('temperature') && 'Temperature', on('cloud_cover') && 'Cloud Cover']
.filter(Boolean)
.join(' & '),
unit: tempUnit,
// Hidden inverted right axis (0-250) so cloud cover hangs from the top,
// occupying at most the upper 40% of the plot
yMinRight: 0,
yMaxRight: 250,
invertRight: true,
series
});
}
const precipChart: ChartDef = {
title: 'Precipitation & Probability',
unit: precipUnit,
unitRight: '%',
yMin: 0,
yMinRight: 0,
yMaxRight: 100,
series: [
{
if (on('precipitation') || on('precipitation_probability')) {
const series: ChartSeries[] = [];
if (on('precipitation')) {
series.push({
name: 'Precipitation',
type: 'bar',
color: 'rgba(30, 136, 229, 0.8)',
data: hourly.precipitation,
format: (v) => `${v.toFixed(1)} ${precipUnit}`
},
{
});
}
if (on('precipitation_probability')) {
series.push({
name: 'Precip. Probability',
type: 'line',
color: '#5c6bc0',
@@ -148,20 +164,28 @@
dashed: true,
axis: 'right',
format: (v) => `${v.toFixed(0)}%`
}
]
};
});
}
defs.push({
title: [
on('precipitation') && 'Precipitation',
on('precipitation_probability') && 'Probability'
]
.filter(Boolean)
.join(' & '),
unit: precipUnit,
unitRight: on('precipitation_probability') ? '%' : undefined,
yMin: 0,
yMinRight: 0,
yMaxRight: 100,
series
});
}
const windChart: ChartDef = {
title: 'Wind Speed & Humidity',
unit: windUnit,
unitRight: '%',
yMin: 0,
yMinRight: 0,
yMaxRight: 100,
showCredit: true,
series: [
{
if (on('wind') || on('humidity')) {
const series: ChartSeries[] = [];
if (on('wind')) {
series.push({
name: 'Wind Speed',
type: 'line',
color: '#26a69a',
@@ -174,8 +198,10 @@
const dirLabel = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : '';
return `${v.toFixed(0)} ${windUnit}${dirLabel}`;
}
},
{
});
}
if (on('humidity')) {
series.push({
name: 'Humidity',
type: 'line',
color: '#8d6e63',
@@ -184,11 +210,24 @@
dashed: true,
axis: 'right',
format: (v) => `${v.toFixed(0)}%`
}
]
};
});
}
defs.push({
title: [on('wind') && 'Wind Speed', on('humidity') && 'Humidity']
.filter(Boolean)
.join(' & '),
unit: windUnit,
unitRight: on('humidity') ? '%' : undefined,
yMin: 0,
yMinRight: 0,
yMaxRight: 100,
series
});
}
return [tempChart, precipChart, windChart];
if (defs.length > 0) defs[defs.length - 1].showCredit = true;
return defs;
});
</script>
@@ -230,30 +269,39 @@
</div>
</div>
<ChartContainer {loading} chartCount={3} chartHeight={300}>
{#each chartDefs as def, i (def.title)}
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={data.timezone}
series={def.series}
bands={data.daylightBands}
unit={def.unit}
unitRight={def.unitRight}
yMin={def.yMin}
yMinRight={def.yMinRight}
yMaxRight={def.yMaxRight}
invertRight={def.invertRight}
title={def.title}
showCredit={def.showCredit}
showLegend
height={300}
group={CHART_GROUP}
/>
{/each}
</ChartContainer>
{#if chartDefs.length > 0}
<ChartContainer {loading} chartCount={chartDefs.length} chartHeight={300}>
{#each chartDefs as def, i (def.title)}
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={data.timezone}
series={def.series}
bands={data.daylightBands}
highlight={selectedDayHighlight}
unit={def.unit}
unitRight={def.unitRight}
yMin={def.yMin}
yMinRight={def.yMinRight}
yMaxRight={def.yMaxRight}
invertRight={def.invertRight}
title={def.title}
showCredit={def.showCredit}
showLegend
height={300}
group={CHART_GROUP}
/>
{/each}
</ChartContainer>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
</div>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
</div>
{:else}
<div
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
>
All chart variables are hidden — enable some under “Variables”.
</div>
{/if}
</section>
@@ -0,0 +1,142 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition';
import { defaultVariablePrefs, storedVariablePrefs } from '$lib/stores/settings';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
const tableVariables = [
{ key: 'icons', label: 'Weather icons' },
{ key: 'temperature', label: 'Temperature' },
{ key: 'feels', label: 'Feels like' },
{ key: 'wind', label: 'Wind' },
{ key: 'humidity', label: 'Humidity' },
{ key: 'clouds', label: 'Cloud cover' },
{ key: 'precipitation', label: 'Precipitation' }
];
const chartVariables = [
{ key: 'temperature', label: 'Temperature' },
{ key: 'cloud_cover', label: 'Cloud cover' },
{ key: 'precipitation', label: 'Precipitation' },
{ key: 'precipitation_probability', label: 'Precipitation probability' },
{ key: 'wind', label: 'Wind speed' },
{ key: 'humidity', label: 'Humidity' }
];
function toggle(section: 'table' | 'charts', key: string) {
storedVariablePrefs.update((prefs) => {
const current = { ...defaultVariablePrefs[section], ...prefs[section] };
return { ...prefs, [section]: { ...current, [key]: !(current[key] ?? true) } };
});
}
function resetDefaults() {
storedVariablePrefs.set(structuredClone(defaultVariablePrefs));
}
</script>
<svelte:window
onkeydown={(e) => {
if (e.key === 'Escape' && open) onClose();
}}
/>
{#if open}
<div class="fixed inset-0 z-50">
<div
class="absolute inset-0 bg-black/30"
transition:fade={{ duration: 150 }}
onclick={onClose}
onkeydown={onClose}
role="presentation"
></div>
<aside
class="absolute inset-y-0 right-0 flex w-80 max-w-[90vw] flex-col overflow-y-auto border-l border-border bg-card shadow-xl"
transition:fly={{ x: 320, duration: 200, opacity: 1 }}
aria-label="Variable selection"
>
<div
class="sticky top-0 flex items-center justify-between border-b border-border bg-card px-5 py-4"
>
<h2 class="text-base font-bold">Variables</h2>
<button
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onclick={onClose}
aria-label="Close variable selection"
>
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="flex flex-1 flex-col gap-6 px-5 py-4">
<section>
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-primary uppercase">
Hourly table
</h3>
<div class="flex flex-col gap-1">
{#each tableVariables as variable (variable.key)}
<div class="flex items-center gap-2.5 rounded-md px-1 py-1 hover:bg-muted/60">
<Checkbox
id="table_var_{variable.key}"
class="cursor-pointer"
checked={$storedVariablePrefs.table?.[variable.key] ?? true}
onCheckedChange={() => toggle('table', variable.key)}
/>
<Label class="flex-1 cursor-pointer text-sm" for="table_var_{variable.key}">
{variable.label}
</Label>
</div>
{/each}
</div>
</section>
<section>
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-primary uppercase">
Meteogram charts
</h3>
<div class="flex flex-col gap-1">
{#each chartVariables as variable (variable.key)}
<div class="flex items-center gap-2.5 rounded-md px-1 py-1 hover:bg-muted/60">
<Checkbox
id="chart_var_{variable.key}"
class="cursor-pointer"
checked={$storedVariablePrefs.charts?.[variable.key] ?? true}
onCheckedChange={() => toggle('charts', variable.key)}
/>
<Label class="flex-1 cursor-pointer text-sm" for="chart_var_{variable.key}">
{variable.label}
</Label>
</div>
{/each}
</div>
</section>
</div>
<div class="border-t border-border px-5 py-3">
<button
class="cursor-pointer text-xs font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
onclick={resetDefaults}
>
Reset to defaults
</button>
</div>
</aside>
</div>
{/if}