Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fb12d03e0 | ||
|
|
b4e509f9cb |
@@ -1,7 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher, onDestroy, tick } from 'svelte';
|
import { createEventDispatcher, onDestroy, tick } from 'svelte';
|
||||||
|
|
||||||
import { type GeoLocation } from '$lib/stores/settings';
|
import {
|
||||||
|
type GeoLocation,
|
||||||
|
locationKey,
|
||||||
|
storedFavoriteLocations,
|
||||||
|
storedRecentLocations
|
||||||
|
} from '$lib/stores/settings';
|
||||||
|
|
||||||
import * as Alert from '$lib/components/ui/alert';
|
import * as Alert from '$lib/components/ui/alert';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -30,11 +35,31 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectLocation = (location: GeoLocation) => {
|
const selectLocation = (location: GeoLocation) => {
|
||||||
|
addRecent(location);
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
closePopover();
|
closePopover();
|
||||||
dispatch('location', location);
|
dispatch('location', location);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function addRecent(loc: GeoLocation) {
|
||||||
|
const key = locationKey(loc);
|
||||||
|
storedRecentLocations.update((list) =>
|
||||||
|
[loc, ...list.filter((l) => locationKey(l) !== key)].slice(0, 8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFavorite(loc: GeoLocation) {
|
||||||
|
const key = locationKey(loc);
|
||||||
|
storedFavoriteLocations.update((list) =>
|
||||||
|
list.some((l) => locationKey(l) === key)
|
||||||
|
? list.filter((l) => locationKey(l) !== key)
|
||||||
|
: [loc, ...list].slice(0, 24)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: favKeys = new Set($storedFavoriteLocations.map(locationKey));
|
||||||
|
$: recentToShow = $storedRecentLocations.filter((l) => !favKeys.has(locationKey(l)));
|
||||||
|
|
||||||
async function focusInput() {
|
async function focusInput() {
|
||||||
await tick();
|
await tick();
|
||||||
searchInputEl?.focus();
|
searchInputEl?.focus();
|
||||||
@@ -101,6 +126,55 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet locationRow(location: GeoLocation)}
|
||||||
|
{@const fav = favKeys.has(locationKey(location))}
|
||||||
|
<div
|
||||||
|
class="group flex items-center rounded-md border border-transparent transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-left"
|
||||||
|
onclick={() => selectLocation(location)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="h-7 w-7 shrink-0 rounded-full"
|
||||||
|
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||||
|
alt={location.country}
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate text-sm font-medium text-foreground">{location.name}</div>
|
||||||
|
<div class="truncate text-xs text-muted-foreground">
|
||||||
|
{location.admin1 || ''}
|
||||||
|
{location.country || ''}
|
||||||
|
· {location.latitude.toFixed(2)}°N {location.longitude.toFixed(2)}°E
|
||||||
|
{#if location.elevation}· {location.elevation.toFixed(0)}m{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mr-1 flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md hover:bg-background hover:text-amber-500 {fav
|
||||||
|
? 'text-amber-500'
|
||||||
|
: 'text-muted-foreground/50'}"
|
||||||
|
onclick={() => toggleFavorite(location)}
|
||||||
|
aria-label={fav ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
|
title={fav ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill={fav ? 'currentColor' : 'none'}
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 3.6l2.5 5.1 5.6.8-4 3.9 1 5.6-5.1-2.7-5 2.7 1-5.6-4-3.9 5.5-.8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<Popover.Root bind:open={popoverOpen}>
|
<Popover.Root bind:open={popoverOpen}>
|
||||||
<Popover.Trigger
|
<Popover.Trigger
|
||||||
class="flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-full border-2 border-primary/30 bg-background px-4 text-[0.8125rem] font-medium text-muted-foreground shadow-xs transition-[border-color,box-shadow] duration-150 hover:border-primary/70 hover:shadow-md"
|
class="flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-full border-2 border-primary/30 bg-background px-4 text-[0.8125rem] font-medium text-muted-foreground shadow-xs transition-[border-color,box-shadow] duration-150 hover:border-primary/70 hover:shadow-md"
|
||||||
@@ -178,8 +252,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:then results}
|
{:then results}
|
||||||
{#if results.results && results.results.length === 0}
|
{#if searchQuery.length < 2}
|
||||||
{#if searchQuery.length < 2}
|
{#if $storedFavoriteLocations.length > 0 || recentToShow.length > 0}
|
||||||
|
{#if $storedFavoriteLocations.length > 0}
|
||||||
|
<div
|
||||||
|
class="mb-1 px-1 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||||
|
>
|
||||||
|
Favorites
|
||||||
|
</div>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
{#each $storedFavoriteLocations as loc (locationKey(loc))}
|
||||||
|
{@render locationRow(loc)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if recentToShow.length > 0}
|
||||||
|
<div
|
||||||
|
class="mb-1 px-1 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase {$storedFavoriteLocations.length
|
||||||
|
? 'mt-3'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
Recent
|
||||||
|
</div>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
{#each recentToShow as loc (locationKey(loc))}
|
||||||
|
{@render locationRow(loc)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="flex items-start gap-2 rounded-md bg-primary/8 p-2.5 text-muted-foreground"
|
class="flex items-start gap-2 rounded-md bg-primary/8 p-2.5 text-muted-foreground"
|
||||||
>
|
>
|
||||||
@@ -200,62 +301,25 @@
|
|||||||
Start typing to search or use GPS to detect your position
|
Start typing to search or use GPS to detect your position
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
|
||||||
<Alert.Root
|
|
||||||
class="border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20"
|
|
||||||
>
|
|
||||||
<Alert.Description class="text-orange-700 dark:text-orange-300">
|
|
||||||
No locations found for "{searchQuery}". Try a different term.
|
|
||||||
</Alert.Description>
|
|
||||||
</Alert.Root>
|
|
||||||
{/if}
|
{/if}
|
||||||
{:else if !results.results}
|
{:else if results.results && results.results.length > 0}
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
{#each results.results as location, i (i)}
|
||||||
|
{@render locationRow(location)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if results.results}
|
||||||
|
<Alert.Root
|
||||||
|
class="border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20"
|
||||||
|
>
|
||||||
|
<Alert.Description class="text-orange-700 dark:text-orange-300">
|
||||||
|
No locations found for "{searchQuery}". Try a different term.
|
||||||
|
</Alert.Description>
|
||||||
|
</Alert.Root>
|
||||||
|
{:else}
|
||||||
<Alert.Root variant="destructive">
|
<Alert.Root variant="destructive">
|
||||||
<Alert.Description>No locations found</Alert.Description>
|
<Alert.Description>No locations found</Alert.Description>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
{:else}
|
|
||||||
<div class="space-y-0.5">
|
|
||||||
{#each results.results || [] as location, i (i)}
|
|
||||||
<button
|
|
||||||
class="group block w-full cursor-pointer rounded-md border border-transparent bg-transparent px-2.5 py-2 transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
|
|
||||||
onclick={() => selectLocation(location)}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2.5">
|
|
||||||
<img
|
|
||||||
class="h-7 w-7 rounded-full"
|
|
||||||
src="/images/country-flags/{(
|
|
||||||
location.country_code || 'united_nations'
|
|
||||||
).toLowerCase()}.svg"
|
|
||||||
alt={location.country}
|
|
||||||
/>
|
|
||||||
<div class="flex-1 text-left">
|
|
||||||
<div class="text-sm font-medium text-foreground">
|
|
||||||
{location.name}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
{location.admin1 || ''}
|
|
||||||
{location.country || ''}
|
|
||||||
· {location.latitude.toFixed(2)}°N {location.longitude.toFixed(2)}°E
|
|
||||||
{#if location.elevation}· {location.elevation.toFixed(0)}m{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
class="h-3.5 w-3.5 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{:catch error}
|
{:catch error}
|
||||||
<Alert.Root variant="destructive">
|
<Alert.Root variant="destructive">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { buildLocationRoute } from '$lib/utils/location';
|
import { buildLocationRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onMenuToggle?: () => void;
|
onMenuToggle?: () => void;
|
||||||
@@ -68,17 +69,22 @@
|
|||||||
<!-- Current location display -->
|
<!-- Current location display -->
|
||||||
{#if location}
|
{#if location}
|
||||||
<div
|
<div
|
||||||
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 sm:flex"
|
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="h-6 w-6 rounded-full ring-1 ring-border"
|
class="h-6 w-6 shrink-0 rounded-full ring-1 ring-border"
|
||||||
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||||
alt={location.country}
|
alt={location.country}
|
||||||
/>
|
/>
|
||||||
<!-- the region lives here; the page hero carries the place name -->
|
<!-- full location (desktop); the page hero carries it on smaller screens -->
|
||||||
<span class="text-sm font-semibold text-foreground">
|
<span class="whitespace-nowrap text-sm font-semibold text-foreground">
|
||||||
{#if location.admin1}{location.admin1}, {location.country}{:else}{location.country ??
|
{location.name}
|
||||||
location.name}{/if}
|
{#if location.admin1 || location.country}
|
||||||
|
<span class="font-normal text-muted-foreground">
|
||||||
|
· {#if location.admin1}{location.admin1},
|
||||||
|
{/if}{location.country ?? ''}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -96,6 +102,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Measurement units -->
|
||||||
|
<UnitSelector />
|
||||||
|
|
||||||
<!-- Theme toggle: system → light → dark -->
|
<!-- Theme toggle: system → light → dark -->
|
||||||
<button
|
<button
|
||||||
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
|||||||
@@ -125,30 +125,36 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Collapse toggle -->
|
<!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
|
||||||
<div class="border-t border-sidebar-border px-2 py-3">
|
{#if onToggle}
|
||||||
<button
|
<div class="border-t border-sidebar-border px-2 py-3">
|
||||||
class="relative flex w-full items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100"
|
<button
|
||||||
onclick={onToggle}
|
class="relative flex w-full items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100"
|
||||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
onclick={onToggle}
|
||||||
>
|
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
|
>
|
||||||
<svg
|
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||||
class="h-4.5 w-4.5 transition-transform duration-200"
|
<svg
|
||||||
class:rotate-180={collapsed}
|
class="h-4.5 w-4.5 transition-transform duration-200"
|
||||||
fill="none"
|
class:rotate-180={collapsed}
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
stroke="currentColor"
|
||||||
stroke-width="1.75"
|
viewBox="0 0 24 24"
|
||||||
>
|
stroke-width="1.75"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
>
|
||||||
</svg>
|
<path
|
||||||
</div>
|
stroke-linecap="round"
|
||||||
{#if !collapsed}
|
stroke-linejoin="round"
|
||||||
<span class="ml-2.5 whitespace-nowrap">Collapse</span>
|
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
|
||||||
{/if}
|
/>
|
||||||
</button>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
{#if !collapsed}
|
||||||
|
<span class="ml-2.5 whitespace-nowrap">Collapse</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
+3
-3
@@ -44,8 +44,9 @@
|
|||||||
|
|
||||||
<Popover.Root>
|
<Popover.Root>
|
||||||
<Popover.Trigger
|
<Popover.Trigger
|
||||||
class="flex h-11 cursor-pointer items-center gap-2 rounded-xl border-2 border-border bg-card px-3.5 text-sm font-semibold text-muted-foreground shadow-sm transition-colors hover:border-primary/50 hover:text-foreground data-[state=open]:border-primary/60 data-[state=open]:text-foreground"
|
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 px-2.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
|
||||||
aria-label="Choose measurement units"
|
aria-label="Choose measurement units"
|
||||||
|
title="Units"
|
||||||
>
|
>
|
||||||
<!-- gauge icon -->
|
<!-- gauge icon -->
|
||||||
<svg
|
<svg
|
||||||
@@ -55,7 +56,6 @@
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke-width="1.75"
|
stroke-width="1.75"
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" d="M12 13a9 9 0 0 1 8.5-9a9 9 0 0 1-8.5 15" opacity="0" />
|
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
/>
|
/>
|
||||||
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none" />
|
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="hidden md:inline">Units</span>
|
<span class="hidden text-xs font-semibold sm:inline">Units</span>
|
||||||
</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">
|
<div class="flex flex-col gap-4">
|
||||||
@@ -114,3 +114,12 @@ export const defaultUnits: UnitPrefs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const storedUnits = persisted<UnitPrefs>('units_v1', defaultUnits);
|
export const storedUnits = persisted<UnitPrefs>('units_v1', defaultUnits);
|
||||||
|
|
||||||
|
/** Recently visited and starred locations, shown in the search dropdown. */
|
||||||
|
export const storedRecentLocations = persisted<GeoLocation[]>('recent_locations_v1', []);
|
||||||
|
export const storedFavoriteLocations = persisted<GeoLocation[]>('favorite_locations_v1', []);
|
||||||
|
|
||||||
|
/** Stable key for de-duping locations (geocoding id, or rounded coordinates). */
|
||||||
|
export function locationKey(l: GeoLocation): string {
|
||||||
|
return l.id && l.id !== 0 ? `id:${l.id}` : `c:${l.latitude.toFixed(3)},${l.longitude.toFixed(3)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { fade, fly } from 'svelte/transition';
|
||||||
|
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
import { storedTheme } from '$lib/stores/settings';
|
import { storedTheme } from '$lib/stores/settings';
|
||||||
@@ -63,10 +65,14 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-black/30"
|
class="absolute inset-0 bg-black/30"
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
onclick={closeMobileMenu}
|
onclick={closeMobileMenu}
|
||||||
onkeydown={closeMobileMenu}
|
onkeydown={closeMobileMenu}
|
||||||
></div>
|
></div>
|
||||||
<div class="relative z-1 h-full w-55 shadow-lg">
|
<div
|
||||||
|
class="relative z-1 h-full w-55 shadow-lg"
|
||||||
|
transition:fly={{ x: -220, duration: 200, opacity: 1 }}
|
||||||
|
>
|
||||||
<WeatherNav collapsed={false} onMobileClose={closeMobileMenu} />
|
<WeatherNav collapsed={false} onMobileClose={closeMobileMenu} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
|
|
||||||
import { defaultParameters, ensembleModelGroups } from '../../options';
|
import { defaultParameters, ensembleModelGroups } from '../../options';
|
||||||
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
|
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
|
||||||
import UnitSelector from '../../week/[location]/UnitSelector.svelte';
|
|
||||||
|
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
@@ -155,6 +154,11 @@
|
|||||||
fetchedData ? fetchedData.timestamps.slice(0, validLength).map((t) => t / 1000) : []
|
fetchedData ? fetchedData.timestamps.slice(0, validLength).map((t) => t / 1000) : []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Surface a note when the chosen model's ensemble stops short of the request.
|
||||||
|
let fullHours = $derived(fetchedData?.timestamps.length ?? 0);
|
||||||
|
let validDays = $derived(Math.max(0, Math.round(validLength / 24)));
|
||||||
|
let isTrimmed = $derived(!!fetchedData && validLength > 0 && validLength < fullHours - 1);
|
||||||
|
|
||||||
interface ChartDef {
|
interface ChartDef {
|
||||||
title?: string;
|
title?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
@@ -165,6 +169,23 @@
|
|||||||
|
|
||||||
const BAND_COLOR = 'rgba(115, 192, 222, 0.9)';
|
const BAND_COLOR = 'rgba(115, 192, 222, 0.9)';
|
||||||
|
|
||||||
|
// Human labels for the plotted ensemble variables (API names → readable title)
|
||||||
|
const VAR_LABELS: Record<string, string> = {
|
||||||
|
temperature_2m: 'Temperature',
|
||||||
|
apparent_temperature: 'Feels like',
|
||||||
|
precipitation: 'Precipitation',
|
||||||
|
rain: 'Rain',
|
||||||
|
snowfall: 'Snowfall',
|
||||||
|
wind_speed_10m: 'Wind speed',
|
||||||
|
wind_gusts_10m: 'Wind gusts',
|
||||||
|
relative_humidity_2m: 'Relative humidity',
|
||||||
|
cloud_cover: 'Cloud cover',
|
||||||
|
pressure_msl: 'Pressure (MSL)',
|
||||||
|
dew_point_2m: 'Dew point'
|
||||||
|
};
|
||||||
|
const varLabel = (v: string): string =>
|
||||||
|
VAR_LABELS[v] ?? v.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
|
||||||
let chartDefs = $derived.by((): ChartDef[] => {
|
let chartDefs = $derived.by((): ChartDef[] => {
|
||||||
if (!fetchedData) return [];
|
if (!fetchedData) return [];
|
||||||
|
|
||||||
@@ -217,10 +238,11 @@
|
|||||||
const isLast = vi === variables.length - 1;
|
const isLast = vi === variables.length - 1;
|
||||||
|
|
||||||
defs.push({
|
defs.push({
|
||||||
title: isFirst ? 'Ensemble Spread' : undefined,
|
// each chart is labelled so the variable is obvious at a glance
|
||||||
|
title: `${varLabel(variable)}${isColumn ? '' : ' spread'}`,
|
||||||
subtitle: isFirst
|
subtitle: isFirst
|
||||||
? `${variable} min/mean/max across ${memberCount} members (${params.models?.[0] ?? ''})`
|
? `min · mean · max across ${memberCount} ensemble members`
|
||||||
: undefined,
|
: `min · mean · max (${unit})`,
|
||||||
unit,
|
unit,
|
||||||
showCredit: isLast,
|
showCredit: isLast,
|
||||||
series
|
series
|
||||||
@@ -244,7 +266,12 @@
|
|||||||
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
||||||
{location.name}
|
{location.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="truncate text-sm text-muted-foreground">14-day ensemble forecast</p>
|
<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
|
||||||
|
>14-day ensemble forecast
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -258,12 +285,35 @@
|
|||||||
storedEnsembleModel.set(model);
|
storedEnsembleModel.set(model);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<UnitSelector />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||||
|
|
||||||
|
{#if isTrimmed}
|
||||||
|
<div
|
||||||
|
class="mb-4 flex items-start gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3.5 py-2.5 text-sm text-amber-800 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="mt-0.5 h-4 w-4 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 9v4m0 4h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead — the spread
|
||||||
|
is trimmed to its available range.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if loadError}
|
{#if loadError}
|
||||||
<div
|
<div
|
||||||
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
|
|
||||||
import { findModel, hourly, modelGroups } from '../../options';
|
import { findModel, hourly, modelGroups } from '../../options';
|
||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters } from '../../options';
|
||||||
import UnitSelector from '../../week/[location]/UnitSelector.svelte';
|
|
||||||
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
||||||
|
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
@@ -277,12 +276,9 @@
|
|||||||
<div class="mt-6 md:mt-10">
|
<div class="mt-6 md:mt-10">
|
||||||
<ChartToolbar charts={liveCharts} fileName="model-comparison">
|
<ChartToolbar charts={liveCharts} fileName="model-comparison">
|
||||||
{#snippet controls()}
|
{#snippet controls()}
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
<Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
|
||||||
<Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
|
|
||||||
</div>
|
|
||||||
<UnitSelector />
|
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ChartToolbar>
|
</ChartToolbar>
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ const weatherCodes: Record<number, string> = {
|
|||||||
51: 'sprinkle',
|
51: 'sprinkle',
|
||||||
52: 'rain',
|
52: 'rain',
|
||||||
53: 'rain',
|
53: 'rain',
|
||||||
54: 'snowflake-cold',
|
54: 'sprinkle',
|
||||||
55: 'snowflake-cold',
|
55: 'rain',
|
||||||
56: 'snowflake-cold',
|
56: 'rain-mix',
|
||||||
57: 'sprinkle',
|
57: 'sprinkle',
|
||||||
58: 'rain',
|
58: 'rain',
|
||||||
60: 'sprinkle',
|
60: 'sprinkle',
|
||||||
@@ -56,11 +56,11 @@ const weatherCodes: Record<number, string> = {
|
|||||||
71: 'snow',
|
71: 'snow',
|
||||||
72: 'snow',
|
72: 'snow',
|
||||||
73: 'snow',
|
73: 'snow',
|
||||||
74: 'snowflake-cold',
|
74: 'snow',
|
||||||
75: 'snowflake-cold',
|
75: 'snow',
|
||||||
76: 'snowflake-cold',
|
76: 'snow',
|
||||||
77: 'snow',
|
77: 'snow',
|
||||||
78: 'snowflake-cold',
|
78: 'snow',
|
||||||
80: 'rain',
|
80: 'rain',
|
||||||
81: 'sprinkle',
|
81: 'sprinkle',
|
||||||
82: 'rain',
|
82: 'rain',
|
||||||
@@ -80,10 +80,13 @@ const weatherCodes: Record<number, string> = {
|
|||||||
99: 'tornado'
|
99: 'tornado'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// These conditions ship only as a single neutral glyph (no day/night variant).
|
||||||
|
const NEUTRAL_ICONS = new Set(['snowflake-cold', 'strong-wind', 'dust', 'tornado']);
|
||||||
|
|
||||||
export function getWeatherIconName(code: number, daytime: boolean): string {
|
export function getWeatherIconName(code: number, daytime: boolean): string {
|
||||||
const prefix = daytime ? 'day' : 'night';
|
|
||||||
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
|
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
|
||||||
return `wi-${prefix}-${name}`;
|
if (NEUTRAL_ICONS.has(name)) return `wi-${name}`;
|
||||||
|
return `wi-${daytime ? 'day' : 'night'}-${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default weatherCodes;
|
export default weatherCodes;
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
import HourlyTable from './HourlyTable.svelte';
|
import HourlyTable from './HourlyTable.svelte';
|
||||||
import MeteogramCharts from './MeteogramCharts.svelte';
|
import MeteogramCharts from './MeteogramCharts.svelte';
|
||||||
import ModelSelector from './ModelSelector.svelte';
|
import ModelSelector from './ModelSelector.svelte';
|
||||||
import UnitSelector from './UnitSelector.svelte';
|
|
||||||
import VariableSidebar from './VariableSidebar.svelte';
|
import VariableSidebar from './VariableSidebar.svelte';
|
||||||
import { neededHourlyApiVars } from './variables';
|
import { neededHourlyApiVars } from './variables';
|
||||||
|
|
||||||
@@ -72,6 +71,8 @@
|
|||||||
|
|
||||||
// 7 by default; the user can extend to the model's longer range (up to 16 days)
|
// 7 by default; the user can extend to the model's longer range (up to 16 days)
|
||||||
let forecastDays = $state(7);
|
let forecastDays = $state(7);
|
||||||
|
// 0 by default; the user can pull in a few recent past days
|
||||||
|
let pastDays = $state(0);
|
||||||
|
|
||||||
const selectedDay = new SvelteDate();
|
const selectedDay = new SvelteDate();
|
||||||
|
|
||||||
@@ -111,7 +112,7 @@
|
|||||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
||||||
forecast_days: forecastDays,
|
forecast_days: forecastDays,
|
||||||
past_days: 0,
|
past_days: pastDays,
|
||||||
timezone: loc.timezone
|
timezone: loc.timezone
|
||||||
})
|
})
|
||||||
.then((result: WeekForecastResult) => {
|
.then((result: WeekForecastResult) => {
|
||||||
@@ -164,7 +165,12 @@
|
|||||||
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
||||||
{location.name}
|
{location.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="truncate text-sm text-muted-foreground">7-day forecast</p>
|
<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
|
||||||
|
>7-day forecast
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -174,10 +180,11 @@
|
|||||||
onModelChange={(model) => {
|
onModelChange={(model) => {
|
||||||
params.models = [model];
|
params.models = [model];
|
||||||
storedModel.set(model);
|
storedModel.set(model);
|
||||||
forecastDays = 7; // a new model may not support the extended range
|
// a new model may not support the extended / past range
|
||||||
|
forecastDays = 7;
|
||||||
|
pastDays = 0;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<UnitSelector />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -198,6 +205,8 @@
|
|||||||
onSelectDay={switchDay}
|
onSelectDay={switchDay}
|
||||||
canExtend={forecastDays < 15}
|
canExtend={forecastDays < 15}
|
||||||
onExtend={() => (forecastDays = 15)}
|
onExtend={() => (forecastDays = 15)}
|
||||||
|
canExtendPast={pastDays < 3}
|
||||||
|
onExtendPast={() => (pastDays = 3)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if fetchedHourly && fetchedDaily}
|
{#if fetchedHourly && fetchedDaily}
|
||||||
|
|||||||
@@ -15,9 +15,44 @@
|
|||||||
/** Offer a button after the last day to load the model's longer range */
|
/** Offer a button after the last day to load the model's longer range */
|
||||||
canExtend?: boolean;
|
canExtend?: boolean;
|
||||||
onExtend?: () => void;
|
onExtend?: () => void;
|
||||||
|
/** Offer a button before the first day to load recent past days */
|
||||||
|
canExtendPast?: boolean;
|
||||||
|
onExtendPast?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { daily, selectedDay, units, onSelectDay, canExtend = false, onExtend }: Props = $props();
|
let {
|
||||||
|
daily,
|
||||||
|
selectedDay,
|
||||||
|
units,
|
||||||
|
onSelectDay,
|
||||||
|
canExtend = false,
|
||||||
|
onExtend,
|
||||||
|
canExtendPast = false,
|
||||||
|
onExtendPast
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// The "past days" button sits before the first card but starts scrolled out
|
||||||
|
// of view — the user reveals it by scrolling left. Re-hide only when the
|
||||||
|
// dataset (location) changes, not on every re-render.
|
||||||
|
let scrollEl = $state<HTMLDivElement>();
|
||||||
|
let pastBtnEl = $state<HTMLButtonElement>();
|
||||||
|
let hiddenForRef: FetchedDaily | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const d = daily;
|
||||||
|
if (!d || !canExtendPast || !scrollEl || !pastBtnEl || hiddenForRef === d) return;
|
||||||
|
hiddenForRef = d;
|
||||||
|
const el = scrollEl;
|
||||||
|
const btn = pastBtnEl;
|
||||||
|
// defer to after layout so the measured positions and scroll width are final
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const b = btn.getBoundingClientRect();
|
||||||
|
const c = el.getBoundingClientRect();
|
||||||
|
// scroll so the button's right edge sits just past the left edge (a small
|
||||||
|
// gap of extra margin keeps the first day card from hugging the edge)
|
||||||
|
el.scrollLeft += b.right - c.left + 6;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function getDaylightSeconds(index: number): number {
|
function getDaylightSeconds(index: number): number {
|
||||||
if (!daily) return 0;
|
if (!daily) return 0;
|
||||||
@@ -77,10 +112,42 @@
|
|||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<div in:fade out:fade class="mb-6 min-h-[260px]">
|
<div in:fade out:fade class="mb-6 min-h-[260px]">
|
||||||
<!-- generous padding on all sides so a lifted/scaled active or hovered card
|
<!-- negative margin + matching padding: the scroll box gains room so a
|
||||||
(and its shadow) is never clipped by the horizontal scroll container -->
|
lifted/scaled/shadowed card is never clipped, while the first card still
|
||||||
<div class="flex gap-2 overflow-x-auto px-3 pt-3 pb-5" style="scrollbar-width: thin">
|
lines up with the page content edge -->
|
||||||
|
<div
|
||||||
|
bind:this={scrollEl}
|
||||||
|
class="-mx-3 flex gap-2 overflow-x-auto px-3 pt-5 pb-11"
|
||||||
|
style="scrollbar-width: thin"
|
||||||
|
>
|
||||||
{#if daily}
|
{#if daily}
|
||||||
|
{#if canExtendPast && onExtendPast}
|
||||||
|
<button
|
||||||
|
bind:this={pastBtnEl}
|
||||||
|
type="button"
|
||||||
|
class="flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
|
||||||
|
onclick={onExtendPast}
|
||||||
|
aria-label="Load recent past days"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
|
||||||
|
/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 13l-3 3 3 3" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-center text-[11px] leading-tight font-semibold">
|
||||||
|
Past<br />3 days
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#each daily.dailyDates as time, index (index)}
|
{#each daily.dailyDates as time, index (index)}
|
||||||
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||||
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
||||||
@@ -264,15 +331,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Mobile: keep the exact desktop layout (so nothing wraps), then scale the
|
/* Mobile: keep the exact desktop layout, just scale the whole card down. */
|
||||||
whole card down uniformly. A fixed width matches a full desktop card and
|
|
||||||
the row scrolls horizontally. */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.day-card {
|
.day-card {
|
||||||
flex: 0 0 auto;
|
zoom: 0.72;
|
||||||
width: 168px;
|
|
||||||
max-width: none;
|
|
||||||
zoom: 0.82;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -387,7 +387,7 @@
|
|||||||
<!-- "Now" label, aligned with the sunrise/sunset labels along the bottom -->
|
<!-- "Now" label, aligned with the sunrise/sunset labels along the bottom -->
|
||||||
{#if isTodaySelected && nowPercent != null}
|
{#if isTodaySelected && nowPercent != null}
|
||||||
<span
|
<span
|
||||||
class="absolute bottom-0.5 z-30 -translate-x-1/2 rounded-full bg-red-500 px-1.5 py-px text-[9px] font-bold tracking-wide whitespace-nowrap text-white uppercase shadow-sm"
|
class="absolute bottom-0.5 z-15 -translate-x-1/2 rounded-full bg-red-500 px-1.5 py-px text-[9px] font-bold tracking-wide whitespace-nowrap text-white uppercase shadow-sm"
|
||||||
style="left:{nowPercent}%"
|
style="left:{nowPercent}%"
|
||||||
>
|
>
|
||||||
Now
|
Now
|
||||||
|
|||||||
@@ -241,7 +241,7 @@
|
|||||||
{#each renderPanels as panel, i (panel.id)}
|
{#each renderPanels as panel, i (panel.id)}
|
||||||
<!-- full-bleed to the screen edges on mobile; a contained card on md+ -->
|
<!-- full-bleed to the screen edges on mobile; a contained card on md+ -->
|
||||||
<div
|
<div
|
||||||
class="-mx-5 border-y border-border/70 bg-card px-1 py-3 shadow-sm md:mx-0 md:rounded-2xl md:border md:px-4 md:py-4"
|
class="-mx-5 border-y border-border/70 bg-card px-0 py-3 shadow-sm md:mx-0 md:rounded-2xl md:border md:px-4 md:py-4"
|
||||||
>
|
>
|
||||||
<div class="mb-1 flex items-center justify-between px-3 md:px-1">
|
<div class="mb-1 flex items-center justify-between px-3 md:px-1">
|
||||||
<h4 class="truncate text-sm font-bold text-muted-foreground">
|
<h4 class="truncate text-sm font-bold text-muted-foreground">
|
||||||
|
|||||||
Reference in New Issue
Block a user