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
+72 -30
View File
@@ -31,6 +31,9 @@
width?: number;
/** Draw a low-alpha area fill below (or above, on inverted axes) the line */
fill?: boolean;
/** With `fill`, fill the area between this line and another data array
* instead of the baseline (e.g. an ensemble min-max band) */
bandTo?: (number | null)[];
/** Opacity of the area fill (default 0.15) */
fillOpacity?: number;
/** Draw the line dashed */
@@ -92,6 +95,8 @@
series: ChartSeries[];
/** Background bands (epoch seconds), e.g. daylight */
bands?: { start: number; end: number }[];
/** Highlighted time range (epoch seconds), e.g. the selected day */
highlight?: { start: number; end: number };
/** Unit label for the left y axis (also used in tooltip values) */
unit?: string;
/** Unit label for the right y axis; when set, right-axis labels are drawn */
@@ -129,6 +134,7 @@
timezone,
series,
bands = [],
highlight,
unit = '',
unitRight,
height = 300,
@@ -443,6 +449,33 @@
if (x2 > x1) ctx.fillRect(x1, padTop, x2 - x1, plotH);
}
// Selected-day highlight: soft tint + dashed edge lines
if (highlight && highlight.end > viewStart && highlight.start < viewEnd) {
const accent = cssColor('--primary', '#e08a3c');
const x1 = Math.max(PAD_LEFT, xPix(highlight.start));
const x2 = Math.min(plotRight, xPix(highlight.end));
if (x2 > x1) {
ctx.save();
ctx.globalAlpha = 0.08;
ctx.fillStyle = accent;
ctx.fillRect(x1, padTop, x2 - x1, plotH);
ctx.globalAlpha = 0.55;
ctx.strokeStyle = accent;
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.beginPath();
for (const edge of [highlight.start, highlight.end]) {
const x = xPix(edge);
if (x >= PAD_LEFT && x <= plotRight) {
ctx.moveTo(x, padTop);
ctx.lineTo(x, plotBottom);
}
}
ctx.stroke();
ctx.restore();
}
}
// Horizontal grid lines + left axis labels
ctx.font = font;
ctx.textAlign = 'right';
@@ -530,17 +563,20 @@
}
// Line series: draw fill and stroke per contiguous non-null run
// (points outside the view are handled by the clip rect)
const runs: Array<Array<[number, number]>> = [];
let run: Array<[number, number]> = [];
// (points outside the view are handled by the clip rect). Each point
// is [x, y, yBand] — yBand only used when s.bandTo is set.
const runs: Array<Array<[number, number, number]>> = [];
let run: Array<[number, number, number]> = [];
for (let i = 0; i < timestamps.length; i++) {
const v = s.data[i];
if (v === null || v === undefined || !isFinite(v)) {
const b = s.bandTo?.[i];
const bandInvalid = s.bandTo != null && (b === null || b === undefined || !isFinite(b));
if (v === null || v === undefined || !isFinite(v) || bandInvalid) {
if (run.length > 0) runs.push(run);
run = [];
continue;
}
run.push([xPix(timestamps[i]), yPix(v, axis)]);
run.push([xPix(timestamps[i]), yPix(v, axis), s.bandTo ? yPix(b as number, axis) : 0]);
}
if (run.length > 0) runs.push(run);
@@ -551,8 +587,13 @@
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
ctx.lineTo(points[points.length - 1][0], baseline);
ctx.lineTo(points[0][0], baseline);
if (s.bandTo) {
// close the polygon along the second line, walked backwards
for (let i = points.length - 1; i >= 0; i--) ctx.lineTo(points[i][0], points[i][2]);
} else {
ctx.lineTo(points[points.length - 1][0], baseline);
ctx.lineTo(points[0][0], baseline);
}
ctx.closePath();
ctx.globalAlpha = s.fillOpacity ?? 0.15;
ctx.fillStyle = s.color;
@@ -789,29 +830,6 @@
</script>
<div bind:this={containerEl} class="relative w-full select-none {className}">
{#if showLegend && series.length > 0}
<div class="mb-1 flex flex-wrap items-center gap-x-3 gap-y-1 px-1">
{#each series.filter((s) => s.showInLegend !== false) as s (s.name)}
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 text-xs transition-opacity {legendHidden.has(
s.name
)
? 'opacity-40'
: ''}"
onclick={() => toggleSeries(s.name)}
title="Toggle {s.name}"
>
<span
class="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
style:background-color={s.color}
></span>
<span class="text-muted-foreground">{s.name}</span>
</button>
{/each}
</div>
{/if}
<div class="relative">
<canvas
bind:this={canvasEl}
@@ -856,4 +874,28 @@
</div>
{/if}
</div>
<!-- Legend sits below the graph -->
{#if showLegend && series.length > 0}
<div class="mt-1.5 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 px-1">
{#each series.filter((s) => s.showInLegend !== false) as s (s.name)}
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 text-xs transition-opacity {legendHidden.has(
s.name
)
? 'opacity-40'
: ''}"
onclick={() => toggleSeries(s.name)}
title="Toggle {s.name}"
>
<span
class="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
style:background-color={s.color}
></span>
<span class="text-muted-foreground">{s.name}</span>
</button>
{/each}
</div>
{/if}
</div>
@@ -61,7 +61,7 @@
<!-- Loading overlay -->
<div
class="loading-overlay absolute inset-0 z-30 flex items-center justify-center rounded-lg bg-accent transition-opacity duration-300"
class="loading-overlay absolute inset-0 z-30 flex items-center justify-center rounded-lg bg-background transition-opacity duration-300"
class:pointer-events-none={!loading}
class:opacity-0={!loading}
class:opacity-100={loading}
+31
View File
@@ -47,3 +47,34 @@ export const storedLocation = persisted('stored_location', defaultLocation as Ge
export type Theme = 'system' | 'light' | 'dark';
export const storedTheme = persisted<Theme>('theme', 'system');
/** Selected forecast model, shared across the whole site. */
export const storedModel = persisted<string>('selected_model', 'best_match');
/** Which variables are visible in the hourly table and the meteograms. */
export interface VariablePrefs {
table: Record<string, boolean>;
charts: Record<string, boolean>;
}
export const defaultVariablePrefs: VariablePrefs = {
table: {
icons: true,
temperature: true,
feels: true,
wind: true,
humidity: true,
clouds: true,
precipitation: true
},
charts: {
temperature: true,
cloud_cover: true,
precipitation: true,
precipitation_probability: true,
wind: true,
humidity: true
}
};
export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs);
+8
View File
@@ -118,6 +118,14 @@
}
@layer base {
/* color-scheme drives native widgets AND propagates into embedded
iframes (the open-meteo map reads it via prefers-color-scheme) */
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
}
* {
@apply border-border outline-ring/50;
}
@@ -1,8 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { storedLocation } from '$lib/stores/settings';
import { storedLocation, storedModel } from '$lib/stores/settings';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Checkbox } from '$lib/components/ui/checkbox';
@@ -93,6 +94,11 @@
// ─── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => {
// the model chosen elsewhere on the site is always part of the comparison
const selectedModel = get(storedModel);
if (selectedModel !== 'best_match' && !params.models.includes(selectedModel)) {
params.models = [selectedModel, ...params.models];
}
mounted = true;
});
@@ -230,7 +236,13 @@
</div>
{/if}
<ChartContainer {loading} chartCount={chartDefs.length || 1} chartHeight={300}>
<!-- chart count derives from the selected variables (not the fetched data),
so the reserved height is right even before the response arrives -->
<ChartContainer
{loading}
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
chartHeight={300}
>
{#if fetchedData}
{#each chartDefs as def, i (i)}
<CanvasChart
+6 -5
View File
@@ -2,10 +2,10 @@
import { storedLocation } from '$lib/stores/settings';
// the embedded map understands maplibre's #zoom/lat/lng hash, so the iframe
// opens focused on the selected location; picking a new location while on
// this page recenters the map
// opens focused on the selected location (zoomed out to regional scale);
// picking a new location while on this page recenters the map
const iframeSrc = $derived(
`https://maps.open-meteo.com/#8/${$storedLocation.latitude.toFixed(3)}/${$storedLocation.longitude.toFixed(3)}`
`https://maps.open-meteo.com/#6/${$storedLocation.latitude.toFixed(3)}/${$storedLocation.longitude.toFixed(3)}`
);
</script>
@@ -15,8 +15,9 @@
<meta name="description" content="Interactive weather map powered by Open-Meteo" />
</svelte:head>
<!-- Full-bleed map: the layout drops its padding for this route -->
<div class="h-full w-full bg-black">
<!-- Full-bleed map: the layout drops its padding for this route. The map
follows our theme through the color-scheme declared on :root/.dark -->
<div class="h-full w-full bg-background">
<iframe
src={iframeSrc}
title="Open-Meteo Interactive Map"
@@ -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}
+13 -1
View File
@@ -9,9 +9,21 @@ const config = {
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
// fallback: the SPA shell served for routes that were not prerendered
// (unlisted cities, GPS coordinate routes); the universal load then
// resolves the location client-side. Most static hosts serve 404.html
// for unknown paths automatically.
adapter: adapter({ fallback: '404.html' }),
// Pregenerate city pages to improve SEO during static build
prerender: {
// dynamic per-location routes (14-day, compare, unlisted cities) are
// served by the SPA fallback instead of being prerendered
handleUnseenRoutes: 'ignore',
// a transient geocoding failure for one city should skip that page
// (the fallback still serves it), not abort the whole build
handleHttpError: ({ path, message }) => {
console.warn(`prerender skipped ${path}: ${message}`);
},
entries: (() => {
try {
const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json');