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/week'), label: '7-Day Forecast' },
|
||||||
{ href: resolve('/weather/compare'), label: 'Model Comparison' },
|
{ href: resolve('/weather/compare'), label: 'Model Comparison' },
|
||||||
{ href: resolve('/weather/14-day'), label: '14-Day Forecast' },
|
{ 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/historical'), label: 'Historical Weather' },
|
||||||
{ href: resolve('/weather/maps'), label: 'Weather Maps' }
|
{ href: resolve('/weather/maps'), label: 'Weather Maps' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
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';
|
import SupporterBadge from '$lib/paywall/SupporterBadge.svelte';
|
||||||
|
|
||||||
|
import SettingsMenu from './settings-menu.svelte';
|
||||||
|
import ThemeIcon from './theme-icon.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onMenuToggle?: () => void;
|
onMenuToggle?: () => void;
|
||||||
@@ -60,6 +63,8 @@
|
|||||||
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
|
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
|
||||||
} else if (currentPath.startsWith('/weather/historical')) {
|
} else if (currentPath.startsWith('/weather/historical')) {
|
||||||
goto(resolve('/weather/historical/[location]', { location: locationRoute }));
|
goto(resolve('/weather/historical/[location]', { location: locationRoute }));
|
||||||
|
} else if (currentPath.startsWith('/weather/seasonal')) {
|
||||||
|
goto(resolve('/weather/seasonal/[location]', { location: locationRoute }));
|
||||||
} else {
|
} else {
|
||||||
goto(resolve('/weather/week/[location]', { location: locationRoute }));
|
goto(resolve('/weather/week/[location]', { location: locationRoute }));
|
||||||
}
|
}
|
||||||
@@ -117,11 +122,20 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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 -->
|
<!-- Measurement units -->
|
||||||
<UnitSelector />
|
<UnitSelector />
|
||||||
|
|
||||||
<!-- Premium status / unlock -->
|
<!-- Supporter status / unlock -->
|
||||||
<PremiumBadge />
|
<SupporterBadge />
|
||||||
|
|
||||||
<!-- Theme toggle: system → light → dark -->
|
<!-- Theme toggle: system → light → dark -->
|
||||||
<button
|
<button
|
||||||
@@ -130,50 +144,9 @@
|
|||||||
title={themeTitles[$storedTheme]}
|
title={themeTitles[$storedTheme]}
|
||||||
aria-label={themeTitles[$storedTheme]}
|
aria-label={themeTitles[$storedTheme]}
|
||||||
>
|
>
|
||||||
{#if $storedTheme === 'light'}
|
<ThemeIcon theme={$storedTheme} />
|
||||||
<!-- 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>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<style>
|
<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'
|
'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',
|
title: 'Historical',
|
||||||
url: '/weather/historical' as const,
|
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">
|
<script lang="ts">
|
||||||
import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
|
import UnitOptions from '$lib/components/unit-options.svelte';
|
||||||
// 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>
|
</script>
|
||||||
|
|
||||||
<Popover.Root>
|
<Popover.Root>
|
||||||
@@ -65,30 +26,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
<Popover.Content align="end" class="w-64 border-border">
|
<Popover.Content align="end" class="w-64 border-border">
|
||||||
<div class="flex flex-col gap-4">
|
<UnitOptions />
|
||||||
{#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>
|
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
import UnlockDialog from './UnlockDialog.svelte';
|
import UnlockDialog from './UnlockDialog.svelte';
|
||||||
import { PREMIUM_PERKS, SIGNUP_URL, getPremiumPrice } from './config';
|
import { SUPPORTER_PERKS, SIGNUP_URL, getSupporterPrice } from './config';
|
||||||
import { isPremium, premiumState, refreshPremium } from './premium';
|
import { isSupporter, supporterState, refreshSupporter } from './supporter';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Short feature name shown in the locked panel headline. */
|
/** Short feature name shown in the locked panel headline. */
|
||||||
@@ -14,16 +14,16 @@
|
|||||||
let { feature = 'This page', children }: Props = $props();
|
let { feature = 'This page', children }: Props = $props();
|
||||||
|
|
||||||
let unlockOpen = $state(false);
|
let unlockOpen = $state(false);
|
||||||
const price = getPremiumPrice();
|
const price = getSupporterPrice();
|
||||||
|
|
||||||
// Re-verify the stored key whenever the gate mounts.
|
// 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.
|
// 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>
|
</script>
|
||||||
|
|
||||||
{#if $isPremium}
|
{#if $isSupporter}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{:else if initialChecking}
|
{:else if initialChecking}
|
||||||
<div class="flex items-center justify-center py-24 text-sm text-muted-foreground">
|
<div class="flex items-center justify-center py-24 text-sm text-muted-foreground">
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul class="mx-auto mt-5 grid max-w-sm gap-2 text-left">
|
<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">
|
<li class="flex items-start gap-2.5 text-sm">
|
||||||
<svg
|
<svg
|
||||||
class="mt-0.5 h-4 w-4 shrink-0 text-primary"
|
class="mt-0.5 h-4 w-4 shrink-0 text-primary"
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $premiumState.status === 'invalid'}
|
{#if $supporterState.status === 'invalid'}
|
||||||
<p class="mt-4 text-xs text-amber-600 dark:text-amber-400">
|
<p class="mt-4 text-xs text-amber-600 dark:text-amber-400">
|
||||||
Your saved key is no longer valid or has expired.
|
Your saved key is no longer valid or has expired.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -2,31 +2,31 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
import UnlockDialog from './UnlockDialog.svelte';
|
import UnlockDialog from './UnlockDialog.svelte';
|
||||||
import { isPremium, refreshPremium } from './premium';
|
import { isSupporter, refreshSupporter } from './supporter';
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
|
|
||||||
// Verify once on load so the badge reflects real status site-wide.
|
// Verify once on load so the badge reflects real status site-wide.
|
||||||
onMount(refreshPremium);
|
onMount(refreshSupporter);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="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-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'}"
|
: 'border-border/70 text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||||
onclick={() => (open = true)}
|
onclick={() => (open = true)}
|
||||||
title={$isPremium ? 'Supporter extras active' : 'Support Drizz.li'}
|
title={$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'}
|
||||||
aria-label={$isPremium ? 'Supporter extras active' : 'Support Drizz.li'}
|
aria-label={$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'}
|
||||||
>
|
>
|
||||||
{#if $isPremium}
|
{#if $isSupporter}
|
||||||
<!-- star -->
|
<!-- star -->
|
||||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
<path
|
<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"
|
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>
|
</svg>
|
||||||
<span class="hidden sm:inline">Premium</span>
|
<span class="hidden sm:inline">Supporter</span>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- padlock -->
|
<!-- padlock -->
|
||||||
<svg
|
<svg
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<rect x="4" y="10" width="16" height="11" rx="2" />
|
<rect x="4" y="10" width="16" height="11" rx="2" />
|
||||||
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
|
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="hidden sm:inline">Premium</span>
|
<span class="hidden sm:inline">Supporter</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
||||||
import { SIGNUP_URL, getPremiumPrice } from './config';
|
import { SIGNUP_URL, getSupporterPrice } from './config';
|
||||||
import { clearLicense, isPremium, premiumState, storedLicenseKey, verifyKey } from './premium';
|
import { clearLicense, isSupporter, supporterState, storedLicenseKey, verifyKey } from './supporter';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
let { open = $bindable(false) }: Props = $props();
|
let { open = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
const price = getPremiumPrice();
|
const price = getSupporterPrice();
|
||||||
|
|
||||||
// Prefill with the stored key so an existing subscriber sees their key.
|
// Prefill with the stored key so an existing subscriber sees their key.
|
||||||
let keyInput = $state('');
|
let keyInput = $state('');
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let expiresLabel = $derived.by(() => {
|
let expiresLabel = $derived.by(() => {
|
||||||
const exp = $premiumState.expires;
|
const exp = $supporterState.expires;
|
||||||
if ($premiumState.status !== 'valid') return null;
|
if ($supporterState.status !== 'valid') return null;
|
||||||
if (!exp) return 'Lifetime access';
|
if (!exp) return 'Lifetime access';
|
||||||
return `Active until ${formatZoned(new Date(exp), 'UTC', 'd LLL yyyy')}`;
|
return `Active until ${formatZoned(new Date(exp), 'UTC', 'd LLL yyyy')}`;
|
||||||
});
|
});
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
{#if $isPremium && $premiumState.status === 'valid'}
|
{#if $isSupporter && $supporterState.status === 'valid'}
|
||||||
<div
|
<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"
|
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" />
|
<circle cx="12" cy="12" r="9" />
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<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}
|
{#if expiresLabel}<p class="text-xs opacity-80">{expiresLabel}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
|
|
||||||
{#if localError}
|
{#if localError}
|
||||||
<p class="text-sm text-destructive">{localError}</p>
|
<p class="text-sm text-destructive">{localError}</p>
|
||||||
{:else if $premiumState.status === 'error'}
|
{:else if $supporterState.status === 'error'}
|
||||||
<p class="text-sm text-destructive">
|
<p class="text-sm text-destructive">
|
||||||
Couldn't reach the server. Check your connection and try again.
|
Couldn't reach the server. Check your connection and try again.
|
||||||
</p>
|
</p>
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Dialog.Footer class="flex-col items-stretch gap-2 sm:flex-col sm:items-stretch">
|
<Dialog.Footer class="flex-col items-stretch gap-2 sm:flex-col sm:items-stretch">
|
||||||
{#if $isPremium}
|
{#if $isSupporter}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="cursor-pointer text-center text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
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 =
|
const US_ZONES =
|
||||||
/^America\/(New_York|Detroit|Chicago|Denver|Boise|Phoenix|Los_Angeles|Anchorage|Adak|Juneau|Sitka|Nome|Yakutat|Menominee|Indiana|Kentucky|North_Dakota)/;
|
/^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';
|
if (typeof Intl === 'undefined') return 'EUR';
|
||||||
let tz = '';
|
let tz = '';
|
||||||
try {
|
try {
|
||||||
@@ -53,15 +53,19 @@ export function detectCurrency(): PremiumCurrency {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / month". */
|
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / month". */
|
||||||
export function getPremiumPrice(): string {
|
export function getSupporterPrice(): string {
|
||||||
if (env.VITE_PREMIUM_PRICE) return env.VITE_PREMIUM_PRICE;
|
// 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 currency = detectCurrency();
|
||||||
const symbol = CURRENCY_SYMBOL[currency];
|
const symbol = CURRENCY_SYMBOL[currency];
|
||||||
return currency === 'CHF' ? `${symbol} 3 / month` : `${symbol}3 / month`;
|
return currency === 'CHF' ? `${symbol} 3 / month` : `${symbol}3 / month`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Short, human list of what supporting unlocks (shown on the locked panel). */
|
/** 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',
|
'Historical weather & climate-normal comparisons',
|
||||||
|
'Seasonal outlook: months ahead vs the climate normal',
|
||||||
'New supporter extras as they land'
|
'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
|
* 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
|
* 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';
|
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 const storedLicenseKey = persisted<string>('license_key', '');
|
||||||
|
|
||||||
export interface PremiumCache {
|
export interface SupporterCache {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
tier?: string;
|
tier?: string;
|
||||||
/** ISO date the subscription lapses, or null for a lifetime key. */
|
/** ISO date the subscription lapses, or null for a lifetime key. */
|
||||||
@@ -28,20 +28,40 @@ export interface PremiumCache {
|
|||||||
checkedAt: number;
|
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. */
|
/** 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 {
|
export interface SupporterState {
|
||||||
status: PremiumStatus;
|
status: SupporterStatus;
|
||||||
tier?: string;
|
tier?: string;
|
||||||
expires?: string | null;
|
expires?: string | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Live verification state for the current session. */
|
/** 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 {
|
function notExpired(expires: string | null | undefined): boolean {
|
||||||
if (!expires) return true; // lifetime key
|
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;
|
* Whether supporter content should be shown. A live "valid"/"invalid" result
|
||||||
* otherwise we fall back to the cached result (so a reload or a brief network
|
* wins; otherwise we fall back to the cached result (so a reload or a brief
|
||||||
* blip doesn't lock a paying user out).
|
* network blip doesn't lock a paying user out).
|
||||||
*/
|
*/
|
||||||
export const isPremium = derived(
|
export const isSupporter = derived(
|
||||||
[premiumState, storedPremiumCache],
|
[supporterState, storedSupporterCache],
|
||||||
([$state, $cache]): boolean => {
|
([$state, $cache]): boolean => {
|
||||||
if ($state.status === 'valid') return true;
|
if ($state.status === 'valid') return true;
|
||||||
if ($state.status === 'invalid') return false;
|
if ($state.status === 'invalid') return false;
|
||||||
@@ -78,11 +98,11 @@ export interface VerifyResult {
|
|||||||
export async function verifyKey(key: string): Promise<VerifyResult> {
|
export async function verifyKey(key: string): Promise<VerifyResult> {
|
||||||
const trimmed = key.trim();
|
const trimmed = key.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
premiumState.set({ status: 'invalid' });
|
supporterState.set({ status: 'invalid' });
|
||||||
return { valid: false, error: 'Enter your access key.' };
|
return { valid: false, error: 'Enter your access key.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
premiumState.set({ status: 'checking' });
|
supporterState.set({ status: 'checking' });
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
|
const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
|
||||||
headers: { accept: 'application/json' }
|
headers: { accept: 'application/json' }
|
||||||
@@ -91,39 +111,39 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
|
|||||||
|
|
||||||
if (res.ok && data.valid) {
|
if (res.ok && data.valid) {
|
||||||
storedLicenseKey.set(trimmed);
|
storedLicenseKey.set(trimmed);
|
||||||
storedPremiumCache.set({
|
storedSupporterCache.set({
|
||||||
valid: true,
|
valid: true,
|
||||||
tier: data.tier,
|
tier: data.tier,
|
||||||
expires: data.expires ?? null,
|
expires: data.expires ?? null,
|
||||||
checkedAt: Date.now()
|
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 };
|
return { valid: true, tier: data.tier, expires: data.expires ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
storedPremiumCache.set({ valid: false, checkedAt: Date.now() });
|
storedSupporterCache.set({ valid: false, checkedAt: Date.now() });
|
||||||
premiumState.set({ status: 'invalid' });
|
supporterState.set({ status: 'invalid' });
|
||||||
return { valid: false };
|
return { valid: false };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(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 };
|
return { valid: false, error: message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */
|
/** 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);
|
const key = get(storedLicenseKey);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
premiumState.set({ status: 'idle' });
|
supporterState.set({ status: 'idle' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await verifyKey(key);
|
await verifyKey(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Forget the key and premium state ("sign out"). */
|
/** Forget the key and supporter state ("sign out"). */
|
||||||
export function clearLicense(): void {
|
export function clearLicense(): void {
|
||||||
storedLicenseKey.set('');
|
storedLicenseKey.set('');
|
||||||
storedPremiumCache.set(null);
|
storedSupporterCache.set(null);
|
||||||
premiumState.set({ status: 'idle' });
|
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 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';
|
const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
|
||||||
|
const SEASONAL_URL = 'https://seasonal-api.open-meteo.com/v1/seasonal';
|
||||||
|
|
||||||
// ─── Core Helpers ───────────────────────────────────────────────────────────────
|
// ─── Core Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1143,3 +1144,197 @@ export async function fetchClimateNormals(params: ClimateNormalsParams): Promise
|
|||||||
precipitation_unit: params.precipitation_unit ?? 'mm'
|
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 { ChartContainer } from '$lib/components/charts';
|
||||||
|
|
||||||
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||||
import { isPremium } from '$lib/paywall/premium';
|
import { isSupporter } from '$lib/paywall/supporter';
|
||||||
import {
|
import {
|
||||||
type ClimateNormals,
|
type ClimateNormals,
|
||||||
type HistoricalForecastResult,
|
type HistoricalForecastResult,
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
const s = startDate;
|
const s = startDate;
|
||||||
const e = endDate;
|
const e = endDate;
|
||||||
const vars = hourlyVars;
|
const vars = hourlyVars;
|
||||||
if (!mounted || !$isPremium || !loc || !s || !e) return;
|
if (!mounted || !$isSupporter || !loc || !s || !e) return;
|
||||||
|
|
||||||
const version = ++requestVersion;
|
const version = ++requestVersion;
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const key = normalsKey;
|
const key = normalsKey;
|
||||||
const loc = location;
|
const loc = location;
|
||||||
if (!mounted || !$isPremium || !loc) return;
|
if (!mounted || !$isSupporter || !loc) return;
|
||||||
|
|
||||||
const version = ++normalsVersion;
|
const version = ++normalsVersion;
|
||||||
normals = null;
|
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
|
storedVariablePrefs
|
||||||
} from '$lib/stores/settings';
|
} from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { buildLocationRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
import { ChartContainer } from '$lib/components/charts';
|
import { ChartContainer } from '$lib/components/charts';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -76,6 +78,10 @@
|
|||||||
storedLocation.set(data.location);
|
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 mounted = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let loadError = $state<FriendlyWeatherError | null>(null);
|
let loadError = $state<FriendlyWeatherError | null>(null);
|
||||||
@@ -318,6 +324,7 @@
|
|||||||
onExtend={() => (forecastDays = 15)}
|
onExtend={() => (forecastDays = 15)}
|
||||||
canExtendPast={pastDays < 3}
|
canExtendPast={pastDays < 3}
|
||||||
onExtendPast={() => (pastDays = 3)}
|
onExtendPast={() => (pastDays = 3)}
|
||||||
|
{locationRoute}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||||
|
|
||||||
import { getTempStyle } from '../../utils/colors';
|
import { getTempStyle } from '../../utils/colors';
|
||||||
@@ -23,6 +25,11 @@
|
|||||||
onExtend?: () => void;
|
onExtend?: () => void;
|
||||||
canExtendPast?: boolean;
|
canExtendPast?: boolean;
|
||||||
onExtendPast?: () => void;
|
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 {
|
let {
|
||||||
@@ -33,7 +40,8 @@
|
|||||||
canExtend = false,
|
canExtend = false,
|
||||||
onExtend,
|
onExtend,
|
||||||
canExtendPast = false,
|
canExtendPast = false,
|
||||||
onExtendPast
|
onExtendPast,
|
||||||
|
locationRoute
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// ─── Scroll-driven collapse (full cards → compact strip) ────────────────────
|
// ─── Scroll-driven collapse (full cards → compact strip) ────────────────────
|
||||||
@@ -82,10 +90,16 @@
|
|||||||
let scrolledForRef: FetchedDaily | null = null;
|
let scrolledForRef: FetchedDaily | null = null;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const d = daily;
|
const d = daily;
|
||||||
if (!d || !canExtendPast || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
|
if (!d || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
|
||||||
scrolledForRef = d;
|
scrolledForRef = d;
|
||||||
const scroll = stripScrollEl;
|
const scroll = stripScrollEl;
|
||||||
const wrap = daysWrapEl;
|
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
|
// 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
|
// layout and once more after late-loading CSS/fonts settle — otherwise a
|
||||||
// sliver of the past button can stay visible on first paint
|
// 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 bind:this={sentinelEl} class="sentinel" aria-hidden="true"></div>
|
||||||
|
|
||||||
<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:js-snap={needsSnapFallback}
|
||||||
class:compact
|
class:compact
|
||||||
class:stuck
|
class:stuck
|
||||||
@@ -122,7 +136,7 @@
|
|||||||
match the 15-days button on the other end -->
|
match the 15-days button on the other end -->
|
||||||
<button
|
<button
|
||||||
type="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}
|
onclick={onExtendPast}
|
||||||
aria-label="Load the past 3 days"
|
aria-label="Load the past 3 days"
|
||||||
title="Load the past 3 days"
|
title="Load the past 3 days"
|
||||||
@@ -141,6 +155,31 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="text-[9px] leading-tight font-semibold">past</span>
|
<span class="text-[9px] leading-tight font-semibold">past</span>
|
||||||
</button>
|
</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}
|
{/if}
|
||||||
|
|
||||||
<div class="strip-days flex" bind:this={daysWrapEl}>
|
<div class="strip-days flex" bind:this={daysWrapEl}>
|
||||||
@@ -322,6 +361,27 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="text-[9px] leading-tight font-semibold">days</span>
|
<span class="text-[9px] leading-tight font-semibold">days</span>
|
||||||
</button>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -514,7 +574,7 @@
|
|||||||
(Browsers too old to register --strip-p simply switch instantly.) */
|
(Browsers too old to register --strip-p simply switch instantly.) */
|
||||||
.daystrip.js-snap {
|
.daystrip.js-snap {
|
||||||
transition:
|
transition:
|
||||||
--strip-p 0.28s ease,
|
--strip-p 0.32s ease,
|
||||||
--stuck 0.15s ease;
|
--stuck 0.15s ease;
|
||||||
}
|
}
|
||||||
.daystrip.js-snap.compact {
|
.daystrip.js-snap.compact {
|
||||||
@@ -527,7 +587,7 @@
|
|||||||
/* larger cards need a touch longer to feel smooth */
|
/* larger cards need a touch longer to feel smooth */
|
||||||
.daystrip.js-snap {
|
.daystrip.js-snap {
|
||||||
transition:
|
transition:
|
||||||
--strip-p 0.55s ease,
|
--strip-p 0.42s ease,
|
||||||
--stuck 0.15s ease;
|
--stuck 0.15s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -549,13 +609,14 @@
|
|||||||
}
|
}
|
||||||
/* Scroll containers clip at the PADDING edge, so a parked past button would
|
/* 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
|
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
|
group's left edge (only while the parked past button exists) moves
|
||||||
the button parks fully beyond the clip edge. */
|
max-scroll so the button parks fully beyond the clip edge. The history
|
||||||
.strip-side + .strip-days {
|
link that replaces it is never parked, so it keeps the normal gap. */
|
||||||
|
.strip-park + .strip-days {
|
||||||
padding-left: calc(12px - var(--gap-min));
|
padding-left: calc(12px - var(--gap-min));
|
||||||
}
|
}
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.strip-side + .strip-days {
|
.strip-park + .strip-days {
|
||||||
padding-left: calc(32px - var(--gap-min));
|
padding-left: calc(32px - var(--gap-min));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user