strip more seamless and margin improvements
This commit is contained in:
@@ -14,11 +14,15 @@
|
||||
|
||||
import { ChartContainer } from '$lib/components/charts';
|
||||
|
||||
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather';
|
||||
import {
|
||||
type FriendlyWeatherError,
|
||||
type WeekForecastResult,
|
||||
fetchWeekForecast,
|
||||
humanizeWeatherError
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { defaultParameters } from '../../options';
|
||||
import { computeDayNightWeatherCodes } from '../../utils/weather-codes';
|
||||
import DailyCards from './DailyCards.svelte';
|
||||
import DailyStripSticky from './DailyStripSticky.svelte';
|
||||
import HourlyTable from './HourlyTable.svelte';
|
||||
import MeteogramCharts from './MeteogramCharts.svelte';
|
||||
@@ -74,8 +78,29 @@
|
||||
|
||||
let mounted = $state(false);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let loadError = $state<FriendlyWeatherError | null>(null);
|
||||
let requestVersion = 0;
|
||||
// bumped by the "Try again" button to re-run the fetch effect
|
||||
let retryNonce = $state(0);
|
||||
|
||||
/** Back to the model that always has data (also what ModelSelector does). */
|
||||
function resetToBestMatch() {
|
||||
params.models = ['best_match'];
|
||||
storedModel.set('best_match');
|
||||
forecastDays = 7;
|
||||
pastDays = 0;
|
||||
}
|
||||
|
||||
// A request can succeed yet contain nothing usable: regional models return
|
||||
// all-NaN outside their coverage area. Detect that so the page can say so
|
||||
// instead of silently rendering an empty strip.
|
||||
let noData = $derived.by((): boolean => {
|
||||
const fd = fetchedDaily;
|
||||
if (loading || !fd) return false;
|
||||
return !fd.daily.temperature_2m_max.some(
|
||||
(v, i) => v != null && !isNaN(v) && !(v === 0 && fd.daily.temperature_2m_min[i] === 0)
|
||||
);
|
||||
});
|
||||
|
||||
// 7 by default; the user can extend to the model's longer range (up to 16 days)
|
||||
let forecastDays = $state(7);
|
||||
@@ -103,6 +128,7 @@
|
||||
const loc = location;
|
||||
const modelList = params.models;
|
||||
const requestVars = hourlyVars;
|
||||
void retryNonce; // re-run on "Try again"
|
||||
|
||||
if (!mounted || !loc || !modelList?.length) return;
|
||||
|
||||
@@ -156,7 +182,7 @@
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (version !== requestVersion) return;
|
||||
loadError = err instanceof Error ? err.message : String(err);
|
||||
loadError = humanizeWeatherError(err);
|
||||
loading = false;
|
||||
});
|
||||
});
|
||||
@@ -210,33 +236,77 @@
|
||||
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
|
||||
|
||||
{#if loadError}
|
||||
<div
|
||||
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
Failed to load weather data: {loadError}
|
||||
<div class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm">
|
||||
<p class="font-semibold text-destructive">{loadError.title}</p>
|
||||
{#if loadError.hint}
|
||||
<p class="mt-0.5 text-destructive/90">{loadError.hint}</p>
|
||||
{/if}
|
||||
<div class="mt-2.5 flex flex-wrap gap-2">
|
||||
<button
|
||||
class="cursor-pointer rounded-md border border-destructive/40 bg-background px-3 py-1 text-xs font-semibold text-destructive transition-colors hover:bg-destructive/10"
|
||||
onclick={() => retryNonce++}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
{#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
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if loadError.detail}
|
||||
<details class="mt-2 text-xs text-destructive/70">
|
||||
<summary class="cursor-pointer select-none">Technical details</summary>
|
||||
<p class="mt-1 font-mono break-all">{loadError.detail}</p>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Desktop: the full day cards -->
|
||||
<div class="hidden md:block">
|
||||
<DailyCards
|
||||
daily={fetchedDaily}
|
||||
{selectedDay}
|
||||
units={params}
|
||||
onSelectDay={switchDay}
|
||||
canExtend={forecastDays < 15}
|
||||
onExtend={() => (forecastDays = 15)}
|
||||
canExtendPast={pastDays < 3}
|
||||
onExtendPast={() => (pastDays = 3)}
|
||||
/>
|
||||
</div>
|
||||
{#if noData && !loadError}
|
||||
<!-- the request succeeded but every value is NaN: the selected (regional)
|
||||
model doesn't cover this location -->
|
||||
<div
|
||||
class="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-md border border-amber-300/60 bg-amber-50 px-3.5 py-2.5 text-sm text-amber-800 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-200"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v4m0 4h.01M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold">No forecast data for this model here</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.
|
||||
</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>
|
||||
{/if}
|
||||
|
||||
<!-- The sticky day strip, the hourly table AND the meteograms share this
|
||||
wrapper, so the strip stays stuck for the entire page (it collapses
|
||||
as it sticks on mobile; on md+ it's a slim always-compact bar under
|
||||
the topbar, complementing the full cards above). timeline-scope
|
||||
hoists the strip's sentinel view-timeline so the sticky strip (a
|
||||
sibling of the sentinel) can scrub its collapse from it. -->
|
||||
wrapper, so the strip stays stuck for the entire page: the full day
|
||||
cards collapse into the compact strip as it sticks (on md+ the bar
|
||||
docks under the topbar at its exact height). timeline-scope hoists
|
||||
the strip's sentinel view-timeline so the sticky strip (a sibling of
|
||||
the sentinel) can scrub its collapse from it. -->
|
||||
<div style="timeline-scope: --daystrip-sentinel">
|
||||
{#if fetchedDaily}
|
||||
<DailyStripSticky
|
||||
|
||||
Reference in New Issue
Block a user