move settings to one icon mobile
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
{ href: resolve('/weather/week'), label: '7-Day Forecast' },
|
||||
{ href: resolve('/weather/compare'), label: 'Model Comparison' },
|
||||
{ href: resolve('/weather/14-day'), label: '14-Day Forecast' },
|
||||
{ href: resolve('/weather/seasonal'), label: 'Seasonal Outlook' },
|
||||
{ href: resolve('/weather/historical'), label: 'Historical Weather' },
|
||||
{ href: resolve('/weather/maps'), label: 'Weather Maps' }
|
||||
];
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
import LocationSearch from '$lib/components/location/location-search.svelte';
|
||||
import UnitSelector from '$lib/components/unit-selector.svelte';
|
||||
|
||||
import PremiumBadge from '$lib/paywall/PremiumBadge.svelte';
|
||||
import SupporterBadge from '$lib/paywall/SupporterBadge.svelte';
|
||||
|
||||
import SettingsMenu from './settings-menu.svelte';
|
||||
import ThemeIcon from './theme-icon.svelte';
|
||||
|
||||
interface Props {
|
||||
onMenuToggle?: () => void;
|
||||
@@ -60,6 +63,8 @@
|
||||
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
|
||||
} else if (currentPath.startsWith('/weather/historical')) {
|
||||
goto(resolve('/weather/historical/[location]', { location: locationRoute }));
|
||||
} else if (currentPath.startsWith('/weather/seasonal')) {
|
||||
goto(resolve('/weather/seasonal/[location]', { location: locationRoute }));
|
||||
} else {
|
||||
goto(resolve('/weather/week/[location]', { location: locationRoute }));
|
||||
}
|
||||
@@ -117,63 +122,31 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Measurement units -->
|
||||
<UnitSelector />
|
||||
<!-- Phones only have room for one control, so units, theme and supporter
|
||||
status collapse into a single settings menu below md. -->
|
||||
<div class="md:hidden">
|
||||
<SettingsMenu />
|
||||
</div>
|
||||
|
||||
<!-- Premium status / unlock -->
|
||||
<PremiumBadge />
|
||||
<!-- md+: the same settings as individual controls. Kept mounted (not `{#if}`)
|
||||
so SupporterBadge still verifies the key on every viewport. -->
|
||||
<div class="hidden items-center gap-3 md:flex">
|
||||
<!-- Measurement units -->
|
||||
<UnitSelector />
|
||||
|
||||
<!-- 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"
|
||||
onclick={cycleTheme}
|
||||
title={themeTitles[$storedTheme]}
|
||||
aria-label={themeTitles[$storedTheme]}
|
||||
>
|
||||
{#if $storedTheme === 'light'}
|
||||
<!-- sun -->
|
||||
<svg
|
||||
class="h-4.5 w-4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32 1.41-1.41"
|
||||
/>
|
||||
</svg>
|
||||
{:else if $storedTheme === 'dark'}
|
||||
<!-- moon -->
|
||||
<svg
|
||||
class="h-4.5 w-4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- monitor (system) -->
|
||||
<svg
|
||||
class="h-4.5 w-4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="13" rx="2" />
|
||||
<path stroke-linecap="round" d="M8 21h8m-4-4v4" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Supporter status / unlock -->
|
||||
<SupporterBadge />
|
||||
|
||||
<!-- 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"
|
||||
onclick={cycleTheme}
|
||||
title={themeTitles[$storedTheme]}
|
||||
aria-label={themeTitles[$storedTheme]}
|
||||
>
|
||||
<ThemeIcon theme={$storedTheme} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import { type Theme, storedTheme } from '$lib/stores/settings';
|
||||
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import UnitOptions from '$lib/components/unit-options.svelte';
|
||||
|
||||
import UnlockDialog from '$lib/paywall/UnlockDialog.svelte';
|
||||
import { isSupporter } from '$lib/paywall/supporter';
|
||||
|
||||
import ThemeIcon from './theme-icon.svelte';
|
||||
|
||||
// The topbar has room for one control on a phone, so units, theme and the
|
||||
// supporter status share this menu instead of each carrying its own trigger.
|
||||
const THEMES: { value: Theme; label: string }[] = [
|
||||
{ value: 'system', label: 'System' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' }
|
||||
];
|
||||
|
||||
let open = $state(false);
|
||||
let unlockOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
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 data-[state=open]:bg-muted data-[state=open]:text-foreground"
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
>
|
||||
<!-- gear -->
|
||||
<svg
|
||||
class="h-4.5 w-4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.9 1.2v.2a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0-1.2-2.9H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 2.9 1.2l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"
|
||||
/>
|
||||
</svg>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content align="end" class="w-72 border-border">
|
||||
<div class="flex flex-col gap-4">
|
||||
<UnitOptions />
|
||||
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
Theme
|
||||
</span>
|
||||
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
|
||||
{#each THEMES as option (option.value)}
|
||||
{@const active = $storedTheme === option.value}
|
||||
<button
|
||||
class="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
aria-pressed={active}
|
||||
onclick={() => storedTheme.set(option.value)}
|
||||
>
|
||||
<ThemeIcon theme={option.value} class="h-4 w-4" />
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border/70 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
|
||||
onclick={() => {
|
||||
open = false;
|
||||
unlockOpen = true;
|
||||
}}
|
||||
>
|
||||
{#if $isSupporter}
|
||||
<!-- star -->
|
||||
<svg
|
||||
class="h-4.5 w-4.5 shrink-0 text-amber-500"
|
||||
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>
|
||||
{:else}
|
||||
<!-- padlock -->
|
||||
<svg
|
||||
class="h-4.5 w-4.5 shrink-0 text-muted-foreground"
|
||||
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>
|
||||
{/if}
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-[13px] font-semibold">
|
||||
{$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'}
|
||||
</span>
|
||||
<span class="block text-[11px] text-muted-foreground">
|
||||
{$isSupporter ? 'Manage your access key' : 'Unlock the supporter extras'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<UnlockDialog bind:open={unlockOpen} />
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { Theme } from '$lib/stores/settings';
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { theme, class: className = 'h-4.5 w-4.5' }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if theme === 'light'}
|
||||
<!-- sun -->
|
||||
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32 1.41-1.41"
|
||||
/>
|
||||
</svg>
|
||||
{:else if theme === 'dark'}
|
||||
<!-- moon -->
|
||||
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- monitor (system) -->
|
||||
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
||||
<rect x="3" y="4" width="18" height="13" rx="2" />
|
||||
<path stroke-linecap="round" d="M8 21h8m-4-4v4" />
|
||||
</svg>
|
||||
{/if}
|
||||
@@ -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: 'Seasonal',
|
||||
url: '/weather/seasonal' as const,
|
||||
// rising trend line (long-range outlook)
|
||||
iconPaths: ['M3 17l6-6 4 4 7-7', 'M16 8h5v5']
|
||||
},
|
||||
{
|
||||
title: 'Historical',
|
||||
url: '/weather/historical' as const,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
|
||||
|
||||
// each group maps a stored unit key to its selectable options
|
||||
const UNIT_GROUPS: {
|
||||
key: keyof UnitPrefs;
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
}[] = [
|
||||
{
|
||||
key: 'temperature_unit',
|
||||
label: 'Temperature',
|
||||
options: [
|
||||
{ value: 'celsius', label: '°C' },
|
||||
{ value: 'fahrenheit', label: '°F' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'wind_speed_unit',
|
||||
label: 'Wind speed',
|
||||
options: [
|
||||
{ value: 'kmh', label: 'km/h' },
|
||||
{ value: 'ms', label: 'm/s' },
|
||||
{ value: 'mph', label: 'mph' },
|
||||
{ value: 'kn', label: 'kn' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'precipitation_unit',
|
||||
label: 'Precipitation',
|
||||
options: [
|
||||
{ value: 'mm', label: 'mm' },
|
||||
{ value: 'inch', label: 'inch' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function setUnit(key: keyof UnitPrefs, value: string) {
|
||||
storedUnits.update((u) => ({ ...u, [key]: value }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each UNIT_GROUPS as group (group.key)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
|
||||
{#each group.options as opt (opt.value)}
|
||||
{@const active = $storedUnits[group.key] === opt.value}
|
||||
<button
|
||||
class="flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
aria-pressed={active}
|
||||
onclick={() => setUnit(group.key, opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,45 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
|
||||
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
|
||||
// each group maps a stored unit key to its selectable options
|
||||
const UNIT_GROUPS: {
|
||||
key: keyof UnitPrefs;
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
}[] = [
|
||||
{
|
||||
key: 'temperature_unit',
|
||||
label: 'Temperature',
|
||||
options: [
|
||||
{ value: 'celsius', label: '°C' },
|
||||
{ value: 'fahrenheit', label: '°F' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'wind_speed_unit',
|
||||
label: 'Wind speed',
|
||||
options: [
|
||||
{ value: 'kmh', label: 'km/h' },
|
||||
{ value: 'ms', label: 'm/s' },
|
||||
{ value: 'mph', label: 'mph' },
|
||||
{ value: 'kn', label: 'kn' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'precipitation_unit',
|
||||
label: 'Precipitation',
|
||||
options: [
|
||||
{ value: 'mm', label: 'mm' },
|
||||
{ value: 'inch', label: 'inch' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function setUnit(key: keyof UnitPrefs, value: string) {
|
||||
storedUnits.update((u) => ({ ...u, [key]: value }));
|
||||
}
|
||||
import UnitOptions from '$lib/components/unit-options.svelte';
|
||||
</script>
|
||||
|
||||
<Popover.Root>
|
||||
@@ -65,30 +26,6 @@
|
||||
</svg>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="end" class="w-64 border-border">
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each UNIT_GROUPS as group (group.key)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
|
||||
{#each group.options as opt (opt.value)}
|
||||
{@const active = $storedUnits[group.key] === opt.value}
|
||||
<button
|
||||
class="flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
aria-pressed={active}
|
||||
onclick={() => setUnit(group.key, opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<UnitOptions />
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import UnlockDialog from './UnlockDialog.svelte';
|
||||
import { PREMIUM_PERKS, SIGNUP_URL, getPremiumPrice } from './config';
|
||||
import { isPremium, premiumState, refreshPremium } from './premium';
|
||||
import { SUPPORTER_PERKS, SIGNUP_URL, getSupporterPrice } from './config';
|
||||
import { isSupporter, supporterState, refreshSupporter } from './supporter';
|
||||
|
||||
interface Props {
|
||||
/** Short feature name shown in the locked panel headline. */
|
||||
@@ -14,16 +14,16 @@
|
||||
let { feature = 'This page', children }: Props = $props();
|
||||
|
||||
let unlockOpen = $state(false);
|
||||
const price = getPremiumPrice();
|
||||
const price = getSupporterPrice();
|
||||
|
||||
// Re-verify the stored key whenever the gate mounts.
|
||||
onMount(refreshPremium);
|
||||
onMount(refreshSupporter);
|
||||
|
||||
// Show a brief spinner only when we have no cached answer yet and are checking.
|
||||
let initialChecking = $derived($premiumState.status === 'checking' && !$isPremium);
|
||||
let initialChecking = $derived($supporterState.status === 'checking' && !$isSupporter);
|
||||
</script>
|
||||
|
||||
{#if $isPremium}
|
||||
{#if $isSupporter}
|
||||
{@render children()}
|
||||
{:else if initialChecking}
|
||||
<div class="flex items-center justify-center py-24 text-sm text-muted-foreground">
|
||||
@@ -59,7 +59,7 @@
|
||||
</p>
|
||||
|
||||
<ul class="mx-auto mt-5 grid max-w-sm gap-2 text-left">
|
||||
{#each PREMIUM_PERKS as perk (perk)}
|
||||
{#each SUPPORTER_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"
|
||||
@@ -93,7 +93,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $premiumState.status === 'invalid'}
|
||||
{#if $supporterState.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>
|
||||
|
||||
@@ -2,31 +2,31 @@
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import UnlockDialog from './UnlockDialog.svelte';
|
||||
import { isPremium, refreshPremium } from './premium';
|
||||
import { isSupporter, refreshSupporter } from './supporter';
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
// Verify once on load so the badge reflects real status site-wide.
|
||||
onMount(refreshPremium);
|
||||
onMount(refreshSupporter);
|
||||
</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
|
||||
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs font-semibold transition-colors {$isSupporter
|
||||
? '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 ? 'Supporter extras active' : 'Support Drizz.li'}
|
||||
aria-label={$isPremium ? 'Supporter extras active' : 'Support Drizz.li'}
|
||||
title={$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'}
|
||||
aria-label={$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'}
|
||||
>
|
||||
{#if $isPremium}
|
||||
{#if $isSupporter}
|
||||
<!-- 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>
|
||||
<span class="hidden sm:inline">Supporter</span>
|
||||
{:else}
|
||||
<!-- padlock -->
|
||||
<svg
|
||||
@@ -40,7 +40,7 @@
|
||||
<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>
|
||||
<span class="hidden sm:inline">Supporter</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
import { SIGNUP_URL, getPremiumPrice } from './config';
|
||||
import { clearLicense, isPremium, premiumState, storedLicenseKey, verifyKey } from './premium';
|
||||
import { SIGNUP_URL, getSupporterPrice } from './config';
|
||||
import { clearLicense, isSupporter, supporterState, storedLicenseKey, verifyKey } from './supporter';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
let { open = $bindable(false) }: Props = $props();
|
||||
|
||||
const price = getPremiumPrice();
|
||||
const price = getSupporterPrice();
|
||||
|
||||
// Prefill with the stored key so an existing subscriber sees their key.
|
||||
let keyInput = $state('');
|
||||
@@ -50,8 +50,8 @@
|
||||
}
|
||||
|
||||
let expiresLabel = $derived.by(() => {
|
||||
const exp = $premiumState.expires;
|
||||
if ($premiumState.status !== 'valid') return null;
|
||||
const exp = $supporterState.expires;
|
||||
if ($supporterState.status !== 'valid') return null;
|
||||
if (!exp) return 'Lifetime access';
|
||||
return `Active until ${formatZoned(new Date(exp), 'UTC', 'd LLL yyyy')}`;
|
||||
});
|
||||
@@ -66,7 +66,7 @@
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if $isPremium && $premiumState.status === 'valid'}
|
||||
{#if $isSupporter && $supporterState.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"
|
||||
>
|
||||
@@ -81,7 +81,7 @@
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold">Premium is active</p>
|
||||
<p class="font-semibold">Supporter extras are active</p>
|
||||
{#if expiresLabel}<p class="text-xs opacity-80">{expiresLabel}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,7 +102,7 @@
|
||||
|
||||
{#if localError}
|
||||
<p class="text-sm text-destructive">{localError}</p>
|
||||
{:else if $premiumState.status === 'error'}
|
||||
{:else if $supporterState.status === 'error'}
|
||||
<p class="text-sm text-destructive">
|
||||
Couldn't reach the server. Check your connection and try again.
|
||||
</p>
|
||||
@@ -114,7 +114,7 @@
|
||||
</form>
|
||||
|
||||
<Dialog.Footer class="flex-col items-stretch gap-2 sm:flex-col sm:items-stretch">
|
||||
{#if $isPremium}
|
||||
{#if $isSupporter}
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer text-center text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
|
||||
@@ -28,9 +28,9 @@ const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CH
|
||||
const US_ZONES =
|
||||
/^America\/(New_York|Detroit|Chicago|Denver|Boise|Phoenix|Los_Angeles|Anchorage|Adak|Juneau|Sitka|Nome|Yakutat|Menominee|Indiana|Kentucky|North_Dakota)/;
|
||||
|
||||
export type PremiumCurrency = 'EUR' | 'USD' | 'CHF';
|
||||
export type SupporterCurrency = 'EUR' | 'USD' | 'CHF';
|
||||
|
||||
export function detectCurrency(): PremiumCurrency {
|
||||
export function detectCurrency(): SupporterCurrency {
|
||||
if (typeof Intl === 'undefined') return 'EUR';
|
||||
let tz = '';
|
||||
try {
|
||||
@@ -53,15 +53,19 @@ export function detectCurrency(): PremiumCurrency {
|
||||
}
|
||||
|
||||
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / month". */
|
||||
export function getPremiumPrice(): string {
|
||||
if (env.VITE_PREMIUM_PRICE) return env.VITE_PREMIUM_PRICE;
|
||||
export function getSupporterPrice(): string {
|
||||
// VITE_PREMIUM_PRICE is the pre-rename name, still honoured so an existing
|
||||
// deployment's .env keeps working.
|
||||
const configured = env.VITE_SUPPORTER_PRICE ?? env.VITE_PREMIUM_PRICE;
|
||||
if (configured) return configured;
|
||||
const currency = detectCurrency();
|
||||
const symbol = CURRENCY_SYMBOL[currency];
|
||||
return currency === 'CHF' ? `${symbol} 3 / month` : `${symbol}3 / month`;
|
||||
}
|
||||
|
||||
/** Short, human list of what supporting unlocks (shown on the locked panel). */
|
||||
export const PREMIUM_PERKS = [
|
||||
export const SUPPORTER_PERKS = [
|
||||
'Historical weather & climate-normal comparisons',
|
||||
'Seasonal outlook: months ahead vs the climate normal',
|
||||
'New supporter extras as they land'
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Premium (subscription) state.
|
||||
* Supporter (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
|
||||
@@ -16,10 +16,10 @@ 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. */
|
||||
/** The supporter's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
|
||||
export const storedLicenseKey = persisted<string>('license_key', '');
|
||||
|
||||
export interface PremiumCache {
|
||||
export interface SupporterCache {
|
||||
valid: boolean;
|
||||
tier?: string;
|
||||
/** ISO date the subscription lapses, or null for a lifetime key. */
|
||||
@@ -28,20 +28,40 @@ export interface PremiumCache {
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
const CACHE_KEY = 'supporter_cache_v1';
|
||||
const LEGACY_CACHE_KEY = 'premium_cache_v1';
|
||||
|
||||
/**
|
||||
* Carries the pre-rename cache over on first load, so supporters who already
|
||||
* verified aren't shown a locked page while the key re-verifies.
|
||||
*/
|
||||
function readLegacyCache(): SupporterCache | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
try {
|
||||
if (localStorage.getItem(CACHE_KEY)) return null;
|
||||
const legacy = localStorage.getItem(LEGACY_CACHE_KEY);
|
||||
if (!legacy) return null;
|
||||
localStorage.removeItem(LEGACY_CACHE_KEY);
|
||||
return JSON.parse(legacy) as SupporterCache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Last verify result, persisted so the UI doesn't flash "locked" on reload. */
|
||||
export const storedPremiumCache = persisted<PremiumCache | null>('premium_cache_v1', null);
|
||||
export const storedSupporterCache = persisted<SupporterCache | null>(CACHE_KEY, readLegacyCache());
|
||||
|
||||
export type PremiumStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error';
|
||||
export type SupporterStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error';
|
||||
|
||||
export interface PremiumState {
|
||||
status: PremiumStatus;
|
||||
export interface SupporterState {
|
||||
status: SupporterStatus;
|
||||
tier?: string;
|
||||
expires?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Live verification state for the current session. */
|
||||
export const premiumState = writable<PremiumState>({ status: 'idle' });
|
||||
export const supporterState = writable<SupporterState>({ status: 'idle' });
|
||||
|
||||
function notExpired(expires: string | null | undefined): boolean {
|
||||
if (!expires) return true; // lifetime key
|
||||
@@ -50,12 +70,12 @@ function notExpired(expires: string | null | undefined): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Whether supporter 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],
|
||||
export const isSupporter = derived(
|
||||
[supporterState, storedSupporterCache],
|
||||
([$state, $cache]): boolean => {
|
||||
if ($state.status === 'valid') return true;
|
||||
if ($state.status === 'invalid') return false;
|
||||
@@ -78,11 +98,11 @@ export interface VerifyResult {
|
||||
export async function verifyKey(key: string): Promise<VerifyResult> {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) {
|
||||
premiumState.set({ status: 'invalid' });
|
||||
supporterState.set({ status: 'invalid' });
|
||||
return { valid: false, error: 'Enter your access key.' };
|
||||
}
|
||||
|
||||
premiumState.set({ status: 'checking' });
|
||||
supporterState.set({ status: 'checking' });
|
||||
try {
|
||||
const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
|
||||
headers: { accept: 'application/json' }
|
||||
@@ -91,39 +111,39 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
|
||||
|
||||
if (res.ok && data.valid) {
|
||||
storedLicenseKey.set(trimmed);
|
||||
storedPremiumCache.set({
|
||||
storedSupporterCache.set({
|
||||
valid: true,
|
||||
tier: data.tier,
|
||||
expires: data.expires ?? null,
|
||||
checkedAt: Date.now()
|
||||
});
|
||||
premiumState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null });
|
||||
supporterState.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' });
|
||||
storedSupporterCache.set({ valid: false, checkedAt: Date.now() });
|
||||
supporterState.set({ status: 'invalid' });
|
||||
return { valid: false };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
premiumState.set({ status: 'error', error: message });
|
||||
supporterState.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> {
|
||||
export async function refreshSupporter(): Promise<void> {
|
||||
const key = get(storedLicenseKey);
|
||||
if (!key) {
|
||||
premiumState.set({ status: 'idle' });
|
||||
supporterState.set({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
await verifyKey(key);
|
||||
}
|
||||
|
||||
/** Forget the key and premium state ("sign out"). */
|
||||
/** Forget the key and supporter state ("sign out"). */
|
||||
export function clearLicense(): void {
|
||||
storedLicenseKey.set('');
|
||||
storedPremiumCache.set(null);
|
||||
premiumState.set({ status: 'idle' });
|
||||
storedSupporterCache.set(null);
|
||||
supporterState.set({ status: 'idle' });
|
||||
}
|
||||
@@ -23,6 +23,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';
|
||||
const SEASONAL_URL = 'https://seasonal-api.open-meteo.com/v1/seasonal';
|
||||
|
||||
// ─── Core Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1143,3 +1144,197 @@ export async function fetchClimateNormals(params: ClimateNormalsParams): Promise
|
||||
precipitation_unit: params.precipitation_unit ?? 'mm'
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Seasonal (Long-Range) Types ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One daily variable of the seasonal ensemble: every member plus the spread
|
||||
* statistics the outlook renders (percentile band, mean, extremes).
|
||||
*/
|
||||
export interface SeasonalVariableData {
|
||||
/** Raw members, `members[m][t]`. */
|
||||
members: number[][];
|
||||
mean: number[];
|
||||
min: number[];
|
||||
max: number[];
|
||||
p25: number[];
|
||||
p75: number[];
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface SeasonalForecastParams extends WeatherLocation, WeatherUnitParams {
|
||||
/** Daily API variables to request; defaults to SEASONAL_DAILY_VARS. */
|
||||
dailyVariables?: string[];
|
||||
/** Lead time in days; the API allows at most 216. */
|
||||
forecast_days?: number;
|
||||
}
|
||||
|
||||
export interface SeasonalForecastResult {
|
||||
variables: Record<string, SeasonalVariableData>;
|
||||
/** Milliseconds, one entry per day (already trimmed to the model's horizon). */
|
||||
timestamps: number[];
|
||||
/**
|
||||
* Local wall time (local midnight) expressed as a UTC instant - read these
|
||||
* with the UTC getters, never with the location's IANA zone. The seasonal API
|
||||
* keeps ONE offset for the whole series, so a half-year range that crosses a
|
||||
* DST change would otherwise land two days on the same local date.
|
||||
*/
|
||||
dailyDates: Date[];
|
||||
/** `YYYY-MM-DD` local calendar date per day, matching the API's own labels. */
|
||||
dateKeys: string[];
|
||||
memberCount: number;
|
||||
utcOffsetSeconds: number;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
/** The API caps the lead time here; the model itself usually stops earlier. */
|
||||
export const SEASONAL_MAX_DAYS = 216;
|
||||
|
||||
/** Requested in this order; the daily block returns variables positionally. */
|
||||
export const SEASONAL_DAILY_VARS = [
|
||||
'temperature_2m_max',
|
||||
'temperature_2m_min',
|
||||
'temperature_2m_mean',
|
||||
'precipitation_sum',
|
||||
'wind_speed_10m_mean',
|
||||
'cloud_cover_mean'
|
||||
] as const;
|
||||
|
||||
// ─── Seasonal (Long-Range) Fetch ────────────────────────────────────────────
|
||||
|
||||
/** Linear-interpolated percentile over an already ascending array. */
|
||||
function percentileSorted(sorted: number[], p: number): number {
|
||||
if (sorted.length === 0) return NaN;
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
const pos = (sorted.length - 1) * p;
|
||||
const lo = Math.floor(pos);
|
||||
const hi = Math.ceil(pos);
|
||||
if (lo === hi) return sorted[lo];
|
||||
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the seasonal (multi-month) ensemble outlook from Open-Meteo's
|
||||
* seasonal API. Unlike the medium-range ensemble this is daily data: each
|
||||
* requested variable comes back once per member, so the members are collapsed
|
||||
* into the spread statistics the outlook page plots.
|
||||
*
|
||||
* The requested lead time is only an upper bound - the model's own horizon is
|
||||
* shorter, and every day past it comes back empty. Those trailing days are
|
||||
* trimmed here so callers never plot a flat-lined tail.
|
||||
*/
|
||||
export async function fetchSeasonalForecast(
|
||||
params: SeasonalForecastParams
|
||||
): Promise<SeasonalForecastResult> {
|
||||
const dailyVars =
|
||||
params.dailyVariables && params.dailyVariables.length > 0
|
||||
? [...new Set(params.dailyVariables)]
|
||||
: [...SEASONAL_DAILY_VARS];
|
||||
|
||||
const apiParams: Record<string, string | number | undefined> = {
|
||||
latitude: params.latitude,
|
||||
longitude: params.longitude,
|
||||
daily: dailyVars.join(','),
|
||||
forecast_days: Math.min(params.forecast_days ?? SEASONAL_MAX_DAYS, SEASONAL_MAX_DAYS),
|
||||
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(SEASONAL_URL, cleanParams);
|
||||
const response = responses[0];
|
||||
const utcOffsetSeconds = response.utcOffsetSeconds();
|
||||
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
|
||||
|
||||
const dailyBlock = response.daily()!;
|
||||
const allTimestamps = getTimestamps(dailyBlock);
|
||||
const timeLength = allTimestamps.length;
|
||||
|
||||
// Members are laid out like the ensemble API: var0_member0 … var0_memberM-1,
|
||||
// var1_member0 …, so the count follows from the totals instead of being
|
||||
// hard-coded (it differs per seasonal model).
|
||||
const totalVariables = dailyBlock.variablesLength();
|
||||
const memberCount = dailyVars.length > 0 ? Math.floor(totalVariables / dailyVars.length) : 0;
|
||||
|
||||
const variables: Record<string, SeasonalVariableData> = {};
|
||||
|
||||
for (let vi = 0; vi < dailyVars.length; vi++) {
|
||||
const members: number[][] = [];
|
||||
let unitStr = '';
|
||||
|
||||
for (let mi = 0; mi < memberCount; mi++) {
|
||||
const variable = dailyBlock.variables(vi * memberCount + mi);
|
||||
if (!variable) continue;
|
||||
members.push(getValues(variable));
|
||||
if (mi === 0) unitStr = unitToDisplayString(variable.unit());
|
||||
}
|
||||
|
||||
const mean = new Array<number>(timeLength).fill(NaN);
|
||||
const min = new Array<number>(timeLength).fill(NaN);
|
||||
const max = new Array<number>(timeLength).fill(NaN);
|
||||
const p25 = new Array<number>(timeLength).fill(NaN);
|
||||
const p75 = new Array<number>(timeLength).fill(NaN);
|
||||
|
||||
for (let t = 0; t < timeLength; t++) {
|
||||
const values: number[] = [];
|
||||
for (const memberValues of members) {
|
||||
const val = memberValues[t];
|
||||
if (val != null && Number.isFinite(val)) values.push(val);
|
||||
}
|
||||
if (values.length === 0) continue;
|
||||
values.sort((a, b) => a - b);
|
||||
mean[t] = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
min[t] = values[0];
|
||||
max[t] = values[values.length - 1];
|
||||
p25[t] = percentileSorted(values, 0.25);
|
||||
p75[t] = percentileSorted(values, 0.75);
|
||||
}
|
||||
|
||||
variables[dailyVars[vi]] = { members, mean, min, max, p25, p75, unit: unitStr };
|
||||
}
|
||||
|
||||
// Past the model's horizon every member is empty (or padded to a constant
|
||||
// zero); cut the axis at the last day that carries real spread.
|
||||
const sentinel = variables[dailyVars[0]];
|
||||
let validLength = timeLength;
|
||||
if (sentinel) {
|
||||
let last = 0;
|
||||
for (let t = 0; t < timeLength; t++) {
|
||||
const hasSpread = !(sentinel.min[t] === 0 && sentinel.max[t] === 0);
|
||||
if (Number.isFinite(sentinel.mean[t]) && hasSpread) last = t + 1;
|
||||
}
|
||||
validLength = last || timeLength;
|
||||
}
|
||||
|
||||
if (validLength < timeLength) {
|
||||
for (const data of Object.values(variables)) {
|
||||
data.members = data.members.map((m) => m.slice(0, validLength));
|
||||
data.mean = data.mean.slice(0, validLength);
|
||||
data.min = data.min.slice(0, validLength);
|
||||
data.max = data.max.slice(0, validLength);
|
||||
data.p25 = data.p25.slice(0, validLength);
|
||||
data.p75 = data.p75.slice(0, validLength);
|
||||
}
|
||||
}
|
||||
|
||||
const timestamps = allTimestamps.slice(0, validLength);
|
||||
// Shifted by the response's single offset (not the IANA zone) so each day
|
||||
// carries the exact local date the API labelled it with.
|
||||
const dailyDates = timestamps.map((t) => new Date(t + utcOffsetSeconds * 1000));
|
||||
|
||||
return {
|
||||
variables,
|
||||
timestamps,
|
||||
dailyDates,
|
||||
dateKeys: dailyDates.map((d) => d.toISOString().slice(0, 10)),
|
||||
memberCount,
|
||||
utcOffsetSeconds,
|
||||
timezone
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { ChartContainer } from '$lib/components/charts';
|
||||
|
||||
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||
import { isPremium } from '$lib/paywall/premium';
|
||||
import { isSupporter } from '$lib/paywall/supporter';
|
||||
import {
|
||||
type ClimateNormals,
|
||||
type HistoricalForecastResult,
|
||||
@@ -97,7 +97,7 @@
|
||||
const s = startDate;
|
||||
const e = endDate;
|
||||
const vars = hourlyVars;
|
||||
if (!mounted || !$isPremium || !loc || !s || !e) return;
|
||||
if (!mounted || !$isSupporter || !loc || !s || !e) return;
|
||||
|
||||
const version = ++requestVersion;
|
||||
loading = true;
|
||||
@@ -138,7 +138,7 @@
|
||||
$effect(() => {
|
||||
const key = normalsKey;
|
||||
const loc = location;
|
||||
if (!mounted || !$isPremium || !loc) return;
|
||||
if (!mounted || !$isSupporter || !loc) return;
|
||||
|
||||
const version = ++normalsVersion;
|
||||
normals = null;
|
||||
|
||||
@@ -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/seasonal/[location]', {
|
||||
location: buildLocationRoute(get(storedLocation))
|
||||
}),
|
||||
{ replaceState: true }
|
||||
);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { storedLocation, storedUnits } from '$lib/stores/settings';
|
||||
|
||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
|
||||
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||
import { isSupporter } from '$lib/paywall/supporter';
|
||||
import {
|
||||
type ClimateNormals,
|
||||
type SeasonalForecastResult,
|
||||
fetchClimateNormals,
|
||||
fetchSeasonalForecast
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { defaultParameters } from '../../options';
|
||||
import { getPrecipUnit } from '../../week/[location]/types';
|
||||
import SeasonalCharts from './SeasonalCharts.svelte';
|
||||
import SeasonalMonths from './SeasonalMonths.svelte';
|
||||
import { buildMonthOutlooks, sliceSeasonal } from './outlook';
|
||||
|
||||
import type { CanvasChart } from '$lib/charts';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// the URL is the source of truth: location comes from the load function,
|
||||
// which is also correct on hydrated prerendered pages. The persisted store
|
||||
// only mirrors it so the header and bare /weather/* redirects follow along.
|
||||
let location = $derived(data.location);
|
||||
$effect(() => {
|
||||
storedLocation.set(data.location);
|
||||
});
|
||||
|
||||
let params = $state({ ...defaultParameters });
|
||||
$effect(() => {
|
||||
params.temperature_unit = $storedUnits.temperature_unit;
|
||||
params.wind_speed_unit = $storedUnits.wind_speed_unit;
|
||||
params.precipitation_unit = $storedUnits.precipitation_unit;
|
||||
});
|
||||
|
||||
// Only the variables the outlook actually renders: every extra one costs a
|
||||
// full member set (50+ series) over half a year of days.
|
||||
const SEASONAL_VARS = [
|
||||
'temperature_2m_max',
|
||||
'temperature_2m_min',
|
||||
'temperature_2m_mean',
|
||||
'precipitation_sum'
|
||||
];
|
||||
|
||||
// ─── Display state (no refetch) ─────────────────────────────────────────────
|
||||
const RANGES = [
|
||||
{ label: '3 months', days: 92 },
|
||||
{ label: '6 months', days: 183 },
|
||||
{ label: 'Full range', days: Infinity }
|
||||
];
|
||||
let rangeIndex = $state(1);
|
||||
let showLegend = $state(true);
|
||||
let chartComponents: CanvasChart[] = $state([]);
|
||||
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
||||
|
||||
// ─── Fetch state ────────────────────────────────────────────────────────────
|
||||
let mounted = $state(false);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let requestVersion = 0;
|
||||
|
||||
let result = $state<SeasonalForecastResult | null>(null);
|
||||
let normals = $state<ClimateNormals | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
// The full horizon is fetched once per location/units; the range buttons only
|
||||
// reslice it.
|
||||
$effect(() => {
|
||||
const loc = location;
|
||||
const tempUnit = params.temperature_unit;
|
||||
const precipUnit = params.precipitation_unit;
|
||||
if (!mounted || !$isSupporter || !loc) return;
|
||||
|
||||
const version = ++requestVersion;
|
||||
loading = true;
|
||||
loadError = null;
|
||||
|
||||
fetchSeasonalForecast({
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
dailyVariables: SEASONAL_VARS,
|
||||
temperature_unit: tempUnit as 'celsius' | 'fahrenheit',
|
||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||
precipitation_unit: precipUnit as 'mm' | 'inch',
|
||||
timezone: loc.timezone
|
||||
})
|
||||
.then((r) => {
|
||||
if (version !== requestVersion) return;
|
||||
result = r;
|
||||
loading = false;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (version !== requestVersion) return;
|
||||
loadError = err instanceof Error ? err.message : String(err);
|
||||
loading = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Normals are the whole point of a seasonal outlook (everything is shown as a
|
||||
// departure from them), but a failure only drops the comparison.
|
||||
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 || !$isSupporter || !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(() => {
|
||||
if (version === normalsVersion) normals = null;
|
||||
});
|
||||
void key;
|
||||
});
|
||||
|
||||
// ─── Derived view ───────────────────────────────────────────────────────────
|
||||
let visible = $derived(result ? sliceSeasonal(result, RANGES[rangeIndex].days) : null);
|
||||
|
||||
// 1 mm and 0.04 in are the same "measurable rain" threshold in either unit.
|
||||
let wetDayThreshold = $derived(getPrecipUnit(params) === 'in' ? 0.04 : 1);
|
||||
|
||||
let months = $derived(visible ? buildMonthOutlooks(visible, normals, { wetDayThreshold }) : []);
|
||||
|
||||
let horizonDays = $derived(result?.timestamps.length ?? 0);
|
||||
let lastDay = $derived(
|
||||
visible && visible.dailyDates.length > 0
|
||||
? visible.dailyDates[visible.dailyDates.length - 1]
|
||||
: null
|
||||
);
|
||||
// dailyDates are local wall time held as UTC instants (see the service), so
|
||||
// the label has to be formatted in UTC to read back the local date.
|
||||
let lastDayLabel = $derived(
|
||||
lastDay
|
||||
? lastDay.toLocaleDateString(undefined, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
})
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Drizz.li | Seasonal forecast</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Multi-month seasonal outlook: monthly temperature and precipitation trends against the 1991-2020 climate normal"
|
||||
/>
|
||||
</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
|
||||
>Seasonal outlook
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if result}
|
||||
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
|
||||
<div
|
||||
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
|
||||
role="group"
|
||||
aria-label="Outlook range"
|
||||
>
|
||||
{#each RANGES as range, i (range.label)}
|
||||
{@const disabled = range.days !== Infinity && range.days > horizonDays}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
|
||||
i
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'} {disabled
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: ''}"
|
||||
aria-pressed={rangeIndex === i}
|
||||
{disabled}
|
||||
onclick={() => (rangeIndex = i)}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- What a seasonal forecast is (and is not): without this the daily-looking
|
||||
charts invite over-reading. -->
|
||||
<div
|
||||
class="mb-4 flex items-start gap-2.5 rounded-md border border-border bg-muted/40 px-3.5 py-2.5 text-sm"
|
||||
>
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 16v-4m0-4h.01" />
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
<p class="text-muted-foreground">
|
||||
A seasonal forecast shows how a whole month is likely to
|
||||
<strong class="font-semibold text-foreground">depart from its climate normal</strong> - not the
|
||||
weather on any given day. Read the monthly trend and the ensemble agreement, not the daily
|
||||
wiggles.
|
||||
{#if lastDayLabel}
|
||||
This outlook runs to <strong class="font-semibold text-foreground">{lastDayLabel}</strong>.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PaywallGate feature="The seasonal outlook">
|
||||
{#if loadError}
|
||||
<div
|
||||
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
Failed to load the seasonal outlook: {loadError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if visible && months.length > 0}
|
||||
<SeasonalMonths {months} units={params} {normals} />
|
||||
|
||||
<div class="mt-6">
|
||||
<SeasonalCharts
|
||||
result={visible}
|
||||
{normals}
|
||||
units={params}
|
||||
{loading}
|
||||
{showLegend}
|
||||
bind:charts={chartComponents}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={liveCharts} fileName="seasonal-outlook">
|
||||
{#snippet controls()}
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||
<Label for="show_legend" class="cursor-pointer text-base leading-none"
|
||||
>Show legend</Label
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
</div>
|
||||
{:else if !loadError}
|
||||
<div transition:fade={{ duration: 200 }} class="grid gap-3">
|
||||
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each [0, 1, 2, 3, 4, 5] as i (i)}
|
||||
<div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
|
||||
{/each}
|
||||
</div>
|
||||
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
|
||||
</div>
|
||||
{/if}
|
||||
</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/seasonal/',
|
||||
event
|
||||
});
|
||||
|
||||
return { location };
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
<script lang="ts">
|
||||
import { ChartContainer } from '$lib/components/charts';
|
||||
|
||||
import { CHART_COLORS, CanvasChart, type ChartSeries } from '$lib/charts';
|
||||
import {
|
||||
type ClimateNormals,
|
||||
type SeasonalForecastResult,
|
||||
monthDayToOrdinal
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
|
||||
import { rollingMean, rollingSum } from './outlook';
|
||||
|
||||
interface Props {
|
||||
result: SeasonalForecastResult;
|
||||
normals: ClimateNormals | null;
|
||||
units: WeatherUnits;
|
||||
loading?: boolean;
|
||||
showLegend?: boolean;
|
||||
/** Smoothing window in days, shared by both charts. */
|
||||
smoothing?: number;
|
||||
charts?: CanvasChart[];
|
||||
}
|
||||
|
||||
let {
|
||||
result,
|
||||
normals,
|
||||
units,
|
||||
loading = false,
|
||||
showLegend = true,
|
||||
smoothing = 7,
|
||||
charts = $bindable([])
|
||||
}: Props = $props();
|
||||
|
||||
const CHART_GROUP = 'seasonal-outlook';
|
||||
const SPREAD_COLOR = 'rgba(115, 192, 222, 0.75)';
|
||||
const BAND_COLOR = 'rgba(56, 132, 189, 0.9)';
|
||||
const NORMAL_COLOR = '#9ca3af';
|
||||
|
||||
const tempUnit = $derived(getTempUnit(units));
|
||||
const precipUnit = $derived(getPrecipUnit(units));
|
||||
|
||||
// CanvasChart works in epoch seconds. The axis is fed local wall time (and
|
||||
// labelled in UTC) because the seasonal series carries a single fixed offset:
|
||||
// resolving it against the IANA zone would slide every date past a DST change.
|
||||
let timestamps = $derived(result.dailyDates.map((d) => d.getTime() / 1000));
|
||||
|
||||
// Climate normal for each forecast day, so it can be drawn as a reference
|
||||
// line on the same axis as the ensemble.
|
||||
let ordinals = $derived(
|
||||
result.dateKeys.map((key) =>
|
||||
monthDayToOrdinal(Number(key.slice(5, 7)), Number(key.slice(8, 10)))
|
||||
)
|
||||
);
|
||||
let normalTemp = $derived(normals ? ordinals.map((o) => normals.tmean[o]) : null);
|
||||
let normalPrecip = $derived(normals ? ordinals.map((o) => normals.precip[o]) : null);
|
||||
|
||||
const fmt =
|
||||
(unit: string, digits = 1) =>
|
||||
(v: number) =>
|
||||
`${v.toFixed(digits)} ${unit}`;
|
||||
|
||||
let tempSeries = $derived.by((): ChartSeries[] => {
|
||||
const v = result.variables['temperature_2m_mean'];
|
||||
if (!v) return [];
|
||||
const smooth = (xs: number[]) => rollingMean(xs, smoothing);
|
||||
|
||||
const series: ChartSeries[] = [
|
||||
{
|
||||
name: 'Full spread',
|
||||
type: 'line',
|
||||
color: SPREAD_COLOR,
|
||||
data: smooth(v.max),
|
||||
width: 0,
|
||||
fill: true,
|
||||
fillOpacity: 0.16,
|
||||
bandTo: smooth(v.min),
|
||||
shortName: 'spread',
|
||||
format: fmt(tempUnit)
|
||||
},
|
||||
{
|
||||
name: 'Likely range (25-75%)',
|
||||
type: 'line',
|
||||
color: BAND_COLOR,
|
||||
data: smooth(v.p75),
|
||||
width: 0,
|
||||
fill: true,
|
||||
fillOpacity: 0.3,
|
||||
bandTo: smooth(v.p25),
|
||||
shortName: 'likely',
|
||||
format: fmt(tempUnit)
|
||||
},
|
||||
{
|
||||
name: 'Ensemble mean',
|
||||
type: 'line',
|
||||
color: CHART_COLORS.average,
|
||||
data: smooth(v.mean),
|
||||
width: 3,
|
||||
outline: true,
|
||||
format: fmt(tempUnit)
|
||||
}
|
||||
];
|
||||
|
||||
if (normalTemp) {
|
||||
series.push({
|
||||
name: 'Climate normal',
|
||||
type: 'line',
|
||||
color: NORMAL_COLOR,
|
||||
data: smooth(normalTemp),
|
||||
width: 2,
|
||||
dashed: true,
|
||||
shortName: 'normal',
|
||||
format: fmt(tempUnit)
|
||||
});
|
||||
}
|
||||
|
||||
return series;
|
||||
});
|
||||
|
||||
let precipSeries = $derived.by((): ChartSeries[] => {
|
||||
const v = result.variables['precipitation_sum'];
|
||||
if (!v) return [];
|
||||
const total = (xs: number[]) => rollingSum(xs, smoothing);
|
||||
|
||||
const series: ChartSeries[] = [
|
||||
{
|
||||
name: 'Likely range (25-75%)',
|
||||
type: 'line',
|
||||
color: BAND_COLOR,
|
||||
data: total(v.p75),
|
||||
width: 0,
|
||||
fill: true,
|
||||
fillOpacity: 0.28,
|
||||
bandTo: total(v.p25),
|
||||
shortName: 'likely',
|
||||
format: fmt(precipUnit)
|
||||
},
|
||||
{
|
||||
name: 'Ensemble mean',
|
||||
type: 'line',
|
||||
color: CHART_COLORS.average,
|
||||
data: total(v.mean),
|
||||
width: 3,
|
||||
outline: true,
|
||||
format: fmt(precipUnit)
|
||||
}
|
||||
];
|
||||
|
||||
if (normalPrecip) {
|
||||
series.push({
|
||||
name: 'Climate normal',
|
||||
type: 'line',
|
||||
color: NORMAL_COLOR,
|
||||
data: total(normalPrecip),
|
||||
width: 2,
|
||||
dashed: true,
|
||||
shortName: 'normal',
|
||||
format: fmt(precipUnit)
|
||||
});
|
||||
}
|
||||
|
||||
return series;
|
||||
});
|
||||
|
||||
let chartDefs = $derived(
|
||||
[
|
||||
{
|
||||
title: 'Temperature outlook',
|
||||
subtitle: `${smoothing}-day smoothed daily mean · ${result.memberCount} ensemble members`,
|
||||
unit: tempUnit,
|
||||
series: tempSeries,
|
||||
zeroBaseLeft: false,
|
||||
showCredit: false
|
||||
},
|
||||
{
|
||||
title: 'Precipitation outlook',
|
||||
subtitle: `${smoothing}-day rolling total (${precipUnit})`,
|
||||
unit: precipUnit,
|
||||
series: precipSeries,
|
||||
zeroBaseLeft: true,
|
||||
showCredit: true
|
||||
}
|
||||
].filter((def) => def.series.length > 0)
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- full-bleed graphs until lg / contained card on lg+ (matches the 14-day page) -->
|
||||
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||
{#each chartDefs as def, i (def.title)}
|
||||
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
||||
<div class="mb-1 px-3 lg:px-0">
|
||||
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
|
||||
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
||||
</div>
|
||||
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
|
||||
<CanvasChart
|
||||
bind:this={charts[i]}
|
||||
{timestamps}
|
||||
timezone="UTC"
|
||||
series={def.series}
|
||||
unit={def.unit}
|
||||
zeroBaseLeft={def.zeroBaseLeft}
|
||||
showCredit={def.showCredit}
|
||||
{showLegend}
|
||||
height={300}
|
||||
group={CHART_GROUP}
|
||||
/>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { getColor } from '../../utils/colors';
|
||||
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
|
||||
|
||||
import type { ClimateNormals } from '$lib/services/weather';
|
||||
import type { MonthOutlook } from './outlook';
|
||||
|
||||
interface Props {
|
||||
months: MonthOutlook[];
|
||||
units: WeatherUnits;
|
||||
normals: ClimateNormals | null;
|
||||
}
|
||||
|
||||
let { months, units, normals }: Props = $props();
|
||||
|
||||
const tempUnit = $derived(getTempUnit(units));
|
||||
const precipUnit = $derived(getPrecipUnit(units));
|
||||
|
||||
const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v);
|
||||
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)}`;
|
||||
|
||||
// Members agreeing on the sign of the anomaly is the honest confidence signal
|
||||
// for a seasonal outlook: a big anomaly that only half the members share means
|
||||
// nothing. Below this the card says so instead of implying a trend.
|
||||
const AGREEMENT_FLOOR = 0.6;
|
||||
|
||||
function anomalyLabel(delta: number): string {
|
||||
const a = Math.abs(delta);
|
||||
if (a < 0.3) return 'near normal';
|
||||
const word = a < 1 ? 'slightly' : a < 2.5 ? '' : 'well';
|
||||
return `${word} ${delta > 0 ? 'above' : 'below'} normal`.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each months as month (month.key)}
|
||||
{@const agree = Math.max(month.warmerShare, 1 - month.warmerShare)}
|
||||
{@const confident = agree >= AGREEMENT_FLOOR}
|
||||
<article class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<h3 class="text-base font-bold tracking-tight">{month.label}</h3>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{month.days}
|
||||
{month.days === 1 ? 'day' : 'days'}{month.partial ? ' (partial)' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- headline: mean temperature and its departure from the 1991-2020 normal -->
|
||||
<div class="mt-2.5 flex items-end gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-muted-foreground">Mean temperature</p>
|
||||
<p class="text-2xl leading-tight font-bold tabular-nums">
|
||||
{fmtTemp(month.tMean)}<span class="text-base font-semibold text-muted-foreground"
|
||||
>{tempUnit.replace('°', '')}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-auto text-right">
|
||||
{#if finite(month.anomaly)}
|
||||
<p
|
||||
class="text-lg leading-tight font-bold tabular-nums"
|
||||
class:text-red-600={month.anomaly >= 0}
|
||||
class:text-blue-600={month.anomaly < 0}
|
||||
class:dark:text-red-400={month.anomaly >= 0}
|
||||
class:dark:text-blue-400={month.anomaly < 0}
|
||||
>
|
||||
{fmtSigned(month.anomaly)}
|
||||
</p>
|
||||
<p class="text-[11px] text-muted-foreground">{anomalyLabel(month.anomaly)}</p>
|
||||
{:else if normals}
|
||||
<p class="text-[11px] text-muted-foreground">no normal for this month</p>
|
||||
{:else}
|
||||
<p class="text-[11px] text-muted-foreground">normal loading…</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- day / night means, coloured on the same temperature scale as the strip -->
|
||||
<div class="mt-2.5 flex items-center gap-2 text-xs">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style="background:{getColor(month.tMax, units.temperature_unit)}"
|
||||
></span>
|
||||
<span class="font-semibold tabular-nums">{fmtTemp(month.tMax)}</span>
|
||||
<span class="text-muted-foreground">day</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style="background:{getColor(month.tMin, units.temperature_unit)}"
|
||||
></span>
|
||||
<span class="font-semibold tabular-nums">{fmtTemp(month.tMin)}</span>
|
||||
<span class="text-muted-foreground">night</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- precipitation against its own normal, as a share bar -->
|
||||
<div class="mt-3 border-t border-border/60 pt-2.5">
|
||||
<div class="flex items-baseline justify-between gap-2 text-xs">
|
||||
<span class="font-medium text-muted-foreground">Precipitation</span>
|
||||
<span class="font-semibold tabular-nums">
|
||||
{fmtPrecip(month.precip)}
|
||||
{precipUnit}
|
||||
{#if finite(month.precipNormal)}
|
||||
<span class="font-medium text-muted-foreground">
|
||||
/ normal {fmtPrecip(month.precipNormal)}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if finite(month.precipShare)}
|
||||
{@const pct = Math.min(200, month.precipShare * 100)}
|
||||
<div class="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style="width:{pct / 2}%;background:{month.precipShare >= 1
|
||||
? 'var(--color-sky-500, #0ea5e9)'
|
||||
: 'var(--color-amber-500, #f59e0b)'}"
|
||||
></div>
|
||||
</div>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||
{Math.round(month.precipShare * 100)}% of normal · {month.wetDays} wet days
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ensemble agreement: the actual confidence in the anomaly above -->
|
||||
<div class="mt-2.5 flex items-center gap-2">
|
||||
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
class:bg-primary={confident}
|
||||
class:bg-muted-foreground={!confident}
|
||||
style="width:{Math.round(agree * 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[11px] whitespace-nowrap text-muted-foreground">
|
||||
{#if confident}
|
||||
{Math.round(agree * 100)}% of members {month.warmerShare >= 0.5 ? 'warmer' : 'colder'}
|
||||
{:else}
|
||||
members split - low confidence
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Turns the raw seasonal ensemble into the per-calendar-month outlook the page
|
||||
* shows. Seasonal models carry no day-to-day skill, so everything here is an
|
||||
* aggregate: monthly means, the departure from the climate normal, and how much
|
||||
* of the ensemble actually agrees on that departure.
|
||||
*/
|
||||
import {
|
||||
type ClimateNormals,
|
||||
type SeasonalForecastResult,
|
||||
monthDayToOrdinal
|
||||
} from '$lib/services/weather';
|
||||
|
||||
export interface MonthOutlook {
|
||||
/** `YYYY-MM` in the location's local calendar. */
|
||||
key: string;
|
||||
/** Month name and year, e.g. "August 2026". */
|
||||
label: string;
|
||||
/** Forecast days covered (a leading/trailing month is usually incomplete). */
|
||||
days: number;
|
||||
partial: boolean;
|
||||
tMean: number;
|
||||
tMax: number;
|
||||
tMin: number;
|
||||
/** Mean temperature minus the climate normal, or null without normals. */
|
||||
anomaly: number | null;
|
||||
precip: number;
|
||||
precipNormal: number | null;
|
||||
/** Forecast precipitation as a share of normal (1 = exactly normal). */
|
||||
precipShare: number | null;
|
||||
wetDays: number;
|
||||
/** Share of ensemble members whose monthly mean is above the normal. */
|
||||
warmerShare: number;
|
||||
}
|
||||
|
||||
const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v);
|
||||
|
||||
// The month key already carries the local calendar month, so the label is
|
||||
// formatted in UTC - anything zone-aware would just reintroduce the shift.
|
||||
const MONTH_LABEL = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
|
||||
const mean = (xs: number[]): number =>
|
||||
xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN;
|
||||
|
||||
/** Days in a calendar month; `month` is 1-based. */
|
||||
function daysInMonth(year: number, month: number): number {
|
||||
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
interface BuildOptions {
|
||||
/** Daily total counted as a wet day, in the active precipitation unit. */
|
||||
wetDayThreshold: number;
|
||||
}
|
||||
|
||||
export function buildMonthOutlooks(
|
||||
result: SeasonalForecastResult,
|
||||
normals: ClimateNormals | null,
|
||||
{ wetDayThreshold }: BuildOptions
|
||||
): MonthOutlook[] {
|
||||
const tMeanVar = result.variables['temperature_2m_mean'];
|
||||
const tMaxVar = result.variables['temperature_2m_max'];
|
||||
const tMinVar = result.variables['temperature_2m_min'];
|
||||
const precipVar = result.variables['precipitation_sum'];
|
||||
if (!tMeanVar) return [];
|
||||
|
||||
const { dateKeys } = result;
|
||||
|
||||
// Day indices grouped by calendar month, plus the normals lookup per day.
|
||||
// Both read the API's own local dates: deriving them from the instants would
|
||||
// double-count the day a DST change falls on.
|
||||
const groups = new Map<string, number[]>();
|
||||
const ordinals = dateKeys.map((key) =>
|
||||
monthDayToOrdinal(Number(key.slice(5, 7)), Number(key.slice(8, 10)))
|
||||
);
|
||||
|
||||
for (let i = 0; i < dateKeys.length; i++) {
|
||||
const key = dateKeys[i].slice(0, 7);
|
||||
const bucket = groups.get(key);
|
||||
if (bucket) bucket.push(i);
|
||||
else groups.set(key, [i]);
|
||||
}
|
||||
|
||||
const months: MonthOutlook[] = [];
|
||||
|
||||
for (const [key, indices] of groups) {
|
||||
const pick = (values: number[] | undefined): number =>
|
||||
values ? mean(indices.map((i) => values[i]).filter(finite)) : NaN;
|
||||
|
||||
const [year, month] = key.split('-').map(Number);
|
||||
|
||||
// Normals are per day-of-year, so the comparison uses exactly the days the
|
||||
// forecast covers - a half month is never compared against a full one.
|
||||
let normalMean = NaN;
|
||||
let normalPrecip = NaN;
|
||||
if (normals) {
|
||||
normalMean = mean(indices.map((i) => normals.tmean[ordinals[i]]).filter(finite));
|
||||
const np = indices.map((i) => normals.precip[ordinals[i]]).filter(finite);
|
||||
if (np.length) normalPrecip = np.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
const tMean = pick(tMeanVar.mean);
|
||||
|
||||
// Per-member monthly means decide the agreement share: a member counts as
|
||||
// warmer only if it beats the same normal the anomaly is measured against.
|
||||
let warmer = 0;
|
||||
let counted = 0;
|
||||
if (finite(normalMean)) {
|
||||
for (const member of tMeanVar.members) {
|
||||
const memberMean = mean(indices.map((i) => member[i]).filter(finite));
|
||||
if (!finite(memberMean)) continue;
|
||||
counted++;
|
||||
if (memberMean > normalMean) warmer++;
|
||||
}
|
||||
}
|
||||
|
||||
const precipDays = precipVar ? indices.map((i) => precipVar.mean[i]).filter(finite) : [];
|
||||
const precip = precipDays.reduce((a, b) => a + b, 0);
|
||||
|
||||
months.push({
|
||||
key,
|
||||
label: MONTH_LABEL.format(Date.UTC(year, month - 1, 1)),
|
||||
days: indices.length,
|
||||
partial: indices.length < daysInMonth(year, month),
|
||||
tMean,
|
||||
tMax: pick(tMaxVar?.mean),
|
||||
tMin: pick(tMinVar?.mean),
|
||||
anomaly: finite(normalMean) && finite(tMean) ? tMean - normalMean : null,
|
||||
precip,
|
||||
precipNormal: finite(normalPrecip) ? normalPrecip : null,
|
||||
precipShare: finite(normalPrecip) && normalPrecip > 0 ? precip / normalPrecip : null,
|
||||
wetDays: precipDays.filter((p) => p >= wetDayThreshold).length,
|
||||
warmerShare: counted > 0 ? warmer / counted : 0.5
|
||||
});
|
||||
}
|
||||
|
||||
return months;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows an outlook to its first `days` days. The fetch always asks for the
|
||||
* model's full horizon, so the range buttons only reslice what is already in
|
||||
* memory instead of issuing another (large) request.
|
||||
*/
|
||||
export function sliceSeasonal(
|
||||
result: SeasonalForecastResult,
|
||||
days: number
|
||||
): SeasonalForecastResult {
|
||||
if (days >= result.timestamps.length) return result;
|
||||
|
||||
const variables: SeasonalForecastResult['variables'] = {};
|
||||
for (const [name, data] of Object.entries(result.variables)) {
|
||||
variables[name] = {
|
||||
members: data.members.map((m) => m.slice(0, days)),
|
||||
mean: data.mean.slice(0, days),
|
||||
min: data.min.slice(0, days),
|
||||
max: data.max.slice(0, days),
|
||||
p25: data.p25.slice(0, days),
|
||||
p75: data.p75.slice(0, days),
|
||||
unit: data.unit
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
variables,
|
||||
timestamps: result.timestamps.slice(0, days),
|
||||
dailyDates: result.dailyDates.slice(0, days),
|
||||
dateKeys: result.dateKeys.slice(0, days)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered rolling mean. Seasonal ensembles are far too noisy to read day by
|
||||
* day; smoothing shows the trend the model actually claims to resolve. Windows
|
||||
* shrink at the edges instead of dropping data.
|
||||
*/
|
||||
export function rollingMean(values: number[], window: number): number[] {
|
||||
const half = Math.floor(window / 2);
|
||||
return values.map((_, i) => {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let k = i - half; k <= i + half; k++) {
|
||||
const v = values[k];
|
||||
if (finite(v)) {
|
||||
sum += v;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count > 0 ? sum / count : NaN;
|
||||
});
|
||||
}
|
||||
|
||||
/** Rolling total over the same window (used for precipitation). */
|
||||
export function rollingSum(values: number[], window: number): number[] {
|
||||
return rollingMean(values, window).map((v) => (finite(v) ? v * window : NaN));
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
storedVariablePrefs
|
||||
} from '$lib/stores/settings';
|
||||
|
||||
import { buildLocationRoute } from '$lib/utils/location';
|
||||
|
||||
import { ChartContainer } from '$lib/components/charts';
|
||||
|
||||
import {
|
||||
@@ -76,6 +78,10 @@
|
||||
storedLocation.set(data.location);
|
||||
});
|
||||
|
||||
// Lets the strip's side buttons hand over to the archive / seasonal outlook
|
||||
// for the same place once their range is exhausted.
|
||||
let locationRoute = $derived(buildLocationRoute(location));
|
||||
|
||||
let mounted = $state(false);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<FriendlyWeatherError | null>(null);
|
||||
@@ -318,6 +324,7 @@
|
||||
onExtend={() => (forecastDays = 15)}
|
||||
canExtendPast={pastDays < 3}
|
||||
onExtendPast={() => (pastDays = 3)}
|
||||
{locationRoute}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
@@ -23,6 +25,11 @@
|
||||
onExtend?: () => void;
|
||||
canExtendPast?: boolean;
|
||||
onExtendPast?: () => void;
|
||||
/**
|
||||
* Location segment for the hand-off links that replace the side buttons
|
||||
* once their range is exhausted. Omit to leave the slots empty.
|
||||
*/
|
||||
locationRoute?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -33,7 +40,8 @@
|
||||
canExtend = false,
|
||||
onExtend,
|
||||
canExtendPast = false,
|
||||
onExtendPast
|
||||
onExtendPast,
|
||||
locationRoute
|
||||
}: Props = $props();
|
||||
|
||||
// ─── Scroll-driven collapse (full cards → compact strip) ────────────────────
|
||||
@@ -82,10 +90,16 @@
|
||||
let scrolledForRef: FetchedDaily | null = null;
|
||||
$effect(() => {
|
||||
const d = daily;
|
||||
if (!d || !canExtendPast || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
|
||||
if (!d || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
|
||||
scrolledForRef = d;
|
||||
const scroll = stripScrollEl;
|
||||
const wrap = daysWrapEl;
|
||||
if (!canExtendPast) {
|
||||
// the past button has been replaced by the history link, which is a
|
||||
// destination rather than a control: it stays in view instead of parked
|
||||
scroll.scrollLeft = 0;
|
||||
return;
|
||||
}
|
||||
// the delta form is self-correcting (a no-op once right), so apply after
|
||||
// layout and once more after late-loading CSS/fonts settle — otherwise a
|
||||
// sliver of the past button can stay visible on first paint
|
||||
@@ -110,7 +124,7 @@
|
||||
<div bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div>
|
||||
|
||||
<div
|
||||
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8"
|
||||
class="daystrip sticky -top-3 z-30 -mx-3 lg:-top-6 lg:-mx-8 mt-1"
|
||||
class:js-snap={needsSnapFallback}
|
||||
class:compact
|
||||
class:stuck
|
||||
@@ -122,7 +136,7 @@
|
||||
match the 15-days button on the other end -->
|
||||
<button
|
||||
type="button"
|
||||
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
||||
class="strip-side strip-park flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
||||
onclick={onExtendPast}
|
||||
aria-label="Load the past 3 days"
|
||||
title="Load the past 3 days"
|
||||
@@ -141,6 +155,31 @@
|
||||
</span>
|
||||
<span class="text-[9px] leading-tight font-semibold">past</span>
|
||||
</button>
|
||||
{:else if locationRoute}
|
||||
<!-- the past days are already in the strip: the only way further back
|
||||
is the archive, so the button hands over to it -->
|
||||
<a
|
||||
href={resolve('/weather/historical/[location]', { location: locationRoute })}
|
||||
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
||||
aria-label="Open the historical weather archive"
|
||||
title="Historical weather: any past date back to 1940"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 2" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-[9px] leading-tight font-semibold">history</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<div class="strip-days flex" bind:this={daysWrapEl}>
|
||||
@@ -322,6 +361,27 @@
|
||||
</span>
|
||||
<span class="text-[9px] leading-tight font-semibold">days</span>
|
||||
</button>
|
||||
{:else if locationRoute}
|
||||
<!-- the strip is at the model's limit: anything further out is a
|
||||
seasonal outlook, so the button hands over to it -->
|
||||
<a
|
||||
href={resolve('/weather/seasonal/[location]', { location: locationRoute })}
|
||||
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
||||
aria-label="Open the seasonal outlook"
|
||||
title="Seasonal outlook: monthly trends for the months ahead"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 17l6-6 4 4 7-7" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16 8h5v5" />
|
||||
</svg>
|
||||
<span class="text-[9px] leading-tight font-semibold">seasonal</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -514,7 +574,7 @@
|
||||
(Browsers too old to register --strip-p simply switch instantly.) */
|
||||
.daystrip.js-snap {
|
||||
transition:
|
||||
--strip-p 0.28s ease,
|
||||
--strip-p 0.32s ease,
|
||||
--stuck 0.15s ease;
|
||||
}
|
||||
.daystrip.js-snap.compact {
|
||||
@@ -527,7 +587,7 @@
|
||||
/* larger cards need a touch longer to feel smooth */
|
||||
.daystrip.js-snap {
|
||||
transition:
|
||||
--strip-p 0.55s ease,
|
||||
--strip-p 0.42s ease,
|
||||
--stuck 0.15s ease;
|
||||
}
|
||||
}
|
||||
@@ -549,13 +609,14 @@
|
||||
}
|
||||
/* Scroll containers clip at the PADDING edge, so a parked past button would
|
||||
always leak a sliver across the row's left padding. Extending the days
|
||||
group's left edge (only while the past button exists) moves max-scroll so
|
||||
the button parks fully beyond the clip edge. */
|
||||
.strip-side + .strip-days {
|
||||
group's left edge (only while the parked past button exists) moves
|
||||
max-scroll so the button parks fully beyond the clip edge. The history
|
||||
link that replaces it is never parked, so it keeps the normal gap. */
|
||||
.strip-park + .strip-days {
|
||||
padding-left: calc(12px - var(--gap-min));
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.strip-side + .strip-days {
|
||||
.strip-park + .strip-days {
|
||||
padding-left: calc(32px - var(--gap-min));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user