overlay
This commit is contained in:
+13
@@ -33,3 +33,16 @@ AGENTS.md
|
|||||||
|
|
||||||
# Claude Code scratch: throwaway probe scripts, never committed
|
# Claude Code scratch: throwaway probe scripts, never committed
|
||||||
/.scratch
|
/.scratch
|
||||||
|
|
||||||
|
# Empty stubs the Claude Code sandbox mounts over shell/editor dotfiles while it
|
||||||
|
# runs. They are not project files and keep sneaking into commits via `git add -A`.
|
||||||
|
/.bash_profile
|
||||||
|
/.bashrc
|
||||||
|
/.profile
|
||||||
|
/.zprofile
|
||||||
|
/.zshrc
|
||||||
|
/.gitconfig
|
||||||
|
/.gitmodules
|
||||||
|
/.ripgreprc
|
||||||
|
/.idea
|
||||||
|
/.mcp.json
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ Pages that are not prerendered (unlisted cities, GPS coordinate routes like
|
|||||||
and resolves the location client-side. Configure the server to serve
|
and resolves the location client-side. Configure the server to serve
|
||||||
`404.html` for unknown paths.
|
`404.html` for unknown paths.
|
||||||
|
|
||||||
|
Serve it as an **internal rewrite (200)**, not as an error page. `error_page
|
||||||
|
404 /404.html` sends the right body with a 404 status: the page works, but
|
||||||
|
every hard reload of an unprerendered URL logs a 404 in the network panel and
|
||||||
|
tells crawlers the page does not exist. `try_files` with a URI as its last
|
||||||
|
argument does an internal redirect instead, and answers 200.
|
||||||
|
|
||||||
### 2. Cross-origin isolation (SharedArrayBuffer for the embedded map)
|
### 2. Cross-origin isolation (SharedArrayBuffer for the embedded map)
|
||||||
|
|
||||||
The `/weather/maps/` page embeds `maps.open-meteo.com`, which uses
|
The `/weather/maps/` page embeds `maps.open-meteo.com`, which uses
|
||||||
@@ -83,11 +89,12 @@ drizzli.example.com {
|
|||||||
server {
|
server {
|
||||||
server_name drizzli.example.com;
|
server_name drizzli.example.com;
|
||||||
root /srv/drizzli;
|
root /srv/drizzli;
|
||||||
error_page 404 /404.html;
|
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
# the trailing /404.html is a URI, so nginx rewrites internally and
|
||||||
|
# answers 200 - `error_page 404 /404.html` would answer 404 instead
|
||||||
|
try_files $uri $uri/index.html /404.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -260,6 +260,7 @@
|
|||||||
"table_interval_aria": "Stundenintervall",
|
"table_interval_aria": "Stundenintervall",
|
||||||
"table_now": "Jetzt",
|
"table_now": "Jetzt",
|
||||||
"interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln",
|
"interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln",
|
||||||
|
"page_loading": "Wird geladen…",
|
||||||
"charts_loading": "Diagramme werden geladen…",
|
"charts_loading": "Diagramme werden geladen…",
|
||||||
"chart_download": "Meteogramm als PNG-Bild herunterladen",
|
"chart_download": "Meteogramm als PNG-Bild herunterladen",
|
||||||
"chart_credit_viz": "Visualisierung von",
|
"chart_credit_viz": "Visualisierung von",
|
||||||
|
|||||||
@@ -260,6 +260,7 @@
|
|||||||
"table_interval_aria": "Hourly interval",
|
"table_interval_aria": "Hourly interval",
|
||||||
"table_now": "Now",
|
"table_now": "Now",
|
||||||
"interval_toggle": "Toggle between 1-hour and 3-hour intervals",
|
"interval_toggle": "Toggle between 1-hour and 3-hour intervals",
|
||||||
|
"page_loading": "Loading…",
|
||||||
"charts_loading": "Loading charts...",
|
"charts_loading": "Loading charts...",
|
||||||
"chart_download": "Download meteogram as PNG image",
|
"chart_download": "Download meteogram as PNG image",
|
||||||
"chart_credit_viz": "visualisation by",
|
"chart_credit_viz": "visualisation by",
|
||||||
|
|||||||
@@ -260,6 +260,7 @@
|
|||||||
"table_interval_aria": "Intervalo horario",
|
"table_interval_aria": "Intervalo horario",
|
||||||
"table_now": "Ahora",
|
"table_now": "Ahora",
|
||||||
"interval_toggle": "Alternar entre intervalos de 1 y 3 horas",
|
"interval_toggle": "Alternar entre intervalos de 1 y 3 horas",
|
||||||
|
"page_loading": "Cargando…",
|
||||||
"charts_loading": "Cargando gráficos…",
|
"charts_loading": "Cargando gráficos…",
|
||||||
"chart_download": "Descargar el meteograma como imagen PNG",
|
"chart_download": "Descargar el meteograma como imagen PNG",
|
||||||
"chart_credit_viz": "visualización de",
|
"chart_credit_viz": "visualización de",
|
||||||
|
|||||||
@@ -260,6 +260,7 @@
|
|||||||
"table_interval_aria": "Intervalle horaire",
|
"table_interval_aria": "Intervalle horaire",
|
||||||
"table_now": "Maintenant",
|
"table_now": "Maintenant",
|
||||||
"interval_toggle": "Basculer entre les intervalles de 1 h et 3 h",
|
"interval_toggle": "Basculer entre les intervalles de 1 h et 3 h",
|
||||||
|
"page_loading": "Chargement…",
|
||||||
"charts_loading": "Chargement des graphiques…",
|
"charts_loading": "Chargement des graphiques…",
|
||||||
"chart_download": "Télécharger le météogramme en PNG",
|
"chart_download": "Télécharger le météogramme en PNG",
|
||||||
"chart_credit_viz": "visualisation par",
|
"chart_credit_viz": "visualisation par",
|
||||||
|
|||||||
@@ -260,6 +260,7 @@
|
|||||||
"table_interval_aria": "Intervallo orario",
|
"table_interval_aria": "Intervallo orario",
|
||||||
"table_now": "Ora",
|
"table_now": "Ora",
|
||||||
"interval_toggle": "Alterna tra intervalli di 1 e 3 ore",
|
"interval_toggle": "Alterna tra intervalli di 1 e 3 ore",
|
||||||
|
"page_loading": "Caricamento…",
|
||||||
"charts_loading": "Caricamento dei grafici…",
|
"charts_loading": "Caricamento dei grafici…",
|
||||||
"chart_download": "Scarica il meteogramma come immagine PNG",
|
"chart_download": "Scarica il meteogramma come immagine PNG",
|
||||||
"chart_credit_viz": "visualizzazione di",
|
"chart_credit_viz": "visualizzazione di",
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
<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" />
|
||||||
|
<!-- Declared in the shell, not in the layout head: the SPA fallback page
|
||||||
|
(404.html) ships no rendered head, so without this the browser falls
|
||||||
|
back to requesting /favicon.ico and takes a 404 on every load of an
|
||||||
|
unprerendered URL. -->
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<script>
|
<script>
|
||||||
// apply the persisted theme before first paint to avoid a flash
|
// apply the persisted theme before first paint to avoid a flash
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -41,6 +41,13 @@
|
|||||||
if (flagEl && !flagEl.src.endsWith(src)) flagEl.src = src;
|
if (flagEl && !flagEl.src.endsWith(src)) flagEl.src = src;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Built as a string rather than inline markup: the pieces are optional, and
|
||||||
|
// separators spelled out in the template lose their spacing to Svelte's
|
||||||
|
// whitespace trimming ("Canton of Schwyz,Switzerland").
|
||||||
|
let locationRegion = $derived([location?.admin1, location?.country].filter(Boolean).join(', '));
|
||||||
|
// the pill ellipses, so the full name still has to be readable somewhere
|
||||||
|
let locationLine = $derived([location?.name, locationRegion].filter(Boolean).join(' · '));
|
||||||
|
|
||||||
const themeCycle: Theme[] = ['system', 'light', 'dark'];
|
const themeCycle: Theme[] = ['system', 'light', 'dark'];
|
||||||
const themeTitles: Record<Theme, () => string> = {
|
const themeTitles: Record<Theme, () => string> = {
|
||||||
system: m.theme_follow_system,
|
system: m.theme_follow_system,
|
||||||
@@ -99,8 +106,10 @@
|
|||||||
|
|
||||||
<!-- Current location display -->
|
<!-- Current location display -->
|
||||||
{#if location}
|
{#if location}
|
||||||
|
<!-- min-w-0 + a ceiling so a long "Sant Pere de Ribes, Catalonia, Spain"
|
||||||
|
ellipses inside the pill instead of pushing the search box off centre -->
|
||||||
<div
|
<div
|
||||||
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex"
|
class="hidden min-w-0 max-w-70 items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex xl:max-w-96"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
bind:this={flagEl}
|
bind:this={flagEl}
|
||||||
@@ -109,13 +118,10 @@
|
|||||||
alt={location.country}
|
alt={location.country}
|
||||||
/>
|
/>
|
||||||
<!-- full location (desktop); the page hero carries it on smaller screens -->
|
<!-- full location (desktop); the page hero carries it on smaller screens -->
|
||||||
<span class="whitespace-nowrap text-sm font-semibold text-foreground">
|
<span class="min-w-0 truncate text-sm font-semibold text-foreground" title={locationLine}>
|
||||||
{location.name}
|
{location.name}
|
||||||
{#if location.admin1 || location.country}
|
{#if locationRegion}
|
||||||
<span class="font-normal text-muted-foreground">
|
<span class="font-normal text-muted-foreground">· {locationRegion}</span>
|
||||||
· {#if location.admin1}{location.admin1},
|
|
||||||
{/if}{location.country ?? ''}
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+44
-10
@@ -11,12 +11,40 @@ import type { Locale as DateFnsLocale } from 'date-fns';
|
|||||||
// locale the messages do.
|
// locale the messages do.
|
||||||
const DATE_LOCALES: Record<string, DateFnsLocale> = { en: enGB, de, es, fr, it };
|
const DATE_LOCALES: Record<string, DateFnsLocale> = { en: enGB, de, es, fr, it };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalises anything date-shaped into a plain `Date`, or null when it does not
|
||||||
|
* describe a real instant.
|
||||||
|
*
|
||||||
|
* Two reasons every helper below starts here:
|
||||||
|
*
|
||||||
|
* 1. date-fns copies its input with `new date.constructor(value)`. Hand it the
|
||||||
|
* reactive `SvelteDate` the pages use for the selected day and it builds
|
||||||
|
* *another* SvelteDate, then reads the copy's fields back through memoised
|
||||||
|
* signals - a signal graph per formatted timestamp, in a path that runs
|
||||||
|
* hundreds of times per render.
|
||||||
|
* 2. date-fns throws `RangeError: Invalid time value` on an invalid date. Thrown
|
||||||
|
* from inside a render (or inside a view-transition callback, where it
|
||||||
|
* surfaces as an unhandled rejection) that takes the whole page down for what
|
||||||
|
* is really one unformattable cell.
|
||||||
|
*/
|
||||||
|
function plainDate(date: Date | null | undefined): Date | null {
|
||||||
|
const time = date?.getTime?.();
|
||||||
|
if (time == null || !Number.isFinite(time)) return null;
|
||||||
|
// already a plain Date: no copy needed
|
||||||
|
return date!.constructor === Date ? (date as Date) : new Date(time);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a UTC Date into a string for a specific timezone using date-fns patterns.
|
* Formats a UTC Date into a string for a specific timezone using date-fns patterns.
|
||||||
* Patterns: 'HH:mm' for 24h time, 'EEE' for short weekday, etc.
|
* Patterns: 'HH:mm' for 24h time, 'EEE' for short weekday, etc.
|
||||||
|
*
|
||||||
|
* Returns '' for a date or zone it cannot format - callers compare these strings
|
||||||
|
* or print them, and both degrade gracefully on an empty one.
|
||||||
*/
|
*/
|
||||||
export function formatZoned(date: Date, timeZone: string, pattern: string): string {
|
export function formatZoned(date: Date, timeZone: string, pattern: string): string {
|
||||||
return formatInTimeZone(date, timeZone, pattern, {
|
const d = plainDate(date);
|
||||||
|
if (!d || !timeZone) return '';
|
||||||
|
return formatInTimeZone(d, timeZone, pattern, {
|
||||||
locale: DATE_LOCALES[getLocale()] ?? enGB
|
locale: DATE_LOCALES[getLocale()] ?? enGB
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -26,16 +54,20 @@ export function formatZoned(date: Date, timeZone: string, pattern: string): stri
|
|||||||
* Important for comparing weather forecast days against a selected date.
|
* Important for comparing weather forecast days against a selected date.
|
||||||
*/
|
*/
|
||||||
export function isSameDayInZone(date1: Date, date2: Date, timeZone: string): boolean {
|
export function isSameDayInZone(date1: Date, date2: Date, timeZone: string): boolean {
|
||||||
const z1 = toZonedTime(date1, timeZone);
|
const d1 = plainDate(date1);
|
||||||
const z2 = toZonedTime(date2, timeZone);
|
const d2 = plainDate(date2);
|
||||||
return isSameDayDateFns(z1, z2);
|
if (!d1 || !d2 || !timeZone) return false;
|
||||||
|
return isSameDayDateFns(toZonedTime(d1, timeZone), toZonedTime(d2, timeZone));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the numeric hour (0-23) for a date in a specific timezone.
|
* Gets the numeric hour (0-23) for a date in a specific timezone, or NaN when
|
||||||
|
* the date cannot be read.
|
||||||
*/
|
*/
|
||||||
export function getZonedHour(date: Date, timeZone: string): number {
|
export function getZonedHour(date: Date, timeZone: string): number {
|
||||||
return parseInt(formatInTimeZone(date, timeZone, 'H'), 10);
|
const d = plainDate(date);
|
||||||
|
if (!d || !timeZone) return NaN;
|
||||||
|
return parseInt(formatInTimeZone(d, timeZone, 'H'), 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,9 +75,11 @@ export function getZonedHour(date: Date, timeZone: string): number {
|
|||||||
* or a formatted date string, all relative to the target timezone.
|
* or a formatted date string, all relative to the target timezone.
|
||||||
*/
|
*/
|
||||||
export function getRelativeDayLabel(date: Date, timeZone: string): string {
|
export function getRelativeDayLabel(date: Date, timeZone: string): string {
|
||||||
const now = new Date();
|
const d = plainDate(date);
|
||||||
const zonedDate = toZonedTime(date, timeZone);
|
if (!d || !timeZone) return '';
|
||||||
const zonedNow = toZonedTime(now, timeZone);
|
|
||||||
|
const zonedDate = toZonedTime(d, timeZone);
|
||||||
|
const zonedNow = toZonedTime(new Date(), timeZone);
|
||||||
|
|
||||||
if (isSameDayDateFns(zonedDate, zonedNow)) return m.day_today();
|
if (isSameDayDateFns(zonedDate, zonedNow)) return m.day_today();
|
||||||
|
|
||||||
@@ -57,7 +91,7 @@ export function getRelativeDayLabel(date: Date, timeZone: string): string {
|
|||||||
yesterday.setDate(yesterday.getDate() - 1);
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
if (isSameDayDateFns(zonedDate, yesterday)) return m.day_yesterday();
|
if (isSameDayDateFns(zonedDate, yesterday)) return m.day_yesterday();
|
||||||
|
|
||||||
return formatZoned(date, timeZone, 'EEE d MMM');
|
return formatZoned(d, timeZone, 'EEE d MMM');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,10 +24,18 @@ export async function runDayTransition(update: () => void): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
// Svelte applies the change on the next tick; the transition has to wait
|
// Svelte applies the change on the next tick; the transition has to wait
|
||||||
// for that before it snapshots the new state.
|
// for that before it snapshots the new state.
|
||||||
await document.startViewTransition(async () => {
|
const transition = document.startViewTransition(async () => {
|
||||||
update();
|
update();
|
||||||
await tick();
|
await tick();
|
||||||
}).finished;
|
});
|
||||||
|
// A throw inside the callback rejects `updateCallbackDone` as well as
|
||||||
|
// `finished`. Nothing awaits the former, and that unhandled rejection is
|
||||||
|
// what turns one bad render into an "Uncaught" error on the page - so
|
||||||
|
// report it here instead and let `finished` drive the flow.
|
||||||
|
transition.updateCallbackDone.catch((error: unknown) => {
|
||||||
|
console.error('day transition failed to apply', error);
|
||||||
|
});
|
||||||
|
await transition.finished;
|
||||||
} catch {
|
} catch {
|
||||||
/* a superseded transition is fine - the DOM is already up to date */
|
/* a superseded transition is fine - the DOM is already up to date */
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ interface ResolveLocationOptions {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geocoding results for a route segment, kept for the life of the process.
|
||||||
|
*
|
||||||
|
* A city's coordinates do not change, and the same segment is resolved over and
|
||||||
|
* over: once per per-location route during the prerender (five builds of the
|
||||||
|
* same lookup for every city), and again on every client-side hop from a city's
|
||||||
|
* week page to its comparison or archive.
|
||||||
|
*/
|
||||||
|
const resolvedLocations = new Map<string, GeoLocation>();
|
||||||
|
|
||||||
export async function resolveLocationFromRoute({
|
export async function resolveLocationFromRoute({
|
||||||
urlLocation,
|
urlLocation,
|
||||||
routePrefix,
|
routePrefix,
|
||||||
@@ -86,6 +96,11 @@ export async function resolveLocationFromRoute({
|
|||||||
return coordinateLocation(parseFloat(coordMatch[1]), parseFloat(coordMatch[2]));
|
return coordinateLocation(parseFloat(coordMatch[1]), parseFloat(coordMatch[2]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The canonical-path check below still has to run per call (the same city is
|
||||||
|
// reached under different route prefixes), so only the lookup is cached.
|
||||||
|
const cached = resolvedLocations.get(urlLocation);
|
||||||
|
if (cached) return finishResolve(cached, routePrefix, event);
|
||||||
|
|
||||||
let urlLocationName: string;
|
let urlLocationName: string;
|
||||||
let urlLocationId: string | undefined;
|
let urlLocationId: string | undefined;
|
||||||
|
|
||||||
@@ -124,6 +139,15 @@ export async function resolveLocationFromRoute({
|
|||||||
location = candidate;
|
location = candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolvedLocations.set(urlLocation, location);
|
||||||
|
return finishResolve(location, routePrefix, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishResolve(
|
||||||
|
location: GeoLocation,
|
||||||
|
routePrefix: string,
|
||||||
|
event: ResolveLocationOptions['event']
|
||||||
|
): GeoLocation {
|
||||||
// trailingSlash is 'always' (see routes/+layout.ts), so the router serves
|
// trailingSlash is 'always' (see routes/+layout.ts), so the router serves
|
||||||
// every path with a trailing slash. Match that here or the equality check
|
// every path with a trailing slash. Match that here or the equality check
|
||||||
// never holds and the redirect loops forever. The comparison also has to
|
// never holds and the redirect loops forever. The comparison also has to
|
||||||
|
|||||||
+92
-16
@@ -13,9 +13,8 @@
|
|||||||
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';
|
||||||
|
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
|
||||||
|
|
||||||
import { routePath } from '$lib/i18n';
|
import { routePath } from '$lib/i18n';
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
|
|
||||||
@@ -63,18 +62,46 @@
|
|||||||
// ── Page cross-fade ───────────────────────────────────────────────────────
|
// ── Page cross-fade ───────────────────────────────────────────────────────
|
||||||
// A real cross-fade needs the outgoing page still on screen while the
|
// A real cross-fade needs the outgoing page still on screen while the
|
||||||
// incoming one appears - so the view transition is opened at navigation and
|
// incoming one appears - so the view transition is opened at navigation and
|
||||||
// deliberately held open until the new page reports that its data has landed.
|
// held open for a short grace period, waiting for the new page to report that
|
||||||
// The browser keeps showing the old snapshot for that whole time (rather than
|
// its data has landed. A cached or fast response lands inside that window and
|
||||||
// flashing a skeleton), then fades it into the finished page.
|
// the old page fades straight into the finished one, no skeleton in between.
|
||||||
//
|
//
|
||||||
// The wait is capped: past the ceiling we cross-fade into whatever is on
|
// Past the grace period the wait gives up and the loading overlay takes over:
|
||||||
// screen instead of freezing the UI on a slow network.
|
// holding the old page frozen any longer looks like a dead click, and the
|
||||||
const READY_CEILING_MS = 2200;
|
// overlay is the honest answer - something is happening, it just isn't here
|
||||||
|
// yet. Note the order: the overlay has to be in the DOM *before* the
|
||||||
|
// transition captures the incoming state, because a running view transition
|
||||||
|
// freezes the page and nothing painted after that point can appear.
|
||||||
|
const READY_HOLD_MS = 350;
|
||||||
const FADE_OUT_MS = 170;
|
const FADE_OUT_MS = 170;
|
||||||
|
// Nothing reports ready when a fetch fails outright, so the overlay needs its
|
||||||
|
// own way out rather than sitting on top of an error message forever.
|
||||||
|
const OVERLAY_CEILING_MS = 15000;
|
||||||
|
|
||||||
let contentVisible = $state(true);
|
let contentVisible = $state(true);
|
||||||
let revealTimer = 0;
|
let revealTimer = 0;
|
||||||
|
|
||||||
|
let loadingOverlay = $state(false);
|
||||||
|
let overlayHoldTimer = 0;
|
||||||
|
let overlayCeilingTimer = 0;
|
||||||
|
|
||||||
|
function showOverlay(): void {
|
||||||
|
loadingOverlay = true;
|
||||||
|
clearTimeout(overlayCeilingTimer);
|
||||||
|
overlayCeilingTimer = window.setTimeout(() => (loadingOverlay = false), OVERLAY_CEILING_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideOverlay(): void {
|
||||||
|
clearTimeout(overlayHoldTimer);
|
||||||
|
clearTimeout(overlayCeilingTimer);
|
||||||
|
loadingOverlay = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page that just mounted has its data: whatever we were waiting for is in. */
|
||||||
|
$effect(() => {
|
||||||
|
if ($pageContentReady) hideOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
const reducedMotion = () =>
|
const reducedMotion = () =>
|
||||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
@@ -89,22 +116,36 @@
|
|||||||
'/weather/historical/[location]'
|
'/weather/historical/[location]'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Resolves once the freshly mounted page has its data, or at the ceiling. */
|
/**
|
||||||
|
* Resolves once the freshly mounted page has its data, or once the grace
|
||||||
|
* period is up - in which case the overlay goes up first, so it is part of
|
||||||
|
* the state the transition is about to snapshot.
|
||||||
|
*/
|
||||||
async function waitForContent(): Promise<void> {
|
async function waitForContent(): Promise<void> {
|
||||||
const deadline = Date.now() + READY_CEILING_MS;
|
const deadline = Date.now() + READY_HOLD_MS;
|
||||||
while (!get(pageContentReady) && Date.now() < deadline) {
|
while (!get(pageContentReady) && Date.now() < deadline) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
}
|
}
|
||||||
|
if (!get(pageContentReady)) showOverlay();
|
||||||
// one more frame so the page paints its data before the snapshot is taken
|
// one more frame so the page paints its data before the snapshot is taken
|
||||||
await tick();
|
await tick();
|
||||||
}
|
}
|
||||||
|
|
||||||
onNavigate((navigation) => {
|
onNavigate((navigation) => {
|
||||||
if (reducedMotion()) return;
|
|
||||||
|
|
||||||
const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? '');
|
const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? '');
|
||||||
if (loadsData) markPageLoading();
|
if (loadsData) markPageLoading();
|
||||||
|
|
||||||
|
if (reducedMotion()) {
|
||||||
|
// No transition to hang the wait on, so the overlay gets its own timer.
|
||||||
|
if (loadsData) {
|
||||||
|
clearTimeout(overlayHoldTimer);
|
||||||
|
overlayHoldTimer = window.setTimeout(() => {
|
||||||
|
if (!get(pageContentReady)) showOverlay();
|
||||||
|
}, READY_HOLD_MS);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof document.startViewTransition === 'function') {
|
if (typeof document.startViewTransition === 'function') {
|
||||||
return new Promise<void>((swap) => {
|
return new Promise<void>((swap) => {
|
||||||
document.startViewTransition(async () => {
|
document.startViewTransition(async () => {
|
||||||
@@ -142,11 +183,17 @@
|
|||||||
}
|
}
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const reveal = () => {
|
const reveal = () => {
|
||||||
if (get(pageContentReady) || Date.now() - startedAt > READY_CEILING_MS) {
|
if (get(pageContentReady)) {
|
||||||
contentVisible = true;
|
contentVisible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
revealTimer = window.setTimeout(reveal, 60);
|
if (Date.now() - startedAt > READY_HOLD_MS) {
|
||||||
|
// same trade as above: show the skeleton, and say why it is empty
|
||||||
|
contentVisible = true;
|
||||||
|
showOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
revealTimer = window.setTimeout(reveal, 20);
|
||||||
};
|
};
|
||||||
reveal();
|
reveal();
|
||||||
});
|
});
|
||||||
@@ -171,11 +218,40 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<!-- the icon itself lives in app.html, so the SPA fallback carries it too -->
|
||||||
<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" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<!-- Page-wide loading veil. Deliberately `pointer-events-none`: it is a status
|
||||||
|
indicator, not a modal, so the nav and the search stay usable while a slow
|
||||||
|
forecast is still on its way. -->
|
||||||
|
{#if loadingOverlay}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none fixed inset-0 z-60 flex items-center justify-center bg-background/55 backdrop-blur-[2px]"
|
||||||
|
in:fade={{ duration: 120 }}
|
||||||
|
out:fade={{ duration: 280 }}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2.5 rounded-full border border-border bg-card px-4 py-2 shadow-lg"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 animate-spin text-primary"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-semibold">{m.page_loading()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex h-screen overflow-hidden bg-background text-foreground">
|
<div class="flex h-screen overflow-hidden bg-background text-foreground">
|
||||||
<!-- Desktop sidebar -->
|
<!-- Desktop sidebar -->
|
||||||
<div class="hidden h-full shrink-0 md:block">
|
<div class="hidden h-full shrink-0 md:block">
|
||||||
|
|||||||
@@ -42,6 +42,11 @@
|
|||||||
let subtitle = $derived(
|
let subtitle = $derived(
|
||||||
SUBTITLES.find(([prefix]) => routePath($page.url.pathname).startsWith(prefix))?.[1]?.() ?? null
|
SUBTITLES.find(([prefix]) => routePath($page.url.pathname).startsWith(prefix))?.[1]?.() ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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(', '));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if subtitle && location}
|
{#if subtitle && location}
|
||||||
@@ -57,10 +62,8 @@
|
|||||||
{location.name}
|
{location.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="truncate text-sm text-muted-foreground">
|
<p class="truncate text-sm text-muted-foreground">
|
||||||
<span class="lg:hidden"
|
{#if region}<span class="lg:hidden">{region}<span class="mx-1 opacity-50">·</span></span
|
||||||
>{#if location.admin1}{location.admin1},
|
>{/if}{subtitle}
|
||||||
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
|
||||||
>{subtitle}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
// the page cross-fade waits for this before revealing the new page
|
// the page cross-fade waits for this before revealing the new page
|
||||||
reportPageReady(() => fetchedData != null);
|
reportPageReady(() => fetchedData != null || loadError != null);
|
||||||
|
|
||||||
useHeroActions(heroActions);
|
useHeroActions(heroActions);
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
// the page cross-fade waits for this before revealing the new page
|
// the page cross-fade waits for this before revealing the new page
|
||||||
reportPageReady(() => fetchedData != null);
|
reportPageReady(() => fetchedData != null || loadError != null);
|
||||||
|
|
||||||
useHeroActions(heroActions);
|
useHeroActions(heroActions);
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,10 @@
|
|||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
// the page cross-fade waits for this before revealing the new page
|
// The page cross-fade waits for this before revealing the new page - and so
|
||||||
reportPageReady(() => result != null);
|
// does the loading overlay, so a visitor without a key has to count as ready:
|
||||||
|
// the paywall is the finished page here, nothing is on its way.
|
||||||
|
reportPageReady(() => result != null || loadError != null || !$isSupporter);
|
||||||
|
|
||||||
useHeroActions(heroActions);
|
useHeroActions(heroActions);
|
||||||
|
|
||||||
@@ -213,7 +215,9 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
function switchDay(date: Date) {
|
function switchDay(date: Date) {
|
||||||
selectedDay.setTime(date.getTime());
|
// see the week page: an unformattable selected day breaks every consumer
|
||||||
|
const time = date?.getTime();
|
||||||
|
if (Number.isFinite(time)) selectedDay.setTime(time);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,10 @@
|
|||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
// the page cross-fade waits for this before revealing the new page
|
// The page cross-fade waits for this before revealing the new page - and so
|
||||||
reportPageReady(() => result != null);
|
// does the loading overlay, so a visitor without a key has to count as ready:
|
||||||
|
// the paywall is the finished page here, nothing is on its way.
|
||||||
|
reportPageReady(() => result != null || loadError != null || !$isSupporter);
|
||||||
|
|
||||||
useHeroActions(heroActions);
|
useHeroActions(heroActions);
|
||||||
|
|
||||||
@@ -189,33 +191,39 @@
|
|||||||
|
|
||||||
<!-- the range buttons ride in the layout's location row (see weather/+layout) -->
|
<!-- the range buttons ride in the layout's location row (see weather/+layout) -->
|
||||||
{#snippet heroActions()}
|
{#snippet heroActions()}
|
||||||
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto">
|
<!-- Out of flow on lg+ (the hero row is `relative`), same as the week, 14-day
|
||||||
{#if result}
|
and archive pages: the controls then cannot move the heading when they
|
||||||
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
|
change size. -->
|
||||||
<div
|
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto lg:absolute lg:top-0 lg:right-0">
|
||||||
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
|
<!-- Range buttons reslice the already-fetched horizon (no refetch). Kept
|
||||||
role="group"
|
mounted and merely hidden while the forecast is on its way: mounting
|
||||||
aria-label={m.seasonal_range_aria()}
|
them on arrival re-flowed the row and nudged the heading. -->
|
||||||
>
|
<div
|
||||||
{#each RANGES as range, i (range.label)}
|
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
|
||||||
{@const disabled = range.days !== Infinity && range.days > horizonDays}
|
class:invisible={!result}
|
||||||
<button
|
role="group"
|
||||||
type="button"
|
aria-label={m.seasonal_range_aria()}
|
||||||
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
|
aria-hidden={!result}
|
||||||
i
|
>
|
||||||
? 'bg-primary text-primary-foreground'
|
{#each RANGES as range, i (range.label)}
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'} {disabled
|
{@const disabled = !result || (range.days !== Infinity && range.days > horizonDays)}
|
||||||
? 'cursor-not-allowed opacity-40'
|
<button
|
||||||
: ''}"
|
type="button"
|
||||||
aria-pressed={rangeIndex === i}
|
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
|
||||||
{disabled}
|
i
|
||||||
onclick={() => (rangeIndex = i)}
|
? 'bg-primary text-primary-foreground'
|
||||||
>
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground'} {disabled
|
||||||
{range.label}
|
? 'cursor-not-allowed opacity-40'
|
||||||
</button>
|
: ''}"
|
||||||
{/each}
|
aria-pressed={rangeIndex === i}
|
||||||
</div>
|
{disabled}
|
||||||
{/if}
|
tabindex={result ? undefined : -1}
|
||||||
|
onclick={() => (rangeIndex = i)}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
selectedModel={seasonalModel}
|
selectedModel={seasonalModel}
|
||||||
groups={seasonalModelGroups}
|
groups={seasonalModelGroups}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
// the page cross-fade waits for this before revealing the new page
|
// the page cross-fade waits for this before revealing the new page
|
||||||
reportPageReady(() => fetchedDaily != null && fetchedHourly != null);
|
reportPageReady(() => (fetchedDaily != null && fetchedHourly != null) || loadError != null);
|
||||||
|
|
||||||
useHeroActions(heroActions);
|
useHeroActions(heroActions);
|
||||||
|
|
||||||
@@ -211,8 +211,13 @@
|
|||||||
|
|
||||||
// Charts intentionally keep their current range: they show the full week
|
// Charts intentionally keep their current range: they show the full week
|
||||||
// unless the user narrows it via the range presets or Ctrl+scroll.
|
// unless the user narrows it via the range presets or Ctrl+scroll.
|
||||||
|
// A model that answers with a broken timestamp must not be able to park an
|
||||||
|
// unreadable date in `selectedDay`: everything downstream formats it, and a
|
||||||
|
// date that cannot be formatted takes the page with it.
|
||||||
const switchDay = (date: Date) => {
|
const switchDay = (date: Date) => {
|
||||||
runDayTransition(() => selectedDay.setTime(date.getTime()));
|
const time = date?.getTime();
|
||||||
|
if (!Number.isFinite(time)) return;
|
||||||
|
runDayTransition(() => selectedDay.setTime(time));
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||||
|
<title></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>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
+15
-1
@@ -45,12 +45,26 @@ const config = {
|
|||||||
];
|
];
|
||||||
const localized = locales.flatMap((locale) => shared.map((p) => `/${locale}${p}`));
|
const localized = locales.flatMap((locale) => shared.map((p) => `/${locale}${p}`));
|
||||||
|
|
||||||
|
// Every per-location route, not just the week page: an unprerendered
|
||||||
|
// path is served by the SPA fallback, which the host answers with a
|
||||||
|
// 404 status. The page still works, but it costs a bogus 404 on every
|
||||||
|
// hard reload (and tells crawlers the page does not exist).
|
||||||
|
const cityRoutes = [
|
||||||
|
'/weather/week',
|
||||||
|
'/weather/compare',
|
||||||
|
'/weather/14-day',
|
||||||
|
'/weather/seasonal',
|
||||||
|
'/weather/historical'
|
||||||
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json');
|
const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json');
|
||||||
const raw = fs.readFileSync(citiesPath, 'utf-8');
|
const raw = fs.readFileSync(citiesPath, 'utf-8');
|
||||||
const cities = JSON.parse(raw);
|
const cities = JSON.parse(raw);
|
||||||
if (Array.isArray(cities)) {
|
if (Array.isArray(cities)) {
|
||||||
const cityEntries = cities.map((c) => `/en/weather/week/${c}`);
|
const cityEntries = cities.flatMap((c) =>
|
||||||
|
cityRoutes.map((route) => `/en${route}/${c}`)
|
||||||
|
);
|
||||||
// Keep the default wildcard to include other routes
|
// Keep the default wildcard to include other routes
|
||||||
return ['*', ...localized, ...cityEntries];
|
return ['*', ...localized, ...cityEntries];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user