more fluent texts
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import LanguageOptions from '$lib/components/language-options.svelte';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
|
||||
import { page } from '$app/stores';
|
||||
|
||||
// the URL decides the locale, so re-read it on navigation
|
||||
let current = $derived.by(() => {
|
||||
void $page.url.pathname;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* Whether the current page has enough data on screen to be worth revealing.
|
||||
*
|
||||
* The weather pages fetch their forecast *after* the route swap, so a plain
|
||||
* navigation transition would cross-fade one skeleton into another and then cut
|
||||
* hard to the real content. The layout waits on this instead, so the fade-in
|
||||
* lines up with the page actually being loaded.
|
||||
*/
|
||||
export const pageContentReady = writable(true);
|
||||
|
||||
/**
|
||||
* Declare a page's readiness. Pass a getter for "my data has arrived"; the flag
|
||||
* is released when the page unmounts, so a page that never reports readiness
|
||||
* (static content) is revealed immediately.
|
||||
*/
|
||||
export function reportPageReady(isReady: () => boolean): void {
|
||||
$effect(() => {
|
||||
pageContentReady.set(isReady());
|
||||
return () => pageContentReady.set(true);
|
||||
});
|
||||
}
|
||||
+45
-14
@@ -1,24 +1,55 @@
|
||||
import { tick } from 'svelte';
|
||||
|
||||
const prefersReducedMotion = () =>
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/**
|
||||
* Fades a block back in whenever the value passed to it changes - used to make
|
||||
* a day switch visible in the hourly table, the written forecast and the
|
||||
* meteograms.
|
||||
* Runs a day change inside a view transition, so the outgoing day is still on
|
||||
* screen while the incoming one fades in - a real cross-fade rather than the
|
||||
* old content vanishing and the new one fading up from nothing.
|
||||
*
|
||||
* It animates the existing node instead of remounting it: the meteograms hold
|
||||
* canvas charts with their own zoom state, and rebuilding those on every day
|
||||
* change would be both expensive and lossy.
|
||||
* Only the regions that actually change carry a `view-transition-name` (see
|
||||
* `.day-region-*` in routes/layout.css); everything else - the strip, the
|
||||
* header, the page chrome - is pinned by the `day-switch` class so it stays
|
||||
* completely still.
|
||||
*/
|
||||
export async function runDayTransition(update: () => void): Promise<void> {
|
||||
if (prefersReducedMotion() || !document.startViewTransition) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
root.classList.add('day-switch');
|
||||
try {
|
||||
// Svelte applies the change on the next tick; the transition has to wait
|
||||
// for that before it snapshots the new state.
|
||||
await document.startViewTransition(async () => {
|
||||
update();
|
||||
await tick();
|
||||
}).finished;
|
||||
} catch {
|
||||
/* a superseded transition is fine - the DOM is already up to date */
|
||||
} finally {
|
||||
root.classList.remove('day-switch');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for browsers without view transitions: fade the block back in when
|
||||
* the value passed to it changes. Animates the existing node rather than
|
||||
* remounting it, so the canvas charts keep their zoom state.
|
||||
*/
|
||||
export function daySwap(node: HTMLElement, key: unknown) {
|
||||
let current = key;
|
||||
|
||||
const play = () => {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
node.animate(
|
||||
[
|
||||
{ opacity: 0.15, transform: 'translateY(4px)' },
|
||||
{ opacity: 1, transform: 'translateY(0)' }
|
||||
],
|
||||
{ duration: 280, easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)' }
|
||||
);
|
||||
// view transitions handle it properly where they exist
|
||||
if (typeof document.startViewTransition === 'function' || prefersReducedMotion()) return;
|
||||
node.animate([{ opacity: 0.1 }, { opacity: 1 }], {
|
||||
duration: 460,
|
||||
easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)'
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Mirrors UI state into the query string so a view can be linked and reloaded
|
||||
* exactly as it was: which day is open, which model is plotted, which variables
|
||||
* are compared.
|
||||
*
|
||||
* Writes use `replaceState` rather than `goto`, so mirroring state never adds a
|
||||
* history entry or re-runs a load - the back button still means "the page
|
||||
* before", not "the previous day I clicked".
|
||||
*/
|
||||
import { browser } from '$app/environment';
|
||||
import { replaceState } from '$app/navigation';
|
||||
|
||||
export function syncSearchParams(url: URL, updates: Record<string, string | null>): void {
|
||||
if (!browser) return;
|
||||
const next = new URL(url);
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value == null || value === '') next.searchParams.delete(key);
|
||||
else next.searchParams.set(key, value);
|
||||
}
|
||||
if (next.href === url.href) return;
|
||||
try {
|
||||
replaceState(next, {});
|
||||
} catch {
|
||||
// A page whose state settles during mount can get here before the router
|
||||
// has taken over. The URL is cosmetic, so retry on the next frame rather
|
||||
// than letting it break the page.
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
replaceState(next, {});
|
||||
} catch {
|
||||
/* give up: the view still works, it just isn't linkable yet */
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads a comma-separated list, dropping empties. */
|
||||
export function readList(url: URL, key: string): string[] | null {
|
||||
const raw = url.searchParams.get(key);
|
||||
if (!raw) return null;
|
||||
const list = raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return list.length > 0 ? list : null;
|
||||
}
|
||||
Reference in New Issue
Block a user