small bug fixes
This commit is contained in:
+5
-1
@@ -203,5 +203,9 @@
|
|||||||
"var_visibility_short": "Sicht",
|
"var_visibility_short": "Sicht",
|
||||||
"var_cape": "CAPE",
|
"var_cape": "CAPE",
|
||||||
"var_cape_short": "CAPE",
|
"var_cape_short": "CAPE",
|
||||||
"var_time": "Zeit"
|
"var_time": "Zeit",
|
||||||
|
"no_data_title": "Für dieses Modell gibt es hier keine Daten",
|
||||||
|
"no_data_body": "Das gewählte Wettermodell deckt {location} nicht ab - regionale Modelle liefern nur Daten in ihrem eigenen Gebiet.",
|
||||||
|
"no_data_try_city": "Ort auf {city} ändern",
|
||||||
|
"no_data_best_match": "Zu „Best match“ wechseln"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -203,5 +203,9 @@
|
|||||||
"var_visibility_short": "Vis",
|
"var_visibility_short": "Vis",
|
||||||
"var_cape": "CAPE",
|
"var_cape": "CAPE",
|
||||||
"var_cape_short": "CAPE",
|
"var_cape_short": "CAPE",
|
||||||
"var_time": "Time"
|
"var_time": "Time",
|
||||||
|
"no_data_title": "No forecast data for this model here",
|
||||||
|
"no_data_body": "The selected weather model doesn't cover {location} - regional models only provide data inside their own area.",
|
||||||
|
"no_data_try_city": "Change location to {city}",
|
||||||
|
"no_data_best_match": "Switch to Best match"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -203,5 +203,9 @@
|
|||||||
"var_visibility_short": "Vis.",
|
"var_visibility_short": "Vis.",
|
||||||
"var_cape": "CAPE",
|
"var_cape": "CAPE",
|
||||||
"var_cape_short": "CAPE",
|
"var_cape_short": "CAPE",
|
||||||
"var_time": "Hora"
|
"var_time": "Hora",
|
||||||
|
"no_data_title": "Este modelo no tiene datos aquí",
|
||||||
|
"no_data_body": "El modelo seleccionado no cubre {location}: los modelos regionales solo ofrecen datos dentro de su propia área.",
|
||||||
|
"no_data_try_city": "Cambiar la ubicación a {city}",
|
||||||
|
"no_data_best_match": "Cambiar a «Best match»"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -203,5 +203,9 @@
|
|||||||
"var_visibility_short": "Vis.",
|
"var_visibility_short": "Vis.",
|
||||||
"var_cape": "CAPE",
|
"var_cape": "CAPE",
|
||||||
"var_cape_short": "CAPE",
|
"var_cape_short": "CAPE",
|
||||||
"var_time": "Heure"
|
"var_time": "Heure",
|
||||||
|
"no_data_title": "Ce modèle n'a pas de données ici",
|
||||||
|
"no_data_body": "Le modèle sélectionné ne couvre pas {location} : les modèles régionaux ne fournissent des données que dans leur propre zone.",
|
||||||
|
"no_data_try_city": "Changer de lieu pour {city}",
|
||||||
|
"no_data_best_match": "Passer à « Best match »"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -203,5 +203,9 @@
|
|||||||
"var_visibility_short": "Vis.",
|
"var_visibility_short": "Vis.",
|
||||||
"var_cape": "CAPE",
|
"var_cape": "CAPE",
|
||||||
"var_cape_short": "CAPE",
|
"var_cape_short": "CAPE",
|
||||||
"var_time": "Ora"
|
"var_time": "Ora",
|
||||||
|
"no_data_title": "Nessun dato per questo modello qui",
|
||||||
|
"no_data_body": "Il modello selezionato non copre {location}: i modelli regionali forniscono dati solo nella propria area.",
|
||||||
|
"no_data_try_city": "Cambia località in {city}",
|
||||||
|
"no_data_best_match": "Passa a «Best match»"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,35 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</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) -->
|
<!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
|
||||||
{#if onToggle}
|
{#if onToggle}
|
||||||
|
|||||||
@@ -155,13 +155,13 @@
|
|||||||
in the cross-fade. Everything else keeps its pixels: the `day-switch`
|
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
|
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. */
|
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;
|
view-transition-name: day-table;
|
||||||
}
|
}
|
||||||
.day-region-summary {
|
:root.day-switch .day-region-summary {
|
||||||
view-transition-name: day-summary;
|
view-transition-name: day-summary;
|
||||||
}
|
}
|
||||||
.day-region-charts {
|
:root.day-switch .day-region-charts {
|
||||||
view-transition-name: day-charts;
|
view-transition-name: day-charts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
captured too: view-transition snapshots all paint in one layer above the
|
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
|
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. */
|
fading table and charts would slide over the top of it. */
|
||||||
.daystrip {
|
:root.day-switch .daystrip {
|
||||||
view-transition-name: daystrip;
|
view-transition-name: daystrip;
|
||||||
}
|
}
|
||||||
::view-transition-group(daystrip) {
|
::view-transition-group(daystrip) {
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
|
|
||||||
/* Same for the topbar: a captured region's snapshot is painted at its layout
|
/* Same for the topbar: a captured region's snapshot is painted at its layout
|
||||||
position, including the part normally scrolled up behind the chrome. */
|
position, including the part normally scrolled up behind the chrome. */
|
||||||
.topbar {
|
:root.day-switch .topbar {
|
||||||
view-transition-name: topbar;
|
view-transition-name: topbar;
|
||||||
}
|
}
|
||||||
::view-transition-group(topbar) {
|
::view-transition-group(topbar) {
|
||||||
|
|||||||
@@ -479,3 +479,37 @@ export const ensembleModels: WeatherModel[] = ensembleModelGroups.flatMap((group
|
|||||||
|
|
||||||
export const findEnsembleModel = (value: string): WeatherModel | undefined =>
|
export const findEnsembleModel = (value: string): WeatherModel | undefined =>
|
||||||
ensembleModels.find((model) => model.value === value);
|
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 { ChartContainer } from '$lib/components/charts';
|
||||||
|
|
||||||
|
import { href } from '$lib/i18n';
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
import {
|
import {
|
||||||
type FriendlyWeatherError,
|
type FriendlyWeatherError,
|
||||||
type WeekForecastResult,
|
type WeekForecastResult,
|
||||||
@@ -30,7 +32,7 @@
|
|||||||
} from '$lib/services/weather';
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
import { useHeroActions } from '../../hero.svelte';
|
import { useHeroActions } from '../../hero.svelte';
|
||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters, inDomainCity } from '../../options';
|
||||||
import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
|
import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
|
||||||
import DailyStripSticky from './DailyStripSticky.svelte';
|
import DailyStripSticky from './DailyStripSticky.svelte';
|
||||||
import DaySummary from './DaySummary.svelte';
|
import DaySummary from './DaySummary.svelte';
|
||||||
@@ -114,6 +116,14 @@
|
|||||||
// for the same place once their range is exhausted.
|
// for the same place once their range is exhausted.
|
||||||
let locationRoute = $derived(buildLocationRoute(location));
|
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 mounted = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let loadError = $state<FriendlyWeatherError | null>(null);
|
let loadError = $state<FriendlyWeatherError | null>(null);
|
||||||
@@ -157,6 +167,36 @@
|
|||||||
return fd ? formatZoned(selectedDay, fd.timezone, 'yyyy-MM-dd') : '';
|
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
|
// 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.
|
||||||
const switchDay = (date: Date) => {
|
const switchDay = (date: Date) => {
|
||||||
@@ -324,12 +364,20 @@
|
|||||||
>
|
>
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</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'}
|
{#if params.models?.[0] !== 'best_match'}
|
||||||
<button
|
<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"
|
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}
|
onclick={resetToBestMatch}
|
||||||
>
|
>
|
||||||
Switch to Best match
|
{m.no_data_best_match()}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -362,19 +410,28 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="min-w-0 flex-1">
|
<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">
|
<p class="text-[13px] opacity-90">
|
||||||
The selected weather model doesn't cover {location.name} — regional models only provide data
|
{m.no_data_body({ location: location.name ?? '' })}
|
||||||
inside their own area.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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
|
<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"
|
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}
|
onclick={resetToBestMatch}
|
||||||
>
|
>
|
||||||
Switch to Best match
|
{m.no_data_best_match()}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- The sticky day strip, the hourly table AND the meteograms share this
|
<!-- The sticky day strip, the hourly table AND the meteograms share this
|
||||||
|
|||||||
Reference in New Issue
Block a user