282 lines
9.1 KiB
Svelte
282 lines
9.1 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { SvelteDate } from 'svelte/reactivity';
|
|
import { get } from 'svelte/store';
|
|
import { fade } from 'svelte/transition';
|
|
|
|
import {
|
|
storedChartLayout,
|
|
storedLocation,
|
|
storedModel,
|
|
storedUnits,
|
|
storedVariablePrefs
|
|
} from '$lib/stores/settings';
|
|
|
|
import { ChartContainer } from '$lib/components/charts';
|
|
|
|
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather';
|
|
|
|
import { defaultParameters } from '../../options';
|
|
import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
|
|
import DailyCards from './DailyCards.svelte';
|
|
import DailyStripSticky from './DailyStripSticky.svelte';
|
|
import HourlyTable from './HourlyTable.svelte';
|
|
import MeteogramCharts from './MeteogramCharts.svelte';
|
|
import ModelSelector from './ModelSelector.svelte';
|
|
import VariableSidebar from './VariableSidebar.svelte';
|
|
import { neededHourlyApiVars } from './variables';
|
|
|
|
import type { PageData } from './$types';
|
|
import type { FetchedDaily, FetchedHourly } from './types';
|
|
|
|
let { data }: { data: PageData } = $props();
|
|
|
|
let params = $state({
|
|
models: ['best_match'],
|
|
...defaultParameters
|
|
});
|
|
|
|
// units live in a persisted store; mirror them into params so a change
|
|
// re-runs the fetch effect below (which reads params.*_unit)
|
|
$effect(() => {
|
|
params.temperature_unit = $storedUnits.temperature_unit;
|
|
params.wind_speed_unit = $storedUnits.wind_speed_unit;
|
|
params.precipitation_unit = $storedUnits.precipitation_unit;
|
|
});
|
|
|
|
let variableSidebarOpen = $state(false);
|
|
|
|
// Number of meteogram panels: reserves the chart area height before data
|
|
// arrives (no layout shift)
|
|
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
|
|
|
|
// Request only the hourly variables the table rows and meteograms actually
|
|
// show, so unused variables are never fetched. weather_code is always
|
|
// included: the day cards / strip derive their day- and night-period icons
|
|
// from the hourly codes locally.
|
|
let hourlyVars = $derived([
|
|
...new Set([
|
|
...neededHourlyApiVars(
|
|
$storedVariablePrefs.table,
|
|
$storedChartLayout.flatMap((p) => p.variables)
|
|
),
|
|
'weather_code'
|
|
])
|
|
]);
|
|
|
|
// 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;
|
|
|
|
// 7 by default; the user can extend to the model's longer range (up to 16 days)
|
|
let forecastDays = $state(7);
|
|
// 0 by default; the user can pull in a few recent past days
|
|
let pastDays = $state(0);
|
|
|
|
const selectedDay = new SvelteDate();
|
|
|
|
let fetchedHourly: FetchedHourly | null = $state(null);
|
|
let fetchedDaily: FetchedDaily | null = $state(null);
|
|
|
|
// Charts intentionally keep their current range: they show the full week
|
|
// unless the user narrows it via the range presets or Ctrl+scroll.
|
|
const switchDay = (date: Date) => {
|
|
selectedDay.setTime(date.getTime());
|
|
};
|
|
|
|
onMount(() => {
|
|
// preselect the persisted model (client-only so prerendered HTML stays stable)
|
|
params.models = [get(storedModel)];
|
|
mounted = true;
|
|
});
|
|
|
|
$effect(() => {
|
|
const loc = location;
|
|
const modelList = params.models;
|
|
const requestVars = hourlyVars;
|
|
|
|
if (!mounted || !loc || !modelList?.length) return;
|
|
|
|
// versioned so a slow stale response can never overwrite a newer one
|
|
const version = ++requestVersion;
|
|
loading = true;
|
|
loadError = null;
|
|
|
|
fetchWeekForecast({
|
|
latitude: loc.latitude!,
|
|
longitude: loc.longitude!,
|
|
model: modelList[0],
|
|
hourlyVariables: requestVars,
|
|
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: forecastDays,
|
|
past_days: pastDays,
|
|
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,
|
|
daylightBands: result.daylightBands
|
|
};
|
|
|
|
// Split the hourly codes into daylight / following-night buckets so
|
|
// the night badge shows the actual night conditions instead of a
|
|
// night-styled copy of the day icon.
|
|
const parts = computeDayNightWeatherCodes(
|
|
result.hourlyTimestamps,
|
|
result.hourly.weather_code,
|
|
result.daily.sunrise,
|
|
result.daily.sunset
|
|
);
|
|
fetchedDaily = {
|
|
daily: result.daily,
|
|
timezone: result.timezone,
|
|
dailyDates: result.dailyDates,
|
|
dayCodes: parts.day.map((c, i) => c ?? result.daily.weather_code[i]),
|
|
nightCodes: parts.night.map((c, i) => c ?? parts.day[i] ?? result.daily.weather_code[i])
|
|
};
|
|
|
|
loading = false;
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (version !== requestVersion) return;
|
|
loadError = err instanceof Error ? err.message : String(err);
|
|
loading = false;
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Drizz.li | Weather</title>
|
|
<link rel="canonical" href="https://drizz.li/weather/week" />
|
|
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
|
</svelte:head>
|
|
|
|
<div class="week-page">
|
|
<div class="weather-content" style="min-height: 50vh">
|
|
<!-- Page hero: prominent location + weather model selection -->
|
|
<div class="relative flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
|
<div class="flex min-w-0 items-center gap-3">
|
|
<img
|
|
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
|
src="/images/country-flags/{(
|
|
location.country_code || 'united_nations'
|
|
).toLowerCase()}.svg"
|
|
alt={location.country ?? ''}
|
|
/>
|
|
<div class="min-w-0">
|
|
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
|
{location.name}
|
|
</h1>
|
|
<p class="truncate text-sm text-muted-foreground">
|
|
<span class="lg:hidden"
|
|
>{#if location.admin1}{location.admin1},
|
|
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
|
>7-day forecast
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
|
<ModelSelector
|
|
selectedModel={params.models?.[0] ?? 'best_match'}
|
|
onModelChange={(model) => {
|
|
params.models = [model];
|
|
storedModel.set(model);
|
|
// a new model may not support the extended / past range
|
|
forecastDays = 7;
|
|
pastDays = 0;
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
|
|
|
|
{#if loadError}
|
|
<div
|
|
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
|
>
|
|
Failed to load weather data: {loadError}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Desktop: the full day cards -->
|
|
<div class="hidden md:block">
|
|
<DailyCards
|
|
daily={fetchedDaily}
|
|
{selectedDay}
|
|
units={params}
|
|
onSelectDay={switchDay}
|
|
canExtend={forecastDays < 15}
|
|
onExtend={() => (forecastDays = 15)}
|
|
canExtendPast={pastDays < 3}
|
|
onExtendPast={() => (pastDays = 3)}
|
|
/>
|
|
</div>
|
|
|
|
<!-- The sticky day strip, the hourly table AND the meteograms share this
|
|
wrapper, so the strip stays stuck for the entire page (it collapses
|
|
as it sticks on mobile; on md+ it's a slim always-compact bar under
|
|
the topbar, complementing the full cards above). timeline-scope
|
|
hoists the strip's sentinel view-timeline so the sticky strip (a
|
|
sibling of the sentinel) can scrub its collapse from it. -->
|
|
<div style="timeline-scope: --daystrip-sentinel">
|
|
{#if fetchedDaily}
|
|
<DailyStripSticky
|
|
daily={fetchedDaily}
|
|
{selectedDay}
|
|
units={params}
|
|
onSelectDay={switchDay}
|
|
canExtend={forecastDays < 15}
|
|
onExtend={() => (forecastDays = 15)}
|
|
canExtendPast={pastDays < 3}
|
|
onExtendPast={() => (pastDays = 3)}
|
|
/>
|
|
{/if}
|
|
|
|
{#if fetchedHourly && fetchedDaily}
|
|
<HourlyTable
|
|
data={fetchedHourly}
|
|
daily={fetchedDaily}
|
|
{selectedDay}
|
|
units={params}
|
|
locationName={location.name ?? ''}
|
|
onCustomize={() => (variableSidebarOpen = true)}
|
|
/>
|
|
{:else}
|
|
<!-- placeholder with the table's approximate height: no layout shift -->
|
|
<div
|
|
transition:fade={{ duration: 200 }}
|
|
class="h-107.5 animate-pulse rounded-2xl border border-border/70 bg-card"
|
|
></div>
|
|
{/if}
|
|
|
|
{#if fetchedHourly}
|
|
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
|
{:else}
|
|
<!-- reserve the exact chart area height before the first fetch resolves -->
|
|
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
|
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
|
|
</section>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|