finish animations and add elevation

This commit is contained in:
Vincent van der Wal
2026-08-06 22:19:17 +02:00
parent d0f94094ca
commit 83b0e46e64
21 changed files with 328 additions and 112 deletions
+16 -6
View File
@@ -45,8 +45,18 @@
// Joined here rather than in the markup: Svelte trims the whitespace around a
// line break, so a separator written as "{admin1},\n{country}" renders as
// "Canton of Schwyz,Switzerland".
let region = $derived([location?.admin1, location?.country].filter(Boolean).join(', '));
// "Canton of Schwyz,Switzerland". Elevation joins the same line; from lg up
// the whole line is hidden, because the topbar pill carries the region and
// elevation there.
let region = $derived(
[
location?.admin1,
location?.country,
location?.elevation != null ? `${Math.round(location.elevation)}m` : null
]
.filter(Boolean)
.join(', ')
);
</script>
{#if subtitle && location}
@@ -60,11 +70,11 @@
<div class="min-w-0">
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
{location.name}
<span class="font-medium text-muted-foreground">· {subtitle}</span>
</h1>
<p class="truncate text-sm text-muted-foreground">
{#if region}<span class="lg:hidden">{region}<span class="mx-1 opacity-50">·</span></span
>{/if}{subtitle}
</p>
{#if region}
<p class="truncate text-sm text-muted-foreground lg:hidden">{region}</p>
{/if}
</div>
</div>
@@ -298,7 +298,7 @@
<!-- the ensemble 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 items-center gap-3 sm:w-auto">
<div class="flex w-full items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={params.models?.[0] ?? DEFAULT_MODEL}
groups={ensembleModelGroups}
@@ -143,7 +143,7 @@
// components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
// ─── Zoom range controls (mirrors the 7-day meteograms) ─────────────────────
// ─── Zoom range controls (mirrors the week-page meteograms) ─────────────────────
const SECONDS_PER_DAY = 24 * 3600;
@@ -321,9 +321,62 @@
});
</script>
<!-- the zoom controls ride in the layout's location row (see weather/+layout) -->
<!-- the models button and zoom controls ride in the layout's location row -->
{#snippet heroActions()}
<!-- Range / zoom controls, aligned with the title like the other pages -->
<!-- Hero models control: same footprint and look as the model selector on
the other pages, so the title row lines up everywhere. Model selection
here is a multi-select grid further down, so this hands over to it. -->
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
<button
type="button"
class="group flex h-auto min-h-12 w-full min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 pe-3 text-left shadow-sm transition-colors hover:border-primary/70 hover:shadow-md sm:min-h-14 sm:w-80 sm:flex-none sm:gap-3 sm:py-2"
onclick={() =>
document.getElementById('models')?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9"
>
<!-- layered-globe icon, matching the model selector -->
<svg
class="size-4.5 sm: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">
<span class="text-[11px] font-semibold tracking-wide text-primary uppercase">
{m.compare_models_heading()}
</span>
<span class="max-w-full truncate text-[13px] font-bold text-foreground sm:text-sm">
{params.models?.length || 0} / {models.flat().length}
</span>
<span
class="hidden max-w-full truncate text-[11px] leading-tight text-muted-foreground sm:block"
>
{m.compare_models_choose()}
</span>
</div>
<svg
class="size-4 shrink-0 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 9l6 6 6-6" />
</svg>
</button>
</div>
<!-- Range / zoom controls, floated over the chart area on lg -->
<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">
{m.meteograms_zoom_hint()}
@@ -225,7 +225,7 @@
<!-- the reanalysis picker rides in the layout's location row -->
{#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">
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={archiveModel}
groups={archiveModelGroups}
@@ -31,7 +31,7 @@
let chartComponents: (CanvasChart | null)[] = $state([]);
let liveCharts = $derived(chartComponents.filter((c): c is CanvasChart => c != null));
// Same customizable layout as the 7-day meteograms, so the two pages match.
// Same customizable layout as the week-page meteograms, so the two pages match.
let panels = $derived(
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
);
+25 -2
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { reportPageReady } from '$lib/stores/page-transition.svelte';
import { mapTransitionCover, reportPageReady } from '$lib/stores/page-transition.svelte';
import { storedLocation, storedModel, storedTheme } from '$lib/stores/settings';
import { mapsDomainForModel } from '$lib/utils/maps-domain';
@@ -27,6 +28,14 @@
// backstop, so a map that fails to boot still releases the page.
reportPageReady(() => mapReady || frameLoaded);
// The map stays under an opaque cover until it is ready, so it fades up from
// a clean panel instead of flashing the iframe's white boot document (worst
// in dark mode). The same cover is what makes navigation transitions work at
// all here: a cross-origin iframe is never painted into a view transition
// snapshot, so the cover is raised again just before a departure is captured
// (see mapTransitionCover) - both snapshots then hold real pixels.
let revealed = $derived(mapReady || frameLoaded);
const postToMap = (message: Record<string, unknown>) => {
iframeEl?.contentWindow?.postMessage(message, MAPS_ORIGIN);
};
@@ -93,7 +102,7 @@
<!-- Full-bleed map: the layout drops its padding for this route. The map
follows our theme through the color-scheme declared on :root/.dark -->
<div class="h-full w-full bg-background">
<div class="relative h-full w-full bg-background">
<!-- allow="cross-origin-isolated" delegates SharedArrayBuffer use to the
map; it only takes effect when this site itself is served with
COOP/COEP headers (see README, Deployment) -->
@@ -107,6 +116,20 @@
referrerpolicy="no-referrer"
onload={() => (frameLoaded = true)}
class="block h-full w-full border-0"
class:invisible={$mapTransitionCover}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
></iframe>
<!-- No `in:` transition on purpose: when the cover comes back for a
departure it has to be at full opacity by the time the snapshot is
taken, not fading towards it. The iframe is additionally made
invisible then (above), so the capture never has to paint cross-origin
content at all - some engines degrade the whole transition over it. -->
{#if !revealed || $mapTransitionCover}
<div
class="absolute inset-0 bg-background"
out:fade={{ duration: 300 }}
aria-hidden="true"
></div>
{/if}
</div>
@@ -196,7 +196,7 @@
<!-- Out of flow on lg+ (the hero row is `relative`), same as the week, 14-day
and archive pages: the controls then cannot move the heading when they
change size. -->
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto lg:absolute lg:top-0 lg:right-0">
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto">
<!-- Range buttons reslice the already-fetched horizon (no refetch). While a
forecast is on its way they stay mounted but invisible, because
mounting them on arrival re-flowed the row and nudged the heading.
@@ -346,12 +346,12 @@
<svelte:head>
<title>Drizz.li | {m.page_week_title()}</title>
<link rel="canonical" href="https://drizz.li/weather/week" />
<meta name="description" content="7-day weather forecast with detailed hourly data" />
<meta name="description" content="Weekly weather forecast with detailed hourly data" />
</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">
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
@@ -322,6 +322,10 @@
// positioned relative to the data columns only.
let headerColWidth = $state(0);
let tableWidth = $state(0);
// Measured so the centred NOW badge can be clamped fully inside the row:
// centred on a time near midnight it would poke past the last column and
// hand the scroller a sliver of phantom overflow.
let nowBadgeWidth = $state(0);
let nowLeftPx = $derived(
nowPercent != null && tableWidth > 0
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
@@ -608,14 +612,17 @@
{#if nowPercent != null}
<div
class="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-red-500/75"
style="left:{nowPercent}%"
style="left:clamp(1px, {nowPercent}%, calc(100% - 1px))"
></div>
{/if}
<!-- "Now" label, aligned with the sunrise/sunset labels along the bottom -->
<!-- "Now" label, aligned with the sunrise/sunset labels along the
bottom; its centre is clamped so the pill never leaves the row -->
{#if isTodaySelected && nowPercent != null}
<span
bind:clientWidth={nowBadgeWidth}
class="absolute bottom-0.5 z-15 -translate-x-1/2 rounded-full bg-red-500 px-1.5 py-px text-[9px] font-bold tracking-wide whitespace-nowrap text-white uppercase shadow-sm"
style="left:{nowPercent}%"
style="left:clamp({nowBadgeWidth /
2}px, {nowPercent}%, calc(100% - {nowBadgeWidth / 2}px))"
>
{m.table_now()}
</span>
@@ -904,24 +911,31 @@
</tbody>
</table>
<!-- Hovered-column highlight, mirroring the meteogram crosshair -->
<!-- Hovered-column highlight, mirroring the meteogram crosshair.
max-width caps the box at the wrapper's true (fractional) right
edge: the widths here derive from rounded clientWidth bindings, so
on the last column left+width can land a fraction of a pixel past
the edge - enough scrollable overflow for a phantom scrollbar. -->
{#if hoveredCol >= 0 && tableWidth > 0}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
{@const colLeft = headerColWidth + hoveredCol * colWidth}
<div
class="pointer-events-none absolute inset-y-0 z-10 border-x border-primary/40 bg-primary/10"
style="left:{headerColWidth + hoveredCol * colWidth}px;width:{colWidth}px"
style="left:{colLeft}px;width:{colWidth}px;max-width:calc(100% - {colLeft}px)"
></div>
{/if}
<!-- "Now" column highlight. The current-time line itself is painted per
cell (.now-cell) so it stays under the values and icons. -->
cell (.now-cell) so it stays under the values and icons. Same
max-width cap as the hover highlight above. -->
{#if nowLeftPx != null}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
{@const nowIdx = cellData.findIndex((c) => c.isNow)}
{#if nowIdx >= 0}
{@const colLeft = headerColWidth + nowIdx * colWidth}
<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"
style="left:{colLeft}px;width:{colWidth}px;max-width:calc(100% - {colLeft}px)"
></div>
{/if}
{/if}
@@ -64,9 +64,13 @@
if (val) onModelChange(val);
}}
>
<!-- Fixed width from sm up (mobile stays full-width): the trigger used to hug
its content, so its size changed with every model name and differed
between pages. One constant footprint, sized for the longest label in
the catalogue; anything longer truncates. -->
<Select.Trigger
aria-label={m.model_selector_aria({ label })}
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 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-h-14 sm:gap-3 sm:py-2 sm:min-w-72 sm:flex-none"
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 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-h-14 sm:w-80 sm:gap-3 sm:py-2 sm:flex-none"
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9"