This commit is contained in:
Vincent van der Wal
2026-07-19 16:13:51 +02:00
parent e031716ce6
commit baa4840b38
26 changed files with 1077 additions and 702 deletions
View File
View File
View File
View File
View File
View File
View File
View File
+14
View File
@@ -3,6 +3,20 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
// apply the persisted theme before first paint to avoid a flash
try {
var theme = JSON.parse(localStorage.getItem('theme') || '"system"');
if (
theme === 'dark' ||
(theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark');
}
} catch (e) {
/* ignore */
}
</script>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
+46 -1
View File
@@ -1 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<title>OMbrella</title>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e0f2fe" />
<stop offset="1" stop-color="#bae6fd" />
</linearGradient>
<linearGradient id="canopy" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fb923c" />
<stop offset="1" stop-color="#ea580c" />
</linearGradient>
<linearGradient id="drop" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#38bdf8" />
<stop offset="1" stop-color="#2563eb" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#sky)" />
<!-- raindrops falling onto the umbrella -->
<path d="M12 5c2 2.9 3 4.6 3 6.2a3 3 0 0 1-6 0c0-1.6 1-3.3 3-6.2Z" fill="url(#drop)" />
<path d="M53 4c2 2.9 3 4.6 3 6.2a3 3 0 0 1-6 0c0-1.6 1-3.3 3-6.2Z" fill="url(#drop)" />
<path d="M23 2.5c1.7 2.4 2.5 3.9 2.5 5.2a2.5 2.5 0 0 1-5 0c0-1.3.8-2.8 2.5-5.2Z" fill="url(#drop)" />
<path d="M45 12c1.7 2.4 2.5 3.9 2.5 5.2a2.5 2.5 0 0 1-5 0c0-1.3.8-2.8 2.5-5.2Z" fill="url(#drop)" />
<!-- pole with curved handle -->
<path
d="M33 35v17a4.5 4.5 0 0 1-9 0"
fill="none"
stroke="#475569"
stroke-width="3.25"
stroke-linecap="round"
/>
<!-- canopy tip -->
<path d="M33 13.5v4" fill="none" stroke="#475569" stroke-width="3" stroke-linecap="round" />
<!-- canopy with scalloped edge -->
<path
d="M11 37c0-11.6 9.8-21 22-21s22 9.4 22 21c-1.8-2.9-5.5-2.9-7.3 0-1.9-2.9-5.5-2.9-7.4 0-1.8-2.9-5.5-2.9-7.3 0-1.9-2.9-5.5-2.9-7.4 0-1.8-2.9-5.5-2.9-7.3 0-1.9-2.9-5.5-2.9-7.3 0Z"
fill="url(#canopy)"
/>
<!-- ribs -->
<path
d="M33 16.5c-5.2 3-7.4 11-7.3 19M33 16.5c5.2 3 7.4 11 7.3 19"
fill="none"
stroke="#9a3412"
stroke-width="1.5"
opacity="0.35"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

+31 -1
View File
@@ -4,7 +4,7 @@
Renders line and bar series over a shared hourly time axis on a Renders line and bar series over a shared hourly time axis on a
devicePixelRatio-aware canvas. Supports daylight background bands, devicePixelRatio-aware canvas. Supports daylight background bands,
timezone-aware axis labels with emphasized day boundaries, a DOM tooltip timezone-aware axis labels with emphasized day boundaries, a DOM tooltip
with crosshair, wheel/pinch zoom, drag panning, and cross-chart with crosshair, Ctrl+wheel / pinch zoom, drag panning, and cross-chart
synchronization via the `group` prop. synchronization via the `group` prop.
Usage: Usage:
@@ -161,6 +161,16 @@
let themeVersion = $state(0); let themeVersion = $state(0);
const legendHidden = new SvelteSet<string>(); const legendHidden = new SvelteSet<string>();
// Briefly shown when the user scrolls over the chart without holding Ctrl
let zoomHintVisible = $state(false);
let zoomHintTimeout: ReturnType<typeof setTimeout> | undefined;
function showZoomHint(): void {
zoomHintVisible = true;
clearTimeout(zoomHintTimeout);
zoomHintTimeout = setTimeout(() => (zoomHintVisible = false), 1200);
}
// Zoom range and crosshair: either group-shared or local to this chart. // Zoom range and crosshair: either group-shared or local to this chart.
// The group is acquired once at component init (the prop is treated as fixed). // The group is acquired once at component init (the prop is treated as fixed).
const groupName = untrack(() => group); const groupName = untrack(() => group);
@@ -170,6 +180,7 @@
onDestroy(() => { onDestroy(() => {
if (groupName) releaseGroup(groupName); if (groupName) releaseGroup(groupName);
clearTimeout(zoomHintTimeout);
}); });
// ─── Derived: view window & scales ────────────────────────────────────────── // ─── Derived: view window & scales ──────────────────────────────────────────
@@ -737,6 +748,12 @@
}; };
const onWheel = (e: WheelEvent): void => { const onWheel = (e: WheelEvent): void => {
// Zoom only while Ctrl (or ⌘) is held — a plain scroll should keep
// scrolling the page. Trackpad pinch also arrives as ctrlKey wheel.
if (!e.ctrlKey && !e.metaKey) {
showZoomHint();
return;
}
e.preventDefault(); e.preventDefault();
const t = pixToTime(localX(e)); const t = pixToTime(localX(e));
zoomAt(t, e.deltaY < 0 ? 1 / 1.3 : 1.3); zoomAt(t, e.deltaY < 0 ? 1 / 1.3 : 1.3);
@@ -803,6 +820,19 @@
style:touch-action="pan-y" style:touch-action="pan-y"
></canvas> ></canvas>
{#if zoomHintVisible}
<div
class="pointer-events-none absolute inset-0 z-30 flex items-center justify-center rounded-md bg-black/25 transition-opacity"
>
<span
class="rounded-lg bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
>
Hold <kbd class="rounded border border-border bg-muted px-1.5 py-0.5 text-xs">Ctrl</kbd>
and scroll to zoom
</span>
</div>
{/if}
{#if tooltipVisible} {#if tooltipVisible}
<div <div
class="pointer-events-none absolute z-20 rounded-md border border-border bg-popover px-3 py-2 text-xs whitespace-nowrap text-popover-foreground shadow-md" class="pointer-events-none absolute z-20 rounded-md border border-border bg-popover px-3 py-2 text-xs whitespace-nowrap text-popover-foreground shadow-md"
@@ -103,10 +103,10 @@
<Popover.Root bind:open={popoverOpen}> <Popover.Root bind:open={popoverOpen}>
<Popover.Trigger <Popover.Trigger
class="flex h-9 w-full cursor-pointer items-center gap-2 rounded-md border border-border bg-background px-3 text-[0.8125rem] text-muted-foreground transition-[border-color,box-shadow] duration-150 hover:border-primary" 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"
> >
<svg <svg
class="h-3.5 w-3.5 shrink-0 opacity-50" class="h-4 w-4 shrink-0 text-primary"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
+73 -6
View File
@@ -5,7 +5,7 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { type GeoLocation, storedLocation } from '$lib/stores/settings'; import { type GeoLocation, type Theme, storedLocation, storedTheme } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location'; import { buildLocationRoute } from '$lib/utils/location';
@@ -23,6 +23,19 @@
location = value; location = value;
}); });
const themeCycle: Theme[] = ['system', 'light', 'dark'];
const themeTitles: Record<Theme, string> = {
system: 'Theme: follow system',
light: 'Theme: light',
dark: 'Theme: dark'
};
function cycleTheme() {
storedTheme.update(
(current) => themeCycle[(themeCycle.indexOf(current) + 1) % themeCycle.length]
);
}
function navigateToLocation(newLocation: GeoLocation) { function navigateToLocation(newLocation: GeoLocation) {
storedLocation.set(newLocation); storedLocation.set(newLocation);
const locationRoute = buildLocationRoute(newLocation); const locationRoute = buildLocationRoute(newLocation);
@@ -54,17 +67,19 @@
<!-- Current location display --> <!-- Current location display -->
{#if location} {#if location}
<div class="hidden items-center gap-2 sm:flex"> <div
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 sm:flex"
>
<img <img
class="h-5 w-5 rounded-full" class="h-6 w-6 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}
/> />
<span class="text-sm font-medium text-foreground"> <span class="text-sm font-semibold text-foreground">
{location.name} {location.name}
</span> </span>
{#if location.admin1 || location.country} {#if location.admin1 || location.country}
<span class="text-xs text-muted-foreground"> <span class="hidden text-xs text-muted-foreground lg:inline">
{#if location.admin1}{location.admin1},{/if} {#if location.admin1}{location.admin1},{/if}
{location.country} {location.country}
</span> </span>
@@ -75,7 +90,7 @@
<!-- Spacer --> <!-- Spacer -->
<div class="flex-1"></div> <div class="flex-1"></div>
<!-- Location search --> <!-- Location search: primary way to switch places, so keep it loud -->
<div class="w-full max-w-sm md:max-w-md"> <div class="w-full max-w-sm md:max-w-md">
<LocationSearch <LocationSearch
label="Search location..." label="Search location..."
@@ -84,6 +99,58 @@
}} }}
/> />
</div> </div>
<!-- Theme toggle: system → light → dark -->
<button
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onclick={cycleTheme}
title={themeTitles[$storedTheme]}
aria-label={themeTitles[$storedTheme]}
>
{#if $storedTheme === 'light'}
<!-- sun -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<circle cx="12" cy="12" r="4" />
<path
stroke-linecap="round"
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32 1.41-1.41"
/>
</svg>
{:else if $storedTheme === 'dark'}
<!-- moon -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"
/>
</svg>
{:else}
<!-- monitor (system) -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<rect x="3" y="4" width="18" height="13" rx="2" />
<path stroke-linecap="round" d="M8 21h8m-4-4v4" />
</svg>
{/if}
</button>
</header> </header>
<style> <style>
@@ -66,22 +66,27 @@
<div <div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground" class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground"
> >
<!-- umbrella-with-rain logo mark (matches favicon) -->
<svg <svg
class="h-4.5 w-4.5" class="h-5 w-5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="2" stroke-width="1.75"
> >
<!-- rain falls from above onto the canopy -->
<path stroke-linecap="round" d="M4.5 3v.01M19.5 3v.01M8.5 1.5v.01M15.5 2.5v.01" />
<path stroke-linecap="round" d="M12 4.5V6" />
<path <path
stroke-linecap="round" fill="currentColor"
stroke-linejoin="round" stroke="none"
d="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" d="M4 14a8 8 0 0 1 16 0c-.66-1-1.99-1-2.66 0-.67-1-2-1-2.67 0-.67-1-2-1-2.67 0-.67-1-2-1-2.67 0C8.66 13 7.33 13 6.66 14 6 13 4.66 13 4 14Z"
/> />
<path stroke-linecap="round" d="M12 14v5a1.9 1.9 0 0 1-3.8 0" />
</svg> </svg>
</div> </div>
{#if !collapsed} {#if !collapsed}
<span class="text-sm font-semibold whitespace-nowrap text-sidebar-foreground"> <span class="text-sm font-bold tracking-tight whitespace-nowrap text-sidebar-foreground">
OMbrella OMbrella
</span> </span>
{/if} {/if}
+4
View File
@@ -43,3 +43,7 @@ export const defaultLocation: GeoLocation = {
}; };
export const storedLocation = persisted('stored_location', defaultLocation as GeoLocation); export const storedLocation = persisted('stored_location', defaultLocation as GeoLocation);
export type Theme = 'system' | 'light' | 'dark';
export const storedTheme = persisted<Theme>('theme', 'system');
+16
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { storedTheme } from '$lib/stores/settings';
import Header from '$lib/components/navigation/header.svelte'; import Header from '$lib/components/navigation/header.svelte';
import WeatherNav from '$lib/components/navigation/weather-nav.svelte'; import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
@@ -10,6 +12,20 @@
let { children } = $props(); let { children } = $props();
// keep the .dark class in sync with the persisted theme; in 'system' mode
// follow the OS preference live
$effect(() => {
const theme = $storedTheme;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => {
const dark = theme === 'dark' || (theme === 'system' && mq.matches);
document.documentElement.classList.toggle('dark', dark);
};
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
});
// the maps page embeds a full-bleed map: no padding, no scrolling // the maps page embeds a full-bleed map: no padding, no scrolling
let fullBleed = $derived($page.url.pathname.startsWith('/weather/maps')); let fullBleed = $derived($page.url.pathname.startsWith('/weather/maps'));
+2 -2
View File
@@ -18,8 +18,8 @@
--secondary-foreground: oklch(0.25 0.02 60); --secondary-foreground: oklch(0.25 0.02 60);
--muted: oklch(0.96 0.008 85); --muted: oklch(0.96 0.008 85);
--muted-foreground: oklch(0.5 0.02 60); --muted-foreground: oklch(0.5 0.02 60);
--accent: oklch(0.96 0.008 85); --accent: oklch(0.95 0.02 70);
--accent-foreground: oklch(1 0 0); --accent-foreground: oklch(0.25 0.02 60);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.91 0.01 80); --border: oklch(0.91 0.01 80);
--input: oklch(0.91 0.01 80); --input: oklch(0.91 0.01 80);
@@ -24,13 +24,13 @@
fetchModelComparison fetchModelComparison
} from '$lib/services/weather'; } from '$lib/services/weather';
import { hourly, models as modelsFlat } from '../../options'; import { hourly, modelGroups } from '../../options';
import { defaultParameters } from '../../options'; import { defaultParameters } from '../../options';
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte'; import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
const models = [modelsFlat]; const models = modelGroups.map((group) => group.models);
const CHART_GROUP = 'model-compare'; const CHART_GROUP = 'model-compare';
+302 -48
View File
@@ -5,56 +5,310 @@ export const defaultParameters = {
precipitation_unit: 'mm' precipitation_unit: 'mm'
}; };
export const models = [ export interface WeatherModel {
{ value: 'best_match', label: 'Best match' }, value: string;
{ value: 'ecmwf_ifs', label: 'ECMWF IFS' }, label: string;
{ value: 'ecmwf_ifs025', label: 'ECMWF IFS 0.25' }, /** Native grid resolution, from open-meteo/weather-map-layer domains.ts */
{ value: 'ecmwf_aifs025_single', label: 'ECMWF AIFS 0.25 Single' }, resolution?: string;
{ value: 'cma_grapes_global', label: 'CMA GRAPES Global' }, /** Model-run cadence (model_interval), from domains.ts */
{ value: 'bom_access_global', label: 'BOM ACCESS Global' }, update?: string;
{ value: 'kma_seamless', label: 'KMA Seamless' }, }
{ value: 'kma_ldps', label: 'KMA LDPS' },
{ value: 'kma_gdps', label: 'KMA GDPS' }, export interface WeatherModelGroup {
{ value: 'meteofrance_seamless', label: 'Meteo-France Seamless' }, value: string;
{ value: 'meteofrance_arpege_world', label: 'Meteo-France ARPEGE World' }, label: string;
{ value: 'meteofrance_arpege_europe', label: 'Meteo-France ARPEGE Europe' }, models: WeatherModel[];
{ value: 'meteofrance_arome_france', label: 'Meteo-France AROME France' }, }
{ value: 'meteofrance_arome_france_hd', label: 'Meteo-France AROME France HD' },
{ value: 'knmi_seamless', label: 'KNMI Seamless' }, /**
{ value: 'knmi_harmonie_arome_europe', label: 'KNMI Harmonie Arome Europe' }, * Forecast models grouped by provider. Labels, grid resolutions and update
{ value: 'knmi_harmonie_arome_netherlands', label: 'KNMI Harmonie Arome Netherlands' }, * cadences are synced from the open-meteo/weather-map-layer project
{ value: 'dmi_seamless', label: 'DMI Seamless' }, * (src/domains.ts: domainGroups + domainOptions grid/model_interval data).
{ value: 'dmi_harmonie_arome_europe', label: 'DMI Harmonie Arome Europe' }, */
{ value: 'ukmo_seamless', label: 'UKMO Seamless' }, export const modelGroups: WeatherModelGroup[] = [
{ value: 'ukmo_global_deterministic_10km', label: 'UKMO Global Deterministic 10km' }, {
{ value: 'ukmo_uk_deterministic_2km', label: 'UKMO UK Deterministic 2km' }, value: 'auto',
{ value: 'meteoswiss_icon_seamless', label: 'MeteoSwiss ICON Seamless' }, label: 'Automatic',
{ value: 'meteoswiss_icon_ch2', label: 'MeteoSwiss ICON CH2' }, models: [{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'varies' }]
{ value: 'meteoswiss_icon_ch1', label: 'MeteoSwiss ICON CH1' }, },
{ value: 'metno_nordic', label: 'MET Norway Nordic' }, {
{ value: 'metno_seamless', label: 'MET Norway Seamless' }, value: 'ecmwf',
{ value: 'gem_hrdps_west', label: 'GEM HRDPS West' }, label: 'ECMWF',
{ value: 'gem_regional', label: 'GEM Regional' }, models: [
{ value: 'gem_global', label: 'GEM Global' }, { value: 'ecmwf_ifs', label: 'ECMWF IFS HRES', resolution: '9 km', update: 'every 6 h' },
{ value: 'gem_seamless', label: 'GEM Seamless' }, { value: 'ecmwf_ifs025', label: 'ECMWF IFS 0.25°', resolution: '25 km', update: 'every 6 h' },
{ value: 'jma_seamless', label: 'JMA Seamless' }, {
{ value: 'jma_msm', label: 'JMA MSM' }, value: 'ecmwf_aifs025_single',
{ value: 'jma_gsm', label: 'JMA GSM' }, label: 'ECMWF AIFS 0.25° Single',
{ value: 'gfs_seamless', label: 'GFS Seamless' }, resolution: '25 km',
{ value: 'gfs_global', label: 'GFS Global' }, update: 'every 6 h'
{ value: 'gfs_hrrr', label: 'GFS HRRR' }, }
{ value: 'gfs_graphcast025', label: 'GFS Graphcast 0.25' }, ]
{ value: 'ncep_nbm_conus', label: 'NCEP NBM CONUS' }, },
{ value: 'ncep_nam_conus', label: 'NCEP NAM CONUS' }, {
{ value: 'ncep_aigfs025', label: 'NCEP AIGFS 0.25' }, value: 'dwd',
{ value: 'ncep_hgefs025_ensemble_mean', label: 'NCEP HG-EFS 0.25 Ensemble Mean' }, label: 'DWD Germany',
{ value: 'icon_seamless', label: 'ICON Seamless (DWD)' }, models: [
{ value: 'icon_global', label: 'ICON Global' }, {
{ value: 'icon_eu', label: 'ICON EU' }, value: 'icon_seamless',
{ value: 'icon_d2', label: 'ICON-D2' }, label: 'DWD ICON Seamless',
{ value: 'italia_meteo_arpae_icon_2i', label: 'Italia Meteo ARPAE ICON 2i' } resolution: '2-13 km',
update: 'every 3 h'
},
{ value: 'icon_global', label: 'DWD ICON', resolution: '13 km', update: 'every 6 h' },
{ value: 'icon_eu', label: 'DWD ICON EU', resolution: '7 km', update: 'every 3 h' },
{ value: 'icon_d2', label: 'DWD ICON D2', resolution: '2 km', update: 'every 3 h' }
]
},
{
value: 'ncep',
label: 'NOAA U.S.',
models: [
{ value: 'gfs_seamless', label: 'GFS Seamless', resolution: '3-25 km', update: 'every hour' },
{ value: 'gfs_global', label: 'GFS Global', resolution: '13 km', update: 'every 6 h' },
{ value: 'gfs_hrrr', label: 'GFS HRRR Conus', resolution: '3 km', update: 'every hour' },
{
value: 'gfs_graphcast025',
label: 'GFS GraphCast 0.25°',
resolution: '25 km',
update: 'every 6 h'
},
{
value: 'ncep_aigfs025',
label: 'GFS AIGFS 0.25°',
resolution: '25 km',
update: 'every 6 h'
},
{
value: 'ncep_hgefs025_ensemble_mean',
label: 'GFS HGEFS 0.25° Ensemble Mean',
resolution: '25 km',
update: 'every 6 h'
},
{
value: 'ncep_nbm_conus',
label: 'GFS NBM Conus',
resolution: '2.5 km',
update: 'every hour'
},
{ value: 'ncep_nam_conus', label: 'GFS NAM Conus', resolution: '12 km', update: 'every 6 h' }
]
},
{
value: 'meteofrance',
label: 'Météo-France',
models: [
{
value: 'meteofrance_seamless',
label: 'MF Seamless',
resolution: '1-25 km',
update: 'every 3 h'
},
{
value: 'meteofrance_arpege_world',
label: 'MF ARPEGE World',
resolution: '25 km',
update: 'every 3 h'
},
{
value: 'meteofrance_arpege_europe',
label: 'MF ARPEGE Europe',
resolution: '10 km',
update: 'every 3 h'
},
{
value: 'meteofrance_arome_france',
label: 'MF AROME France',
resolution: '2.5 km',
update: 'every 3 h'
},
{
value: 'meteofrance_arome_france_hd',
label: 'MF AROME France HD',
resolution: '1 km',
update: 'every 3 h'
}
]
},
{
value: 'ukmo',
label: 'UK Met Office',
models: [
{
value: 'ukmo_seamless',
label: 'UKMO Seamless',
resolution: '2-10 km',
update: 'every 3 h'
},
{
value: 'ukmo_global_deterministic_10km',
label: 'UK Met Office 10km',
resolution: '10 km',
update: 'every 3 h'
},
{
value: 'ukmo_uk_deterministic_2km',
label: 'UK Met Office 2km',
resolution: '2 km',
update: 'every 3 h'
}
]
},
{
value: 'knmi',
label: 'KNMI Netherlands',
models: [
{
value: 'knmi_seamless',
label: 'KNMI Seamless',
resolution: '2-3 km',
update: 'every hour'
},
{
value: 'knmi_harmonie_arome_europe',
label: 'KNMI Harmonie Arome Europe',
resolution: '5.5 km',
update: 'every hour'
},
{
value: 'knmi_harmonie_arome_netherlands',
label: 'KNMI Harmonie Arome Netherlands',
resolution: '2 km',
update: 'every hour'
}
]
},
{
value: 'dmi',
label: 'DMI Denmark',
models: [
{ value: 'dmi_seamless', label: 'DMI Seamless', resolution: '2 km', update: 'every 3 h' },
{
value: 'dmi_harmonie_arome_europe',
label: 'DMI Harmonie Arome Europe',
resolution: '2 km',
update: 'every 3 h'
}
]
},
{
value: 'metno',
label: 'MET Norway',
models: [
{
value: 'metno_seamless',
label: 'MET Norway Seamless',
resolution: '1 km',
update: 'every 3 h'
},
{ value: 'metno_nordic', label: 'MET Norway Nordic', resolution: '1 km', update: 'every 3 h' }
]
},
{
value: 'meteoswiss',
label: 'MeteoSwiss',
models: [
{
value: 'meteoswiss_icon_seamless',
label: 'MeteoSwiss ICON Seamless',
resolution: '1-2 km',
update: 'every 3 h'
},
{
value: 'meteoswiss_icon_ch1',
label: 'MeteoSwiss ICON CH1',
resolution: '1 km',
update: 'every 3 h'
},
{
value: 'meteoswiss_icon_ch2',
label: 'MeteoSwiss ICON CH2',
resolution: '2 km',
update: 'every 3 h'
}
]
},
{
value: 'kma',
label: 'KMA Korea',
models: [
{
value: 'kma_seamless',
label: 'KMA Seamless',
resolution: '1.5-13 km',
update: 'every 3 h'
},
{ value: 'kma_ldps', label: 'KMA LDPS', resolution: '1.5 km', update: 'every 3 h' },
{ value: 'kma_gdps', label: 'KMA GDPS 12km', resolution: '13 km', update: 'every 3 h' }
]
},
{
value: 'jma',
label: 'JMA Japan',
models: [
{ value: 'jma_seamless', label: 'JMA Seamless', resolution: '5-55 km', update: 'every 3 h' },
{ value: 'jma_msm', label: 'JMA MSM', resolution: '5 km', update: 'every 3 h' },
{ value: 'jma_gsm', label: 'JMA GSM', resolution: '55 km', update: 'every 6 h' }
]
},
{
value: 'cma',
label: 'CMA China',
models: [
{
value: 'cma_grapes_global',
label: 'CMA GRAPES Global',
resolution: '14 km',
update: 'every 6 h'
}
]
},
{
value: 'bom',
label: 'BOM Australia',
models: [
{
value: 'bom_access_global',
label: 'BOM ACCESS Global',
resolution: '15 km',
update: 'every 12 h'
}
]
},
{
value: 'cmc_gem',
label: 'GEM Canada',
models: [
{ value: 'gem_seamless', label: 'GEM Seamless', resolution: '1-15 km', update: 'every 6 h' },
{ value: 'gem_global', label: 'GEM Global', resolution: '15 km', update: 'every 12 h' },
{ value: 'gem_regional', label: 'GEM Regional', resolution: '10 km', update: 'every 6 h' },
{
value: 'gem_hrdps_west',
label: 'GEM HRDPS West',
resolution: '1 km',
update: 'every 12 h'
}
]
},
{
value: 'italia_meteo',
label: 'ItaliaMeteo',
models: [
{
value: 'italia_meteo_arpae_icon_2i',
label: 'IM ARPAE ICON 2i',
resolution: '2.5 km',
update: 'every 3 h'
}
]
}
]; ];
export const models: WeatherModel[] = modelGroups.flatMap((group) => group.models);
export const findModel = (value: string): WeatherModel | undefined =>
models.find((model) => model.value === value);
export const hourly = [ export const hourly = [
[ [
{ value: 'temperature_2m', label: 'Temperature 2m' }, { value: 'temperature_2m', label: 'Temperature 2m' },
-102
View File
@@ -1,102 +0,0 @@
export default [
'#800080',
'#800083',
'#800087',
'#7f008a',
'#7f008d',
'#7e0090',
'#7d0094',
'#7c0097',
'#7a009a',
'#79009d',
'#7700a1',
'#7600a4',
'#7400a7',
'#7200aa',
'#6f00ae',
'#6d00b1',
'#6a00b4',
'#6700b7',
'#6400bb',
'#6100be',
'#5e00c1',
'#5b00c4',
'#5700c8',
'#5300cb',
'#4f00ce',
'#4b00d1',
'#4700d5',
'#4200d8',
'#3e00db',
'#3900de',
'#3400e2',
'#2f00e5',
'#2a00e8',
'#2400eb',
'#1f00ef',
'#1900f2',
'#1300f5',
'#0d00f8',
'#0600fc',
'#0000ff',
'#0000ff',
'#0021f7',
'#003fee',
'#005ce6',
'#0076dd',
'#008ed5',
'#00a3cc',
'#00b7c4',
'#00bbaf',
'#00b38f',
'#00aa72',
'#00a256',
'#00993d',
'#009127',
'#008812',
'#008000',
'#008000',
'#118c00',
'#259700',
'#3ca300',
'#56ae00',
'#72ba00',
'#92c500',
'#b4d100',
'#d9dc00',
'#e8cf00',
'#f3bb00',
'#ffa500',
'#ffa500',
'#ff9800',
'#ff8c00',
'#ff7f00',
'#ff7200',
'#ff6600',
'#ff5900',
'#ff4c00',
'#ff3f00',
'#ff3300',
'#ff2600',
'#ff1900',
'#ff0d00',
'#ff0000',
'#ff0000',
'#f8000f',
'#f0001c',
'#e90029',
'#e10035',
'#da0040',
'#d2004a',
'#cb0053',
'#c3005c',
'#bc0063',
'#b4006a',
'#ad0070',
'#a50075',
'#9e0079',
'#96007c',
'#8f007e',
'#870080',
'#800080'
];
+100
View File
@@ -0,0 +1,100 @@
/**
* Temperature color scale ported from the open-meteo/weather-map-layer
* project (src/utils/color-scales.ts) so tables and maps share the same
* color language. Values between breakpoints are linearly interpolated.
*/
export type RGBA = [number, number, number, number];
export interface BreakpointScale {
unit: string;
breakpoints: number[];
colors: RGBA[];
}
export const temperatureScale: BreakpointScale = {
unit: '°C',
breakpoints: [
-80, -65, -50, -40, -32, -28, -24, -20, -17.5, -15, -12.5, -10, -8, -6, -4, -2, 0, 2, 4, 6, 8,
10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50
],
colors: [
[74, 13, 0, 1],
[130, 1, 29, 1],
[185, 4, 114, 1],
[221, 6, 193, 1],
[207, 6, 241, 1],
[163, 5, 243, 1],
[118, 4, 246, 1],
[71, 3, 249, 1],
[41, 2, 250, 1],
[11, 1, 252, 1],
[1, 22, 253, 1],
[0, 52, 255, 1],
[35, 99, 251, 1],
[69, 139, 247, 1],
[102, 171, 245, 1],
[134, 197, 245, 1],
[114, 232, 165, 1],
[78, 232, 133, 1],
[40, 233, 96, 1],
[17, 220, 61, 1],
[9, 191, 36, 1],
[4, 160, 15, 1],
[0, 128, 0, 1],
[40, 160, 0, 1],
[96, 192, 0, 1],
[167, 223, 0, 1],
[255, 255, 0, 1],
[255, 237, 0, 1],
[255, 219, 0, 1],
[255, 201, 0, 1],
[255, 183, 0, 1],
[255, 165, 0, 1],
[255, 138, 0, 1],
[255, 110, 0, 1],
[255, 83, 0, 1],
[255, 55, 0, 1],
[255, 27, 0, 1],
[255, 0, 0, 1],
[228, 0, 10, 1],
[201, 0, 18, 1],
[174, 0, 23, 1],
[147, 0, 26, 1]
]
};
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
/** Linearly interpolated color for `value` on a breakpoint scale. */
export const sampleScale = (breakpoints: number[], colors: RGBA[], value: number): RGBA => {
if (!Number.isFinite(value) || value <= breakpoints[0]) return colors[0];
const last = breakpoints.length - 1;
if (value >= breakpoints[last]) return colors[last];
let i = 0;
while (value > breakpoints[i + 1]) i++;
const t = (value - breakpoints[i]) / (breakpoints[i + 1] - breakpoints[i]);
const [r1, g1, b1, a1] = colors[i];
const [r2, g2, b2, a2] = colors[i + 1];
return [
Math.round(lerp(r1, r2, t)),
Math.round(lerp(g1, g2, t)),
Math.round(lerp(b1, b2, t)),
lerp(a1, a2, t)
];
};
export const rgbaCss = ([r, g, b, a]: RGBA): string =>
`rgba(${r}, ${g}, ${b}, ${Math.round(a * 1000) / 1000})`;
/**
* Whether text over `color` should be white, after alpha-compositing the
* color onto the page background (light or dark).
*/
export const needsWhiteText = ([r, g, b, a]: RGBA, dark = false): boolean => {
const base = dark ? 26 : 255;
const cr = r * a + base * (1 - a);
const cg = g * a + base * (1 - a);
const cb = b * a + base * (1 - a);
return cr * 0.299 + cg * 0.587 + cb * 0.114 <= 150;
};
+9 -71
View File
@@ -1,57 +1,13 @@
import colorScaleHex from './color-scale-hex'; import { type RGBA, needsWhiteText, rgbaCss, sampleScale, temperatureScale } from './color-scales';
const componentFromStr = (numStr: string, percent: number) => { const toCelsius = (temperature: number, unit: string): number =>
const num = Math.max(0, parseInt(numStr, 10)); unit === 'celsius' ? temperature : ((temperature - 32) * 5) / 9;
return percent ? Math.floor((255 * Math.min(100, num)) / 100) : Math.min(255, num);
};
export const rgbToHex = (rgb: string): string => { export const getTempColor = (temperature: number, unit = 'celsius'): RGBA =>
const rgbRegex = /^rgb\(\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*\)$/; sampleScale(temperatureScale.breakpoints, temperatureScale.colors, toCelsius(temperature, unit));
let result,
r,
g,
b,
hex = '';
if ((result = rgbRegex.exec(rgb))) {
r = componentFromStr(result[1], Number(result[2]));
g = componentFromStr(result[3], Number(result[4]));
b = componentFromStr(result[5], Number(result[6]));
hex = (0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1); export const getColor = (temperature: number, unit = 'celsius'): string =>
} rgbaCss(getTempColor(temperature, unit));
if (!rgb) {
return '355522';
}
return hex;
};
export const hexToRgb = (hex: string): [number, number, number] => {
const h = hex.replace('#', '');
return [
parseInt(h.substring(0, 2), 16),
parseInt(h.substring(2, 4), 16),
parseInt(h.substring(4, 6), 16)
];
};
export const getColor = (temperature: number, unit = 'celsius'): string => {
if (unit !== 'celsius') {
temperature = Math.round(((temperature - 32) * 5) / 9);
}
let index: number;
if (temperature <= -40) {
index = 0;
} else if (temperature >= 60) {
index = colorScaleHex.length - 1;
} else {
// clamp: the scale has exactly 100 entries (-45..54), temperatures in
// [55, 60) would otherwise index past the end
index = Math.min(colorScaleHex.length - 1, Math.max(0, Math.round(temperature) + 45));
}
return colorScaleHex[index];
};
export interface TempStyle { export interface TempStyle {
bg: string; bg: string;
@@ -59,24 +15,6 @@ export interface TempStyle {
} }
export const getTempStyle = (temp: number, unit: string): TempStyle => { export const getTempStyle = (temp: number, unit: string): TempStyle => {
const bg = getColor(temp, unit); const rgba = getTempColor(temp, unit);
const fg = textWhite(hexToRgb(bg)) ? 'white' : 'black'; return { bg: rgbaCss(rgba), fg: needsWhiteText(rgba) ? 'white' : 'black' };
return { bg, fg };
};
export const textWhite = (
[r, g, b, a]: [number, number, number, number] | [number, number, number],
dark?: boolean,
globalOpacity?: number
): boolean => {
const alpha = ((a || 1) * (globalOpacity || 100)) / 100;
if (alpha < 0.65) {
if (dark) {
return true;
} else {
return false;
}
}
// check luminance
return r * 0.299 + g * 0.587 + b * 0.114 <= 150;
}; };
+34 -17
View File
@@ -40,11 +40,10 @@
let fetchedHourly: FetchedHourly | null = $state(null); let fetchedHourly: FetchedHourly | null = $state(null);
let fetchedDaily: FetchedDaily | null = $state(null); let fetchedDaily: FetchedDaily | null = $state(null);
let meteogramCharts: MeteogramCharts | undefined = $state(); // Charts intentionally keep their current range: they show the full week
// unless the user narrows it via the range presets or Ctrl+scroll.
const switchDay = (date: Date) => { const switchDay = (date: Date) => {
selectedDay.setTime(date.getTime()); selectedDay.setTime(date.getTime());
meteogramCharts?.scrollToDay(date);
}; };
onMount(() => { onMount(() => {
@@ -109,6 +108,37 @@
<div class="week-page"> <div class="week-page">
<div class="weather-content" style="min-height: 50vh"> <div class="weather-content" style="min-height: 50vh">
<!-- Page hero: prominent location + weather model selection -->
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<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">
{#if location.admin1}{location.admin1},
{/if}{location.country ?? ''}
<span class="mx-1 opacity-50">·</span>
7-day forecast
</p>
</div>
</div>
<ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
params.models = [model];
}}
/>
</div>
{#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"
@@ -130,20 +160,7 @@
{/if} {/if}
{#if fetchedHourly} {#if fetchedHourly}
<MeteogramCharts <MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
bind:this={meteogramCharts}
data={fetchedHourly}
{selectedDay}
units={params}
{loading}
/>
{/if} {/if}
<ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
params.models = [model];
}}
/>
</div> </div>
</div> </div>
@@ -159,6 +159,24 @@
let sunsetPercent = $derived( let sunsetPercent = $derived(
sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunset) * 100 : null sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunset) * 100 : null
); );
// ─── "Now" indicator ─────────────────────────────────────────────────────
// Full-height vertical line across the table, positioned at the current
// time within the selected day (only when the selected day is today).
let isTodaySelected = $derived(
cellData.length > 0 && isSameDayInZone(today, selectedDay, data.timezone)
);
let nowPercent = $derived(isTodaySelected ? timeToFraction(today) * 100 : null);
// Width of the row-header column, measured so the now-line can be
// positioned relative to the data columns only.
let headerColWidth = $state(0);
let tableWidth = $state(0);
let nowLeftPx = $derived(
nowPercent != null && tableWidth > 0
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
: null
);
</script> </script>
{#snippet weatherIcon(name: string, size: number = 16)} {#snippet weatherIcon(name: string, size: number = 16)}
@@ -169,25 +187,36 @@
{#snippet rowHeader(iconName?: string, unit?: string, label?: string)} {#snippet rowHeader(iconName?: string, unit?: string, label?: string)}
<th class="hdr" scope="row"> <th class="hdr" scope="row">
<div class="flex flex-col items-center leading-tight"> <div class="flex flex-col items-center gap-0.5 leading-tight">
{#if iconName} {#if iconName}
{@render weatherIcon(iconName)} {@render weatherIcon(iconName, 18)}
{/if} {/if}
{#if label} {#if label}
<span class="text-[11px] font-semibold text-muted-foreground">{label}</span> <span class="text-[11px] font-semibold">{label}</span>
{/if} {/if}
{#if unit} {#if unit}
<span class="text-[10px] font-semibold text-muted-foreground">{unit}</span> <span class="text-[10px] font-medium text-muted-foreground">{unit}</span>
{/if} {/if}
</div> </div>
</th> </th>
{/snippet} {/snippet}
<!-- Header --> {#if cellData.length > 0}
<div class="mb-2 flex flex-wrap items-center justify-between gap-2"> {@const hourly = data.hourly}
<h3 class="text-lg font-bold"> {@const iconPx = is3h ? 38 : 26}
{formatZoned(selectedDay, data.timezone, 'EEEE')} Hourly <section class="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
<span class="ml-1 text-xs font-normal text-muted-foreground">({timezoneLabel})</span> <!-- Card toolbar -->
<div
class="flex flex-wrap items-center justify-between gap-2 border-b border-border/70 bg-muted/40 px-4 py-2.5"
>
<h3 class="text-base font-bold">
{formatZoned(selectedDay, data.timezone, 'EEEE')}
<span class="font-semibold text-muted-foreground"> hourly</span>
<span
class="ms-2 rounded-full bg-muted px-2 py-0.5 align-middle text-[10px] font-semibold text-muted-foreground"
>
{timezoneLabel}
</span>
</h3> </h3>
<div <div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold" class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
@@ -196,7 +225,8 @@
> >
{#each [3, 1] as interval (interval)} {#each [3, 1] as interval (interval)}
<button <button
class="cursor-pointer rounded-md px-3 py-1 transition-colors {hourlyInterval === interval class="cursor-pointer rounded-md px-3 py-1 transition-colors {hourlyInterval ===
interval
? 'bg-background text-foreground shadow-sm' ? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}" : 'text-muted-foreground hover:text-foreground'}"
aria-pressed={hourlyInterval === interval} aria-pressed={hourlyInterval === interval}
@@ -206,53 +236,52 @@
</button> </button>
{/each} {/each}
</div> </div>
</div> </div>
{#if cellData.length > 0} <div class="relative" bind:clientWidth={tableWidth}>
{@const hourly = data.hourly}
{@const iconPx = is3h ? 40 : 26}
<div class="overflow-hidden rounded-xl border border-border/70 bg-card shadow-xs">
<table class="w-full table-fixed border-collapse whitespace-nowrap"> <table class="w-full table-fixed border-collapse whitespace-nowrap">
<caption class="sr-only">Hourly weather details for {locationName}</caption> <caption class="sr-only">Hourly weather details for {locationName}</caption>
<colgroup> <colgroup>
<col class="w-14 md:w-16" /> <col class="w-16 md:w-20" />
{#each cellData as _ (_.idx)} {#each cellData as _ (_.idx)}
<col /> <col />
{/each} {/each}
</colgroup> </colgroup>
<tbody> <tbody>
<!-- Time + Daylight bar (merged) --> <!-- Time + Daylight bar (merged) -->
<tr class="!border-t-0"> <tr class="row">
<th class="hdr" scope="row"> <th class="hdr" scope="row" bind:clientWidth={headerColWidth}>
<span class="text-[10px] font-semibold text-muted-foreground">{timezoneLabel}</span> <span class="text-[10px] font-semibold text-muted-foreground">Time</span>
</th> </th>
<td colspan={cellData.length} class="relative h-10 overflow-visible p-0"> <td colspan={cellData.length} class="relative h-10 overflow-visible p-0">
<!-- Daylight background --> <!-- Daylight background -->
{#if sunTimes && sunrisePercent != null && sunsetPercent != null} {#if sunTimes && sunrisePercent != null && sunsetPercent != null}
<div <div
class="absolute inset-y-0 left-0 bg-indigo-950/10 dark:bg-indigo-950/30" class="absolute inset-y-0 left-0 bg-indigo-950/10 dark:bg-indigo-950/40"
style="width:{sunrisePercent}%" style="width:{sunrisePercent}%"
></div> ></div>
<div <div
class="absolute inset-y-0 bg-amber-400/15 dark:bg-amber-400/10" class="absolute inset-y-0 bg-amber-400/15 dark:bg-amber-300/10"
style="left:{sunrisePercent}%;width:{sunsetPercent - sunrisePercent}%" style="left:{sunrisePercent}%;width:{sunsetPercent - sunrisePercent}%"
></div> ></div>
<div <div
class="absolute inset-y-0 right-0 bg-indigo-950/10 dark:bg-indigo-950/30" class="absolute inset-y-0 right-0 bg-indigo-950/10 dark:bg-indigo-950/40"
style="width:{100 - sunsetPercent}%" style="width:{100 - sunsetPercent}%"
></div> ></div>
<!-- Sunrise marker + label --> <!-- Sunrise marker + label -->
<div class="absolute inset-y-0 w-px bg-amber-500/70" style="left:{sunrisePercent}%"> <div class="absolute inset-y-0 w-px bg-amber-500/70" style="left:{sunrisePercent}%">
<span <span
class="absolute bottom-0.5 left-1 whitespace-nowrap text-[10px] font-semibold leading-none text-amber-700 dark:text-amber-300" class="absolute bottom-0.5 left-1 text-[10px] leading-none font-semibold whitespace-nowrap text-amber-700 dark:text-amber-300"
> >
<svg <svg
class="fill-foreground inline-block" class="inline-block fill-foreground"
width="12px" width="12px"
height="12px" height="12px"
aria-hidden="true" aria-hidden="true"
> >
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1" <use
class="stroke-2"
xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"
></use> ></use>
</svg> </svg>
<span class="align-middle">{formatTime(sunTimes.sunrise)}</span> <span class="align-middle">{formatTime(sunTimes.sunrise)}</span>
@@ -261,10 +290,10 @@
<!-- Sunset marker + label --> <!-- Sunset marker + label -->
<div class="absolute inset-y-0 w-px bg-indigo-400/70" style="left:{sunsetPercent}%"> <div class="absolute inset-y-0 w-px bg-indigo-400/70" style="left:{sunsetPercent}%">
<span <span
class="absolute bottom-0.5 right-1 whitespace-nowrap text-[10px] font-semibold leading-none text-indigo-600 dark:text-indigo-300 inline-flex items-center gap-1" class="absolute right-1 bottom-0.5 inline-flex items-center gap-1 text-[10px] leading-none font-semibold whitespace-nowrap text-indigo-600 dark:text-indigo-300"
> >
<svg <svg
class="fill-foreground inline-block" class="inline-block fill-foreground"
width="12px" width="12px"
height="12px" height="12px"
aria-hidden="true" aria-hidden="true"
@@ -281,8 +310,8 @@
{@const leftPct = (i / cellData.length) * 100} {@const leftPct = (i / cellData.length) * 100}
{@const widthPct = 100 / cellData.length} {@const widthPct = 100 / cellData.length}
<span <span
class="absolute top-0 flex items-start pt-1 font-bold pl-0.5 text-sm class="absolute top-0 flex items-start pt-1.5 pl-1 text-sm font-bold
{cell.isNow ? 'text-destructive' : ''}" {cell.isNow ? 'text-red-600 dark:text-red-400' : ''}"
style="left:{leftPct}%;width:{widthPct}%" style="left:{leftPct}%;width:{widthPct}%"
> >
{#if is3h} {#if is3h}
@@ -293,7 +322,7 @@
>{formatZoned(cell.date, data.timezone, 'HH')}</span >{formatZoned(cell.date, data.timezone, 'HH')}</span
> >
<sup <sup
class="text-[9px] font-semibold text-muted-foreground leading-none align-baseline" class="align-baseline text-[9px] leading-none font-semibold text-muted-foreground"
>00</sup >00</sup
> >
</span> </span>
@@ -304,13 +333,12 @@
</tr> </tr>
<!-- Weather Icons --> <!-- Weather Icons -->
<tr> <tr class="row">
{@render rowHeader('wi-day-cloudy')} {@render rowHeader('wi-day-cloudy')}
{#each cellData as cell, i (cell.idx)} {#each cellData as cell, i (cell.idx)}
{@const wCode = hourly.weather_code[cell.idx]} {@const wCode = hourly.weather_code[cell.idx]}
<td <td
class="cell leading-[0] {is3h ? 'px-1 py-2.5' : 'px-0.5 py-1.5'}" class="cell leading-0 {is3h ? 'h-14' : 'h-11'}"
class:now={cell.isNow}
class:icon-day={cell.isDaytime} class:icon-day={cell.isDaytime}
class:icon-night={!cell.isDaytime} class:icon-night={!cell.isDaytime}
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime} class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
@@ -322,14 +350,13 @@
</tr> </tr>
<!-- Temperature --> <!-- Temperature -->
<tr> <tr class="row">
{@render rowHeader('wi-thermometer', tempUnit)} {@render rowHeader('wi-thermometer', tempUnit, 'Temp')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const temp = hourly.temperature_2m[cell.idx]} {@const temp = hourly.temperature_2m[cell.idx]}
{@const style = getTempStyle(temp, String(units.temperature_unit))} {@const style = getTempStyle(temp, String(units.temperature_unit))}
<td <td
class="cell font-bold {is3h ? 'py-2.5 text-lg' : 'py-2 text-[15px]'}" class="cell h-10 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
class:now={cell.isNow}
style="background-color:{style.bg};color:{style.fg}" style="background-color:{style.bg};color:{style.fg}"
> >
{formatTemp(temp)} {formatTemp(temp)}
@@ -338,35 +365,32 @@
</tr> </tr>
<!-- Feels Like --> <!-- Feels Like -->
<tr> <tr class="row">
{@render rowHeader(undefined, tempUnit, 'Feels')} {@render rowHeader(undefined, tempUnit, 'Feels')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const temp = hourly.apparent_temperature[cell.idx]} {@const temp = hourly.apparent_temperature[cell.idx]}
<td <td class="cell h-8 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}">
class="cell text-muted-foreground {is3h ? 'py-1 text-[13px]' : 'py-0.5 text-[11px]'}"
class:now={cell.isNow}
>
{formatTemp(temp)} {formatTemp(temp)}
</td> </td>
{/each} {/each}
</tr> </tr>
<!-- Wind --> <!-- Wind -->
<tr> <tr class="row">
{@render rowHeader('wi-strong-wind', windUnit)} {@render rowHeader('wi-strong-wind', windUnit, 'Wind')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const wind = hourly.windspeed_10m[cell.idx]} {@const wind = hourly.windspeed_10m[cell.idx]}
{@const windDir = hourly.winddirection_10m[cell.idx]} {@const windDir = hourly.winddirection_10m[cell.idx]}
<td class="cell text-center align-middle leading-tight" class:now={cell.isNow}> <td class="cell h-13 align-middle leading-tight">
{#if windDir != null && !isNaN(windDir)} {#if windDir != null && !isNaN(windDir)}
<span <span
class="inline-block leading-[0]" class="inline-block leading-0"
style="transform:{getWindArrowRotation(windDir)}" style="transform:{getWindArrowRotation(windDir)}"
> >
{@render weatherIcon('wi-direction-down', 24)} {@render weatherIcon('wi-direction-down', 22)}
</span> </span>
{/if} {/if}
<span class="block font-semibold {is3h ? 'mt-0.5 text-sm' : 'text-xs'}"> <span class="block font-semibold {is3h ? 'text-sm' : 'text-xs'}">
{formatValue(wind)} {formatValue(wind)}
</span> </span>
</td> </td>
@@ -374,24 +398,23 @@
</tr> </tr>
<!-- Humidity --> <!-- Humidity -->
<tr> <tr class="row">
{@render rowHeader('wi-humidity', '%')} {@render rowHeader('wi-humidity', '%', 'Humidity')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const hum = hourly.relative_humidity_2m[cell.idx]} {@const hum = hourly.relative_humidity_2m[cell.idx]}
<td class="cell" class:now={cell.isNow} style="background:{getHumidityBg(hum ?? 0)}"> <td class="cell h-8" style="background:{getHumidityBg(hum ?? 0)}">
{formatValue(hum)} {formatValue(hum)}
</td> </td>
{/each} {/each}
</tr> </tr>
<!-- Cloud Cover --> <!-- Cloud Cover -->
<tr> <tr class="row">
{@render rowHeader('wi-cloud', '%')} {@render rowHeader('wi-cloud', '%', 'Clouds')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const cloud = hourly.cloud_cover[cell.idx]} {@const cloud = hourly.cloud_cover[cell.idx]}
<td <td
class="cell" class="cell h-8"
class:now={cell.isNow}
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})" style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
> >
{formatValue(cloud)} {formatValue(cloud)}
@@ -400,14 +423,13 @@
</tr> </tr>
<!-- Precipitation --> <!-- Precipitation -->
<tr> <tr class="row">
{@render rowHeader('wi-raindrop', precipUnit)} {@render rowHeader('wi-raindrop', precipUnit, 'Precip')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const precip = hourly.precipitation[cell.idx]} {@const precip = hourly.precipitation[cell.idx]}
{@const prob = hourly.precipitation_probability[cell.idx]} {@const prob = hourly.precipitation_probability[cell.idx]}
<td <td
class="precip-cell {is3h ? 'h-14' : 'h-11'}" class="cell precip-cell {is3h ? 'h-14' : 'h-11'}"
class:now={cell.isNow}
style="background:{getPrecipProbBg(prob ?? 0)}" style="background:{getPrecipProbBg(prob ?? 0)}"
title={formatPrecipTooltip(precip, prob)} title={formatPrecipTooltip(precip, prob)}
> >
@@ -422,61 +444,67 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- "Now" column highlight + exact-time line -->
{#if nowLeftPx != null}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
{@const nowIdx = cellData.findIndex((c) => c.isNow)}
{#if nowIdx >= 0}
<div
class="pointer-events-none absolute inset-y-0 z-10 border-x border-red-500/30 bg-red-500/5"
style="left:{headerColWidth + nowIdx * colWidth}px;width:{colWidth}px"
></div>
{/if}
<div
class="pointer-events-none absolute inset-y-0 z-10 w-0.5 -translate-x-1/2 bg-red-500/80"
style="left:{nowLeftPx}px"
>
<span
class="absolute top-1 left-1/2 -translate-x-1/2 rounded-full bg-red-500 px-1.5 py-px text-[9px] font-bold tracking-wide text-white uppercase shadow-sm"
>
Now
</span>
</div> </div>
{/if}
</div>
</section>
{/if} {/if}
<style> <style>
tr { /* ── Uniform grid ───────────────────────────────────────────── */
border-top: 1px solid hsl(var(--border) / 0.6); .row + .row {
border-top: 1px solid color-mix(in oklab, var(--color-border) 70%, transparent);
} }
/* ── Base cell ──────────────────────────────────────────── */
.cell { .cell {
padding: 6px 2px; padding: 2px;
text-align: center; text-align: center;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
border-right: 1px solid hsl(var(--border) / 0.15);
overflow: hidden; overflow: hidden;
} }
.cell:last-child { .cell + .cell {
border-right: none; border-left: 1px solid color-mix(in oklab, var(--color-border) 35%, transparent);
} }
.cell.now { /* ── Row header ─────────────────────────────────────────────── */
font-weight: 700;
box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.55);
}
/* ── Row header ─────────────────────────────────────────── */
.hdr { .hdr {
padding: 4px; padding: 4px 2px;
text-align: center; text-align: center;
font-weight: 600; font-weight: 600;
font-size: 11px; font-size: 11px;
background: hsl(var(--muted) / 0.35); background: color-mix(in oklab, var(--color-muted) 45%, transparent);
border-right: 1px solid hsl(var(--border)); border-right: 1px solid var(--color-border);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
} }
/* ── Precipitation ──────────────────────────────────────── */ /* ── Precipitation ──────────────────────────────────────────── */
.precip-cell { .precip-cell {
position: relative; position: relative;
padding: 0; padding: 0;
text-align: center;
overflow: hidden;
border-right: 1px solid hsl(var(--border) / 0.2);
}
.precip-cell:last-child {
border-right: none;
}
.precip-cell.now {
box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.55);
} }
.precip-bar { .precip-bar {
@@ -503,10 +531,10 @@
} }
:global(.dark) .precip-label { :global(.dark) .precip-label {
color: rgba(120, 180, 255, 0.95); color: rgba(140, 190, 255, 0.95);
} }
/* ── Responsive ─────────────────────────────────────────── */ /* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
.hdr { .hdr {
padding: 3px 2px; padding: 3px 2px;
@@ -514,7 +542,6 @@
} }
.cell { .cell {
font-size: 11px; font-size: 11px;
padding: 4px 1px;
} }
} }
</style> </style>
@@ -29,7 +29,6 @@
const CHART_GROUP = 'week-meteogram'; const CHART_GROUP = 'week-meteogram';
const SECONDS_PER_DAY = 24 * 3600; const SECONDS_PER_DAY = 24 * 3600;
let showCharts = $state(false);
let chartComponents: CanvasChart[] = $state([]); let chartComponents: CanvasChart[] = $state([]);
// Timestamps from the service are in milliseconds; CanvasChart uses seconds. // Timestamps from the service are in milliseconds; CanvasChart uses seconds.
@@ -37,20 +36,21 @@
let liveCharts = $derived(chartComponents.filter((chart) => chart != null)); let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
export function scrollToDay(day: Date): void { function dayStartSec(day: Date): number | null {
if (!data || liveCharts.length === 0) return; if (!data) return null;
const tz = data.timezone; const tz = data.timezone;
const targetDayStr = formatZoned(day, tz, 'yyyy-MM-dd'); const targetDayStr = formatZoned(day, tz, 'yyyy-MM-dd');
const firstHourIdx = data.hourlyDates.findIndex( const firstHourIdx = data.hourlyDates.findIndex(
(d) => formatZoned(d, tz, 'yyyy-MM-dd') === targetDayStr (d) => formatZoned(d, tz, 'yyyy-MM-dd') === targetDayStr
); );
return firstHourIdx === -1 ? null : data.timestamps[firstHourIdx] / 1000;
}
if (firstHourIdx === -1) return; function setRangeDays(from: Date, days: number): void {
const start = dayStartSec(from);
const dayStart = data.timestamps[firstHourIdx] / 1000; if (start == null || liveCharts.length === 0) return;
// Charts share the group, so setting the range on one syncs all of them // Charts share the group, so setting the range on one syncs all of them
liveCharts[0].setRange(dayStart, dayStart + SECONDS_PER_DAY); liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY);
} }
function resetZoom(): void { function resetZoom(): void {
@@ -58,18 +58,15 @@
onResetZoom?.(); onResetZoom?.();
} }
// Zoom to the selected day once all three charts are mounted // Range presets: charts show the full week by default, these (or
let scrolledOnMount = false; // Ctrl+scroll) narrow the window.
$effect(() => { const rangePresets = [
if (!showCharts) { { label: 'Today', apply: () => setRangeDays(new Date(), 1) },
scrolledOnMount = false; { label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) },
return; { label: '3 days', apply: () => setRangeDays(new Date(), 3) },
} { label: '5 days', apply: () => setRangeDays(new Date(), 5) },
if (!scrolledOnMount && liveCharts.length === 3 && data) { { label: 'All', apply: () => resetZoom() }
scrolledOnMount = true; ];
requestAnimationFrame(() => scrollToDay(selectedDay));
}
});
// ─── Series Building ──────────────────────────────────────────────────────── // ─── Series Building ────────────────────────────────────────────────────────
@@ -195,41 +192,42 @@
}); });
</script> </script>
<div class="charts-toggle-section"> <section class="mt-8" in:fade={{ duration: 200 }}>
<button class="charts-toggle-btn" onclick={() => (showCharts = !showCharts)}> <div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<span>Detailed Meteogram Charts</span> <h3 class="text-lg font-bold">
<svg Meteogram
class="toggle-chevron {showCharts ? 'open' : ''}" <span class="font-semibold text-muted-foreground">
xmlns="http://www.w3.org/2000/svg" {formatZoned(selectedDay, data.timezone, 'EEEE')}{getRelativeDayLabel(
width="18" selectedDay,
height="18" data.timezone
viewBox="0 0 24 24" ) !== formatZoned(selectedDay, data.timezone, 'EEEE')
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
{#if showCharts}
<div class="detailed-charts" in:fade={{ duration: 200 }}>
<div class="charts-header">
<h3 class="charts-title">
{formatZoned(selectedDay, data.timezone, 'EEEE')}
<small>
{getRelativeDayLabel(selectedDay, data.timezone) !==
formatZoned(selectedDay, data.timezone, 'EEEE')
? ` (${getRelativeDayLabel(selectedDay, data.timezone)})` ? ` (${getRelativeDayLabel(selectedDay, data.timezone)})`
: ''} : ''}
</small> </span>
</h3> </h3>
<button type="button" class="zoom-reset-btn" title="Show all days (Esc)" onclick={resetZoom}> <div class="flex flex-wrap items-center gap-3">
Show All <span class="hidden text-xs text-muted-foreground md:inline">
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
>Ctrl</kbd
>
+ scroll to zoom
</span>
<div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
role="group"
aria-label="Chart time range"
>
{#each rangePresets as preset (preset.label)}
<button
type="button"
class="cursor-pointer rounded-md px-2.5 py-1 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
onclick={preset.apply}
>
{preset.label}
</button> </button>
{/each}
</div>
</div>
</div> </div>
<ChartContainer {loading} chartCount={3} chartHeight={300}> <ChartContainer {loading} chartCount={3} chartHeight={300}>
@@ -258,79 +256,4 @@
<div class="mt-6 md:mt-10"> <div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="week-forecast" /> <ChartToolbar charts={liveCharts} fileName="week-forecast" />
</div> </div>
</div> </section>
{/if}
<style>
.charts-toggle-section {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
.charts-toggle-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: var(--radius, 0.375rem);
cursor: pointer;
transition: all 150ms ease;
}
.charts-toggle-btn:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted));
}
.toggle-chevron {
transition: transform 200ms;
}
.toggle-chevron.open {
transform: rotate(180deg);
}
.detailed-charts {
margin-top: 0.5rem;
}
.charts-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.charts-title {
font-size: 1.25rem;
font-weight: 700;
}
.zoom-reset-btn {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: var(--radius, 0.375rem);
cursor: pointer;
transition:
color 150ms ease,
background-color 150ms ease;
white-space: nowrap;
user-select: none;
}
.zoom-reset-btn:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted));
}
</style>
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import { models } from '../../options'; import { findModel, modelGroups } from '../../options';
interface Props { interface Props {
selectedModel: string; selectedModel: string;
@@ -11,35 +10,73 @@
let { selectedModel, onModelChange }: Props = $props(); let { selectedModel, onModelChange }: Props = $props();
let modelLabel = $derived( let model = $derived(findModel(selectedModel));
models.find((mo) => String(mo.value) === selectedModel)?.label ?? selectedModel let modelLabel = $derived(model?.label ?? selectedModel);
let modelMeta = $derived(
model?.resolution && model.resolution !== 'varies'
? `${model.resolution} · updated ${model.update}`
: 'Automatically picks the best model for this location'
); );
</script> </script>
<div class="mt-6 flex gap-6 md:mt-12"> <Select.Root
<div class="relative w-1/2">
<Select.Root
name="model_selection" name="model_selection"
type="single" type="single"
value={selectedModel} value={selectedModel}
onValueChange={(val) => { onValueChange={(val) => {
if (val) onModelChange(val); if (val) onModelChange(val);
}} }}
> >
<Select.Trigger <Select.Trigger
aria-label="Forecast model selection" aria-label="Forecast model selection"
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3" class="group h-auto min-w-64 cursor-pointer gap-3 rounded-xl border-2 border-primary/35 bg-card py-2 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-w-72"
> >
{modelLabel} <div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary"
>
<!-- layered-globe icon: weather model -->
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
<circle cx="12" cy="12" r="9" />
<path
stroke-linecap="round"
d="M3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"
/>
</svg>
</div>
<div class="flex min-w-0 flex-1 flex-col items-start gap-0 overflow-hidden text-left">
<span class="text-[11px] font-semibold tracking-wide text-primary uppercase">
Weather model
</span>
<span class="max-w-full truncate text-sm font-bold text-foreground">{modelLabel}</span>
<span class="max-w-full truncate text-[11px] leading-tight text-muted-foreground">
{modelMeta}
</span>
</div>
</Select.Trigger> </Select.Trigger>
<Select.Content preventScroll={false} class="border-border"> <Select.Content preventScroll={false} class="max-h-[min(480px,60vh)] border-border">
{#each models as mo (mo.value)} {#each modelGroups as group (group.value)}
<Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item> <Select.Group>
<Select.GroupHeading
class="text-[10.5px] font-bold tracking-wider text-primary/80 uppercase"
>
{group.label}
</Select.GroupHeading>
{#each group.models as mo (mo.value)}
<Select.Item class="cursor-pointer" value={mo.value} label={mo.label}>
<!-- div, not span: the item base styles force flex row on spans -->
<div class="flex w-full flex-col items-start gap-0 leading-tight">
<span class="font-medium">{mo.label}</span>
{#if mo.resolution && mo.resolution !== 'varies'}
<span class="text-[11px] text-muted-foreground">
{mo.resolution} · updated {mo.update}
</span>
{:else}
<span class="text-[11px] text-muted-foreground">Automatic selection</span>
{/if}
</div>
</Select.Item>
{/each}
</Select.Group>
{/each} {/each}
</Select.Content> </Select.Content>
<Label class="absolute top-[0.35rem] left-2 z-10 px-1 text-xs text-muted-foreground"> </Select.Root>
Weather model
</Label>
</Select.Root>
</div>
</div>