layout shifts
This commit is contained in:
@@ -1086,7 +1086,10 @@
|
|||||||
for (let i = 0; i < xs.length; i++) {
|
for (let i = 0; i < xs.length; i++) {
|
||||||
const pos = Math.max(0, Math.min(1, (xs[i] - first) / span));
|
const pos = Math.max(0, Math.min(1, (xs[i] - first) / span));
|
||||||
const cover = smoothed[i];
|
const cover = smoothed[i];
|
||||||
fill.addColorStop(pos, withAlpha(s.color, base + (peak - base) * Math.pow(cover / 100, curve)));
|
fill.addColorStop(
|
||||||
|
pos,
|
||||||
|
withAlpha(s.color, base + (peak - base) * Math.pow(cover / 100, curve))
|
||||||
|
);
|
||||||
if (edge) {
|
if (edge) {
|
||||||
const above = Math.max(0, (cover - EDGE_FLOOR) / (100 - EDGE_FLOOR));
|
const above = Math.max(0, (cover - EDGE_FLOOR) / (100 - EDGE_FLOOR));
|
||||||
edge.addColorStop(pos, withAlpha(s.color, 0.85 * Math.pow(above, 1.6)));
|
edge.addColorStop(pos, withAlpha(s.color, 0.85 * Math.pow(above, 1.6)));
|
||||||
|
|||||||
@@ -48,6 +48,12 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drop a single entry from the recent list (favourites are unaffected). */
|
||||||
|
function removeRecent(loc: GeoLocation) {
|
||||||
|
const key = locationKey(loc);
|
||||||
|
storedRecentLocations.update((list) => list.filter((l) => locationKey(l) !== key));
|
||||||
|
}
|
||||||
|
|
||||||
function toggleFavorite(loc: GeoLocation) {
|
function toggleFavorite(loc: GeoLocation) {
|
||||||
const key = locationKey(loc);
|
const key = locationKey(loc);
|
||||||
storedFavoriteLocations.update((list) =>
|
storedFavoriteLocations.update((list) =>
|
||||||
@@ -126,7 +132,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet locationRow(location: GeoLocation)}
|
{#snippet locationRow(location: GeoLocation, removable: boolean)}
|
||||||
{@const fav = favKeys.has(locationKey(location))}
|
{@const fav = favKeys.has(locationKey(location))}
|
||||||
<div
|
<div
|
||||||
class="group flex items-center rounded-md border border-transparent transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
|
class="group flex items-center rounded-md border border-transparent transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
|
||||||
@@ -172,6 +178,18 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
{#if removable}
|
||||||
|
<button
|
||||||
|
class="mr-1 flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/50 hover:bg-background hover:text-destructive"
|
||||||
|
onclick={() => removeRecent(location)}
|
||||||
|
aria-label="Remove {location.name} from recent locations"
|
||||||
|
title="Remove from recent"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -262,7 +280,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
{#each $storedFavoriteLocations as loc (locationKey(loc))}
|
{#each $storedFavoriteLocations as loc (locationKey(loc))}
|
||||||
{@render locationRow(loc)}
|
{@render locationRow(loc, false)}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -276,7 +294,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
{#each recentToShow as loc (locationKey(loc))}
|
{#each recentToShow as loc (locationKey(loc))}
|
||||||
{@render locationRow(loc)}
|
{@render locationRow(loc, true)}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -305,7 +323,7 @@
|
|||||||
{:else if results.results && results.results.length > 0}
|
{:else if results.results && results.results.length > 0}
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
{#each results.results as location, i (i)}
|
{#each results.results as location, i (i)}
|
||||||
{@render locationRow(location)}
|
{@render locationRow(location, false)}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if results.results}
|
{:else if results.results}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
import LogoMark from './logo-mark.svelte';
|
||||||
|
|
||||||
// A hand-picked set of the prerendered city pages (see
|
// A hand-picked set of the prerendered city pages (see
|
||||||
// routes/weather/locations/city-names100.json) — instant navigation targets
|
// routes/weather/locations/city-names100.json) — instant navigation targets
|
||||||
// that double as SEO entry points.
|
// that double as SEO entry points.
|
||||||
@@ -37,9 +39,11 @@
|
|||||||
<!-- Brand -->
|
<!-- Brand -->
|
||||||
<div class="max-w-xs">
|
<div class="max-w-xs">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-lg"
|
<span
|
||||||
>☔</span
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"
|
||||||
>
|
>
|
||||||
|
<LogoMark />
|
||||||
|
</span>
|
||||||
<span class="text-lg font-bold tracking-tight">Drizz.li</span>
|
<span class="text-lg font-bold tracking-tight">Drizz.li</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-sm leading-relaxed text-muted-foreground">
|
<p class="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
|||||||
@@ -74,16 +74,26 @@
|
|||||||
<header
|
<header
|
||||||
class="topbar flex h-14 shrink-0 items-center gap-3 border-b border-topbar-border bg-topbar px-3 md:px-4"
|
class="topbar flex h-14 shrink-0 items-center gap-3 border-b border-topbar-border bg-topbar px-3 md:px-4"
|
||||||
>
|
>
|
||||||
<!-- Mobile menu toggle -->
|
<!-- Mobile menu toggle. On phones this side and the settings side both take
|
||||||
<button
|
an equal share of the leftover width, which lands the search box dead
|
||||||
class="-ms-1 flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:hidden"
|
centre; on md+ they collapse and the spacer below does the work. -->
|
||||||
onclick={onMenuToggle}
|
<div class="flex flex-1 items-center md:flex-none">
|
||||||
aria-label="Toggle menu"
|
<button
|
||||||
>
|
class="-ms-1 flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:hidden"
|
||||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
onclick={onMenuToggle}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
aria-label="Toggle menu"
|
||||||
</svg>
|
>
|
||||||
</button>
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Current location display -->
|
<!-- Current location display -->
|
||||||
{#if location}
|
{#if location}
|
||||||
@@ -109,8 +119,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Spacer -->
|
<!-- Spacer (md+ only: on phones the equal side columns centre the search) -->
|
||||||
<div class="flex-1"></div>
|
<div class="hidden flex-1 md:block"></div>
|
||||||
|
|
||||||
<!-- Location search: primary way to switch places, so keep it loud -->
|
<!-- 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">
|
||||||
@@ -123,8 +133,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Phones only have room for one control, so units, theme and supporter
|
<!-- Phones only have room for one control, so units, theme and supporter
|
||||||
status collapse into a single settings menu below md. -->
|
status collapse into a single settings menu below md. This side mirrors
|
||||||
<div class="md:hidden">
|
the menu-button column so the search lands dead centre. -->
|
||||||
|
<div class="flex flex-1 justify-end md:hidden">
|
||||||
<SettingsMenu />
|
<SettingsMenu />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// umbrella-with-rain mark (matches the favicon); shared by the sidebar home
|
||||||
|
// link and the footer so the brand reads the same in both places
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { class: className = 'h-5 w-5' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" 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
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="none"
|
||||||
|
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>
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
import LogoMark from './logo-mark.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
onToggle?: () => void;
|
onToggle?: () => void;
|
||||||
@@ -78,24 +80,7 @@
|
|||||||
<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) -->
|
<LogoMark />
|
||||||
<svg
|
|
||||||
class="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
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
|
|
||||||
fill="currentColor"
|
|
||||||
stroke="none"
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<span class="text-sm font-bold tracking-tight whitespace-nowrap text-sidebar-foreground">
|
<span class="text-sm font-bold tracking-tight whitespace-nowrap text-sidebar-foreground">
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
import UnlockDialog from './UnlockDialog.svelte';
|
import UnlockDialog from './UnlockDialog.svelte';
|
||||||
import { SUPPORTER_PERKS, SIGNUP_URL, getSupporterPrice } from './config';
|
import { SIGNUP_URL, SUPPORTER_PERKS, getSupporterPrice } from './config';
|
||||||
import { isSupporter, supporterState, refreshSupporter } from './supporter';
|
import { isSupporter, refreshSupporter, supporterState } from './supporter';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Short feature name shown in the locked panel headline. */
|
/** Short feature name shown in the locked panel headline. */
|
||||||
|
|||||||
@@ -7,7 +7,13 @@
|
|||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
||||||
import { SIGNUP_URL, getSupporterPrice } from './config';
|
import { SIGNUP_URL, getSupporterPrice } from './config';
|
||||||
import { clearLicense, isSupporter, supporterState, storedLicenseKey, verifyKey } from './supporter';
|
import {
|
||||||
|
clearLicense,
|
||||||
|
isSupporter,
|
||||||
|
storedLicenseKey,
|
||||||
|
supporterState,
|
||||||
|
verifyKey
|
||||||
|
} from './supporter';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
|
|||||||
@@ -138,6 +138,15 @@ export const defaultChartLayout: ChartPanel[] = [
|
|||||||
|
|
||||||
export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout);
|
export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time range the meteograms open on. 'auto' narrows to three days on a phone,
|
||||||
|
* where a full week of hours is too dense to read, and shows everything on
|
||||||
|
* roomier screens.
|
||||||
|
*/
|
||||||
|
export type ChartRangePref = 'auto' | 'today' | '3d' | '5d' | 'all';
|
||||||
|
|
||||||
|
export const storedChartRange = persisted<ChartRangePref>('chart_range_v1', 'auto');
|
||||||
|
|
||||||
/** Selected ensemble model for the 14-day spread forecast. */
|
/** Selected ensemble model for the 14-day spread forecast. */
|
||||||
export const storedEnsembleModel = persisted<string>('ensemble_model', 'ncep_gefs_seamless');
|
export const storedEnsembleModel = persisted<string>('ensemble_model', 'ncep_gefs_seamless');
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,22 @@
|
|||||||
|
|
||||||
// keep the .dark class in sync with the persisted theme; in 'system' mode
|
// keep the .dark class in sync with the persisted theme; in 'system' mode
|
||||||
// follow the OS preference live
|
// follow the OS preference live
|
||||||
|
let themeSettled = false;
|
||||||
|
let themeTimer = 0;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const theme = $storedTheme;
|
const theme = $storedTheme;
|
||||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
const dark = theme === 'dark' || (theme === 'system' && mq.matches);
|
const root = document.documentElement;
|
||||||
document.documentElement.classList.toggle('dark', dark);
|
// The very first application is just painting the stored theme - only
|
||||||
|
// an actual switch afterwards is worth cross-fading.
|
||||||
|
if (themeSettled) {
|
||||||
|
root.classList.add('theme-transition');
|
||||||
|
clearTimeout(themeTimer);
|
||||||
|
themeTimer = window.setTimeout(() => root.classList.remove('theme-transition'), 400);
|
||||||
|
}
|
||||||
|
themeSettled = true;
|
||||||
|
root.classList.toggle('dark', theme === 'dark' || (theme === 'system' && mq.matches));
|
||||||
};
|
};
|
||||||
apply();
|
apply();
|
||||||
mq.addEventListener('change', apply);
|
mq.addEventListener('change', apply);
|
||||||
@@ -83,15 +93,22 @@
|
|||||||
<div class="flex min-w-0 flex-1 flex-col h-full">
|
<div class="flex min-w-0 flex-1 flex-col h-full">
|
||||||
<Header onMenuToggle={toggleMobileMenu} />
|
<Header onMenuToggle={toggleMobileMenu} />
|
||||||
|
|
||||||
|
<!-- The padding stays on <main> itself: the day strip sticks with a
|
||||||
|
negative offset that exactly cancels it, so moving it to an inner
|
||||||
|
wrapper would dock the strip too high and clip its top row.
|
||||||
|
flex-col + flex-1 below keeps the footer on the bottom edge even when
|
||||||
|
the page is too short to fill the viewport. -->
|
||||||
<main
|
<main
|
||||||
class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-3 lg:px-8 lg:py-6'}
|
class={fullBleed
|
||||||
|
? 'flex-1 overflow-hidden'
|
||||||
|
: 'flex flex-1 flex-col overflow-y-auto p-3 lg:px-8 lg:py-6'}
|
||||||
>
|
>
|
||||||
{#if fullBleed}
|
{#if fullBleed}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{:else}
|
{:else}
|
||||||
<!-- cap the content width on very large screens; the footer below
|
<!-- cap the content width on very large screens; the footer below
|
||||||
gives the page its ending, so only modest bottom room is needed -->
|
gives the page its ending, so only modest bottom room is needed -->
|
||||||
<div class="mx-auto w-full max-w-[1536px] pb-24">
|
<div class="mx-auto w-full max-w-[1536px] flex-1 pb-24">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
<!-- full-bleed footer inside the scroll area (its own inner max-w),
|
<!-- full-bleed footer inside the scroll area (its own inner max-w),
|
||||||
|
|||||||
@@ -132,4 +132,30 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Added around a theme change only (see routes/+layout.svelte), so the whole
|
||||||
|
page cross-fades between light and dark instead of snapping. It is off the
|
||||||
|
rest of the time: a permanent global transition would smear every hover
|
||||||
|
and every chart repaint. */
|
||||||
|
:root.theme-transition,
|
||||||
|
:root.theme-transition *,
|
||||||
|
:root.theme-transition *::before,
|
||||||
|
:root.theme-transition *::after {
|
||||||
|
transition:
|
||||||
|
background-color 400ms ease,
|
||||||
|
border-color 400ms ease,
|
||||||
|
color 400ms ease,
|
||||||
|
fill 400ms ease,
|
||||||
|
stroke 400ms ease,
|
||||||
|
box-shadow 400ms ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root.theme-transition,
|
||||||
|
:root.theme-transition *,
|
||||||
|
:root.theme-transition *::before,
|
||||||
|
:root.theme-transition *::after {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,14 @@
|
|||||||
<h2>Service provider</h2>
|
<h2>Service provider</h2>
|
||||||
<address>
|
<address>
|
||||||
Vincent van der Wal<br />
|
Vincent van der Wal<br />
|
||||||
[Street and number]<br />
|
Tschalungasse 2<br />
|
||||||
[Postal code, City]<br />
|
CH-6442 Gersau<br />
|
||||||
[Country]
|
Switzerland
|
||||||
</address>
|
</address>
|
||||||
<p>
|
<p>
|
||||||
Email: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
Email: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>Drizz.li is operated as a personal, independent project.</p>
|
||||||
Drizz.li is operated as a personal, independent project. Responsible for the content of this
|
|
||||||
website: Vincent van der Wal (address above).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2>Disclaimer</h2>
|
<h2>Disclaimer</h2>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -15,8 +15,9 @@
|
|||||||
<h2>1. Controller</h2>
|
<h2>1. Controller</h2>
|
||||||
<address>
|
<address>
|
||||||
Vincent van der Wal<br />
|
Vincent van der Wal<br />
|
||||||
[Street and number], [Postal code, City], [Country]<br />
|
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
|
||||||
Email: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
Email:
|
||||||
|
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||||
</address>
|
</address>
|
||||||
<p>
|
<p>
|
||||||
This policy covers the websites drizz.li (the weather app) and support.drizz.li (the supporter
|
This policy covers the websites drizz.li (the weather app) and support.drizz.li (the supporter
|
||||||
|
|||||||
@@ -1,9 +1,69 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { setContext } from 'svelte';
|
||||||
|
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children?: import('svelte').Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
// The location heading lives in the layout, not in each page: a layout
|
||||||
|
// survives navigation, so switching between forecasts no longer tears the
|
||||||
|
// heading down and rebuilds it once the next page's data has loaded.
|
||||||
|
// Page-specific controls (model pickers, range buttons) render into the same
|
||||||
|
// row through this context.
|
||||||
|
let actions = $state<Snippet | null>(null);
|
||||||
|
setContext('weather-hero', {
|
||||||
|
setActions: (snippet: Snippet | null) => {
|
||||||
|
actions = snippet;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Page data wins; the persisted store covers the moment before the first
|
||||||
|
// load resolves (and any weather page that doesn't carry a location).
|
||||||
|
let location = $derived($page.data.location ?? $storedLocation);
|
||||||
|
|
||||||
|
const SUBTITLES: [string, string][] = [
|
||||||
|
['/weather/week', '7-day forecast'],
|
||||||
|
['/weather/compare', 'Model comparison'],
|
||||||
|
['/weather/14-day', '14-day ensemble forecast'],
|
||||||
|
['/weather/seasonal', 'Seasonal outlook'],
|
||||||
|
['/weather/historical', 'Historical weather']
|
||||||
|
];
|
||||||
|
let subtitle = $derived(
|
||||||
|
SUBTITLES.find(([prefix]) => $page.url.pathname.startsWith(prefix))?.[1] ?? null
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if subtitle && location}
|
||||||
|
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<img
|
||||||
|
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
||||||
|
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||||
|
alt={location.country ?? ''}
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
||||||
|
{location.name}
|
||||||
|
</h1>
|
||||||
|
<p class="truncate text-sm text-muted-foreground">
|
||||||
|
<span class="lg:hidden"
|
||||||
|
>{#if location.admin1}{location.admin1},
|
||||||
|
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
||||||
|
>{subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if actions}{@render actions()}{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
fetchEnsembleForecast
|
fetchEnsembleForecast
|
||||||
} from '$lib/services/weather';
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
|
import { useHeroActions } from '../../hero.svelte';
|
||||||
import { defaultParameters, ensembleModelGroups } from '../../options';
|
import { defaultParameters, ensembleModelGroups } from '../../options';
|
||||||
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
|
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
|
||||||
|
|
||||||
@@ -36,6 +37,8 @@
|
|||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
useHeroActions(heroActions);
|
||||||
|
|
||||||
// the URL is the source of truth: location comes from the load function,
|
// the URL is the source of truth: location comes from the load function,
|
||||||
// which is also correct on hydrated prerendered pages. The persisted store
|
// which is also correct on hydrated prerendered pages. The persisted store
|
||||||
// only mirrors it so the header and bare /weather/* redirects follow along.
|
// only mirrors it so the header and bare /weather/* redirects follow along.
|
||||||
@@ -272,28 +275,8 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- ─── Page hero: location + ensemble model selection ─────────────────────── -->
|
<!-- the ensemble picker rides in the layout's location row (see weather/+layout) -->
|
||||||
|
{#snippet heroActions()}
|
||||||
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
|
||||||
<img
|
|
||||||
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
|
||||||
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
|
||||||
alt={location.country ?? ''}
|
|
||||||
/>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
|
||||||
{location.name}
|
|
||||||
</h1>
|
|
||||||
<p class="truncate text-sm text-muted-foreground">
|
|
||||||
<span class="lg:hidden"
|
|
||||||
>{#if location.admin1}{location.admin1},
|
|
||||||
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
|
||||||
>14-day ensemble forecast
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full items-center gap-3 sm:w-auto">
|
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full items-center gap-3 sm:w-auto">
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
||||||
@@ -305,7 +288,7 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/snippet}
|
||||||
|
|
||||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
fetchModelComparison
|
fetchModelComparison
|
||||||
} from '$lib/services/weather';
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
|
import { useHeroActions } from '../../hero.svelte';
|
||||||
import { findModel, hourly, modelGroups } from '../../options';
|
import { findModel, hourly, modelGroups } from '../../options';
|
||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters } from '../../options';
|
||||||
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
||||||
@@ -52,6 +53,8 @@
|
|||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
useHeroActions(heroActions);
|
||||||
|
|
||||||
// the URL is the source of truth: location comes from the load function,
|
// the URL is the source of truth: location comes from the load function,
|
||||||
// which is also correct on hydrated prerendered pages. The persisted store
|
// which is also correct on hydrated prerendered pages. The persisted store
|
||||||
// only mirrors it so the header and bare /weather/* redirects follow along.
|
// only mirrors it so the header and bare /weather/* redirects follow along.
|
||||||
@@ -280,28 +283,8 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- ─── Page hero: location (matches the other forecast pages) ──────────────── -->
|
<!-- the zoom controls ride in the layout's location row (see weather/+layout) -->
|
||||||
|
{#snippet heroActions()}
|
||||||
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
|
||||||
<img
|
|
||||||
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
|
||||||
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
|
||||||
alt={location.country ?? ''}
|
|
||||||
/>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
|
||||||
{location.name}
|
|
||||||
</h1>
|
|
||||||
<p class="truncate text-sm text-muted-foreground">
|
|
||||||
<span class="lg:hidden"
|
|
||||||
>{#if location.admin1}{location.admin1},
|
|
||||||
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
|
||||||
>Model comparison
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Range / zoom controls, aligned with the title like the other pages -->
|
<!-- Range / zoom controls, aligned with the title like the other pages -->
|
||||||
<div class="lg:absolute lg:right-0 lg:top-20 z-40 flex flex-wrap items-center gap-3">
|
<div class="lg:absolute lg:right-0 lg:top-20 z-40 flex flex-wrap items-center gap-3">
|
||||||
<span class="hidden text-xs text-muted-foreground lg:inline">
|
<span class="hidden text-xs text-muted-foreground lg:inline">
|
||||||
@@ -345,7 +328,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/snippet}
|
||||||
|
|
||||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { getContext } from 'svelte';
|
||||||
|
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface HeroContext {
|
||||||
|
setActions: (snippet: Snippet | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a page's own controls into the shared location row that lives in
|
||||||
|
* `weather/+layout.svelte`. The row itself stays mounted across navigation;
|
||||||
|
* only the controls swap, and they are cleared when the page unmounts.
|
||||||
|
*/
|
||||||
|
export function useHeroActions(actions: Snippet): void {
|
||||||
|
const hero = getContext<HeroContext>('weather-hero');
|
||||||
|
$effect(() => {
|
||||||
|
hero.setActions(actions);
|
||||||
|
return () => hero.setActions(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -204,28 +204,6 @@
|
|||||||
<meta name="description" content="Past weather and climate-normal comparisons for any location" />
|
<meta name="description" content="Past weather and climate-normal comparisons for any location" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<!-- Page hero -->
|
|
||||||
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
|
||||||
<img
|
|
||||||
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
|
||||||
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
|
||||||
alt={location.country ?? ''}
|
|
||||||
/>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
|
||||||
{location.name}
|
|
||||||
</h1>
|
|
||||||
<p class="truncate text-sm text-muted-foreground">
|
|
||||||
<span class="lg:hidden"
|
|
||||||
>{#if location.admin1}{location.admin1},
|
|
||||||
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
|
||||||
>Historical weather
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PaywallGate feature="Historical weather">
|
<PaywallGate feature="Historical weather">
|
||||||
<DateRangeControls
|
<DateRangeControls
|
||||||
start={startDate}
|
start={startDate}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
fetchSeasonalForecast
|
fetchSeasonalForecast
|
||||||
} from '$lib/services/weather';
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
|
import { useHeroActions } from '../../hero.svelte';
|
||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters } from '../../options';
|
||||||
import { getPrecipUnit } from '../../week/[location]/types';
|
import { getPrecipUnit } from '../../week/[location]/types';
|
||||||
import SeasonalCharts from './SeasonalCharts.svelte';
|
import SeasonalCharts from './SeasonalCharts.svelte';
|
||||||
@@ -28,6 +29,8 @@
|
|||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
useHeroActions(heroActions);
|
||||||
|
|
||||||
// the URL is the source of truth: location comes from the load function,
|
// the URL is the source of truth: location comes from the load function,
|
||||||
// which is also correct on hydrated prerendered pages. The persisted store
|
// which is also correct on hydrated prerendered pages. The persisted store
|
||||||
// only mirrors it so the header and bare /weather/* redirects follow along.
|
// only mirrors it so the header and bare /weather/* redirects follow along.
|
||||||
@@ -173,27 +176,8 @@
|
|||||||
/>
|
/>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<!-- Page hero -->
|
<!-- the range buttons ride in the layout's location row (see weather/+layout) -->
|
||||||
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
{#snippet heroActions()}
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
|
||||||
<img
|
|
||||||
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
|
||||||
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
|
||||||
alt={location.country ?? ''}
|
|
||||||
/>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
|
||||||
{location.name}
|
|
||||||
</h1>
|
|
||||||
<p class="truncate text-sm text-muted-foreground">
|
|
||||||
<span class="lg:hidden"
|
|
||||||
>{#if location.admin1}{location.admin1},
|
|
||||||
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
|
||||||
>Seasonal outlook
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if result}
|
{#if result}
|
||||||
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
|
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
|
||||||
<div
|
<div
|
||||||
@@ -220,7 +204,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{/snippet}
|
||||||
|
|
||||||
<!-- What a seasonal forecast is (and is not): without this the daily-looking
|
<!-- What a seasonal forecast is (and is not): without this the daily-looking
|
||||||
charts invite over-reading. -->
|
charts invite over-reading. -->
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
humanizeWeatherError
|
humanizeWeatherError
|
||||||
} from '$lib/services/weather';
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
|
import { useHeroActions } from '../../hero.svelte';
|
||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters } from '../../options';
|
||||||
import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
|
import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
|
||||||
import DailyStripSticky from './DailyStripSticky.svelte';
|
import DailyStripSticky from './DailyStripSticky.svelte';
|
||||||
@@ -37,6 +38,8 @@
|
|||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
useHeroActions(heroActions);
|
||||||
|
|
||||||
let params = $state({
|
let params = $state({
|
||||||
models: ['best_match'],
|
models: ['best_match'],
|
||||||
...defaultParameters
|
...defaultParameters
|
||||||
@@ -56,6 +59,14 @@
|
|||||||
// arrives (no layout shift)
|
// arrives (no layout shift)
|
||||||
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
|
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
|
||||||
|
|
||||||
|
// Height the hourly table will occupy once it renders, so its placeholder
|
||||||
|
// reserves exactly that and nothing below it jumps. The table is a header row
|
||||||
|
// plus one row per enabled variable, so the count drives the estimate.
|
||||||
|
const TABLE_HEADER_PX = 96;
|
||||||
|
const TABLE_ROW_PX = 57;
|
||||||
|
let enabledTableRows = $derived(Object.values($storedVariablePrefs.table).filter(Boolean).length);
|
||||||
|
let tableSkeletonHeight = $derived(TABLE_HEADER_PX + enabledTableRows * TABLE_ROW_PX);
|
||||||
|
|
||||||
// Request only the hourly variables the table rows and meteograms actually
|
// Request only the hourly variables the table rows and meteograms actually
|
||||||
// show, so unused variables are never fetched. weather_code is always
|
// show, so unused variables are never fetched. weather_code is always
|
||||||
// included: the day cards / strip derive their day- and night-period icons
|
// included: the day cards / strip derive their day- and night-period icons
|
||||||
@@ -200,45 +211,24 @@
|
|||||||
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<!-- the model picker rides in the layout's location row (see weather/+layout) -->
|
||||||
|
{#snippet heroActions()}
|
||||||
|
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
||||||
|
<ModelSelector
|
||||||
|
selectedModel={params.models?.[0] ?? 'best_match'}
|
||||||
|
onModelChange={(model) => {
|
||||||
|
params.models = [model];
|
||||||
|
storedModel.set(model);
|
||||||
|
// a new model may not support the extended / past range
|
||||||
|
forecastDays = 7;
|
||||||
|
pastDays = 0;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<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="relative flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
|
||||||
<img
|
|
||||||
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
|
||||||
src="/images/country-flags/{(
|
|
||||||
location.country_code || 'united_nations'
|
|
||||||
).toLowerCase()}.svg"
|
|
||||||
alt={location.country ?? ''}
|
|
||||||
/>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
|
||||||
{location.name}
|
|
||||||
</h1>
|
|
||||||
<p class="truncate text-sm text-muted-foreground">
|
|
||||||
<span class="lg:hidden"
|
|
||||||
>{#if location.admin1}{location.admin1},
|
|
||||||
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
|
||||||
>7-day forecast
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
|
||||||
<ModelSelector
|
|
||||||
selectedModel={params.models?.[0] ?? 'best_match'}
|
|
||||||
onModelChange={(model) => {
|
|
||||||
params.models = [model];
|
|
||||||
storedModel.set(model);
|
|
||||||
// a new model may not support the extended / past range
|
|
||||||
forecastDays = 7;
|
|
||||||
pastDays = 0;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
|
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
|
||||||
|
|
||||||
{#if loadError}
|
{#if loadError}
|
||||||
@@ -314,19 +304,20 @@
|
|||||||
the strip's sentinel view-timeline so the sticky strip (a sibling of
|
the strip's sentinel view-timeline so the sticky strip (a sibling of
|
||||||
the sentinel) can scrub its collapse from it. -->
|
the sentinel) can scrub its collapse from it. -->
|
||||||
<div style="timeline-scope: --daystrip-sentinel">
|
<div style="timeline-scope: --daystrip-sentinel">
|
||||||
{#if fetchedDaily}
|
<!-- rendered even before the data lands: the strip's box is a fixed
|
||||||
<DailyStripSticky
|
height, so keeping it mounted reserves its space (it shows skeleton
|
||||||
daily={fetchedDaily}
|
tiles meanwhile) instead of shoving the page down on arrival -->
|
||||||
{selectedDay}
|
<DailyStripSticky
|
||||||
units={params}
|
daily={fetchedDaily}
|
||||||
onSelectDay={switchDay}
|
{selectedDay}
|
||||||
canExtend={forecastDays < 15}
|
units={params}
|
||||||
onExtend={() => (forecastDays = 15)}
|
onSelectDay={switchDay}
|
||||||
canExtendPast={pastDays < 3}
|
canExtend={forecastDays < 15}
|
||||||
onExtendPast={() => (pastDays = 3)}
|
onExtend={() => (forecastDays = 15)}
|
||||||
{locationRoute}
|
canExtendPast={pastDays < 3}
|
||||||
/>
|
onExtendPast={() => (pastDays = 3)}
|
||||||
{/if}
|
{locationRoute}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if fetchedHourly && fetchedDaily}
|
{#if fetchedHourly && fetchedDaily}
|
||||||
<HourlyTable
|
<HourlyTable
|
||||||
@@ -338,18 +329,41 @@
|
|||||||
onCustomize={() => (variableSidebarOpen = true)}
|
onCustomize={() => (variableSidebarOpen = true)}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- placeholder with the table's approximate height: no layout shift -->
|
<!-- Mirrors the real table: same header bar and the same body height,
|
||||||
|
so the heading doesn't pop in and nothing below moves.
|
||||||
|
Placeholders only fade IN - a fade-out would keep them in the
|
||||||
|
layout while the real content mounts below, and everything below
|
||||||
|
would jump the moment they finally unmount. -->
|
||||||
<div
|
<div
|
||||||
transition:fade={{ duration: 200 }}
|
in:fade={{ duration: 200 }}
|
||||||
class="h-107.5 animate-pulse rounded-2xl border border-border/70 bg-card"
|
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
|
||||||
></div>
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-2 border-b border-border/70 bg-muted/40 px-4 py-2.5"
|
||||||
|
>
|
||||||
|
<div class="h-6 w-44 animate-pulse rounded bg-muted"></div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-8 w-24 animate-pulse rounded-lg bg-muted"></div>
|
||||||
|
<div class="h-8 w-20 animate-pulse rounded-lg bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="animate-pulse bg-card" style="height: {tableSkeletonHeight}px"></div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if fetchedHourly}
|
{#if fetchedHourly}
|
||||||
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
||||||
{:else}
|
{:else}
|
||||||
<!-- reserve the exact chart area height before the first fetch resolves -->
|
<!-- reserve the exact chart area height before the first fetch resolves,
|
||||||
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
header row included -->
|
||||||
|
<section class="mt-8" in:fade={{ duration: 200 }}>
|
||||||
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="h-7 w-52 animate-pulse rounded bg-muted"></div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="h-7 w-56 animate-pulse rounded-lg bg-muted"></div>
|
||||||
|
<div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
|
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade, fly } from 'svelte/transition';
|
import { fade, fly } from 'svelte/transition';
|
||||||
|
|
||||||
import { type ChartPanel, defaultChartLayout, storedChartLayout } from '$lib/stores/settings';
|
import {
|
||||||
|
type ChartPanel,
|
||||||
|
type ChartRangePref,
|
||||||
|
defaultChartLayout,
|
||||||
|
storedChartLayout,
|
||||||
|
storedChartRange
|
||||||
|
} from '$lib/stores/settings';
|
||||||
|
|
||||||
import { CHART_VARIABLES, VARIABLE_BY_KEY } from './variables';
|
import { CHART_VARIABLES, VARIABLE_BY_KEY } from './variables';
|
||||||
|
|
||||||
@@ -14,6 +20,14 @@
|
|||||||
|
|
||||||
// Variables not placed in any panel form the "available" pool.
|
// Variables not placed in any panel form the "available" pool.
|
||||||
let usedKeys = $derived(new Set($storedChartLayout.flatMap((p) => p.variables)));
|
let usedKeys = $derived(new Set($storedChartLayout.flatMap((p) => p.variables)));
|
||||||
|
|
||||||
|
const RANGE_OPTIONS: { value: ChartRangePref; label: string; hint: string }[] = [
|
||||||
|
{ value: 'auto', label: 'Auto', hint: '3 days on phones, everything on wider screens' },
|
||||||
|
{ value: 'today', label: 'Today', hint: 'Open on the current day' },
|
||||||
|
{ value: '3d', label: '3 days', hint: 'Open on the next three days' },
|
||||||
|
{ value: '5d', label: '5 days', hint: 'Open on the next five days' },
|
||||||
|
{ value: 'all', label: 'All', hint: 'Open on the full forecast' }
|
||||||
|
];
|
||||||
let availableVars = $derived(CHART_VARIABLES.filter((v) => !usedKeys.has(v.key)));
|
let availableVars = $derived(CHART_VARIABLES.filter((v) => !usedKeys.has(v.key)));
|
||||||
|
|
||||||
// ─── Drag state ─────────────────────────────────────────────────────────────
|
// ─── Drag state ─────────────────────────────────────────────────────────────
|
||||||
@@ -159,6 +173,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
<div class="flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
||||||
|
<!-- Which slice of the forecast the meteograms open on -->
|
||||||
|
<div class="rounded-xl border border-border p-3">
|
||||||
|
<div class="mb-2 flex items-baseline justify-between gap-2">
|
||||||
|
<span class="text-[11px] font-bold tracking-wider text-primary uppercase">
|
||||||
|
Default time range
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
{RANGE_OPTIONS.find((o) => o.value === $storedChartRange)?.hint}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
|
||||||
|
{#each RANGE_OPTIONS as option (option.value)}
|
||||||
|
{@const active = $storedChartRange === option.value}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px] font-semibold whitespace-nowrap transition-colors {active
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'}"
|
||||||
|
aria-pressed={active}
|
||||||
|
title={option.hint}
|
||||||
|
onclick={() => storedChartRange.set(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#each $storedChartLayout as panel, i (panel.id)}
|
{#each $storedChartLayout as panel, i (panel.id)}
|
||||||
<div
|
<div
|
||||||
data-zone={panel.id}
|
data-zone={panel.id}
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
locationRoute
|
locationRoute
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
// enough placeholder tiles to fill a wide viewport; the row scrolls, so a
|
||||||
|
// couple too many costs nothing
|
||||||
|
const SKELETON_CELLS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||||
|
|
||||||
// ─── Scroll-driven collapse (full cards → compact strip) ────────────────────
|
// ─── Scroll-driven collapse (full cards → compact strip) ────────────────────
|
||||||
// All sizing derives from a single registered custom property `--strip-p`
|
// All sizing derives from a single registered custom property `--strip-p`
|
||||||
// (0 = full cards, 1 = compact strip). Mobile and desktop share the exact
|
// (0 = full cards, 1 = compact strip). Mobile and desktop share the exact
|
||||||
@@ -308,7 +312,7 @@
|
|||||||
class="flex w-full flex-col items-center gap-0.5 md:flex-row md:justify-center md:gap-2"
|
class="flex w-full flex-col items-center gap-0.5 md:flex-row md:justify-center md:gap-2"
|
||||||
>
|
>
|
||||||
<span class="inline-flex items-center gap-1 {lowPrecip ? 'opacity-40' : ''}">
|
<span class="inline-flex items-center gap-1 {lowPrecip ? 'opacity-40' : ''}">
|
||||||
<svg class="fill-sky-500" width="13" height="13">
|
<svg class="fill-sky-500" width="16" height="16">
|
||||||
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
||||||
</svg>
|
</svg>
|
||||||
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
|
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
|
||||||
@@ -319,16 +323,16 @@
|
|||||||
class="hidden shrink-0 md:inline-flex md:-mr-1"
|
class="hidden shrink-0 md:inline-flex md:-mr-1"
|
||||||
style="transform: {getWindArrowRotation(windDir)}"
|
style="transform: {getWindArrowRotation(windDir)}"
|
||||||
>
|
>
|
||||||
<svg class="fill-muted-foreground" width="20" height="20">
|
<svg class="fill-muted-foreground" width="25" height="25">
|
||||||
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"
|
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"
|
||||||
></use>
|
></use>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<svg class="fill-muted-foreground md:hidden" width="13" height="13">
|
<svg class="fill-muted-foreground md:hidden" width="16" height="16">
|
||||||
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||||
</svg>
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg class="fill-muted-foreground" width="13" height="13">
|
<svg class="fill-muted-foreground" width="16" height="16">
|
||||||
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -391,6 +395,16 @@
|
|||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- same cell metrics as the real strip, so the bar looks alive while
|
||||||
|
the forecast lands and nothing moves when it does -->
|
||||||
|
<div class="strip-days flex">
|
||||||
|
{#each SKELETON_CELLS as i (i)}
|
||||||
|
<div
|
||||||
|
class="strip-cell shrink-0 animate-pulse rounded-xl border border-border/50 bg-muted/60"
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
|
import {
|
||||||
|
type ChartPanel,
|
||||||
|
type ChartRangePref,
|
||||||
|
storedChartLayout,
|
||||||
|
storedChartRange
|
||||||
|
} from '$lib/stores/settings';
|
||||||
|
|
||||||
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||||
|
|
||||||
@@ -86,11 +91,47 @@
|
|||||||
liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY);
|
liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetZoom(): void {
|
/** Drop any zoom and show the whole forecast. */
|
||||||
|
function showFullRange(): void {
|
||||||
liveCharts[0]?.resetRange();
|
liveCharts[0]?.resetRange();
|
||||||
onResetZoom?.();
|
onResetZoom?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Back to the range the meteograms are configured to open on. */
|
||||||
|
function resetZoom(): void {
|
||||||
|
applyDefaultRange();
|
||||||
|
onResetZoom?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Default range ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Days the preference resolves to, or null for the full range. */
|
||||||
|
function rangeDaysFor(pref: ChartRangePref): number | null {
|
||||||
|
if (pref === 'today') return 1;
|
||||||
|
if (pref === '3d') return 3;
|
||||||
|
if (pref === '5d') return 5;
|
||||||
|
if (pref === 'all') return null;
|
||||||
|
// auto: a week of hourly data is unreadable on a phone-width plot
|
||||||
|
const phone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||||
|
return phone ? 3 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDefaultRange(): void {
|
||||||
|
const days = rangeDaysFor($storedChartRange);
|
||||||
|
if (days == null) liveCharts[0]?.resetRange();
|
||||||
|
else setRangeDays(new Date(), days);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applied once the charts exist, and again whenever the preference changes -
|
||||||
|
// but never after that, so it can't fight a zoom the user just set by hand.
|
||||||
|
let appliedPref: ChartRangePref | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
const pref = $storedChartRange;
|
||||||
|
if (liveCharts.length === 0 || timestampsSec.length === 0 || appliedPref === pref) return;
|
||||||
|
appliedPref = pref;
|
||||||
|
applyDefaultRange();
|
||||||
|
});
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// "Selected day" is redundant while today is the selected day, so hide it then
|
// "Selected day" is redundant while today is the selected day, so hide it then
|
||||||
let rangePresets = $derived.by(() => {
|
let rangePresets = $derived.by(() => {
|
||||||
@@ -100,7 +141,7 @@
|
|||||||
...(isToday ? [] : [{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) }]),
|
...(isToday ? [] : [{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) }]),
|
||||||
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
|
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
|
||||||
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
|
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
|
||||||
{ label: 'All', apply: () => resetZoom() }
|
{ label: 'All', apply: () => showFullRange() }
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user