refactor
This commit is contained in:
Generated
-84
@@ -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
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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>
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
|
||||||
@@ -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>
|
|
||||||
Reference in New Issue
Block a user