historical weather
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import {
|
||||
storedChartLayout,
|
||||
storedLocation,
|
||||
storedUnits,
|
||||
storedVariablePrefs
|
||||
} from '$lib/stores/settings';
|
||||
|
||||
import { ChartContainer } from '$lib/components/charts';
|
||||
|
||||
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||
import { isPremium } from '$lib/paywall/premium';
|
||||
import {
|
||||
type ClimateNormals,
|
||||
type HistoricalForecastResult,
|
||||
fetchClimateNormals,
|
||||
fetchHistoricalWeather
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { defaultParameters } from '../../options';
|
||||
import HourlyTable from '../../week/[location]/HourlyTable.svelte';
|
||||
import { neededHourlyApiVars } from '../../week/[location]/variables';
|
||||
import DateRangeControls from './DateRangeControls.svelte';
|
||||
import HistoricalDaily from './HistoricalDaily.svelte';
|
||||
import HistoricalMeteograms from './HistoricalMeteograms.svelte';
|
||||
|
||||
import type { FetchedDaily, FetchedHourly } from '../../week/[location]/types';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let location = $derived(data.location);
|
||||
$effect(() => {
|
||||
storedLocation.set(data.location);
|
||||
});
|
||||
|
||||
let params = $state({ ...defaultParameters });
|
||||
$effect(() => {
|
||||
params.temperature_unit = $storedUnits.temperature_unit;
|
||||
params.wind_speed_unit = $storedUnits.wind_speed_unit;
|
||||
params.precipitation_unit = $storedUnits.precipitation_unit;
|
||||
});
|
||||
|
||||
// Request only what the table rows + meteogram layout actually show.
|
||||
let hourlyVars = $derived(
|
||||
neededHourlyApiVars(
|
||||
$storedVariablePrefs.table,
|
||||
$storedChartLayout.flatMap((p) => p.variables)
|
||||
)
|
||||
);
|
||||
|
||||
// ─── Date range ───────────────────────────────────────────────────────────
|
||||
const iso = (d: Date): string => d.toISOString().slice(0, 10);
|
||||
const addDays = (d: Date, n: number): Date => {
|
||||
const c = new Date(d);
|
||||
c.setUTCDate(c.getUTCDate() + n);
|
||||
return c;
|
||||
};
|
||||
|
||||
const MIN_DATE = '1940-01-01'; // ERA5 archive start
|
||||
// The reanalysis archive lags real time by a few days.
|
||||
let maxDate = $state(iso(addDays(new Date(), -5)));
|
||||
let startDate = $state(iso(addDays(new Date(), -34)));
|
||||
let endDate = $state(iso(addDays(new Date(), -5)));
|
||||
|
||||
onMount(() => {
|
||||
const today = new Date();
|
||||
maxDate = iso(addDays(today, -5));
|
||||
endDate = maxDate;
|
||||
startDate = iso(addDays(today, -34));
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
function onRangeChange(s: string, e: string) {
|
||||
startDate = s;
|
||||
endDate = e;
|
||||
}
|
||||
|
||||
// ─── Fetch state ────────────────────────────────────────────────────────────
|
||||
let mounted = $state(false);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let requestVersion = 0;
|
||||
|
||||
let result = $state<HistoricalForecastResult | null>(null);
|
||||
let normals = $state<ClimateNormals | null>(null);
|
||||
|
||||
const selectedDay = new SvelteDate();
|
||||
|
||||
// Historical data: refetch on location / range / units / requested-vars change.
|
||||
$effect(() => {
|
||||
const loc = location;
|
||||
const s = startDate;
|
||||
const e = endDate;
|
||||
const vars = hourlyVars;
|
||||
if (!mounted || !$isPremium || !loc || !s || !e) return;
|
||||
|
||||
const version = ++requestVersion;
|
||||
loading = true;
|
||||
loadError = null;
|
||||
|
||||
fetchHistoricalWeather({
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
start_date: s,
|
||||
end_date: e,
|
||||
hourlyVariables: vars,
|
||||
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((r) => {
|
||||
if (version !== requestVersion) return;
|
||||
result = r;
|
||||
// default the hourly drill-down to the last day in range
|
||||
if (r.dailyDates.length > 0) {
|
||||
selectedDay.setTime(r.dailyDates[r.dailyDates.length - 1].getTime());
|
||||
}
|
||||
loading = false;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (version !== requestVersion) return;
|
||||
loadError = err instanceof Error ? err.message : String(err);
|
||||
loading = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Climate normals: independent of the range, so fetch once per location/units.
|
||||
let normalsKey = $derived(
|
||||
`${location?.latitude},${location?.longitude},${params.temperature_unit},${params.precipitation_unit}`
|
||||
);
|
||||
let normalsVersion = 0;
|
||||
$effect(() => {
|
||||
const key = normalsKey;
|
||||
const loc = location;
|
||||
if (!mounted || !$isPremium || !loc) return;
|
||||
|
||||
const version = ++normalsVersion;
|
||||
normals = null;
|
||||
fetchClimateNormals({
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch'
|
||||
})
|
||||
.then((n) => {
|
||||
if (version === normalsVersion) normals = n;
|
||||
})
|
||||
.catch(() => {
|
||||
// normals are a nice-to-have; a failure just hides the comparison
|
||||
if (version === normalsVersion) normals = null;
|
||||
});
|
||||
// re-read key so the effect tracks it
|
||||
void key;
|
||||
});
|
||||
|
||||
// ─── Adapters so the reused week components accept historical data ──────────
|
||||
let fetchedHourly = $derived<FetchedHourly | null>(
|
||||
result
|
||||
? {
|
||||
hourly: result.hourly,
|
||||
utc_offset_seconds: result.utcOffsetSeconds,
|
||||
timezone: result.timezone,
|
||||
timestamps: result.hourlyTimestamps,
|
||||
hourlyDates: result.hourlyDates,
|
||||
daylightBands: result.daylightBands
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
let fetchedDaily = $derived<FetchedDaily | null>(
|
||||
result
|
||||
? {
|
||||
daily: {
|
||||
weather_code: result.daily.weather_code,
|
||||
temperature_2m_max: result.daily.temperature_2m_max,
|
||||
temperature_2m_min: result.daily.temperature_2m_min,
|
||||
sunrise: result.daily.sunrise,
|
||||
sunset: result.daily.sunset,
|
||||
sunshine_duration: result.daily.sunshine_duration,
|
||||
precipitation_sum: result.daily.precipitation_sum,
|
||||
windspeed_10m_max: result.daily.windspeed_10m_max,
|
||||
windgusts_10m_max: result.daily.windgusts_10m_max,
|
||||
winddirection_10m_dominant: result.daily.winddirection_10m_dominant
|
||||
},
|
||||
timezone: result.timezone,
|
||||
dailyDates: result.dailyDates
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
function switchDay(date: Date) {
|
||||
selectedDay.setTime(date.getTime());
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Drizz.li | Historical weather</title>
|
||||
<meta name="description" content="Past weather and climate-normal comparisons for any location" />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Page hero -->
|
||||
<div class="relative mb-3 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
|
||||
>Historical weather
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaywallGate feature="Historical weather">
|
||||
<DateRangeControls
|
||||
start={startDate}
|
||||
end={endDate}
|
||||
minDate={MIN_DATE}
|
||||
{maxDate}
|
||||
onChange={onRangeChange}
|
||||
/>
|
||||
|
||||
{#if loadError}
|
||||
<div
|
||||
class="mt-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
Failed to load historical data: {loadError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
{#if result && fetchedHourly && fetchedDaily}
|
||||
<HistoricalDaily
|
||||
daily={result.daily}
|
||||
dailyDates={result.dailyDates}
|
||||
timezone={result.timezone}
|
||||
units={params}
|
||||
{normals}
|
||||
{selectedDay}
|
||||
onSelectDay={switchDay}
|
||||
/>
|
||||
|
||||
<div class="mt-6">
|
||||
<HourlyTable
|
||||
data={fetchedHourly}
|
||||
daily={fetchedDaily}
|
||||
{selectedDay}
|
||||
units={params}
|
||||
locationName={location.name ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} />
|
||||
{:else}
|
||||
<div transition:fade={{ duration: 200 }} class="grid gap-3">
|
||||
<div class="h-28 animate-pulse rounded-2xl border border-border/70 bg-card"></div>
|
||||
<ChartContainer loading chartCount={3} chartHeight={300} bleed={false} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</PaywallGate>
|
||||
Reference in New Issue
Block a user