small bug fixes
This commit is contained in:
@@ -139,7 +139,35 @@
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- About / legal links moved to the page footer -->
|
||||
<!-- On phones the footer sits a long scroll away, so the same about/legal
|
||||
links get a quiet home at the bottom of the drawer. -->
|
||||
{#if onMobileClose}
|
||||
<nav
|
||||
aria-label={m.legal_nav()}
|
||||
class="flex flex-wrap gap-x-3 gap-y-1 border-t border-sidebar-border px-4 py-3 text-[11px] text-sidebar-foreground/70"
|
||||
>
|
||||
<a
|
||||
class="hover:text-sidebar-foreground hover:underline"
|
||||
href={href('/about')}
|
||||
onclick={onMobileClose}>{m.legal_about()}</a
|
||||
>
|
||||
<a
|
||||
class="hover:text-sidebar-foreground hover:underline"
|
||||
href={href('/legal/imprint')}
|
||||
onclick={onMobileClose}>{m.legal_imprint()}</a
|
||||
>
|
||||
<a
|
||||
class="hover:text-sidebar-foreground hover:underline"
|
||||
href={href('/legal/privacy')}
|
||||
onclick={onMobileClose}>{m.legal_privacy()}</a
|
||||
>
|
||||
<a
|
||||
class="hover:text-sidebar-foreground hover:underline"
|
||||
href={href('/legal/terms')}
|
||||
onclick={onMobileClose}>{m.legal_terms()}</a
|
||||
>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
|
||||
{#if onToggle}
|
||||
|
||||
@@ -155,13 +155,13 @@
|
||||
in the cross-fade. Everything else keeps its pixels: the `day-switch`
|
||||
class cancels the root animation, so the strip, header and page chrome do
|
||||
not so much as flicker while the table, summary and charts swap over. */
|
||||
.day-region-table {
|
||||
:root.day-switch .day-region-table {
|
||||
view-transition-name: day-table;
|
||||
}
|
||||
.day-region-summary {
|
||||
:root.day-switch .day-region-summary {
|
||||
view-transition-name: day-summary;
|
||||
}
|
||||
.day-region-charts {
|
||||
:root.day-switch .day-region-charts {
|
||||
view-transition-name: day-charts;
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
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 {
|
||||
:root.day-switch .daystrip {
|
||||
view-transition-name: daystrip;
|
||||
}
|
||||
::view-transition-group(daystrip) {
|
||||
@@ -178,7 +178,7 @@
|
||||
|
||||
/* 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 {
|
||||
:root.day-switch .topbar {
|
||||
view-transition-name: topbar;
|
||||
}
|
||||
::view-transition-group(topbar) {
|
||||
|
||||
@@ -479,3 +479,37 @@ export const ensembleModels: WeatherModel[] = ensembleModelGroups.flatMap((group
|
||||
|
||||
export const findEnsembleModel = (value: string): WeatherModel | undefined =>
|
||||
ensembleModels.find((model) => model.value === value);
|
||||
|
||||
/**
|
||||
* A city inside each regional model's domain, used to offer a way out when the
|
||||
* chosen model has no data for the current place. Keyed by the model-id prefix
|
||||
* so new variants of a domain are covered without another entry.
|
||||
*/
|
||||
const IN_DOMAIN_CITY: [string, { slug: string; label: string }][] = [
|
||||
['meteoswiss', { slug: 'zurich', label: 'Zürich' }],
|
||||
['italia_meteo', { slug: 'rome', label: 'Rome' }],
|
||||
['icon', { slug: 'berlin', label: 'Berlin' }],
|
||||
['dwd', { slug: 'berlin', label: 'Berlin' }],
|
||||
['meteofrance', { slug: 'paris', label: 'Paris' }],
|
||||
['arpege', { slug: 'paris', label: 'Paris' }],
|
||||
['arome', { slug: 'paris', label: 'Paris' }],
|
||||
['ukmo', { slug: 'london', label: 'London' }],
|
||||
['knmi', { slug: 'amsterdam', label: 'Amsterdam' }],
|
||||
['dmi', { slug: 'copenhagen', label: 'Copenhagen' }],
|
||||
['metno', { slug: 'oslo', label: 'Oslo' }],
|
||||
['ncep', { slug: 'new-york', label: 'New York' }],
|
||||
['gfs', { slug: 'new-york', label: 'New York' }],
|
||||
['hrrr', { slug: 'new-york', label: 'New York' }],
|
||||
['nbm', { slug: 'new-york', label: 'New York' }],
|
||||
['gem', { slug: 'toronto', label: 'Toronto' }],
|
||||
['jma', { slug: 'tokyo', label: 'Tokyo' }],
|
||||
['kma', { slug: 'seoul', label: 'Seoul' }],
|
||||
['cma', { slug: 'beijing', label: 'Beijing' }],
|
||||
['bom', { slug: 'sydney', label: 'Sydney' }]
|
||||
];
|
||||
|
||||
/** Where this model definitely has data, or null for a global model. */
|
||||
export function inDomainCity(model: string): { slug: string; label: string } | null {
|
||||
const hit = IN_DOMAIN_CITY.find(([prefix]) => model.startsWith(prefix));
|
||||
return hit ? hit[1] : null;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
import { ChartContainer } from '$lib/components/charts';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import {
|
||||
type FriendlyWeatherError,
|
||||
type WeekForecastResult,
|
||||
@@ -30,7 +32,7 @@
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { useHeroActions } from '../../hero.svelte';
|
||||
import { defaultParameters } from '../../options';
|
||||
import { defaultParameters, inDomainCity } from '../../options';
|
||||
import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
|
||||
import DailyStripSticky from './DailyStripSticky.svelte';
|
||||
import DaySummary from './DaySummary.svelte';
|
||||
@@ -114,6 +116,14 @@
|
||||
// for the same place once their range is exhausted.
|
||||
let locationRoute = $derived(buildLocationRoute(location));
|
||||
|
||||
// When a regional model has no data here, point at a place it does cover
|
||||
// rather than only offering to abandon the model.
|
||||
let suggestedCity = $derived.by(() => {
|
||||
const city = inDomainCity(params.models?.[0] ?? '');
|
||||
// pointless to offer the place we are already on
|
||||
return city && city.slug !== locationRoute ? city : null;
|
||||
});
|
||||
|
||||
let mounted = $state(false);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<FriendlyWeatherError | null>(null);
|
||||
@@ -157,6 +167,36 @@
|
||||
return fd ? formatZoned(selectedDay, fd.timezone, 'yyyy-MM-dd') : '';
|
||||
});
|
||||
|
||||
// A model swap can leave the open day outside what the new model covers (a
|
||||
// short-range model after a 15-day one, say). Rather than showing an empty
|
||||
// day, fall back to the nearest day that does have data - searching forward
|
||||
// first, then back.
|
||||
$effect(() => {
|
||||
const fd = fetchedDaily;
|
||||
if (!fd || wantedDay) return;
|
||||
const days = fd.dailyDates;
|
||||
if (days.length === 0) return;
|
||||
|
||||
const covered = (i: number) => {
|
||||
const max = fd.daily.temperature_2m_max[i];
|
||||
return max != null && Number.isFinite(max);
|
||||
};
|
||||
const current = days.findIndex(
|
||||
(d) => formatZoned(d, fd.timezone, 'yyyy-MM-dd') === selectedDayKey
|
||||
);
|
||||
if (current >= 0 && covered(current)) return;
|
||||
|
||||
const from = current >= 0 ? current : 0;
|
||||
for (let step = 0; step < days.length; step++) {
|
||||
for (const i of [from + step, from - step]) {
|
||||
if (i >= 0 && i < days.length && covered(i)) {
|
||||
selectedDay.setTime(days[i].getTime());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Charts intentionally keep their current range: they show the full week
|
||||
// unless the user narrows it via the range presets or Ctrl+scroll.
|
||||
const switchDay = (date: Date) => {
|
||||
@@ -324,12 +364,20 @@
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
{#if suggestedCity}
|
||||
<a
|
||||
class="cursor-pointer rounded-md border border-border bg-background px-3 py-1 text-xs font-semibold text-foreground transition-colors hover:bg-muted"
|
||||
href={href('/weather/week/[location]', { location: suggestedCity.slug })}
|
||||
>
|
||||
{m.no_data_try_city({ city: suggestedCity.label })}
|
||||
</a>
|
||||
{/if}
|
||||
{#if params.models?.[0] !== 'best_match'}
|
||||
<button
|
||||
class="cursor-pointer rounded-md border border-border bg-background px-3 py-1 text-xs font-semibold text-foreground transition-colors hover:bg-muted"
|
||||
onclick={resetToBestMatch}
|
||||
>
|
||||
Switch to Best match
|
||||
{m.no_data_best_match()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -362,18 +410,27 @@
|
||||
/>
|
||||
</svg>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold">No forecast data for this model here</p>
|
||||
<p class="font-semibold">{m.no_data_title()}</p>
|
||||
<p class="text-[13px] opacity-90">
|
||||
The selected weather model doesn't cover {location.name} — regional models only provide data
|
||||
inside their own area.
|
||||
{m.no_data_body({ location: location.name ?? '' })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="cursor-pointer rounded-md border border-amber-500/50 bg-background/60 px-3 py-1 text-xs font-semibold transition-colors hover:bg-background"
|
||||
onclick={resetToBestMatch}
|
||||
>
|
||||
Switch to Best match
|
||||
</button>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if suggestedCity}
|
||||
<a
|
||||
class="cursor-pointer rounded-md border border-amber-500/50 bg-background/60 px-3 py-1 text-xs font-semibold transition-colors hover:bg-background"
|
||||
href={href('/weather/week/[location]', { location: suggestedCity.slug })}
|
||||
>
|
||||
{m.no_data_try_city({ city: suggestedCity.label })}
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
class="cursor-pointer rounded-md border border-amber-500/50 bg-background/60 px-3 py-1 text-xs font-semibold transition-colors hover:bg-background"
|
||||
onclick={resetToBestMatch}
|
||||
>
|
||||
{m.no_data_best_match()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user