crossfade
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const out = {};
|
||||||
|
for (const [label, vp, mobile] of [['mobile', { width: 390, height: 844 }, true], ['desktop', { width: 1280, height: 950 }, false]]) {
|
||||||
|
const ctx = await browser.newContext({ viewport: vp, isMobile: mobile, hasTouch: mobile });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto('http://localhost:5185/en/weather/week/berlin/', { waitUntil: 'load', timeout: 120000 });
|
||||||
|
await page.waitForTimeout(14000);
|
||||||
|
await page.locator('canvas').first().scrollIntoViewIfNeeded();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
out[label] = await page.evaluate(() => {
|
||||||
|
const c = document.querySelector('.strip-cell').getBoundingClientRect();
|
||||||
|
const icon = document.querySelector('.strip-cell .day-icon').getBoundingClientRect();
|
||||||
|
const temps = document.querySelector('.strip-cell .temp-max').getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
cell: { w: Math.round(c.width), h: Math.round(c.height) },
|
||||||
|
iconOffCentre: +(icon.top + icon.height / 2 - (c.top + c.height / 2)).toFixed(1),
|
||||||
|
gapBelowTemps: +(c.bottom - temps.bottom).toFixed(1)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: `/tmp/gap-${label}.png`, clip: { x: 0, y: 0, width: vp.width, height: 200 } });
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(out, null, 1));
|
||||||
|
await browser.close();
|
||||||
@@ -388,7 +388,18 @@
|
|||||||
|
|
||||||
// Minimal gutters on narrow screens so the plot uses nearly the full width
|
// Minimal gutters on narrow screens so the plot uses nearly the full width
|
||||||
// (just enough to keep the axis tick labels legible).
|
// (just enough to keep the axis tick labels legible).
|
||||||
let isNarrow = $derived(width > 0 && width < 520);
|
// Compact gutters (small axis padding, short icon rows) apply to phones AND
|
||||||
|
// tablets: below `lg` the page is edge-to-edge, so wide desktop-style axis
|
||||||
|
// margins would waste most of the width. Desktop keeps its roomier metrics.
|
||||||
|
let belowDesktop = $state(false);
|
||||||
|
onMount(() => {
|
||||||
|
const mq = window.matchMedia('(max-width: 1023px)');
|
||||||
|
const apply = () => (belowDesktop = mq.matches);
|
||||||
|
apply();
|
||||||
|
mq.addEventListener('change', apply);
|
||||||
|
return () => mq.removeEventListener('change', apply);
|
||||||
|
});
|
||||||
|
let isNarrow = $derived(belowDesktop || (width > 0 && width < 520));
|
||||||
let padLeft = $derived(isNarrow ? 26 : 60);
|
let padLeft = $derived(isNarrow ? 26 : 60);
|
||||||
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
|
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
|
||||||
// has a right axis, so a stacked row of charts share the same plot width. On
|
// has a right axis, so a stacked row of charts share the same plot width. On
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { buildLocationRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
import { href, routePath } from '$lib/i18n';
|
import { href, routePath } from '$lib/i18n';
|
||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
@@ -18,6 +22,7 @@
|
|||||||
{
|
{
|
||||||
title: m.nav_week,
|
title: m.nav_week,
|
||||||
url: '/weather/week' as const,
|
url: '/weather/week' as const,
|
||||||
|
route: '/weather/week/[location]' as const,
|
||||||
iconPaths: [
|
iconPaths: [
|
||||||
'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z'
|
'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z'
|
||||||
]
|
]
|
||||||
@@ -25,11 +30,13 @@
|
|||||||
{
|
{
|
||||||
title: m.nav_compare,
|
title: m.nav_compare,
|
||||||
url: '/weather/compare' as const,
|
url: '/weather/compare' as const,
|
||||||
|
route: '/weather/compare/[location]' as const,
|
||||||
iconPaths: ['M13 7h8m0 0v8m0-8l-8 8-4-4-6 6']
|
iconPaths: ['M13 7h8m0 0v8m0-8l-8 8-4-4-6 6']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: m.nav_14day,
|
title: m.nav_14day,
|
||||||
url: '/weather/14-day' as const,
|
url: '/weather/14-day' as const,
|
||||||
|
route: '/weather/14-day/[location]' as const,
|
||||||
iconPaths: [
|
iconPaths: [
|
||||||
'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'
|
'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'
|
||||||
]
|
]
|
||||||
@@ -37,12 +44,14 @@
|
|||||||
{
|
{
|
||||||
title: m.nav_seasonal,
|
title: m.nav_seasonal,
|
||||||
url: '/weather/seasonal' as const,
|
url: '/weather/seasonal' as const,
|
||||||
|
route: '/weather/seasonal/[location]' as const,
|
||||||
// rising trend line (long-range outlook)
|
// rising trend line (long-range outlook)
|
||||||
iconPaths: ['M3 17l6-6 4 4 7-7', 'M16 8h5v5']
|
iconPaths: ['M3 17l6-6 4 4 7-7', 'M16 8h5v5']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: m.nav_historical,
|
title: m.nav_historical,
|
||||||
url: '/weather/historical' as const,
|
url: '/weather/historical' as const,
|
||||||
|
route: '/weather/historical/[location]' as const,
|
||||||
// clock with a counter-clockwise arrow (history)
|
// clock with a counter-clockwise arrow (history)
|
||||||
iconPaths: ['M12 8v4l3 2', 'M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5']
|
iconPaths: ['M12 8v4l3 2', 'M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5']
|
||||||
},
|
},
|
||||||
@@ -59,6 +68,11 @@
|
|||||||
// the URL carries a locale prefix; compare the neutral path behind it
|
// the URL carries a locale prefix; compare the neutral path behind it
|
||||||
let currentPath = $derived(routePath($page.url.pathname));
|
let currentPath = $derived(routePath($page.url.pathname));
|
||||||
|
|
||||||
|
// Link straight to the location page instead of the bare redirect route: it
|
||||||
|
// saves a navigation, and the page cross-fade can then wait for the real
|
||||||
|
// page's data instead of flashing through an empty redirect stub.
|
||||||
|
let locationRoute = $derived(buildLocationRoute($storedLocation));
|
||||||
|
|
||||||
const isActive = (url: string) => {
|
const isActive = (url: string) => {
|
||||||
return currentPath === url || currentPath.startsWith(url + '/');
|
return currentPath === url || currentPath.startsWith(url + '/');
|
||||||
};
|
};
|
||||||
@@ -73,7 +87,7 @@
|
|||||||
home link fills the entire row, padding included -->
|
home link fills the entire row, padding included -->
|
||||||
<div class="flex h-14 shrink-0 items-stretch border-b border-sidebar-border">
|
<div class="flex h-14 shrink-0 items-stretch border-b border-sidebar-border">
|
||||||
<a
|
<a
|
||||||
href={href('/weather/week')}
|
href={href('/weather/week/[location]', { location: locationRoute })}
|
||||||
class="flex flex-1 items-center gap-2.5 transition-colors hover:bg-sidebar-accent {collapsed
|
class="flex flex-1 items-center gap-2.5 transition-colors hover:bg-sidebar-accent {collapsed
|
||||||
? 'justify-center'
|
? 'justify-center'
|
||||||
: 'px-4'}"
|
: 'px-4'}"
|
||||||
@@ -98,7 +112,7 @@
|
|||||||
{#each links as link (link.url)}
|
{#each links as link (link.url)}
|
||||||
{@const active = isActive(link.url)}
|
{@const active = isActive(link.url)}
|
||||||
<a
|
<a
|
||||||
href={href(link.url)}
|
href={link.route ? href(link.route, { location: locationRoute }) : href(link.url)}
|
||||||
class="relative flex items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100 {active
|
class="relative flex items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100 {active
|
||||||
? 'bg-sidebar-accent text-sidebar-primary! opacity-100! font-semibold! nav-active'
|
? 'bg-sidebar-accent text-sidebar-primary! opacity-100! font-semibold! nav-active'
|
||||||
: ''}"
|
: ''}"
|
||||||
|
|||||||
@@ -5,19 +5,29 @@ import { writable } from 'svelte/store';
|
|||||||
*
|
*
|
||||||
* The weather pages fetch their forecast *after* the route swap, so a plain
|
* 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
|
* 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
|
* hard to the real content. The layout holds its transition open on this flag
|
||||||
* lines up with the page actually being loaded.
|
* instead, so the fade lines up with the page actually being loaded.
|
||||||
*/
|
*/
|
||||||
export const pageContentReady = writable(true);
|
export const pageContentReady = writable(true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Declare a page's readiness. Pass a getter for "my data has arrived"; the flag
|
* Called by the layout before it swaps to a route that fetches its own data.
|
||||||
* is released when the page unmounts, so a page that never reports readiness
|
*
|
||||||
* (static content) is revealed immediately.
|
* The layout owns the "not ready yet" side deliberately: the incoming page's
|
||||||
|
* effects have not necessarily run at that point (rendering is paused inside a
|
||||||
|
* view transition), so a page that cleared the flag itself would sometimes be
|
||||||
|
* announced as ready before it had fetched anything.
|
||||||
|
*/
|
||||||
|
export function markPageLoading(): void {
|
||||||
|
pageContentReady.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare a page's readiness. Pass a getter for "my data has arrived". Only
|
||||||
|
* ever sets the flag - clearing it is the layout's job (see above).
|
||||||
*/
|
*/
|
||||||
export function reportPageReady(isReady: () => boolean): void {
|
export function reportPageReady(isReady: () => boolean): void {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
pageContentReady.set(isReady());
|
if (isReady()) pageContentReady.set(true);
|
||||||
return () => pageContentReady.set(true);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-11
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { fade, fly } from 'svelte/transition';
|
import { fade, fly } from 'svelte/transition';
|
||||||
|
|
||||||
import { afterNavigate, onNavigate } from '$app/navigation';
|
import { afterNavigate, onNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
import { markPageLoading, pageContentReady } from '$lib/stores/page-transition.svelte';
|
||||||
import { storedTheme } from '$lib/stores/settings';
|
import { storedTheme } from '$lib/stores/settings';
|
||||||
|
|
||||||
import Footer from '$lib/components/navigation/footer.svelte';
|
import Footer from '$lib/components/navigation/footer.svelte';
|
||||||
@@ -15,8 +17,6 @@
|
|||||||
|
|
||||||
import { routePath } from '$lib/i18n';
|
import { routePath } from '$lib/i18n';
|
||||||
|
|
||||||
import { pageContentReady } from '$lib/stores/page-transition.svelte';
|
|
||||||
|
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
@@ -61,13 +61,16 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Page cross-fade ───────────────────────────────────────────────────────
|
// ── Page cross-fade ───────────────────────────────────────────────────────
|
||||||
// The weather pages fetch their forecast after the route swap, so tying the
|
// A real cross-fade needs the outgoing page still on screen while the
|
||||||
// fade to navigation alone would cross-fade one skeleton into another and
|
// incoming one appears - so the view transition is opened at navigation and
|
||||||
// then cut hard to the real content. Instead the outgoing page fades out on
|
// deliberately held open until the new page reports that its data has landed.
|
||||||
// navigation and the incoming one fades in once it reports that its data has
|
// The browser keeps showing the old snapshot for that whole time (rather than
|
||||||
// landed - with a ceiling so a slow or silent page is never left hidden.
|
// flashing a skeleton), then fades it into the finished page.
|
||||||
|
//
|
||||||
|
// The wait is capped: past the ceiling we cross-fade into whatever is on
|
||||||
|
// screen instead of freezing the UI on a slow network.
|
||||||
|
const READY_CEILING_MS = 2200;
|
||||||
const FADE_OUT_MS = 170;
|
const FADE_OUT_MS = 170;
|
||||||
const REVEAL_CEILING_MS = 2500;
|
|
||||||
|
|
||||||
let contentVisible = $state(true);
|
let contentVisible = $state(true);
|
||||||
let revealTimer = 0;
|
let revealTimer = 0;
|
||||||
@@ -75,21 +78,62 @@
|
|||||||
const reducedMotion = () =>
|
const reducedMotion = () =>
|
||||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
onNavigate(() => {
|
// Routes that fetch their own forecast after mounting. Knowing this up front
|
||||||
|
// is what makes the wait reliable: the layout clears the ready flag before
|
||||||
|
// the swap rather than trusting the incoming page to have done it.
|
||||||
|
const DATA_ROUTES = new Set([
|
||||||
|
'/weather/week/[location]',
|
||||||
|
'/weather/14-day/[location]',
|
||||||
|
'/weather/compare/[location]',
|
||||||
|
'/weather/seasonal/[location]',
|
||||||
|
'/weather/historical/[location]'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Resolves once the freshly mounted page has its data, or at the ceiling. */
|
||||||
|
async function waitForContent(): Promise<void> {
|
||||||
|
const deadline = Date.now() + READY_CEILING_MS;
|
||||||
|
while (!get(pageContentReady) && Date.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||||
|
}
|
||||||
|
// one more frame so the page paints its data before the snapshot is taken
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
onNavigate((navigation) => {
|
||||||
if (reducedMotion()) return;
|
if (reducedMotion()) return;
|
||||||
|
|
||||||
|
const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? '');
|
||||||
|
if (loadsData) markPageLoading();
|
||||||
|
|
||||||
|
if (typeof document.startViewTransition === 'function') {
|
||||||
|
return new Promise<void>((swap) => {
|
||||||
|
document.startViewTransition(async () => {
|
||||||
|
// hand control back so SvelteKit swaps the DOM underneath the
|
||||||
|
// frozen snapshot of the old page
|
||||||
|
swap();
|
||||||
|
// A superseded navigation (a redirect, or a fast second click)
|
||||||
|
// rejects this promise; that is not an error worth surfacing,
|
||||||
|
// and leaving it unhandled shows up as "navigation aborted".
|
||||||
|
await navigation.complete.catch(() => {});
|
||||||
|
if (loadsData) await waitForContent();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// No view transitions: fall back to fading out, then back in on arrival.
|
||||||
clearTimeout(revealTimer);
|
clearTimeout(revealTimer);
|
||||||
contentVisible = false;
|
contentVisible = false;
|
||||||
return new Promise((resolve) => setTimeout(resolve, FADE_OUT_MS));
|
return new Promise((resolve) => setTimeout(resolve, FADE_OUT_MS));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterNavigate(() => {
|
afterNavigate(() => {
|
||||||
if (reducedMotion()) {
|
if (typeof document.startViewTransition === 'function' || reducedMotion()) {
|
||||||
contentVisible = true;
|
contentVisible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const reveal = () => {
|
const reveal = () => {
|
||||||
if (get(pageContentReady) || Date.now() - startedAt > REVEAL_CEILING_MS) {
|
if (get(pageContentReady) || Date.now() - startedAt > READY_CEILING_MS) {
|
||||||
contentVisible = true;
|
contentVisible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -165,6 +165,26 @@
|
|||||||
view-transition-name: day-charts;
|
view-transition-name: day-charts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The sticky day strip never changes during a day switch, but it has to be
|
||||||
|
captured too: view-transition snapshots all paint in one layer above the
|
||||||
|
page, so without its own group (and a higher z-index in that layer) the
|
||||||
|
fading table and charts would slide over the top of it. */
|
||||||
|
.daystrip {
|
||||||
|
view-transition-name: daystrip;
|
||||||
|
}
|
||||||
|
::view-transition-group(daystrip) {
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same for the topbar: a captured region's snapshot is painted at its layout
|
||||||
|
position, including the part normally scrolled up behind the chrome. */
|
||||||
|
.topbar {
|
||||||
|
view-transition-name: topbar;
|
||||||
|
}
|
||||||
|
::view-transition-group(topbar) {
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
::view-transition-old(day-table),
|
::view-transition-old(day-table),
|
||||||
::view-transition-new(day-table),
|
::view-transition-new(day-table),
|
||||||
::view-transition-old(day-summary),
|
::view-transition-old(day-summary),
|
||||||
@@ -183,7 +203,9 @@
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.day-region-table,
|
.day-region-table,
|
||||||
.day-region-summary,
|
.day-region-summary,
|
||||||
.day-region-charts {
|
.day-region-charts,
|
||||||
|
.daystrip,
|
||||||
|
.topbar {
|
||||||
view-transition-name: none;
|
view-transition-name: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,8 @@
|
|||||||
|
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||||
|
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
|
||||||
|
|
||||||
import { syncSearchParams } from '$lib/utils/url-state';
|
import { syncSearchParams } from '$lib/utils/url-state';
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,8 @@
|
|||||||
|
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||||
|
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
|
||||||
|
|
||||||
import { formatZoned } from '$lib/utils/date';
|
import { formatZoned } from '$lib/utils/date';
|
||||||
import { readList, syncSearchParams } from '$lib/utils/url-state';
|
import { readList, syncSearchParams } from '$lib/utils/url-state';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||||
import {
|
import {
|
||||||
storedChartLayout,
|
storedChartLayout,
|
||||||
storedLocation,
|
storedLocation,
|
||||||
@@ -10,8 +11,6 @@
|
|||||||
storedVariablePrefs
|
storedVariablePrefs
|
||||||
} from '$lib/stores/settings';
|
} from '$lib/stores/settings';
|
||||||
|
|
||||||
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
|
||||||
|
|
||||||
import { ChartContainer } from '$lib/components/charts';
|
import { ChartContainer } from '$lib/components/charts';
|
||||||
|
|
||||||
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { storedLocation, storedUnits } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||||
|
import { storedLocation, storedUnits } from '$lib/stores/settings';
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||||
import {
|
import {
|
||||||
storedChartLayout,
|
storedChartLayout,
|
||||||
storedLocation,
|
storedLocation,
|
||||||
@@ -14,8 +15,6 @@
|
|||||||
storedVariablePrefs
|
storedVariablePrefs
|
||||||
} from '$lib/stores/settings';
|
} from '$lib/stores/settings';
|
||||||
|
|
||||||
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
|
||||||
|
|
||||||
import { formatZoned } from '$lib/utils/date';
|
import { formatZoned } from '$lib/utils/date';
|
||||||
import { daySwap, runDayTransition } from '$lib/utils/day-swap';
|
import { daySwap, runDayTransition } from '$lib/utils/day-swap';
|
||||||
import { buildLocationRoute } from '$lib/utils/location';
|
import { buildLocationRoute } from '$lib/utils/location';
|
||||||
@@ -66,6 +65,19 @@
|
|||||||
|
|
||||||
let variableSidebarOpen = $state(false);
|
let variableSidebarOpen = $state(false);
|
||||||
|
|
||||||
|
// Meteogram canvases are shorter on phones, where a 300px plot eats most of
|
||||||
|
// the viewport. The placeholder below uses the same number, so the reserved
|
||||||
|
// space still matches exactly.
|
||||||
|
let narrowViewport = $state(false);
|
||||||
|
onMount(() => {
|
||||||
|
const mq = window.matchMedia('(max-width: 767px)');
|
||||||
|
const apply = () => (narrowViewport = mq.matches);
|
||||||
|
apply();
|
||||||
|
mq.addEventListener('change', apply);
|
||||||
|
return () => mq.removeEventListener('change', apply);
|
||||||
|
});
|
||||||
|
let chartHeight = $derived(narrowViewport ? 215 : 300);
|
||||||
|
|
||||||
// Number of meteogram panels: reserves the chart area height before data
|
// Number of meteogram panels: reserves the chart area height before data
|
||||||
// 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);
|
||||||
@@ -418,7 +430,13 @@
|
|||||||
|
|
||||||
{#if fetchedHourly}
|
{#if fetchedHourly}
|
||||||
<div class="day-region-charts" use:daySwap={selectedDayKey}>
|
<div class="day-region-charts" use:daySwap={selectedDayKey}>
|
||||||
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
<MeteogramCharts
|
||||||
|
data={fetchedHourly}
|
||||||
|
{selectedDay}
|
||||||
|
units={params}
|
||||||
|
{loading}
|
||||||
|
{chartHeight}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- reserve the exact chart area height before the first fetch resolves,
|
<!-- reserve the exact chart area height before the first fetch resolves,
|
||||||
@@ -431,7 +449,7 @@
|
|||||||
<div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div>
|
<div class="h-7 w-24 animate-pulse rounded-lg bg-muted"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
|
<ChartContainer loading chartCount={enabledChartCount || 1} {chartHeight} />
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -443,7 +443,7 @@
|
|||||||
--icon-full: 44px;
|
--icon-full: 44px;
|
||||||
/* the compact icon carries the square tile: sized so the content fills it
|
/* the compact icon carries the square tile: sized so the content fills it
|
||||||
and the leftover slack under the temps stays small */
|
and the leftover slack under the temps stays small */
|
||||||
--icon-min: 27px;
|
--icon-min: 31px;
|
||||||
--pt-full: 7px;
|
--pt-full: 7px;
|
||||||
--pt-min: 2px;
|
--pt-min: 2px;
|
||||||
--gap-full: 8px;
|
--gap-full: 8px;
|
||||||
@@ -518,7 +518,7 @@
|
|||||||
--pb-full: 10px;
|
--pb-full: 10px;
|
||||||
--pb-min: 3px;
|
--pb-min: 3px;
|
||||||
--icon-lift: 0px;
|
--icon-lift: 0px;
|
||||||
--collapse-extra: 60px;
|
--collapse-extra: 0px;
|
||||||
}
|
}
|
||||||
.daystrip {
|
.daystrip {
|
||||||
/* breathing room between the full cards and the table */
|
/* breathing room between the full cards and the table */
|
||||||
@@ -526,6 +526,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Desktop only: the docked tiles get roughly 1.75x bigger, with the icon and
|
||||||
|
type scaled to match so they stay in proportion. Phones and tablets keep
|
||||||
|
the compact bar - there the screen width is the scarce resource. */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.sentinel,
|
||||||
|
.daystrip {
|
||||||
|
--cell-h-min: 86px;
|
||||||
|
--icon-min: 47px;
|
||||||
|
--dow-font-min: 11px;
|
||||||
|
--tmax-font-min: 14px;
|
||||||
|
--tmin-font-min: 12px;
|
||||||
|
--tmax-padx-min: 6px;
|
||||||
|
--pt-min: 5px;
|
||||||
|
--pb-min: 6px;
|
||||||
|
--icon-lift: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Invisible collapse band: the scroll distance over which the collapse
|
/* Invisible collapse band: the scroll distance over which the collapse
|
||||||
plays. The negative margin removes it from layout so nothing shifts. */
|
plays. The negative margin removes it from layout so nothing shifts. */
|
||||||
.sentinel {
|
.sentinel {
|
||||||
|
|||||||
@@ -99,7 +99,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="mt-6" aria-label={m.summary_heading()}>
|
<section class="mt-6" aria-label={m.summary_heading()}>
|
||||||
<div class="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
|
<!-- flush with the screen edges on phones, a contained card from md up -->
|
||||||
|
<div
|
||||||
|
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
|
||||||
|
>
|
||||||
<div class="border-b border-border/70 bg-muted/40 px-4 py-2.5">
|
<div class="border-b border-border/70 bg-muted/40 px-4 py-2.5">
|
||||||
<h3 class="text-base font-bold">
|
<h3 class="text-base font-bold">
|
||||||
{formatZoned(selectedDay, timezone, 'EEEE')}
|
{formatZoned(selectedDay, timezone, 'EEEE')}
|
||||||
|
|||||||
@@ -458,7 +458,9 @@
|
|||||||
1h needs far more room than 3h (24 vs 8 columns) -->
|
1h needs far more room than 3h (24 vs 8 columns) -->
|
||||||
<div class="overflow-x-auto" bind:this={tableScrollEl} onscroll={onTableScroll}>
|
<div class="overflow-x-auto" bind:this={tableScrollEl} onscroll={onTableScroll}>
|
||||||
<div
|
<div
|
||||||
class="relative {is3h ? 'min-w-[560px]' : 'min-w-[1100px]'}"
|
class="relative {is3h
|
||||||
|
? 'min-w-[400px] md:min-w-[560px]'
|
||||||
|
: 'min-w-[780px] md:min-w-[1100px]'}"
|
||||||
bind:clientWidth={tableWidth}
|
bind:clientWidth={tableWidth}
|
||||||
>
|
>
|
||||||
<table class="w-full table-fixed border-collapse whitespace-nowrap">
|
<table class="w-full table-fixed border-collapse whitespace-nowrap">
|
||||||
@@ -885,13 +887,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Responsive ─────────────────────────────────────────────── */
|
/* ── Responsive ─────────────────────────────────────────────── */
|
||||||
|
/* Phones: the table is the tallest thing on the page, so every cell is taken
|
||||||
|
down to roughly 0.7 of its desktop size - padding, type and the icons that
|
||||||
|
actually set the row height. Desktop keeps its original metrics. */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.hdr {
|
.hdr {
|
||||||
padding: 3px 2px;
|
padding: 2px 1px;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
.cell {
|
.cell {
|
||||||
font-size: 11px;
|
padding: 1px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
/* The row-header icon and the fixed cell height are what actually set the
|
||||||
|
row height, so both come down explicitly. */
|
||||||
|
.cell :global(svg),
|
||||||
|
.hdr :global(svg) {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
.cell {
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
.hdr :global(span) {
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
.precip-bar {
|
||||||
|
min-height: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -25,14 +25,15 @@
|
|||||||
selectedDay: Date;
|
selectedDay: Date;
|
||||||
units: WeatherUnits;
|
units: WeatherUnits;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
/** Canvas height in px; the page shrinks it on phones. */
|
||||||
|
chartHeight?: number;
|
||||||
onResetZoom?: () => void;
|
onResetZoom?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data, selectedDay, units, loading, onResetZoom }: Props = $props();
|
let { data, selectedDay, units, loading, chartHeight = 300, onResetZoom }: Props = $props();
|
||||||
|
|
||||||
const CHART_GROUP = 'week-meteogram';
|
const CHART_GROUP = 'week-meteogram';
|
||||||
const SECONDS_PER_DAY = 24 * 3600;
|
const SECONDS_PER_DAY = 24 * 3600;
|
||||||
const CHART_HEIGHT = 300;
|
|
||||||
|
|
||||||
let customizerOpen = $state(false);
|
let customizerOpen = $state(false);
|
||||||
let downloadingPng = $state(false);
|
let downloadingPng = $state(false);
|
||||||
@@ -351,13 +352,7 @@
|
|||||||
<span class="lg:hidden">{panel.titleShort}</span>
|
<span class="lg:hidden">{panel.titleShort}</span>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<ChartContainer
|
<ChartContainer {loading} chartCount={1} {chartHeight} minWidth={520} bleed={false}>
|
||||||
{loading}
|
|
||||||
chartCount={1}
|
|
||||||
chartHeight={CHART_HEIGHT}
|
|
||||||
minWidth={520}
|
|
||||||
bleed={false}
|
|
||||||
>
|
|
||||||
<CanvasChart
|
<CanvasChart
|
||||||
bind:this={chartComponents[i]}
|
bind:this={chartComponents[i]}
|
||||||
timestamps={timestampsSec}
|
timestamps={timestampsSec}
|
||||||
@@ -379,7 +374,7 @@
|
|||||||
yMaxRight={panel.def.yMaxRight}
|
yMaxRight={panel.def.yMaxRight}
|
||||||
showCredit={i === renderPanels.length - 1}
|
showCredit={i === renderPanels.length - 1}
|
||||||
showLegend
|
showLegend
|
||||||
height={CHART_HEIGHT}
|
height={chartHeight}
|
||||||
group={CHART_GROUP}
|
group={CHART_GROUP}
|
||||||
/>
|
/>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const out = {};
|
||||||
|
for (const [label, vp] of [['desktop', { width: 1280, height: 950 }], ['tablet', { width: 900, height: 800 }]]) {
|
||||||
|
const ctx = await browser.newContext({ viewport: vp });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto('http://localhost:5185/en/weather/week/berlin/', { waitUntil: 'load', timeout: 120000 });
|
||||||
|
await page.waitForTimeout(14000);
|
||||||
|
await page.locator('canvas').first().scrollIntoViewIfNeeded();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
out[label] = await page.evaluate(() => {
|
||||||
|
const c = document.querySelector('.strip-cell').getBoundingClientRect();
|
||||||
|
const icon = document.querySelector('.strip-cell .day-icon').getBoundingClientRect();
|
||||||
|
const temps = document.querySelector('.strip-cell .temp-max').getBoundingClientRect();
|
||||||
|
const canvas = document.querySelector('canvas');
|
||||||
|
const cw = canvas ? canvas.getBoundingClientRect() : null;
|
||||||
|
return {
|
||||||
|
cell: { w: Math.round(c.width), h: Math.round(c.height) },
|
||||||
|
iconOffCentre: +(icon.top + icon.height / 2 - (c.top + c.height / 2)).toFixed(1),
|
||||||
|
gapBelowTemps: +(c.bottom - temps.bottom).toFixed(1),
|
||||||
|
canvasWidth: cw ? Math.round(cw.width) : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: `/tmp/tiles-${label}.png`, clip: { x: 0, y: 0, width: vp.width, height: 220 } });
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(out, null, 1));
|
||||||
|
await browser.close();
|
||||||
Reference in New Issue
Block a user