historical weather
This commit is contained in:
@@ -12,6 +12,8 @@
|
||||
import LocationSearch from '$lib/components/location/location-search.svelte';
|
||||
import UnitSelector from '$lib/components/unit-selector.svelte';
|
||||
|
||||
import PremiumBadge from '$lib/paywall/PremiumBadge.svelte';
|
||||
|
||||
interface Props {
|
||||
onMenuToggle?: () => void;
|
||||
}
|
||||
@@ -46,6 +48,8 @@
|
||||
goto(resolve('/weather/compare/[location]', { location: locationRoute }));
|
||||
} else if (currentPath.startsWith('/weather/14-day')) {
|
||||
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
|
||||
} else if (currentPath.startsWith('/weather/historical')) {
|
||||
goto(resolve('/weather/historical/[location]', { location: locationRoute }));
|
||||
} else {
|
||||
goto(resolve('/weather/week/[location]', { location: locationRoute }));
|
||||
}
|
||||
@@ -105,6 +109,9 @@
|
||||
<!-- Measurement units -->
|
||||
<UnitSelector />
|
||||
|
||||
<!-- Premium status / unlock -->
|
||||
<PremiumBadge />
|
||||
|
||||
<!-- Theme toggle: system → light → dark -->
|
||||
<button
|
||||
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Historical',
|
||||
url: '/weather/historical' as const,
|
||||
// clock with a counter-clockwise arrow (history)
|
||||
iconPaths: ['M12 8v4l3 2', 'M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5']
|
||||
},
|
||||
{
|
||||
title: 'Maps',
|
||||
url: '/weather/maps' as const,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import UnlockDialog from './UnlockDialog.svelte';
|
||||
import { PREMIUM_PERKS, PREMIUM_PRICE, SIGNUP_URL } from './config';
|
||||
import { isPremium, premiumState, refreshPremium } from './premium';
|
||||
|
||||
interface Props {
|
||||
/** Short feature name shown in the locked panel headline. */
|
||||
feature?: string;
|
||||
children: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { feature = 'This page', children }: Props = $props();
|
||||
|
||||
let unlockOpen = $state(false);
|
||||
|
||||
// Re-verify the stored key whenever the gate mounts.
|
||||
onMount(refreshPremium);
|
||||
|
||||
// Show a brief spinner only when we have no cached answer yet and are checking.
|
||||
let initialChecking = $derived($premiumState.status === 'checking' && !$isPremium);
|
||||
</script>
|
||||
|
||||
{#if $isPremium}
|
||||
{@render children()}
|
||||
{:else if initialChecking}
|
||||
<div class="flex items-center justify-center py-24 text-sm text-muted-foreground">
|
||||
<svg class="mr-2 h-4 w-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-width="2" d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
Checking your subscription…
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="mx-auto max-w-xl rounded-2xl border border-border bg-card px-6 py-10 text-center shadow-sm"
|
||||
>
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"
|
||||
>
|
||||
<!-- padlock -->
|
||||
<svg
|
||||
class="h-7 w-7"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<rect x="4" y="10" width="16" height="11" rx="2" />
|
||||
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-bold tracking-tight">{feature} is a premium feature</h2>
|
||||
<p class="mx-auto mt-1.5 max-w-sm text-sm text-muted-foreground">
|
||||
Support this open-source weather project and unlock the extras from {PREMIUM_PRICE}.
|
||||
</p>
|
||||
|
||||
<ul class="mx-auto mt-5 grid max-w-sm gap-2 text-left">
|
||||
{#each PREMIUM_PERKS as perk (perk)}
|
||||
<li class="flex items-start gap-2.5 text-sm">
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 shrink-0 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
<span>{perk}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="mt-7 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
<a
|
||||
href={SIGNUP_URL}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex h-10 w-full items-center justify-center rounded-lg bg-primary px-5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 sm:w-auto"
|
||||
>
|
||||
Subscribe
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg border border-border bg-background px-5 text-sm font-semibold text-foreground transition-colors hover:bg-muted sm:w-auto"
|
||||
onclick={() => (unlockOpen = true)}
|
||||
>
|
||||
I have a key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $premiumState.status === 'invalid'}
|
||||
<p class="mt-4 text-xs text-amber-600 dark:text-amber-400">
|
||||
Your saved key is no longer valid or has expired.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<UnlockDialog bind:open={unlockOpen} />
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import UnlockDialog from './UnlockDialog.svelte';
|
||||
import { isPremium, refreshPremium } from './premium';
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
// Verify once on load so the badge reflects real status site-wide.
|
||||
onMount(refreshPremium);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs font-semibold transition-colors {$isPremium
|
||||
? 'border-amber-400/50 bg-amber-400/10 text-amber-700 hover:bg-amber-400/20 dark:text-amber-300'
|
||||
: 'border-border/70 text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||
onclick={() => (open = true)}
|
||||
title={$isPremium ? 'Premium active' : 'Unlock premium'}
|
||||
aria-label={$isPremium ? 'Premium active' : 'Unlock premium'}
|
||||
>
|
||||
{#if $isPremium}
|
||||
<!-- star -->
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.9 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9L12 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Premium</span>
|
||||
{:else}
|
||||
<!-- padlock -->
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4" y="10" width="16" height="11" rx="2" />
|
||||
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Premium</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<UnlockDialog bind:open />
|
||||
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { formatZoned } from '$lib/utils/date';
|
||||
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
import { PREMIUM_PRICE, SIGNUP_URL } from './config';
|
||||
import { clearLicense, isPremium, premiumState, storedLicenseKey, verifyKey } from './premium';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
let { open = $bindable(false) }: Props = $props();
|
||||
|
||||
// Prefill with the stored key so an existing subscriber sees their key.
|
||||
let keyInput = $state('');
|
||||
let submitting = $state(false);
|
||||
let localError = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
keyInput = $storedLicenseKey;
|
||||
localError = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
localError = null;
|
||||
const result = await verifyKey(keyInput);
|
||||
submitting = false;
|
||||
if (result.valid) {
|
||||
// close shortly so the success state is visible for a beat
|
||||
setTimeout(() => (open = false), 700);
|
||||
} else {
|
||||
localError = result.error ?? 'That key is not valid or has expired.';
|
||||
}
|
||||
}
|
||||
|
||||
function removeKey() {
|
||||
clearLicense();
|
||||
keyInput = '';
|
||||
}
|
||||
|
||||
let expiresLabel = $derived.by(() => {
|
||||
const exp = $premiumState.expires;
|
||||
if ($premiumState.status !== 'valid') return null;
|
||||
if (!exp) return 'Lifetime access';
|
||||
return `Active until ${formatZoned(new Date(exp), 'UTC', 'd LLL yyyy')}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Drizz.li Premium</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Paste the access key from your subscription email to unlock premium pages.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if $isPremium && $premiumState.status === 'valid'}
|
||||
<div
|
||||
class="flex items-start gap-3 rounded-lg border border-emerald-300/60 bg-emerald-50 px-3.5 py-3 text-sm text-emerald-800 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-200"
|
||||
>
|
||||
<svg
|
||||
class="mt-0.5 h-5 w-5 shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4" />
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold">Premium is active</p>
|
||||
{#if expiresLabel}<p class="text-xs opacity-80">{expiresLabel}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={submit} class="grid gap-3">
|
||||
<div class="grid gap-1.5">
|
||||
<Label for="license-key">Access key</Label>
|
||||
<Input
|
||||
id="license-key"
|
||||
bind:value={keyInput}
|
||||
placeholder="DRZ-XXXX-XXXX-XXXX"
|
||||
autocomplete="off"
|
||||
spellcheck={false}
|
||||
class="font-mono tracking-wide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if localError}
|
||||
<p class="text-sm text-destructive">{localError}</p>
|
||||
{:else if $premiumState.status === 'error'}
|
||||
<p class="text-sm text-destructive">
|
||||
Couldn't reach the server. Check your connection and try again.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button type="submit" disabled={submitting || !keyInput.trim()}>
|
||||
{#if submitting}Verifying…{:else}Unlock{/if}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Dialog.Footer class="flex-col items-stretch gap-2 sm:flex-col sm:items-stretch">
|
||||
{#if $isPremium}
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer text-center text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
onclick={removeKey}
|
||||
>
|
||||
Remove key from this device
|
||||
</button>
|
||||
{:else}
|
||||
<p class="text-center text-xs text-muted-foreground">
|
||||
No key yet?
|
||||
<a
|
||||
href={SIGNUP_URL}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="font-semibold text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
Subscribe from {PREMIUM_PRICE}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Paywall configuration.
|
||||
*
|
||||
* The frontend stays fully static and open source; the only server piece is the
|
||||
* tiny `drizzli-paywall` verify API (a separate, self-hosted repo). Point the
|
||||
* build at your deployment with the `VITE_PAYWALL_*` env vars (e.g. in a
|
||||
* `.env` file), otherwise the sensible drizz.li defaults are used.
|
||||
*/
|
||||
|
||||
const env = import.meta.env as Record<string, string | undefined>;
|
||||
|
||||
const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '');
|
||||
|
||||
/** Base URL of the self-hosted verify API (drizzli-paywall). */
|
||||
export const PAYWALL_API_BASE = stripTrailingSlash(
|
||||
env.VITE_PAYWALL_API_BASE ?? 'https://paywall.drizz.li'
|
||||
);
|
||||
|
||||
/** Where prospective subscribers go to sign up (the paywall repo's signup form). */
|
||||
export const SIGNUP_URL = env.VITE_PAYWALL_SIGNUP_URL ?? `${PAYWALL_API_BASE}/`;
|
||||
|
||||
/** Display price, shown on the paywall panel. */
|
||||
export const PREMIUM_PRICE = env.VITE_PREMIUM_PRICE ?? '€3 / month';
|
||||
|
||||
/** Short, human list of what premium unlocks (shown on the locked panel). */
|
||||
export const PREMIUM_PERKS = [
|
||||
'Historical weather & climate-normal comparisons',
|
||||
'New premium features as they land'
|
||||
];
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Premium (subscription) state.
|
||||
*
|
||||
* The user pastes an access key once; it is stored locally and re-verified
|
||||
* against the self-hosted verify API on load. The last good result is cached so
|
||||
* gated content shows instantly on reload (and keeps working briefly offline)
|
||||
* without waiting for the network round-trip.
|
||||
*
|
||||
* This gate is a convenience/honor-system gate: the frontend is open source and
|
||||
* static, so it can be bypassed. Keeping the subscriber list server-side (in the
|
||||
* paywall repo) is what makes it meaningful in practice.
|
||||
*/
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
|
||||
import { persisted } from 'svelte-persisted-store';
|
||||
|
||||
import { PAYWALL_API_BASE } from './config';
|
||||
|
||||
/** The subscriber's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
|
||||
export const storedLicenseKey = persisted<string>('license_key', '');
|
||||
|
||||
export interface PremiumCache {
|
||||
valid: boolean;
|
||||
tier?: string;
|
||||
/** ISO date the subscription lapses, or null for a lifetime key. */
|
||||
expires?: string | null;
|
||||
/** epoch ms of the last verify call */
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
/** Last verify result, persisted so the UI doesn't flash "locked" on reload. */
|
||||
export const storedPremiumCache = persisted<PremiumCache | null>('premium_cache_v1', null);
|
||||
|
||||
export type PremiumStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error';
|
||||
|
||||
export interface PremiumState {
|
||||
status: PremiumStatus;
|
||||
tier?: string;
|
||||
expires?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Live verification state for the current session. */
|
||||
export const premiumState = writable<PremiumState>({ status: 'idle' });
|
||||
|
||||
function notExpired(expires: string | null | undefined): boolean {
|
||||
if (!expires) return true; // lifetime key
|
||||
const t = Date.parse(expires);
|
||||
return Number.isFinite(t) && t > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether premium content should be shown. A live "valid"/"invalid" result wins;
|
||||
* otherwise we fall back to the cached result (so a reload or a brief network
|
||||
* blip doesn't lock a paying user out).
|
||||
*/
|
||||
export const isPremium = derived(
|
||||
[premiumState, storedPremiumCache],
|
||||
([$state, $cache]): boolean => {
|
||||
if ($state.status === 'valid') return true;
|
||||
if ($state.status === 'invalid') return false;
|
||||
return !!($cache && $cache.valid && notExpired($cache.expires));
|
||||
}
|
||||
);
|
||||
|
||||
export interface VerifyResult {
|
||||
valid: boolean;
|
||||
tier?: string;
|
||||
expires?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a key against the API. On success the key is persisted and the cache
|
||||
* updated. On an invalid key the stored key is left untouched (so an expired
|
||||
* subscription can still show a "renew" state) but the cache is marked invalid.
|
||||
*/
|
||||
export async function verifyKey(key: string): Promise<VerifyResult> {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) {
|
||||
premiumState.set({ status: 'invalid' });
|
||||
return { valid: false, error: 'Enter your access key.' };
|
||||
}
|
||||
|
||||
premiumState.set({ status: 'checking' });
|
||||
try {
|
||||
const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
|
||||
headers: { accept: 'application/json' }
|
||||
});
|
||||
const data = (await res.json()) as VerifyResult;
|
||||
|
||||
if (res.ok && data.valid) {
|
||||
storedLicenseKey.set(trimmed);
|
||||
storedPremiumCache.set({
|
||||
valid: true,
|
||||
tier: data.tier,
|
||||
expires: data.expires ?? null,
|
||||
checkedAt: Date.now()
|
||||
});
|
||||
premiumState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null });
|
||||
return { valid: true, tier: data.tier, expires: data.expires ?? null };
|
||||
}
|
||||
|
||||
storedPremiumCache.set({ valid: false, checkedAt: Date.now() });
|
||||
premiumState.set({ status: 'invalid' });
|
||||
return { valid: false };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
premiumState.set({ status: 'error', error: message });
|
||||
return { valid: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */
|
||||
export async function refreshPremium(): Promise<void> {
|
||||
const key = get(storedLicenseKey);
|
||||
if (!key) {
|
||||
premiumState.set({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
await verifyKey(key);
|
||||
}
|
||||
|
||||
/** Forget the key and premium state ("sign out"). */
|
||||
export function clearLicense(): void {
|
||||
storedLicenseKey.set('');
|
||||
storedPremiumCache.set(null);
|
||||
premiumState.set({ status: 'idle' });
|
||||
}
|
||||
+306
-25
@@ -22,6 +22,7 @@ import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
|
||||
|
||||
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
|
||||
const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
|
||||
|
||||
// ─── Core Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -303,6 +304,40 @@ const WEEK_DAILY_VARS = [
|
||||
'wind_direction_10m_dominant'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Assembles a WeekHourlyData structure from a name→values map, so variables that
|
||||
* were not requested resolve to empty arrays. Shared by the week and historical
|
||||
* fetchers (both return the same hourly shape, which the meteograms and hourly
|
||||
* table consume).
|
||||
*/
|
||||
function weekHourlyFromByName(byName: Record<string, number[]>): WeekHourlyData {
|
||||
const g = (name: string): number[] => byName[name] ?? [];
|
||||
return {
|
||||
temperature_2m: g('temperature_2m'),
|
||||
precipitation: g('precipitation'),
|
||||
precipitation_probability: g('precipitation_probability'),
|
||||
weather_code: g('weather_code'),
|
||||
windspeed_10m: g('wind_speed_10m'),
|
||||
winddirection_10m: g('wind_direction_10m'),
|
||||
cloud_cover: g('cloud_cover'),
|
||||
relative_humidity_2m: g('relative_humidity_2m'),
|
||||
apparent_temperature: g('apparent_temperature'),
|
||||
dew_point_2m: g('dew_point_2m'),
|
||||
wind_gusts_10m: g('wind_gusts_10m'),
|
||||
pressure_msl: g('pressure_msl'),
|
||||
surface_pressure: g('surface_pressure'),
|
||||
rain: g('rain'),
|
||||
showers: g('showers'),
|
||||
snowfall: g('snowfall'),
|
||||
cloud_cover_low: g('cloud_cover_low'),
|
||||
cloud_cover_mid: g('cloud_cover_mid'),
|
||||
cloud_cover_high: g('cloud_cover_high'),
|
||||
uv_index: g('uv_index'),
|
||||
visibility: g('visibility'),
|
||||
cape: g('cape')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the 7-day (week) weather forecast for a single location and model.
|
||||
* Returns typed hourly and daily data structures.
|
||||
@@ -359,32 +394,8 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
||||
const variable = hourlyBlock.variables(i);
|
||||
byName[name] = variable ? getValues(variable) : [];
|
||||
});
|
||||
const g = (name: string): number[] => byName[name] ?? [];
|
||||
|
||||
const hourly: WeekHourlyData = {
|
||||
temperature_2m: g('temperature_2m'),
|
||||
precipitation: g('precipitation'),
|
||||
precipitation_probability: g('precipitation_probability'),
|
||||
weather_code: g('weather_code'),
|
||||
windspeed_10m: g('wind_speed_10m'),
|
||||
winddirection_10m: g('wind_direction_10m'),
|
||||
cloud_cover: g('cloud_cover'),
|
||||
relative_humidity_2m: g('relative_humidity_2m'),
|
||||
apparent_temperature: g('apparent_temperature'),
|
||||
dew_point_2m: g('dew_point_2m'),
|
||||
wind_gusts_10m: g('wind_gusts_10m'),
|
||||
pressure_msl: g('pressure_msl'),
|
||||
surface_pressure: g('surface_pressure'),
|
||||
rain: g('rain'),
|
||||
showers: g('showers'),
|
||||
snowfall: g('snowfall'),
|
||||
cloud_cover_low: g('cloud_cover_low'),
|
||||
cloud_cover_mid: g('cloud_cover_mid'),
|
||||
cloud_cover_high: g('cloud_cover_high'),
|
||||
uv_index: g('uv_index'),
|
||||
visibility: g('visibility'),
|
||||
cape: g('cape')
|
||||
};
|
||||
const hourly = weekHourlyFromByName(byName);
|
||||
|
||||
// Daily: variables are in the same order as WEEK_DAILY_VARS
|
||||
const dailyDates = getDates(dailyBlock);
|
||||
@@ -803,3 +814,273 @@ function modelEnumToString(modelEnum: number): string {
|
||||
};
|
||||
return modelMap[modelEnum] ?? `model_${modelEnum}`;
|
||||
}
|
||||
|
||||
// ─── Historical (Archive) Types ─────────────────────────────────────────────
|
||||
|
||||
export interface HistoricalDailyData {
|
||||
weather_code: number[];
|
||||
temperature_2m_max: number[];
|
||||
temperature_2m_min: number[];
|
||||
temperature_2m_mean: number[];
|
||||
apparent_temperature_max: number[];
|
||||
apparent_temperature_min: number[];
|
||||
sunrise: number[];
|
||||
sunset: number[];
|
||||
sunshine_duration: number[];
|
||||
precipitation_sum: number[];
|
||||
rain_sum: number[];
|
||||
snowfall_sum: number[];
|
||||
precipitation_hours: number[];
|
||||
windspeed_10m_max: number[];
|
||||
windgusts_10m_max: number[];
|
||||
winddirection_10m_dominant: number[];
|
||||
}
|
||||
|
||||
export interface HistoricalForecastParams extends WeatherLocation, WeatherUnitParams {
|
||||
/** Inclusive range, YYYY-MM-DD (location-local dates). */
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
/** Hourly API variables to request; defaults to the core week set. */
|
||||
hourlyVariables?: string[];
|
||||
}
|
||||
|
||||
export interface HistoricalForecastResult {
|
||||
hourly: WeekHourlyData;
|
||||
daily: HistoricalDailyData;
|
||||
utcOffsetSeconds: number;
|
||||
timezone: string;
|
||||
hourlyTimestamps: number[];
|
||||
hourlyDates: Date[];
|
||||
dailyDates: Date[];
|
||||
daylightBands: DaylightBand[];
|
||||
}
|
||||
|
||||
// Requested in this exact order; the daily block returns variables positionally.
|
||||
const HISTORICAL_DAILY_VARS = [
|
||||
'weather_code',
|
||||
'temperature_2m_max',
|
||||
'temperature_2m_min',
|
||||
'temperature_2m_mean',
|
||||
'apparent_temperature_max',
|
||||
'apparent_temperature_min',
|
||||
'sunrise',
|
||||
'sunset',
|
||||
'sunshine_duration',
|
||||
'precipitation_sum',
|
||||
'rain_sum',
|
||||
'snowfall_sum',
|
||||
'precipitation_hours',
|
||||
'wind_speed_10m_max',
|
||||
'wind_gusts_10m_max',
|
||||
'wind_direction_10m_dominant'
|
||||
] as const;
|
||||
|
||||
// ─── Historical (Archive) Fetch ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetches reanalysis (ERA5) weather for a past date range from the Open-Meteo
|
||||
* archive API. Returns the same hourly shape as the week forecast (so the
|
||||
* existing meteograms and hourly table render it unchanged) plus a richer daily
|
||||
* block for the climate/statistics view.
|
||||
*/
|
||||
export async function fetchHistoricalWeather(
|
||||
params: HistoricalForecastParams
|
||||
): Promise<HistoricalForecastResult> {
|
||||
const hourlyVars =
|
||||
params.hourlyVariables && params.hourlyVariables.length > 0
|
||||
? [...new Set(params.hourlyVariables)]
|
||||
: [...WEEK_HOURLY_VARS];
|
||||
|
||||
const apiParams: Record<string, string | number | undefined> = {
|
||||
latitude: params.latitude,
|
||||
longitude: params.longitude,
|
||||
start_date: params.start_date,
|
||||
end_date: params.end_date,
|
||||
hourly: hourlyVars.join(','),
|
||||
daily: HISTORICAL_DAILY_VARS.join(','),
|
||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||
timezone: params.timezone
|
||||
};
|
||||
|
||||
const cleanParams: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(apiParams)) {
|
||||
if (value !== undefined) cleanParams[key] = String(value);
|
||||
}
|
||||
|
||||
const responses = await fetchWeatherApi(ARCHIVE_URL, cleanParams);
|
||||
const response = responses[0];
|
||||
const utcOffsetSeconds = response.utcOffsetSeconds();
|
||||
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
|
||||
|
||||
const hourlyBlock = response.hourly()!;
|
||||
const dailyBlock = response.daily()!;
|
||||
|
||||
const hourlyTimestamps = getTimestamps(hourlyBlock);
|
||||
const hourlyDates = hourlyTimestamps.map((t) => new Date(t));
|
||||
|
||||
const byName: Record<string, number[]> = {};
|
||||
hourlyVars.forEach((name, i) => {
|
||||
const variable = hourlyBlock.variables(i);
|
||||
byName[name] = variable ? getValues(variable) : [];
|
||||
});
|
||||
const hourly = weekHourlyFromByName(byName);
|
||||
|
||||
// Daily variables come back in HISTORICAL_DAILY_VARS order.
|
||||
const dailyDates = getDates(dailyBlock);
|
||||
const d = (i: number): number[] => {
|
||||
const v = dailyBlock.variables(i);
|
||||
return v ? getValues(v) : [];
|
||||
};
|
||||
const sunrise = getInt64Values(dailyBlock.variables(6)!);
|
||||
const sunset = getInt64Values(dailyBlock.variables(7)!);
|
||||
|
||||
const daily: HistoricalDailyData = {
|
||||
weather_code: d(0),
|
||||
temperature_2m_max: d(1),
|
||||
temperature_2m_min: d(2),
|
||||
temperature_2m_mean: d(3),
|
||||
apparent_temperature_max: d(4),
|
||||
apparent_temperature_min: d(5),
|
||||
sunrise,
|
||||
sunset,
|
||||
sunshine_duration: d(8),
|
||||
precipitation_sum: d(9),
|
||||
rain_sum: d(10),
|
||||
snowfall_sum: d(11),
|
||||
precipitation_hours: d(12),
|
||||
windspeed_10m_max: d(13),
|
||||
windgusts_10m_max: d(14),
|
||||
winddirection_10m_dominant: d(15)
|
||||
};
|
||||
|
||||
const daylightBands = buildDaylightBands(sunrise, sunset);
|
||||
|
||||
return {
|
||||
hourly,
|
||||
daily,
|
||||
utcOffsetSeconds,
|
||||
timezone,
|
||||
hourlyTimestamps,
|
||||
hourlyDates,
|
||||
dailyDates,
|
||||
daylightBands
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Climate Normals ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ClimateNormals {
|
||||
/** Indexed by day-of-year ordinal 1..366 (index 0 unused); NaN where no data. */
|
||||
tmax: number[];
|
||||
tmin: number[];
|
||||
tmean: number[];
|
||||
/** Mean daily precipitation (per calendar day). */
|
||||
precip: number[];
|
||||
baseStart: string;
|
||||
baseEnd: string;
|
||||
temperature_unit: string;
|
||||
precipitation_unit: string;
|
||||
}
|
||||
|
||||
export interface ClimateNormalsParams extends WeatherLocation, WeatherUnitParams {
|
||||
/** Baseline period; defaults to the 1991-2020 WMO normal period. */
|
||||
baseStart?: string;
|
||||
baseEnd?: string;
|
||||
}
|
||||
|
||||
// Days before the first of each month in a leap reference year, so that a
|
||||
// (month, day) pair maps to a stable 1..366 ordinal regardless of leap years.
|
||||
const CUM_DAYS_LEAP = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
|
||||
|
||||
/** Day-of-year ordinal (1..366) from month (1-12) and day-of-month (1-31). */
|
||||
export function monthDayToOrdinal(month: number, day: number): number {
|
||||
const m = Math.min(12, Math.max(1, Math.round(month)));
|
||||
return CUM_DAYS_LEAP[m - 1] + day;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes daily climate normals for a location by averaging a multi-decade
|
||||
* archive across years, per day-of-year, with a ±7-day smoothing window so the
|
||||
* curve is stable. One archive request; used for the "vs normal" comparison.
|
||||
*/
|
||||
export async function fetchClimateNormals(params: ClimateNormalsParams): Promise<ClimateNormals> {
|
||||
const baseStart = params.baseStart ?? '1991-01-01';
|
||||
const baseEnd = params.baseEnd ?? '2020-12-31';
|
||||
|
||||
// UTC keeps the day-of-year bucketing exact (no offset spill across midnight);
|
||||
// timezone is irrelevant to a per-calendar-day normal.
|
||||
const apiParams: Record<string, string> = {
|
||||
latitude: String(params.latitude),
|
||||
longitude: String(params.longitude),
|
||||
start_date: baseStart,
|
||||
end_date: baseEnd,
|
||||
daily: 'temperature_2m_max,temperature_2m_min,temperature_2m_mean,precipitation_sum',
|
||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||
timezone: 'UTC'
|
||||
};
|
||||
|
||||
const responses = await fetchWeatherApi(ARCHIVE_URL, apiParams);
|
||||
const response = responses[0];
|
||||
const dailyBlock = response.daily()!;
|
||||
const dates = getDates(dailyBlock);
|
||||
const tmaxV = getValues(dailyBlock.variables(0)!);
|
||||
const tminV = getValues(dailyBlock.variables(1)!);
|
||||
const tmeanV = getValues(dailyBlock.variables(2)!);
|
||||
const precipV = getValues(dailyBlock.variables(3)!);
|
||||
|
||||
const N = 367; // ordinals 1..366
|
||||
const mk = () => ({ sum: new Array<number>(N).fill(0), cnt: new Array<number>(N).fill(0) });
|
||||
const acc = { tmax: mk(), tmin: mk(), tmean: mk(), precip: mk() };
|
||||
|
||||
const add = (bucket: { sum: number[]; cnt: number[] }, ord: number, val: number) => {
|
||||
if (Number.isFinite(val)) {
|
||||
bucket.sum[ord] += val;
|
||||
bucket.cnt[ord] += 1;
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const dt = dates[i];
|
||||
const ord = monthDayToOrdinal(dt.getUTCMonth() + 1, dt.getUTCDate());
|
||||
add(acc.tmax, ord, tmaxV[i]);
|
||||
add(acc.tmin, ord, tminV[i]);
|
||||
add(acc.tmean, ord, tmeanV[i]);
|
||||
add(acc.precip, ord, precipV[i]);
|
||||
}
|
||||
|
||||
const mean = (bucket: { sum: number[]; cnt: number[] }): number[] =>
|
||||
bucket.sum.map((s, i) => (bucket.cnt[i] > 0 ? s / bucket.cnt[i] : NaN));
|
||||
|
||||
// Circular ±window smoothing across the 366 ordinals (skips empty days).
|
||||
const smooth = (arr: number[], window = 7): number[] => {
|
||||
const out = new Array<number>(N).fill(NaN);
|
||||
for (let o = 1; o <= 366; o++) {
|
||||
let s = 0;
|
||||
let c = 0;
|
||||
for (let k = -window; k <= window; k++) {
|
||||
const idx = ((o - 1 + k + 366) % 366) + 1;
|
||||
const v = arr[idx];
|
||||
if (Number.isFinite(v)) {
|
||||
s += v;
|
||||
c++;
|
||||
}
|
||||
}
|
||||
out[o] = c > 0 ? s / c : NaN;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
return {
|
||||
tmax: smooth(mean(acc.tmax)),
|
||||
tmin: smooth(mean(acc.tmin)),
|
||||
tmean: smooth(mean(acc.tmean)),
|
||||
precip: smooth(mean(acc.precip)),
|
||||
baseStart,
|
||||
baseEnd,
|
||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||
precipitation_unit: params.precipitation_unit ?? 'mm'
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user