finish animations and add elevation
This commit is contained in:
@@ -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)))
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user