strip more seamless and margin improvements

This commit is contained in:
Vincent van der Wal
2026-08-01 11:38:02 +02:00
parent 99c1a89537
commit a0379a1fd6
13 changed files with 706 additions and 188 deletions
+96 -26
View File
@@ -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