darkmode
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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 |
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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' },
|
||||||
|
|||||||
@@ -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'
|
|
||||||
];
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user