Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fb12d03e0 | ||
|
|
b4e509f9cb |
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
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 { Button } from '$lib/components/ui/button';
|
||||
@@ -30,11 +35,31 @@
|
||||
};
|
||||
|
||||
const selectLocation = (location: GeoLocation) => {
|
||||
addRecent(location);
|
||||
searchQuery = '';
|
||||
closePopover();
|
||||
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() {
|
||||
await tick();
|
||||
searchInputEl?.focus();
|
||||
@@ -101,6 +126,55 @@
|
||||
})();
|
||||
</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.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"
|
||||
@@ -178,8 +252,35 @@
|
||||
</div>
|
||||
</div>
|
||||
{:then results}
|
||||
{#if results.results && results.results.length === 0}
|
||||
{#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
|
||||
class="flex items-start gap-2 rounded-md bg-primary/8 p-2.5 text-muted-foreground"
|
||||
>
|
||||
@@ -200,7 +301,14 @@
|
||||
Start typing to search or use GPS to detect your position
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
{/if}
|
||||
{: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"
|
||||
>
|
||||
@@ -208,54 +316,10 @@
|
||||
No locations found for "{searchQuery}". Try a different term.
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
{:else if !results.results}
|
||||
{:else}
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Description>No locations found</Alert.Description>
|
||||
</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}
|
||||
{:catch error}
|
||||
<Alert.Root variant="destructive">
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { buildLocationRoute } from '$lib/utils/location';
|
||||
|
||||
import LocationSearch from '$lib/components/location/location-search.svelte';
|
||||
import UnitSelector from '$lib/components/unit-selector.svelte';
|
||||
|
||||
interface Props {
|
||||
onMenuToggle?: () => void;
|
||||
@@ -68,17 +69,22 @@
|
||||
<!-- Current location display -->
|
||||
{#if location}
|
||||
<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
|
||||
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"
|
||||
alt={location.country}
|
||||
/>
|
||||
<!-- the region lives here; the page hero carries the place name -->
|
||||
<span class="text-sm font-semibold text-foreground">
|
||||
{#if location.admin1}{location.admin1}, {location.country}{:else}{location.country ??
|
||||
location.name}{/if}
|
||||
<!-- full location (desktop); the page hero carries it on smaller screens -->
|
||||
<span class="whitespace-nowrap text-sm font-semibold text-foreground">
|
||||
{location.name}
|
||||
{#if location.admin1 || location.country}
|
||||
<span class="font-normal text-muted-foreground">
|
||||
· {#if location.admin1}{location.admin1},
|
||||
{/if}{location.country ?? ''}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -96,6 +102,9 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Measurement units -->
|
||||
<UnitSelector />
|
||||
|
||||
<!-- Theme toggle: system → light → dark -->
|
||||
<button
|
||||
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
|
||||
@@ -125,7 +125,8 @@
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Collapse toggle -->
|
||||
<!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
|
||||
{#if onToggle}
|
||||
<div class="border-t border-sidebar-border px-2 py-3">
|
||||
<button
|
||||
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"
|
||||
@@ -141,7 +142,11 @@
|
||||
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" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{#if !collapsed}
|
||||
@@ -149,6 +154,7 @@
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
|
||||
+3
-3
@@ -44,8 +44,9 @@
|
||||
|
||||
<Popover.Root>
|
||||
<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"
|
||||
title="Units"
|
||||
>
|
||||
<!-- gauge icon -->
|
||||
<svg
|
||||
@@ -55,7 +56,6 @@
|
||||
viewBox="0 0 24 24"
|
||||
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
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -63,7 +63,7 @@
|
||||
/>
|
||||
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
<span class="hidden md:inline">Units</span>
|
||||
<span class="hidden text-xs font-semibold sm:inline">Units</span>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="end" class="w-64 border-border">
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -114,3 +114,12 @@ export const defaultUnits: UnitPrefs = {
|
||||
};
|
||||
|
||||
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">
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import { storedTheme } from '$lib/stores/settings';
|
||||
@@ -63,10 +65,14 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/30"
|
||||
transition:fade={{ duration: 150 }}
|
||||
onclick={closeMobileMenu}
|
||||
onkeydown={closeMobileMenu}
|
||||
></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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
|
||||
import { defaultParameters, ensembleModelGroups } from '../../options';
|
||||
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
|
||||
import UnitSelector from '../../week/[location]/UnitSelector.svelte';
|
||||
|
||||
import type { PageData } from './$types';
|
||||
|
||||
@@ -155,6 +154,11 @@
|
||||
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 {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
@@ -165,6 +169,23 @@
|
||||
|
||||
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[] => {
|
||||
if (!fetchedData) return [];
|
||||
|
||||
@@ -217,10 +238,11 @@
|
||||
const isLast = vi === variables.length - 1;
|
||||
|
||||
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
|
||||
? `${variable} min/mean/max across ${memberCount} members (${params.models?.[0] ?? ''})`
|
||||
: undefined,
|
||||
? `min · mean · max across ${memberCount} ensemble members`
|
||||
: `min · mean · max (${unit})`,
|
||||
unit,
|
||||
showCredit: isLast,
|
||||
series
|
||||
@@ -244,7 +266,12 @@
|
||||
<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">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>
|
||||
|
||||
@@ -258,12 +285,35 @@
|
||||
storedEnsembleModel.set(model);
|
||||
}}
|
||||
/>
|
||||
<UnitSelector />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 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}
|
||||
<div
|
||||
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 { defaultParameters } from '../../options';
|
||||
import UnitSelector from '../../week/[location]/UnitSelector.svelte';
|
||||
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
||||
|
||||
import type { PageData } from './$types';
|
||||
@@ -277,13 +276,10 @@
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={liveCharts} fileName="model-comparison">
|
||||
{#snippet controls()}
|
||||
<div class="flex items-center gap-4">
|
||||
<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>
|
||||
<UnitSelector />
|
||||
</div>
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
</div>
|
||||
|
||||
@@ -38,9 +38,9 @@ const weatherCodes: Record<number, string> = {
|
||||
51: 'sprinkle',
|
||||
52: 'rain',
|
||||
53: 'rain',
|
||||
54: 'snowflake-cold',
|
||||
55: 'snowflake-cold',
|
||||
56: 'snowflake-cold',
|
||||
54: 'sprinkle',
|
||||
55: 'rain',
|
||||
56: 'rain-mix',
|
||||
57: 'sprinkle',
|
||||
58: 'rain',
|
||||
60: 'sprinkle',
|
||||
@@ -56,11 +56,11 @@ const weatherCodes: Record<number, string> = {
|
||||
71: 'snow',
|
||||
72: 'snow',
|
||||
73: 'snow',
|
||||
74: 'snowflake-cold',
|
||||
75: 'snowflake-cold',
|
||||
76: 'snowflake-cold',
|
||||
74: 'snow',
|
||||
75: 'snow',
|
||||
76: 'snow',
|
||||
77: 'snow',
|
||||
78: 'snowflake-cold',
|
||||
78: 'snow',
|
||||
80: 'rain',
|
||||
81: 'sprinkle',
|
||||
82: 'rain',
|
||||
@@ -80,10 +80,13 @@ const weatherCodes: Record<number, string> = {
|
||||
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 {
|
||||
const prefix = daytime ? 'day' : 'night';
|
||||
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;
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
import HourlyTable from './HourlyTable.svelte';
|
||||
import MeteogramCharts from './MeteogramCharts.svelte';
|
||||
import ModelSelector from './ModelSelector.svelte';
|
||||
import UnitSelector from './UnitSelector.svelte';
|
||||
import VariableSidebar from './VariableSidebar.svelte';
|
||||
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)
|
||||
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();
|
||||
|
||||
@@ -111,7 +112,7 @@
|
||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
||||
forecast_days: forecastDays,
|
||||
past_days: 0,
|
||||
past_days: pastDays,
|
||||
timezone: loc.timezone
|
||||
})
|
||||
.then((result: WeekForecastResult) => {
|
||||
@@ -164,7 +165,12 @@
|
||||
<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">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>
|
||||
|
||||
@@ -174,10 +180,11 @@
|
||||
onModelChange={(model) => {
|
||||
params.models = [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>
|
||||
|
||||
@@ -198,6 +205,8 @@
|
||||
onSelectDay={switchDay}
|
||||
canExtend={forecastDays < 15}
|
||||
onExtend={() => (forecastDays = 15)}
|
||||
canExtendPast={pastDays < 3}
|
||||
onExtendPast={() => (pastDays = 3)}
|
||||
/>
|
||||
|
||||
{#if fetchedHourly && fetchedDaily}
|
||||
|
||||
@@ -15,9 +15,44 @@
|
||||
/** Offer a button after the last day to load the model's longer range */
|
||||
canExtend?: boolean;
|
||||
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 {
|
||||
if (!daily) return 0;
|
||||
@@ -77,10 +112,42 @@
|
||||
</svg>
|
||||
|
||||
<div in:fade out:fade class="mb-6 min-h-[260px]">
|
||||
<!-- generous padding on all sides so a lifted/scaled active or hovered card
|
||||
(and its shadow) is never clipped by the horizontal scroll container -->
|
||||
<div class="flex gap-2 overflow-x-auto px-3 pt-3 pb-5" style="scrollbar-width: thin">
|
||||
<!-- negative margin + matching padding: the scroll box gains room so a
|
||||
lifted/scaled/shadowed card is never clipped, while the first card still
|
||||
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 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)}
|
||||
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
||||
@@ -264,15 +331,10 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Mobile: keep the exact desktop layout (so nothing wraps), then scale the
|
||||
whole card down uniformly. A fixed width matches a full desktop card and
|
||||
the row scrolls horizontally. */
|
||||
/* Mobile: keep the exact desktop layout, just scale the whole card down. */
|
||||
@media (max-width: 768px) {
|
||||
.day-card {
|
||||
flex: 0 0 auto;
|
||||
width: 168px;
|
||||
max-width: none;
|
||||
zoom: 0.82;
|
||||
zoom: 0.72;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -387,7 +387,7 @@
|
||||
<!-- "Now" label, aligned with the sunrise/sunset labels along the bottom -->
|
||||
{#if isTodaySelected && nowPercent != null}
|
||||
<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}%"
|
||||
>
|
||||
Now
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
{#each renderPanels as panel, i (panel.id)}
|
||||
<!-- full-bleed to the screen edges on mobile; a contained card on md+ -->
|
||||
<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">
|
||||
<h4 class="truncate text-sm font-bold text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user