This commit is contained in:
Vincent van der Wal
2026-07-19 14:57:26 +02:00
parent 9e1944396e
commit 6d81af8df5
26 changed files with 408 additions and 466 deletions
-84
View File
@@ -28,7 +28,6 @@
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.21.0", "eslint-plugin-svelte": "^3.21.0",
"globals": "^17.7.0", "globals": "^17.7.0",
"mode-watcher": "^1.1.0",
"openmeteo": "^1.2.3", "openmeteo": "^1.2.3",
"playwright": "^1.61.1", "playwright": "^1.61.1",
"prettier": "^3.9.5", "prettier": "^3.9.5",
@@ -3138,73 +3137,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/mode-watcher": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
"integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"runed": "^0.25.0",
"svelte-toolbelt": "^0.7.1"
},
"peerDependencies": {
"svelte": "^5.27.0"
}
},
"node_modules/mode-watcher/node_modules/runed": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz",
"integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/mode-watcher/node_modules/svelte-toolbelt": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz",
"integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte"
],
"dependencies": {
"clsx": "^2.1.1",
"runed": "^0.23.2",
"style-to-object": "^1.0.8"
},
"engines": {
"node": ">=18",
"pnpm": ">=8.7.0"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
},
"node_modules/mode-watcher/node_modules/svelte-toolbelt/node_modules/runed": {
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
"integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/mri": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -4559,22 +4491,6 @@
} }
} }
}, },
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"extraneous": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+5 -6
View File
@@ -30,10 +30,14 @@
"@vitest/browser-playwright": "^4.1.10", "@vitest/browser-playwright": "^4.1.10",
"bits-ui": "^2.18.1", "bits-ui": "^2.18.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0",
"date-fns-tz": "^3.2.0",
"echarts": "^6.1.0",
"eslint": "^10.7.0", "eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.21.0", "eslint-plugin-svelte": "^3.21.0",
"globals": "^17.7.0", "globals": "^17.7.0",
"openmeteo": "^1.2.3",
"playwright": "^1.61.1", "playwright": "^1.61.1",
"prettier": "^3.9.5", "prettier": "^3.9.5",
"prettier-plugin-svelte": "^3.5.2", "prettier-plugin-svelte": "^3.5.2",
@@ -49,11 +53,6 @@
"typescript-eslint": "^8.64.0", "typescript-eslint": "^8.64.0",
"vite": "^8.1.5", "vite": "^8.1.5",
"vitest": "^4.1.10", "vitest": "^4.1.10",
"vitest-browser-svelte": "^3.0.0", "vitest-browser-svelte": "^3.0.0"
"date-fns": "^4.4.0",
"date-fns-tz": "^3.2.0",
"echarts": "^6.1.0",
"mode-watcher": "^1.1.0",
"openmeteo": "^1.2.3"
} }
} }
-7
View File
@@ -1,7 +0,0 @@
import { describe, expect, it } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});
+6 -4
View File
@@ -16,7 +16,9 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import * as echarts from 'echarts'; import { echarts } from './echarts';
import type { ECharts } from 'echarts';
// ─── Props ────────────────────────────────────────────────────────────────── // ─── Props ──────────────────────────────────────────────────────────────────
@@ -36,7 +38,7 @@
/** Optional CSS class for the outer container */ /** Optional CSS class for the outer container */
class?: string; class?: string;
/** Callback fired once the chart instance is initialized */ /** Callback fired once the chart instance is initialized */
onChartReady?: (chart: echarts.ECharts) => void; onChartReady?: (chart: ECharts) => void;
/** Callback fired when the chart is disposed */ /** Callback fired when the chart is disposed */
onChartDisposed?: () => void; onChartDisposed?: () => void;
} }
@@ -56,7 +58,7 @@
// ─── Internal State ───────────────────────────────────────────────────────── // ─── Internal State ─────────────────────────────────────────────────────────
let containerEl: HTMLDivElement; let containerEl: HTMLDivElement;
let chartInstance: echarts.ECharts | null = null; let chartInstance: ECharts | null = null;
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
// ─── Public API ───────────────────────────────────────────────────────────── // ─── Public API ─────────────────────────────────────────────────────────────
@@ -64,7 +66,7 @@
/** /**
* Returns the underlying ECharts instance, or null if not yet initialized. * Returns the underlying ECharts instance, or null if not yet initialized.
*/ */
export function getChart(): echarts.ECharts | null { export function getChart(): ECharts | null {
return chartInstance; return chartInstance;
} }
+34
View File
@@ -0,0 +1,34 @@
// Tree-shaken ECharts build: only the pieces the app actually renders (line
// and bar series with grid/tooltip/legend/dataZoom/mark/graphic features) are
// registered, instead of the ~1 MB full bundle.
import { BarChart, LineChart } from 'echarts/charts';
import {
DataZoomInsideComponent,
DataZoomSliderComponent,
GraphicComponent,
GridComponent,
LegendComponent,
MarkAreaComponent,
MarkLineComponent,
TitleComponent,
TooltipComponent
} from 'echarts/components';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([
LineChart,
BarChart,
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
DataZoomInsideComponent,
DataZoomSliderComponent,
MarkLineComponent,
MarkAreaComponent,
GraphicComponent,
CanvasRenderer
]);
export { echarts };
@@ -64,17 +64,19 @@
return { return {
results: [ results: [
{ {
id: 100000000 + Math.floor(latitude * 100 + longitude + 1000), // coordinate-only location: id 0 + COORD makes
// buildLocationRoute emit a "52.52N13.41E" route
id: 0,
name: `GPS ${latitude.toFixed(2)}°N ${longitude.toFixed(2)}°E`, name: `GPS ${latitude.toFixed(2)}°N ${longitude.toFixed(2)}°E`,
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
elevation: position.coords.altitude ?? NaN, elevation: position.coords.altitude ?? 0,
feature_code: '', feature_code: 'COORD',
country_code: undefined, country_code: undefined,
admin1_id: undefined, admin1_id: undefined,
admin3_id: undefined, admin3_id: undefined,
admin4_id: undefined, admin4_id: undefined,
timezone: '', timezone: 'UTC',
population: undefined, population: undefined,
postcodes: undefined, postcodes: undefined,
country_id: undefined, country_id: undefined,
-27
View File
@@ -1,27 +0,0 @@
export {
fetchWeekForecast,
fetchModelComparison,
fetchEnsembleForecast,
range,
getTimestamps,
getDates,
getValues,
getInt64Values,
unitToDisplayString
} from './weather';
export type {
WeatherLocation,
WeatherUnitParams,
MarkArea,
WeekForecastParams,
WeekHourlyData,
WeekDailyData,
WeekForecastResult,
ModelCompareParams,
ModelSeriesData,
ModelCompareResult,
EnsembleForecastParams,
EnsembleVariableData,
EnsembleForecastResult
} from './weather';
+62 -46
View File
@@ -1,13 +1,21 @@
import { error, redirect } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit';
import { type GeoLocation, storedLocation } from '$lib/stores/settings'; import type { GeoLocation } from '$lib/stores/settings';
export const geoLocationNameToRoute = (name: string) => { export const geoLocationNameToRoute = (name: string) => {
const lowerCase = name.toLowerCase().replaceAll(' ', '-'); const lowerCase = name.toLowerCase().replaceAll(' ', '-');
return lowerCase.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); return lowerCase.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}; };
// coordinate routes look like "52.52N13.41E" (negative values for S/W); GPS
// selections navigate here directly, no geocoding id involved
const COORD_ROUTE = /^(-?\d+(?:\.\d+)?)N(-?\d+(?:\.\d+)?)E$/i;
export function buildLocationRoute(location: GeoLocation): string { export function buildLocationRoute(location: GeoLocation): string {
// coordinate-only locations (GPS) have no real geocoding id
if (location.feature_code === 'COORD' || !location.id) {
return `${location.latitude.toFixed(4)}N${location.longitude.toFixed(4)}E`;
}
const locationRoute = geoLocationNameToRoute(location.name); const locationRoute = geoLocationNameToRoute(location.name);
if (location.population && location.population > 543000) { if (location.population && location.population > 543000) {
return locationRoute; return locationRoute;
@@ -15,30 +23,9 @@ export function buildLocationRoute(location: GeoLocation): string {
return locationRoute + '_' + location.id; return locationRoute + '_' + location.id;
} }
interface ResolveLocationOptions { export const coordinateLocation = (latitude: number, longitude: number): GeoLocation => ({
urlLocation: string;
routePrefix: string;
event: {
fetch: typeof fetch;
url: URL;
};
}
export async function resolveLocationFromRoute({
urlLocation,
routePrefix,
event
}: ResolveLocationOptions): Promise<GeoLocation> {
let location: GeoLocation;
if (urlLocation.includes('N') && urlLocation.includes('E')) {
const parts = urlLocation.split(/N|E/);
const latitude = parseFloat(parts[0]);
const longitude = parseFloat(parts[1]);
location = {
id: 0, id: 0,
name: `${latitude}N° ${longitude}E°`, name: `${latitude.toFixed(2)}°N ${longitude.toFixed(2)}°E`,
latitude, latitude,
longitude, longitude,
elevation: 0, elevation: 0,
@@ -55,8 +42,41 @@ export async function resolveLocationFromRoute({
admin1: undefined, admin1: undefined,
admin3: undefined, admin3: undefined,
admin4: undefined admin4: undefined
});
// the geocoding API response is untrusted input: it can be an error object or
// (with a crafted URL) something else entirely, so the shape is checked before
// anything downstream dereferences it
const isGeoLocation = (value: unknown): value is GeoLocation => {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.name === 'string' &&
typeof candidate.id === 'number' &&
Number.isFinite(candidate.latitude) &&
Number.isFinite(candidate.longitude)
);
};
interface ResolveLocationOptions {
urlLocation: string;
routePrefix: string;
event: {
fetch: typeof fetch;
url: URL;
}; };
} else { }
export async function resolveLocationFromRoute({
urlLocation,
routePrefix,
event
}: ResolveLocationOptions): Promise<GeoLocation> {
const coordMatch = urlLocation.match(COORD_ROUTE);
if (coordMatch) {
return coordinateLocation(parseFloat(coordMatch[1]), parseFloat(coordMatch[2]));
}
let urlLocationName: string; let urlLocationName: string;
let urlLocationId: string | undefined; let urlLocationId: string | undefined;
@@ -64,45 +84,41 @@ export async function resolveLocationFromRoute({
const split = urlLocation.split('_'); const split = urlLocation.split('_');
urlLocationName = split[0]; urlLocationName = split[0];
urlLocationId = split[1]; urlLocationId = split[1];
} else if (urlLocation.includes('-')) {
urlLocationName = urlLocation.replace(/-/g, ' ');
} else if (/^\d+$/.test(urlLocation)) { } else if (/^\d+$/.test(urlLocation)) {
urlLocationName = ''; urlLocationName = '';
urlLocationId = urlLocation; urlLocationId = urlLocation;
} else { } else {
urlLocationName = urlLocation; urlLocationName = urlLocation.includes('-') ? urlLocation.replace(/-/g, ' ') : urlLocation;
urlLocationId = undefined; urlLocationId = undefined;
} }
if (urlLocationId) { let location: GeoLocation;
// route params are attacker-controlled: ids must be numeric and names are
// URL-encoded so nothing can be injected into the API query string
if (urlLocationId && /^\d+$/.test(urlLocationId)) {
const res = await event.fetch( const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/get?id=${urlLocationId}` `https://geocoding-api.open-meteo.com/v1/get?id=${encodeURIComponent(urlLocationId)}`
); );
location = await res.json(); if (!res.ok) error(404, 'Location not found');
const candidate = await res.json();
if (!isGeoLocation(candidate)) error(404, 'Location not found');
location = candidate;
} else { } else {
const res = await event.fetch( const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${urlLocationName}&count=1&language=en&format=json` `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(urlLocationName)}&count=1&language=en&format=json`
); );
if (!res.ok) error(404, 'Location not found');
const geocodingResponse = await res.json(); const geocodingResponse = await res.json();
if (geocodingResponse.results) { const candidate = geocodingResponse?.results?.[0];
location = geocodingResponse.results[0]; if (!isGeoLocation(candidate)) error(404, 'Location not found');
} else { location = candidate;
error(404, 'Location not found');
}
} }
const locationRoute = geoLocationNameToRoute(location.name); const canonicalPath = `${routePrefix}${buildLocationRoute(location)}`;
const canonicalSuffix =
location.population && location.population > 543000
? locationRoute
: locationRoute + '_' + location.id;
const canonicalPath = `${routePrefix}${canonicalSuffix}`;
if (event.url.pathname !== canonicalPath) { if (event.url.pathname !== canonicalPath) {
throw redirect(303, canonicalPath); throw redirect(303, canonicalPath);
} }
}
storedLocation.set(location);
return location; return location;
} }
View File
+8 -1
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import Header from '$lib/components/navigation/header.svelte'; import Header from '$lib/components/navigation/header.svelte';
import WeatherNav from '$lib/components/navigation/weather-nav.svelte'; import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
@@ -8,6 +10,9 @@
let { children } = $props(); let { children } = $props();
// the maps page embeds a full-bleed map: no padding, no scrolling
let fullBleed = $derived($page.url.pathname.startsWith('/weather/maps'));
let sidebarCollapsed = $state(false); let sidebarCollapsed = $state(false);
let mobileMenuOpen = $state(false); let mobileMenuOpen = $state(false);
@@ -55,7 +60,9 @@
<div class="flex min-w-0 flex-1 flex-col h-full"> <div class="flex min-w-0 flex-1 flex-col h-full">
<Header onMenuToggle={toggleMobileMenu} /> <Header onMenuToggle={toggleMobileMenu} />
<main class="flex-1 overflow-y-auto p-5 md:px-8 md:py-6"> <main
class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-5 md:px-8 md:py-6'}
>
{@render children()} {@render children()}
</main> </main>
</div> </div>
-13
View File
@@ -1,13 +0,0 @@
import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async () => {
const location = get(storedLocation);
return {
title: `Weather ${location.name}`,
location: location
};
};
-13
View File
@@ -1,13 +0,0 @@
import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async () => {
const location = get(storedLocation);
return {
heroTitle: `14 Day Weather ${location.name}`,
heroDescription: location.admin1 ?? '' + ' ' + location.country
};
};
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
// at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender
onMount(() => {
goto(
resolve('/weather/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }),
{
replaceState: true
}
);
});
</script>
-15
View File
@@ -1,15 +0,0 @@
import { get } from 'svelte/store';
import { redirect } from '@sveltejs/kit';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import type { PageLoad } from './$types';
export const load = (async () => {
const location = get(storedLocation);
const locationRoute = buildLocationRoute(location);
throw redirect(303, '/weather/14-day/' + locationRoute);
}) satisfies PageLoad;
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { type GeoLocation, storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import { import {
buildAverageSeries, buildAverageSeries,
@@ -25,6 +25,7 @@
import { defaultParameters } from '../../options'; import { defaultParameters } from '../../options';
import type { PageData } from './$types';
import type * as echarts from 'echarts'; import type * as echarts from 'echarts';
// ─── Display State (does NOT trigger data re-fetch) ───────────────────────── // ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
@@ -38,10 +39,17 @@
let chartOptions: Array<Record<string, unknown>> = $state([]); let chartOptions: Array<Record<string, unknown>> = $state([]);
let mounted = $state(false); let mounted = $state(false);
let loading = $state(true); let loading = $state(true);
let loadError = $state<string | null>(null);
let requestVersion = 0;
let location = $state<GeoLocation>($storedLocation); let { data }: { data: PageData } = $props();
storedLocation.subscribe((value) => {
location = value; // 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.
let location = $derived(data.location);
$effect(() => {
storedLocation.set(data.location);
}); });
let params = $state({ let params = $state({
@@ -79,6 +87,11 @@
chartInstances = [...chartInstances, chart]; chartInstances = [...chartInstances, chart];
} }
// chart components persist across refetches (options update in place), so
// the registry is never reset; only instances disposed because the chart
// count shrank are filtered out
let liveCharts = $derived(chartInstances.filter((chart) => !chart.isDisposed()));
// ─── Data Fetching (only when params.hourly or params.models change) ─────── // ─── Data Fetching (only when params.hourly or params.models change) ───────
$effect(() => { $effect(() => {
@@ -89,12 +102,12 @@
const loc = location; const loc = location;
const loadData = async () => { // versioned so a slow stale response can never overwrite a newer one
const version = ++requestVersion;
loading = true; loading = true;
chartInstances = []; loadError = null;
chartComponents = [];
const result: EnsembleForecastResult = await fetchEnsembleForecast({ fetchEnsembleForecast({
latitude: loc.latitude!, latitude: loc.latitude!,
longitude: loc.longitude!, longitude: loc.longitude!,
hourlyVariables: hourlyVars, hourlyVariables: hourlyVars,
@@ -104,7 +117,9 @@
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn', wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
precipitation_unit: params.precipitation_unit as 'mm' | 'inch', precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
timezone: loc.timezone timezone: loc.timezone
}); })
.then((result: EnsembleForecastResult) => {
if (version !== requestVersion) return;
fetchedData = { fetchedData = {
ensembleResult: result, ensembleResult: result,
@@ -112,12 +127,14 @@
timezone: result.timezone, timezone: result.timezone,
markAreas: result.markAreas markAreas: result.markAreas
}; };
console.log(fetchedData.timezone);
loading = false; loading = false;
}; })
.catch((err: unknown) => {
loadData(); if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err);
loading = false;
});
}); });
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─ // ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
@@ -196,6 +213,14 @@
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── --> <!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
{#if loadError}
<div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
Failed to load weather data: {loadError}
</div>
{/if}
<ChartContainer <ChartContainer
{loading} {loading}
chartCount={params.hourly?.length || 0} chartCount={params.hourly?.length || 0}
@@ -204,6 +229,7 @@
{#each chartOptions as option, i (i)} {#each chartOptions as option, i (i)}
<EChart <EChart
{option} {option}
notMerge
height={showLegend ? '400px' : '300px'} height={showLegend ? '400px' : '300px'}
onChartReady={handleChartReady} onChartReady={handleChartReady}
bind:this={chartComponents[i]} bind:this={chartComponents[i]}
@@ -214,7 +240,7 @@
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── --> <!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
<div class="mt-6 md:mt-10"> <div class="mt-6 md:mt-10">
<ChartToolbar charts={chartInstances} fileName="14-day-forecast"> <ChartToolbar charts={liveCharts} fileName="14-day-forecast">
{#snippet controls()} {#snippet controls()}
<div class="flex gap-2"> <div class="flex gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} /> <Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
// at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender
onMount(() => {
goto(
resolve('/weather/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }),
{
replaceState: true
}
);
});
</script>
-15
View File
@@ -1,15 +0,0 @@
import { get } from 'svelte/store';
import { redirect } from '@sveltejs/kit';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import type { PageLoad } from './$types';
export const load = (async () => {
const location = get(storedLocation);
const locationRoute = buildLocationRoute(location);
throw redirect(303, '/weather/compare/' + locationRoute);
}) satisfies PageLoad;
@@ -2,7 +2,7 @@
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { type GeoLocation, storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import { import {
buildAverageSeries, buildAverageSeries,
@@ -31,6 +31,7 @@
import { defaultParameters } from '../../options'; import { defaultParameters } from '../../options';
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte'; import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
import type { PageData } from './$types';
import type * as echarts from 'echarts'; import type * as echarts from 'echarts';
const models = [modelsFlat]; const models = [modelsFlat];
@@ -46,10 +47,17 @@
let chartOptions: Array<Record<string, unknown>> = $state([]); let chartOptions: Array<Record<string, unknown>> = $state([]);
let mounted = $state(false); let mounted = $state(false);
let loading = $state(true); let loading = $state(true);
let loadError = $state<string | null>(null);
let requestVersion = 0;
let location = $state<GeoLocation>($storedLocation); let { data }: { data: PageData } = $props();
storedLocation.subscribe((value) => {
location = value; // 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.
let location = $derived(data.location);
$effect(() => {
storedLocation.set(data.location);
}); });
let params = $state({ let params = $state({
@@ -104,6 +112,11 @@
chartInstances = [...chartInstances, chart]; chartInstances = [...chartInstances, chart];
} }
// chart components persist across refetches (options update in place), so
// the registry is never reset; only instances disposed because the chart
// count shrank are filtered out
let liveCharts = $derived(chartInstances.filter((chart) => !chart.isDisposed()));
// ─── Data Fetching (only when params.hourly or params.models change) ─────── // ─── Data Fetching (only when params.hourly or params.models change) ───────
$effect(() => { $effect(() => {
@@ -114,12 +127,12 @@
const loc = location; const loc = location;
const loadData = async () => { // versioned so a slow stale response can never overwrite a newer one
const version = ++requestVersion;
loading = true; loading = true;
chartInstances = []; loadError = null;
chartComponents = [];
const result: ModelCompareResult = await fetchModelComparison({ fetchModelComparison({
latitude: loc.latitude!, latitude: loc.latitude!,
longitude: loc.longitude!, longitude: loc.longitude!,
hourlyVariables: [...new Set([...hourlyVars, 'weather_code'])], hourlyVariables: [...new Set([...hourlyVars, 'weather_code'])],
@@ -128,7 +141,9 @@
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn', wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
precipitation_unit: params.precipitation_unit as 'mm' | 'inch', precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
timezone: loc.timezone timezone: loc.timezone
}); })
.then((result: ModelCompareResult) => {
if (version !== requestVersion) return;
fetchedData = { fetchedData = {
hourly: result.hourlyFlat, hourly: result.hourlyFlat,
@@ -141,9 +156,12 @@
}; };
loading = false; loading = false;
}; })
.catch((err: unknown) => {
loadData(); if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err);
loading = false;
});
}); });
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─ // ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
@@ -228,10 +246,19 @@
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── --> <!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
{#if loadError}
<div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
Failed to load weather data: {loadError}
</div>
{/if}
<ChartContainer {loading} chartCount={chartOptions.length} chartHeight={showLegend ? 400 : 300}> <ChartContainer {loading} chartCount={chartOptions.length} chartHeight={showLegend ? 400 : 300}>
{#each chartOptions as option, i (i)} {#each chartOptions as option, i (i)}
<EChart <EChart
{option} {option}
notMerge
height={showLegend ? '400px' : '300px'} height={showLegend ? '400px' : '300px'}
onChartReady={handleChartReady} onChartReady={handleChartReady}
bind:this={chartComponents[i]} bind:this={chartComponents[i]}
@@ -253,7 +280,7 @@
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── --> <!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
<div class="mt-6 md:mt-10"> <div class="mt-6 md:mt-10">
<ChartToolbar charts={chartInstances} fileName="model-comparison"> <ChartToolbar charts={liveCharts} fileName="model-comparison">
{#snippet controls()} {#snippet controls()}
<div class="flex gap-2"> <div class="flex gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} /> <Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
@@ -1,12 +0,0 @@
[
"kinshasa",
"shenzhen",
"shanghai",
"guangzhou",
"chengdu",
"beijing",
"mumbai",
"lagos",
"lahore",
"istanbul"
]
+11 -9
View File
@@ -1,7 +1,12 @@
<script lang="ts"> <script lang="ts">
// Minimal page: no geolocation/state UI, just an embedded Open-Meteo map. import { storedLocation } from '$lib/stores/settings';
// const iframeSrc = 'https://maps.open-meteo.com/';
const iframeSrc = 'https://maps.open-meteo.com'; // 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
const iframeSrc = $derived(
`https://maps.open-meteo.com/#8/${$storedLocation.latitude.toFixed(3)}/${$storedLocation.longitude.toFixed(3)}`
);
</script> </script>
<svelte:head> <svelte:head>
@@ -10,18 +15,15 @@
<meta name="description" content="Interactive weather map powered by Open-Meteo" /> <meta name="description" content="Interactive weather map powered by Open-Meteo" />
</svelte:head> </svelte:head>
<!-- Full-viewport map. No surrounding UI or location state. --> <!-- Full-bleed map: the layout drops its padding for this route -->
<div <div class="h-full w-full bg-black">
style="position:relative; inset:0; margin:0; padding:0; height:100%; width:100%; background:#000;"
>
<iframe <iframe
src={iframeSrc} src={iframeSrc}
title="Open-Meteo Interactive Map" title="Open-Meteo Interactive Map"
loading="lazy" loading="lazy"
allowfullscreen allowfullscreen
referrerpolicy="no-referrer" referrerpolicy="no-referrer"
class="map-iframe" class="block h-full w-full border-0"
style="border:0; width:100%; height:100%; display:block;"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups" sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
></iframe> ></iframe>
</div> </div>
+3 -1
View File
@@ -45,7 +45,9 @@ export const getColor = (temperature: number, unit = 'celsius'): string => {
} else if (temperature >= 60) { } else if (temperature >= 60) {
index = colorScaleHex.length - 1; index = colorScaleHex.length - 1;
} else { } else {
index = Math.round(temperature) + 45; // clamp: the scale has exactly 100 entries (-45..54), temperatures in
// [55, 60) would otherwise index past the end
index = Math.min(colorScaleHex.length - 1, Math.max(0, Math.round(temperature) + 45));
} }
return colorScaleHex[index]; return colorScaleHex[index];
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
// at build time this page knows nothing about the visitor, so the redirect
// target (the persisted location) is resolved in the browser instead of
// being baked to the default city during prerender
onMount(() => {
goto(
resolve('/weather/week/[location]', { location: buildLocationRoute(get(storedLocation)) }),
{
replaceState: true
}
);
});
</script>
-15
View File
@@ -1,15 +0,0 @@
import { get } from 'svelte/store';
import { redirect } from '@sveltejs/kit';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import type { PageLoad } from './$types';
export const load = (async () => {
const location = get(storedLocation);
const locationRoute = buildLocationRoute(location);
throw redirect(303, '/weather/week/' + locationRoute);
}) satisfies PageLoad;
+32 -12
View File
@@ -12,23 +12,28 @@
import MeteogramCharts from './MeteogramCharts.svelte'; import MeteogramCharts from './MeteogramCharts.svelte';
import ModelSelector from './ModelSelector.svelte'; import ModelSelector from './ModelSelector.svelte';
import type { GeoLocation } from '$lib/stores/settings'; import type { PageData } from './$types';
import type { FetchedDaily, FetchedHourly } from './types'; import type { FetchedDaily, FetchedHourly } from './types';
let { data }: { data: PageData } = $props();
let params = $state({ let params = $state({
latitude: [$storedLocation.latitude],
longitude: [$storedLocation.longitude],
models: ['best_match'], models: ['best_match'],
...defaultParameters ...defaultParameters
}); });
let location = $state<GeoLocation>($storedLocation); // the URL is the source of truth: location comes from the load function,
storedLocation.subscribe((value) => { // which is also correct on hydrated prerendered pages. The persisted store
location = value; // only mirrors it so the header and bare /weather/* redirects follow along.
let location = $derived(data.location);
$effect(() => {
storedLocation.set(data.location);
}); });
let mounted = $state(false); let mounted = $state(false);
let loading = $state(true); let loading = $state(true);
let loadError = $state<string | null>(null);
let requestVersion = 0;
const selectedDay = new SvelteDate(); const selectedDay = new SvelteDate();
@@ -52,10 +57,12 @@
if (!mounted || !loc || !modelList?.length) return; if (!mounted || !loc || !modelList?.length) return;
const loadData = async () => { // versioned so a slow stale response can never overwrite a newer one
const version = ++requestVersion;
loading = true; loading = true;
loadError = null;
const result: WeekForecastResult = await fetchWeekForecast({ fetchWeekForecast({
latitude: loc.latitude!, latitude: loc.latitude!,
longitude: loc.longitude!, longitude: loc.longitude!,
model: modelList[0], model: modelList[0],
@@ -65,7 +72,9 @@
forecast_days: 7, forecast_days: 7,
past_days: 0, past_days: 0,
timezone: loc.timezone timezone: loc.timezone
}); })
.then((result: WeekForecastResult) => {
if (version !== requestVersion) return;
fetchedHourly = { fetchedHourly = {
hourly: result.hourly, hourly: result.hourly,
@@ -83,9 +92,12 @@
}; };
loading = false; loading = false;
}; })
.catch((err: unknown) => {
loadData(); if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err);
loading = false;
});
}); });
</script> </script>
@@ -97,6 +109,14 @@
<div class="week-page"> <div class="week-page">
<div class="weather-content" style="min-height: 50vh"> <div class="weather-content" style="min-height: 50vh">
{#if loadError}
<div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
Failed to load weather data: {loadError}
</div>
{/if}
<DailyCards daily={fetchedDaily} {selectedDay} units={params} onSelectDay={switchDay} /> <DailyCards daily={fetchedDaily} {selectedDay} units={params} onSelectDay={switchDay} />
{#if fetchedHourly && fetchedDaily} {#if fetchedHourly && fetchedDaily}
@@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import * as echarts from 'echarts';
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date'; import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts'; import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts';
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts'; import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
import { echarts } from '$lib/components/charts/echarts';
import '$lib/components/charts/echarts.css'; import '$lib/components/charts/echarts.css';
import { getColor } from '../../utils/colors'; import { getColor } from '../../utils/colors';
@@ -19,6 +18,8 @@
getWindUnit getWindUnit
} from './types'; } from './types';
import type { ECharts } from 'echarts';
interface Props { interface Props {
data: FetchedHourly; data: FetchedHourly;
selectedDay: Date; selectedDay: Date;
@@ -34,7 +35,7 @@
let showCharts = $state(false); let showCharts = $state(false);
let chartComponents: EChart[] = $state([]); let chartComponents: EChart[] = $state([]);
let chartInstances: echarts.ECharts[] = $state([]); let chartInstances: ECharts[] = $state([]);
let chartOptions: Array<Record<string, unknown>> = $state([]); let chartOptions: Array<Record<string, unknown>> = $state([]);
export function scrollToDay(day: Date): void { export function scrollToDay(day: Date): void {
@@ -76,7 +77,7 @@
onResetZoom?.(); onResetZoom?.();
} }
function handleChartReady(chart: echarts.ECharts): void { function handleChartReady(chart: ECharts): void {
chart.group = CHART_GROUP; chart.group = CHART_GROUP;
chartInstances = [...chartInstances, chart]; chartInstances = [...chartInstances, chart];
if (chartInstances.length === 3) { if (chartInstances.length === 3) {
@@ -85,14 +86,6 @@
} }
} }
$effect(() => {
// Reset chart instances when data changes
if (data) {
chartInstances = [];
chartComponents = [];
}
});
$effect(() => { $effect(() => {
if (!data) return; if (!data) return;
@@ -530,6 +523,7 @@
{#each chartOptions as option, i (i)} {#each chartOptions as option, i (i)}
<EChart <EChart
{option} {option}
notMerge
height={i === 2 ? '320px' : '300px'} height={i === 2 ? '320px' : '300px'}
onChartReady={handleChartReady} onChartReady={handleChartReady}
bind:this={chartComponents[i]} bind:this={chartComponents[i]}
@@ -1,57 +0,0 @@
<script lang="ts">
import { formatZoned } from '$lib/utils/date';
import type { FetchedDaily } from './types';
interface Props {
daily: FetchedDaily | null;
dayIndex: number;
}
let { daily, dayIndex }: Props = $props();
let sunrise = $derived.by(() => {
if (!daily) return null;
const ts = daily.daily.sunrise[dayIndex];
return ts ? new Date(ts * 1000) : null;
});
let sunset = $derived.by(() => {
if (!daily) return null;
const ts = daily.daily.sunset[dayIndex];
return ts ? new Date(ts * 1000) : null;
});
</script>
{#if daily && sunrise && sunset}
<div class="sun-info">
<div class="sun-item">
<svg class="fill-foreground" width="24px" height="24px">
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"></use>
</svg>
<span>{formatZoned(sunrise, daily.timezone, 'HH:mm')}</span>
</div>
<div class="sun-item">
<svg class="fill-foreground" width="24px" height="24px">
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"></use>
</svg>
<span>{formatZoned(sunset, daily.timezone, 'HH:mm')}</span>
</div>
</div>
{/if}
<style>
.sun-info {
display: flex;
gap: 1.5rem;
margin-top: 1rem;
flex-wrap: wrap;
}
.sun-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
}
</style>