refactor
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import Header from '$lib/components/navigation/header.svelte';
|
||||
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
|
||||
|
||||
@@ -8,6 +10,9 @@
|
||||
|
||||
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 mobileMenuOpen = $state(false);
|
||||
|
||||
@@ -55,7 +60,9 @@
|
||||
<div class="flex min-w-0 flex-1 flex-col h-full">
|
||||
<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()}
|
||||
</main>
|
||||
</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">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
|
||||
import {
|
||||
buildAverageSeries,
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
import { defaultParameters } from '../../options';
|
||||
|
||||
import type { PageData } from './$types';
|
||||
import type * as echarts from 'echarts';
|
||||
|
||||
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
||||
@@ -38,10 +39,17 @@
|
||||
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
||||
let mounted = $state(false);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let requestVersion = 0;
|
||||
|
||||
let location = $state<GeoLocation>($storedLocation);
|
||||
storedLocation.subscribe((value) => {
|
||||
location = value;
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// 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({
|
||||
@@ -79,6 +87,11 @@
|
||||
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) ───────
|
||||
|
||||
$effect(() => {
|
||||
@@ -89,35 +102,39 @@
|
||||
|
||||
const loc = location;
|
||||
|
||||
const loadData = async () => {
|
||||
loading = true;
|
||||
chartInstances = [];
|
||||
chartComponents = [];
|
||||
// versioned so a slow stale response can never overwrite a newer one
|
||||
const version = ++requestVersion;
|
||||
loading = true;
|
||||
loadError = null;
|
||||
|
||||
const result: EnsembleForecastResult = await fetchEnsembleForecast({
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
hourlyVariables: hourlyVars,
|
||||
models: modelList,
|
||||
forecast_days: 14,
|
||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
||||
timezone: loc.timezone
|
||||
fetchEnsembleForecast({
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
hourlyVariables: hourlyVars,
|
||||
models: modelList,
|
||||
forecast_days: 14,
|
||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
||||
timezone: loc.timezone
|
||||
})
|
||||
.then((result: EnsembleForecastResult) => {
|
||||
if (version !== requestVersion) return;
|
||||
|
||||
fetchedData = {
|
||||
ensembleResult: result,
|
||||
timestamps: result.timestamps,
|
||||
timezone: result.timezone,
|
||||
markAreas: result.markAreas
|
||||
};
|
||||
|
||||
loading = false;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (version !== requestVersion) return;
|
||||
loadError = err instanceof Error ? err.message : String(err);
|
||||
loading = false;
|
||||
});
|
||||
|
||||
fetchedData = {
|
||||
ensembleResult: result,
|
||||
timestamps: result.timestamps,
|
||||
timezone: result.timezone,
|
||||
markAreas: result.markAreas
|
||||
};
|
||||
console.log(fetchedData.timezone);
|
||||
|
||||
loading = false;
|
||||
};
|
||||
|
||||
loadData();
|
||||
});
|
||||
|
||||
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
|
||||
@@ -196,6 +213,14 @@
|
||||
|
||||
<!-- ─── 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={params.hourly?.length || 0}
|
||||
@@ -204,6 +229,7 @@
|
||||
{#each chartOptions as option, i (i)}
|
||||
<EChart
|
||||
{option}
|
||||
notMerge
|
||||
height={showLegend ? '400px' : '300px'}
|
||||
onChartReady={handleChartReady}
|
||||
bind:this={chartComponents[i]}
|
||||
@@ -214,7 +240,7 @@
|
||||
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
||||
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={chartInstances} fileName="14-day-forecast">
|
||||
<ChartToolbar charts={liveCharts} fileName="14-day-forecast">
|
||||
{#snippet controls()}
|
||||
<div class="flex gap-2">
|
||||
<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 { fade } from 'svelte/transition';
|
||||
|
||||
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
|
||||
import {
|
||||
buildAverageSeries,
|
||||
@@ -31,6 +31,7 @@
|
||||
import { defaultParameters } from '../../options';
|
||||
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
||||
|
||||
import type { PageData } from './$types';
|
||||
import type * as echarts from 'echarts';
|
||||
|
||||
const models = [modelsFlat];
|
||||
@@ -46,10 +47,17 @@
|
||||
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
||||
let mounted = $state(false);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let requestVersion = 0;
|
||||
|
||||
let location = $state<GeoLocation>($storedLocation);
|
||||
storedLocation.subscribe((value) => {
|
||||
location = value;
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// 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({
|
||||
@@ -104,6 +112,11 @@
|
||||
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) ───────
|
||||
|
||||
$effect(() => {
|
||||
@@ -114,36 +127,41 @@
|
||||
|
||||
const loc = location;
|
||||
|
||||
const loadData = async () => {
|
||||
loading = true;
|
||||
chartInstances = [];
|
||||
chartComponents = [];
|
||||
// versioned so a slow stale response can never overwrite a newer one
|
||||
const version = ++requestVersion;
|
||||
loading = true;
|
||||
loadError = null;
|
||||
|
||||
const result: ModelCompareResult = await fetchModelComparison({
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
hourlyVariables: [...new Set([...hourlyVars, 'weather_code'])],
|
||||
models: modelList,
|
||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
||||
timezone: loc.timezone
|
||||
fetchModelComparison({
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
hourlyVariables: [...new Set([...hourlyVars, 'weather_code'])],
|
||||
models: modelList,
|
||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
||||
timezone: loc.timezone
|
||||
})
|
||||
.then((result: ModelCompareResult) => {
|
||||
if (version !== requestVersion) return;
|
||||
|
||||
fetchedData = {
|
||||
hourly: result.hourlyFlat,
|
||||
hourly_units: result.hourlyUnitsFlat,
|
||||
timezone: result.timezone,
|
||||
markAreas: result.markAreas,
|
||||
timestamps: result.timestamps,
|
||||
sunrise: result.sunrise,
|
||||
sunset: result.sunset
|
||||
};
|
||||
|
||||
loading = false;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (version !== requestVersion) return;
|
||||
loadError = err instanceof Error ? err.message : String(err);
|
||||
loading = false;
|
||||
});
|
||||
|
||||
fetchedData = {
|
||||
hourly: result.hourlyFlat,
|
||||
hourly_units: result.hourlyUnitsFlat,
|
||||
timezone: result.timezone,
|
||||
markAreas: result.markAreas,
|
||||
timestamps: result.timestamps,
|
||||
sunrise: result.sunrise,
|
||||
sunset: result.sunset
|
||||
};
|
||||
|
||||
loading = false;
|
||||
};
|
||||
|
||||
loadData();
|
||||
});
|
||||
|
||||
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
|
||||
@@ -228,10 +246,19 @@
|
||||
|
||||
<!-- ─── 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}>
|
||||
{#each chartOptions as option, i (i)}
|
||||
<EChart
|
||||
{option}
|
||||
notMerge
|
||||
height={showLegend ? '400px' : '300px'}
|
||||
onChartReady={handleChartReady}
|
||||
bind:this={chartComponents[i]}
|
||||
@@ -253,7 +280,7 @@
|
||||
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
||||
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={chartInstances} fileName="model-comparison">
|
||||
<ChartToolbar charts={liveCharts} fileName="model-comparison">
|
||||
{#snippet controls()}
|
||||
<div class="flex gap-2">
|
||||
<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">
|
||||
// Minimal page: no geolocation/state UI, just an embedded Open-Meteo map.
|
||||
// const iframeSrc = 'https://maps.open-meteo.com/';
|
||||
const iframeSrc = 'https://maps.open-meteo.com';
|
||||
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
|
||||
const iframeSrc = $derived(
|
||||
`https://maps.open-meteo.com/#8/${$storedLocation.latitude.toFixed(3)}/${$storedLocation.longitude.toFixed(3)}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -10,18 +15,15 @@
|
||||
<meta name="description" content="Interactive weather map powered by Open-Meteo" />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Full-viewport map. No surrounding UI or location state. -->
|
||||
<div
|
||||
style="position:relative; inset:0; margin:0; padding:0; height:100%; width:100%; background:#000;"
|
||||
>
|
||||
<!-- Full-bleed map: the layout drops its padding for this route -->
|
||||
<div class="h-full w-full bg-black">
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
title="Open-Meteo Interactive Map"
|
||||
loading="lazy"
|
||||
allowfullscreen
|
||||
referrerpolicy="no-referrer"
|
||||
class="map-iframe"
|
||||
style="border:0; width:100%; height:100%; display:block;"
|
||||
class="block h-full w-full border-0"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,9 @@ export const getColor = (temperature: number, unit = 'celsius'): string => {
|
||||
} else if (temperature >= 60) {
|
||||
index = colorScaleHex.length - 1;
|
||||
} 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];
|
||||
|
||||
@@ -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 ModelSelector from './ModelSelector.svelte';
|
||||
|
||||
import type { GeoLocation } from '$lib/stores/settings';
|
||||
import type { PageData } from './$types';
|
||||
import type { FetchedDaily, FetchedHourly } from './types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let params = $state({
|
||||
latitude: [$storedLocation.latitude],
|
||||
longitude: [$storedLocation.longitude],
|
||||
models: ['best_match'],
|
||||
...defaultParameters
|
||||
});
|
||||
|
||||
let location = $state<GeoLocation>($storedLocation);
|
||||
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 mounted = $state(false);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let requestVersion = 0;
|
||||
|
||||
const selectedDay = new SvelteDate();
|
||||
|
||||
@@ -52,40 +57,47 @@
|
||||
|
||||
if (!mounted || !loc || !modelList?.length) return;
|
||||
|
||||
const loadData = async () => {
|
||||
loading = true;
|
||||
// versioned so a slow stale response can never overwrite a newer one
|
||||
const version = ++requestVersion;
|
||||
loading = true;
|
||||
loadError = null;
|
||||
|
||||
const result: WeekForecastResult = await fetchWeekForecast({
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
model: modelList[0],
|
||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
||||
forecast_days: 7,
|
||||
past_days: 0,
|
||||
timezone: loc.timezone
|
||||
fetchWeekForecast({
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
model: modelList[0],
|
||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
||||
forecast_days: 7,
|
||||
past_days: 0,
|
||||
timezone: loc.timezone
|
||||
})
|
||||
.then((result: WeekForecastResult) => {
|
||||
if (version !== requestVersion) return;
|
||||
|
||||
fetchedHourly = {
|
||||
hourly: result.hourly,
|
||||
utc_offset_seconds: result.utcOffsetSeconds,
|
||||
timezone: result.timezone,
|
||||
timestamps: result.hourlyTimestamps,
|
||||
hourlyDates: result.hourlyDates,
|
||||
markAreas: result.markAreas
|
||||
};
|
||||
|
||||
fetchedDaily = {
|
||||
daily: result.daily,
|
||||
timezone: result.timezone,
|
||||
dailyDates: result.dailyDates
|
||||
};
|
||||
|
||||
loading = false;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (version !== requestVersion) return;
|
||||
loadError = err instanceof Error ? err.message : String(err);
|
||||
loading = false;
|
||||
});
|
||||
|
||||
fetchedHourly = {
|
||||
hourly: result.hourly,
|
||||
utc_offset_seconds: result.utcOffsetSeconds,
|
||||
timezone: result.timezone,
|
||||
timestamps: result.hourlyTimestamps,
|
||||
hourlyDates: result.hourlyDates,
|
||||
markAreas: result.markAreas
|
||||
};
|
||||
|
||||
fetchedDaily = {
|
||||
daily: result.daily,
|
||||
timezone: result.timezone,
|
||||
dailyDates: result.dailyDates
|
||||
};
|
||||
|
||||
loading = false;
|
||||
};
|
||||
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -97,6 +109,14 @@
|
||||
|
||||
<div class="week-page">
|
||||
<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} />
|
||||
|
||||
{#if fetchedHourly && fetchedDaily}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
||||
import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts';
|
||||
|
||||
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
|
||||
import { echarts } from '$lib/components/charts/echarts';
|
||||
import '$lib/components/charts/echarts.css';
|
||||
|
||||
import { getColor } from '../../utils/colors';
|
||||
@@ -19,6 +18,8 @@
|
||||
getWindUnit
|
||||
} from './types';
|
||||
|
||||
import type { ECharts } from 'echarts';
|
||||
|
||||
interface Props {
|
||||
data: FetchedHourly;
|
||||
selectedDay: Date;
|
||||
@@ -34,7 +35,7 @@
|
||||
|
||||
let showCharts = $state(false);
|
||||
let chartComponents: EChart[] = $state([]);
|
||||
let chartInstances: echarts.ECharts[] = $state([]);
|
||||
let chartInstances: ECharts[] = $state([]);
|
||||
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
||||
|
||||
export function scrollToDay(day: Date): void {
|
||||
@@ -76,7 +77,7 @@
|
||||
onResetZoom?.();
|
||||
}
|
||||
|
||||
function handleChartReady(chart: echarts.ECharts): void {
|
||||
function handleChartReady(chart: ECharts): void {
|
||||
chart.group = CHART_GROUP;
|
||||
chartInstances = [...chartInstances, chart];
|
||||
if (chartInstances.length === 3) {
|
||||
@@ -85,14 +86,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Reset chart instances when data changes
|
||||
if (data) {
|
||||
chartInstances = [];
|
||||
chartComponents = [];
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!data) return;
|
||||
|
||||
@@ -530,6 +523,7 @@
|
||||
{#each chartOptions as option, i (i)}
|
||||
<EChart
|
||||
{option}
|
||||
notMerge
|
||||
height={i === 2 ? '320px' : '300px'}
|
||||
onChartReady={handleChartReady}
|
||||
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