historical weather
This commit is contained in:
@@ -12,6 +12,8 @@
|
|||||||
import LocationSearch from '$lib/components/location/location-search.svelte';
|
import LocationSearch from '$lib/components/location/location-search.svelte';
|
||||||
import UnitSelector from '$lib/components/unit-selector.svelte';
|
import UnitSelector from '$lib/components/unit-selector.svelte';
|
||||||
|
|
||||||
|
import PremiumBadge from '$lib/paywall/PremiumBadge.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onMenuToggle?: () => void;
|
onMenuToggle?: () => void;
|
||||||
}
|
}
|
||||||
@@ -46,6 +48,8 @@
|
|||||||
goto(resolve('/weather/compare/[location]', { location: locationRoute }));
|
goto(resolve('/weather/compare/[location]', { location: locationRoute }));
|
||||||
} else if (currentPath.startsWith('/weather/14-day')) {
|
} else if (currentPath.startsWith('/weather/14-day')) {
|
||||||
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
|
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
|
||||||
|
} else if (currentPath.startsWith('/weather/historical')) {
|
||||||
|
goto(resolve('/weather/historical/[location]', { location: locationRoute }));
|
||||||
} else {
|
} else {
|
||||||
goto(resolve('/weather/week/[location]', { location: locationRoute }));
|
goto(resolve('/weather/week/[location]', { location: locationRoute }));
|
||||||
}
|
}
|
||||||
@@ -105,6 +109,9 @@
|
|||||||
<!-- Measurement units -->
|
<!-- Measurement units -->
|
||||||
<UnitSelector />
|
<UnitSelector />
|
||||||
|
|
||||||
|
<!-- Premium status / unlock -->
|
||||||
|
<PremiumBadge />
|
||||||
|
|
||||||
<!-- Theme toggle: system → light → dark -->
|
<!-- Theme toggle: system → light → dark -->
|
||||||
<button
|
<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"
|
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'
|
'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',
|
title: 'Maps',
|
||||||
url: '/weather/maps' as const,
|
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 FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||||
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
|
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
|
||||||
|
const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
|
||||||
|
|
||||||
// ─── Core Helpers ───────────────────────────────────────────────────────────────
|
// ─── Core Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -303,6 +304,40 @@ const WEEK_DAILY_VARS = [
|
|||||||
'wind_direction_10m_dominant'
|
'wind_direction_10m_dominant'
|
||||||
] as const;
|
] 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.
|
* Fetches the 7-day (week) weather forecast for a single location and model.
|
||||||
* Returns typed hourly and daily data structures.
|
* Returns typed hourly and daily data structures.
|
||||||
@@ -359,32 +394,8 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
|||||||
const variable = hourlyBlock.variables(i);
|
const variable = hourlyBlock.variables(i);
|
||||||
byName[name] = variable ? getValues(variable) : [];
|
byName[name] = variable ? getValues(variable) : [];
|
||||||
});
|
});
|
||||||
const g = (name: string): number[] => byName[name] ?? [];
|
|
||||||
|
|
||||||
const hourly: WeekHourlyData = {
|
const hourly = weekHourlyFromByName(byName);
|
||||||
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')
|
|
||||||
};
|
|
||||||
|
|
||||||
// Daily: variables are in the same order as WEEK_DAILY_VARS
|
// Daily: variables are in the same order as WEEK_DAILY_VARS
|
||||||
const dailyDates = getDates(dailyBlock);
|
const dailyDates = getDates(dailyBlock);
|
||||||
@@ -803,3 +814,273 @@ function modelEnumToString(modelEnum: number): string {
|
|||||||
};
|
};
|
||||||
return modelMap[modelEnum] ?? `model_${modelEnum}`;
|
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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/historical/[location]', {
|
||||||
|
location: buildLocationRoute(get(storedLocation))
|
||||||
|
}),
|
||||||
|
{ replaceState: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { resolveLocationFromRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = async (event) => {
|
||||||
|
const location = await resolveLocationFromRoute({
|
||||||
|
urlLocation: event.params.location,
|
||||||
|
routePrefix: '/weather/historical/',
|
||||||
|
event
|
||||||
|
});
|
||||||
|
|
||||||
|
return { location };
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
/** Latest selectable date (archive lags real time by a few days). */
|
||||||
|
maxDate: string;
|
||||||
|
minDate: string;
|
||||||
|
onChange: (start: string, end: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { start, end, maxDate, minDate, onChange }: Props = $props();
|
||||||
|
|
||||||
|
// ─── Presets ────────────────────────────────────────────────────────────────
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyLastDays(days: number) {
|
||||||
|
const endD = new Date(`${maxDate}T00:00:00Z`);
|
||||||
|
const startD = addDays(endD, -(days - 1));
|
||||||
|
onChange(iso(startD), iso(endD));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThisMonthLastYear() {
|
||||||
|
const end = new Date(`${maxDate}T00:00:00Z`);
|
||||||
|
const y = end.getUTCFullYear() - 1;
|
||||||
|
const m = end.getUTCMonth();
|
||||||
|
const first = new Date(Date.UTC(y, m, 1));
|
||||||
|
const last = new Date(Date.UTC(y, m + 1, 0));
|
||||||
|
onChange(iso(first), iso(last));
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets = [
|
||||||
|
{ label: '7 days', apply: () => applyLastDays(7) },
|
||||||
|
{ label: '30 days', apply: () => applyLastDays(30) },
|
||||||
|
{ label: '90 days', apply: () => applyLastDays(90) },
|
||||||
|
{ label: 'Month, last year', apply: applyThisMonthLastYear }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Manual inputs ────────────────────────────────────────────────────────────
|
||||||
|
// Editable mirrors of the props, re-seeded whenever a preset changes the range.
|
||||||
|
let localStart = $state('');
|
||||||
|
let localEnd = $state('');
|
||||||
|
$effect(() => {
|
||||||
|
localStart = start;
|
||||||
|
localEnd = end;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep start <= end and inside the allowed window before emitting.
|
||||||
|
function commit() {
|
||||||
|
let s = localStart;
|
||||||
|
let e = localEnd;
|
||||||
|
if (s > e) [s, e] = [e, s];
|
||||||
|
if (s < minDate) s = minDate;
|
||||||
|
if (e > maxDate) e = maxDate;
|
||||||
|
onChange(s, e);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 rounded-xl border border-border/70 bg-card p-3 shadow-sm sm:flex-row sm:items-end sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<div class="grid gap-1">
|
||||||
|
<label for="hist-start" class="text-xs font-semibold text-muted-foreground">From</label>
|
||||||
|
<input
|
||||||
|
id="hist-start"
|
||||||
|
type="date"
|
||||||
|
bind:value={localStart}
|
||||||
|
min={minDate}
|
||||||
|
max={maxDate}
|
||||||
|
onchange={commit}
|
||||||
|
class="h-9 rounded-lg border border-border bg-background px-2.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-1">
|
||||||
|
<label for="hist-end" class="text-xs font-semibold text-muted-foreground">To</label>
|
||||||
|
<input
|
||||||
|
id="hist-end"
|
||||||
|
type="date"
|
||||||
|
bind:value={localEnd}
|
||||||
|
min={minDate}
|
||||||
|
max={maxDate}
|
||||||
|
onchange={commit}
|
||||||
|
class="h-9 rounded-lg border border-border bg-background px-2.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="inline-flex flex-wrap items-center gap-0.5 rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
||||||
|
role="group"
|
||||||
|
aria-label="Quick ranges"
|
||||||
|
>
|
||||||
|
{#each presets as preset (preset.label)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer rounded-md px-2.5 py-1.5 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
|
||||||
|
onclick={preset.apply}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { formatZoned, isSameDayInZone } from '$lib/utils/date';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ClimateNormals,
|
||||||
|
type HistoricalDailyData,
|
||||||
|
monthDayToOrdinal
|
||||||
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
|
import { getColor, getTempStyle } from '../../utils/colors';
|
||||||
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
|
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
daily: HistoricalDailyData;
|
||||||
|
dailyDates: Date[];
|
||||||
|
timezone: string;
|
||||||
|
units: WeatherUnits;
|
||||||
|
normals: ClimateNormals | null;
|
||||||
|
selectedDay: Date;
|
||||||
|
onSelectDay: (date: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { daily, dailyDates, timezone, units, normals, selectedDay, onSelectDay }: Props = $props();
|
||||||
|
|
||||||
|
const tempUnit = $derived(getTempUnit(units));
|
||||||
|
const precipUnit = $derived(getPrecipUnit(units));
|
||||||
|
|
||||||
|
// Day-of-year ordinal for each day, so we can look up its climate normal.
|
||||||
|
let ordinals = $derived(
|
||||||
|
dailyDates.map((d) =>
|
||||||
|
monthDayToOrdinal(
|
||||||
|
Number(formatZoned(d, timezone, 'M')),
|
||||||
|
Number(formatZoned(d, timezone, 'd'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const finite = (v: number | undefined): v is number => v != null && Number.isFinite(v);
|
||||||
|
const mean = (xs: number[]): number =>
|
||||||
|
xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN;
|
||||||
|
|
||||||
|
// ─── Range scaling for the min/max bars ─────────────────────────────────────
|
||||||
|
const BAR_H = 78;
|
||||||
|
let scale = $derived.by(() => {
|
||||||
|
const lo = Math.min(...daily.temperature_2m_min.filter(finite));
|
||||||
|
const hi = Math.max(...daily.temperature_2m_max.filter(finite));
|
||||||
|
const span = hi - lo || 1;
|
||||||
|
return { lo, hi, span };
|
||||||
|
});
|
||||||
|
const yOf = (v: number): number => (1 - (v - scale.lo) / scale.span) * BAR_H;
|
||||||
|
|
||||||
|
// ─── Summary statistics ─────────────────────────────────────────────────────
|
||||||
|
let stats = $derived.by(() => {
|
||||||
|
const means = daily.temperature_2m_mean.filter(finite);
|
||||||
|
const avg = mean(means);
|
||||||
|
|
||||||
|
// warmest / coldest day
|
||||||
|
let warm = { t: -Infinity, i: -1 };
|
||||||
|
let cold = { t: Infinity, i: -1 };
|
||||||
|
for (let i = 0; i < dailyDates.length; i++) {
|
||||||
|
const mx = daily.temperature_2m_max[i];
|
||||||
|
const mn = daily.temperature_2m_min[i];
|
||||||
|
if (finite(mx) && mx > warm.t) warm = { t: mx, i };
|
||||||
|
if (finite(mn) && mn < cold.t) cold = { t: mn, i };
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPrecip = daily.precipitation_sum.filter(finite).reduce((a, b) => a + b, 0);
|
||||||
|
const wetDays = daily.precipitation_sum.filter((p) => finite(p) && p >= 1).length;
|
||||||
|
|
||||||
|
// climate comparison (only when normals are available)
|
||||||
|
let tempAnomaly: number | null = null;
|
||||||
|
let normalPrecip: number | null = null;
|
||||||
|
if (normals) {
|
||||||
|
const normMeans: number[] = [];
|
||||||
|
let np = 0;
|
||||||
|
let npCount = 0;
|
||||||
|
for (let i = 0; i < ordinals.length; i++) {
|
||||||
|
const nm = normals.tmean[ordinals[i]];
|
||||||
|
if (finite(nm)) normMeans.push(nm);
|
||||||
|
const npv = normals.precip[ordinals[i]];
|
||||||
|
if (finite(npv)) {
|
||||||
|
np += npv;
|
||||||
|
npCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normMeans.length && finite(avg)) tempAnomaly = avg - mean(normMeans);
|
||||||
|
if (npCount) normalPrecip = np;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { avg, warm, cold, totalPrecip, wetDays, tempAnomaly, normalPrecip };
|
||||||
|
});
|
||||||
|
|
||||||
|
const fmtTemp = (v: number): string => (finite(v) ? `${v.toFixed(1)}°` : '–');
|
||||||
|
const fmtSigned = (v: number): string => `${v >= 0 ? '+' : ''}${v.toFixed(1)}°`;
|
||||||
|
const fmtPrecip = (v: number): string => `${v.toFixed(v < 10 ? 1 : 0)} ${precipUnit}`;
|
||||||
|
|
||||||
|
function anomalyColor(delta: number): string {
|
||||||
|
const a = Math.min(0.9, 0.25 + Math.abs(delta) / 12);
|
||||||
|
return delta >= 0 ? `rgba(220, 70, 60, ${a})` : `rgba(50, 110, 210, ${a})`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- ─── KPI tiles ────────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="grid grid-cols-2 gap-2.5 lg:grid-cols-4">
|
||||||
|
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Average temperature</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold tabular-nums">
|
||||||
|
{fmtTemp(stats.avg)}<span class="text-base font-semibold text-muted-foreground"
|
||||||
|
>{tempUnit.replace('°', '')}</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
{#if stats.tempAnomaly != null}
|
||||||
|
<p
|
||||||
|
class="mt-0.5 text-xs font-semibold"
|
||||||
|
class:text-red-600={stats.tempAnomaly >= 0}
|
||||||
|
class:text-blue-600={stats.tempAnomaly < 0}
|
||||||
|
class:dark:text-red-400={stats.tempAnomaly >= 0}
|
||||||
|
class:dark:text-blue-400={stats.tempAnomaly < 0}
|
||||||
|
>
|
||||||
|
{fmtSigned(stats.tempAnomaly)} vs normal
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-0.5 text-xs text-muted-foreground">1991–2020 normal loading…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Total precipitation</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtPrecip(stats.totalPrecip)}</p>
|
||||||
|
{#if stats.normalPrecip != null}
|
||||||
|
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
normal {fmtPrecip(stats.normalPrecip)} · {stats.wetDays} wet {stats.wetDays === 1
|
||||||
|
? 'day'
|
||||||
|
: 'days'}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-0.5 text-xs text-muted-foreground">{stats.wetDays} wet days</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Warmest day</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.warm.t)}</p>
|
||||||
|
{#if stats.warm.i >= 0}
|
||||||
|
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
{formatZoned(dailyDates[stats.warm.i], timezone, 'EEE d LLL')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Coldest day</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.cold.t)}</p>
|
||||||
|
{#if stats.cold.i >= 0}
|
||||||
|
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
{formatZoned(dailyDates[stats.cold.i], timezone, 'EEE d LLL')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Daily strip: min/max range bars + anomaly + precip ─────────────────────── -->
|
||||||
|
<section
|
||||||
|
class="mt-4 -mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between border-b border-border/70 bg-muted/40 px-4 py-2.5">
|
||||||
|
<h3 class="text-base font-bold">
|
||||||
|
Daily <span class="font-semibold text-muted-foreground">– select a day for hourly detail</span
|
||||||
|
>
|
||||||
|
</h3>
|
||||||
|
{#if normals}
|
||||||
|
<span class="hidden text-xs text-muted-foreground sm:inline">
|
||||||
|
normal band = 1991–2020 mean
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="flex min-w-min">
|
||||||
|
{#each dailyDates as date, i (date.getTime())}
|
||||||
|
{@const selected = isSameDayInZone(date, selectedDay, timezone)}
|
||||||
|
{@const tMax = daily.temperature_2m_max[i]}
|
||||||
|
{@const tMin = daily.temperature_2m_min[i]}
|
||||||
|
{@const tMean = daily.temperature_2m_mean[i]}
|
||||||
|
{@const norm = normals ? normals.tmean[ordinals[i]] : NaN}
|
||||||
|
{@const anomaly = finite(tMean) && finite(norm) ? tMean - norm : null}
|
||||||
|
{@const precip = daily.precipitation_sum[i]}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-[52px] shrink-0 cursor-pointer flex-col items-center gap-1 border-r border-border/40 px-1 py-2 text-center transition-colors hover:bg-muted/60 {selected
|
||||||
|
? 'bg-primary/10'
|
||||||
|
: ''}"
|
||||||
|
onclick={() => onSelectDay(date)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
>
|
||||||
|
<span class="text-[11px] font-semibold {selected ? 'text-primary' : 'text-foreground'}">
|
||||||
|
{formatZoned(date, timezone, 'EEE')}
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] text-muted-foreground"
|
||||||
|
>{formatZoned(date, timezone, 'd MMM')}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<svg class="my-0.5" width="20" height="20" aria-hidden="true">
|
||||||
|
<use
|
||||||
|
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||||
|
daily.weather_code[i],
|
||||||
|
true
|
||||||
|
)}.svg#Layer_1"
|
||||||
|
></use>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- min/max range bar -->
|
||||||
|
<div class="relative w-3.5" style="height:{BAR_H}px">
|
||||||
|
{#if finite(norm)}
|
||||||
|
<!-- normal marker -->
|
||||||
|
<div
|
||||||
|
class="absolute -left-0.5 -right-0.5 border-t border-dashed border-muted-foreground/50"
|
||||||
|
style="top:{yOf(norm)}px"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{#if finite(tMax) && finite(tMin)}
|
||||||
|
<div
|
||||||
|
class="absolute left-0 w-full rounded-full"
|
||||||
|
style="top:{yOf(tMax)}px;height:{Math.max(
|
||||||
|
3,
|
||||||
|
yOf(tMin) - yOf(tMax)
|
||||||
|
)}px;background:{getColor(tMean ?? (tMax + tMin) / 2, units.temperature_unit)}"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="text-[11px] font-bold tabular-nums"
|
||||||
|
style="color:{getTempStyle(tMax, units.temperature_unit).bg}">{fmtTemp(tMax)}</span
|
||||||
|
>
|
||||||
|
<span class="text-[10px] font-medium text-muted-foreground tabular-nums"
|
||||||
|
>{fmtTemp(tMin)}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
{#if anomaly != null}
|
||||||
|
<span
|
||||||
|
class="mt-0.5 inline-block h-1.5 w-6 rounded-full"
|
||||||
|
style="background:{anomalyColor(anomaly)}"
|
||||||
|
title="{fmtSigned(anomaly)} vs normal"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if finite(precip) && precip >= 0.1}
|
||||||
|
<span
|
||||||
|
class="mt-0.5 text-[10px] font-semibold text-sky-600 dark:text-sky-400 tabular-nums"
|
||||||
|
>
|
||||||
|
{precip.toFixed(precip < 10 ? 1 : 0)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { formatZoned } from '$lib/utils/date';
|
||||||
|
|
||||||
|
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
|
||||||
|
|
||||||
|
import { CanvasChart, groupRange } from '$lib/charts';
|
||||||
|
|
||||||
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
|
import { type FetchedHourly, type WeatherUnits } from '../../week/[location]/types';
|
||||||
|
import { VARIABLE_BY_KEY, buildPanelDef } from '../../week/[location]/variables';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: FetchedHourly;
|
||||||
|
units: WeatherUnits;
|
||||||
|
loading: boolean;
|
||||||
|
selectedDay: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, units, loading, selectedDay }: Props = $props();
|
||||||
|
|
||||||
|
const CHART_GROUP = 'historical-meteogram';
|
||||||
|
const SECONDS_PER_DAY = 24 * 3600;
|
||||||
|
const CHART_HEIGHT = 300;
|
||||||
|
|
||||||
|
let downloadingPng = $state(false);
|
||||||
|
let chartComponents: (CanvasChart | null)[] = $state([]);
|
||||||
|
let liveCharts = $derived(chartComponents.filter((c): c is CanvasChart => c != null));
|
||||||
|
|
||||||
|
// Same customizable layout as the 7-day meteograms, so the two pages match.
|
||||||
|
let panels = $derived(
|
||||||
|
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
|
||||||
|
);
|
||||||
|
|
||||||
|
let timestampsSec = $derived(data.timestamps.map((t) => t / 1000));
|
||||||
|
|
||||||
|
function dayStartSec(day: Date): number | null {
|
||||||
|
const targetDayStr = formatZoned(day, data.timezone, 'yyyy-MM-dd');
|
||||||
|
const idx = data.hourlyDates.findIndex(
|
||||||
|
(d) => formatZoned(d, data.timezone, 'yyyy-MM-dd') === targetDayStr
|
||||||
|
);
|
||||||
|
return idx === -1 ? null : data.timestamps[idx] / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft band marking the day currently open in the hourly table below.
|
||||||
|
let selectedDayHighlight = $derived.by(() => {
|
||||||
|
const start = dayStartSec(selectedDay);
|
||||||
|
return start == null ? undefined : { start, end: start + SECONDS_PER_DAY };
|
||||||
|
});
|
||||||
|
|
||||||
|
function isDaytime(tSec: number): boolean {
|
||||||
|
return data.daylightBands.some((b) => tSec >= b.start && tSec < b.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pictograms = $derived.by((): { t: number; icon: string }[] => {
|
||||||
|
const codes = data.hourly.weather_code ?? [];
|
||||||
|
const out: { t: number; icon: string }[] = [];
|
||||||
|
for (let i = 0; i < timestampsSec.length; i++) {
|
||||||
|
const code = codes[i];
|
||||||
|
if (code == null || !isFinite(code)) continue;
|
||||||
|
const t = timestampsSec[i];
|
||||||
|
out.push({ t, icon: getWeatherIconName(code, isDaytime(t)) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
let windArrowMarks = $derived.by((): { t: number; deg: number }[] => {
|
||||||
|
const dirs = data.hourly.winddirection_10m ?? [];
|
||||||
|
const out: { t: number; deg: number }[] = [];
|
||||||
|
for (let i = 0; i < timestampsSec.length; i++) {
|
||||||
|
const d = dirs[i];
|
||||||
|
if (d == null || !isFinite(d)) continue;
|
||||||
|
out.push({ t: timestampsSec[i], deg: d });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
|
||||||
|
function resetZoom(): void {
|
||||||
|
liveCharts[0]?.resetRange();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderPanel extends ChartPanel {
|
||||||
|
def: ReturnType<typeof buildPanelDef>;
|
||||||
|
title: string;
|
||||||
|
titleShort: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderPanels = $derived.by((): RenderPanel[] =>
|
||||||
|
panels.map((p) => {
|
||||||
|
const def = buildPanelDef(p.variables, data.hourly, units);
|
||||||
|
const title = def.series.map((s) => s.name).join(' · ');
|
||||||
|
const titleShort = def.series.map((s) => s.shortName ?? s.name).join(' · ');
|
||||||
|
return { ...p, def, title, titleShort };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let anyRightAxis = $derived(renderPanels.some((p) => p.def.unitRight != null));
|
||||||
|
let maxTopRows = $derived(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
...renderPanels.map((p) => (p.def.hasPictograms ? 1 : 0) + (p.def.hasWindArrows ? 1 : 0))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function downloadPng(): Promise<void> {
|
||||||
|
if (liveCharts.length === 0 || downloadingPng) return;
|
||||||
|
downloadingPng = true;
|
||||||
|
try {
|
||||||
|
const items = renderPanels.map((p, i) => ({ chart: chartComponents[i], title: p.title }));
|
||||||
|
await downloadChartsPng(items, 'historical-weather');
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => (downloadingPng = false), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
||||||
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 class="text-lg font-bold">
|
||||||
|
Meteograms <span class="font-semibold text-muted-foreground">– full range</span>
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<span class="hidden text-xs text-muted-foreground md:inline">
|
||||||
|
drag or
|
||||||
|
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
|
||||||
|
>Ctrl</kbd
|
||||||
|
>
|
||||||
|
+ scroll to zoom
|
||||||
|
</span>
|
||||||
|
{#if zoomActive}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
|
||||||
|
onclick={resetZoom}
|
||||||
|
>
|
||||||
|
Reset zoom
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={liveCharts.length === 0 || downloadingPng}
|
||||||
|
onclick={downloadPng}
|
||||||
|
title="Download meteogram as PNG image"
|
||||||
|
>
|
||||||
|
{#if downloadingPng}Rendering…{:else}PNG{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if renderPanels.length === 0}
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No meteograms configured. Add variables from the 7-day forecast page.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||||
|
{#each renderPanels as panel, i (panel.id)}
|
||||||
|
<div
|
||||||
|
class="px-0 pt-0.5 pb-0 lg:px-4 lg:pt-2 lg:pb-1 {i > 0
|
||||||
|
? 'border-t border-border/50'
|
||||||
|
: 'lg:pt-4'} {i === renderPanels.length - 1 ? 'pb-1 lg:pb-4' : ''}"
|
||||||
|
>
|
||||||
|
<div class="mb-0 flex items-center justify-between px-3 lg:mb-0.5 lg:px-0">
|
||||||
|
<h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase">
|
||||||
|
<span class="hidden lg:inline">{panel.title}</span>
|
||||||
|
<span class="lg:hidden">{panel.titleShort}</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<ChartContainer
|
||||||
|
{loading}
|
||||||
|
chartCount={1}
|
||||||
|
chartHeight={CHART_HEIGHT}
|
||||||
|
minWidth={520}
|
||||||
|
bleed={false}
|
||||||
|
>
|
||||||
|
<CanvasChart
|
||||||
|
bind:this={chartComponents[i]}
|
||||||
|
timestamps={timestampsSec}
|
||||||
|
timezone={data.timezone}
|
||||||
|
series={panel.def.series}
|
||||||
|
bands={data.daylightBands}
|
||||||
|
pictograms={panel.def.hasPictograms ? pictograms : []}
|
||||||
|
windArrows={panel.def.hasWindArrows ? windArrowMarks : []}
|
||||||
|
reserveRightAxis={anyRightAxis}
|
||||||
|
reserveTopRows={maxTopRows}
|
||||||
|
highlight={selectedDayHighlight}
|
||||||
|
unit={panel.def.unit}
|
||||||
|
unitRight={panel.def.unitRight}
|
||||||
|
yMin={panel.def.yMin}
|
||||||
|
zeroBaseLeft={panel.def.zeroBaseLeft}
|
||||||
|
yMinRight={panel.def.yMinRight}
|
||||||
|
yMaxRight={panel.def.yMaxRight}
|
||||||
|
showCredit={i === renderPanels.length - 1}
|
||||||
|
showLegend
|
||||||
|
height={CHART_HEIGHT}
|
||||||
|
group={CHART_GROUP}
|
||||||
|
/>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
Reference in New Issue
Block a user