move settings to one icon mobile
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { storedLocation, storedUnits } from '$lib/stores/settings';
|
||||
|
||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
|
||||
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||
import { isSupporter } from '$lib/paywall/supporter';
|
||||
import {
|
||||
type ClimateNormals,
|
||||
type SeasonalForecastResult,
|
||||
fetchClimateNormals,
|
||||
fetchSeasonalForecast
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { defaultParameters } from '../../options';
|
||||
import { getPrecipUnit } from '../../week/[location]/types';
|
||||
import SeasonalCharts from './SeasonalCharts.svelte';
|
||||
import SeasonalMonths from './SeasonalMonths.svelte';
|
||||
import { buildMonthOutlooks, sliceSeasonal } from './outlook';
|
||||
|
||||
import type { CanvasChart } from '$lib/charts';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// the URL is the source of truth: location comes from the load function,
|
||||
// which is also correct on hydrated prerendered pages. The persisted store
|
||||
// only mirrors it so the header and bare /weather/* redirects follow along.
|
||||
let location = $derived(data.location);
|
||||
$effect(() => {
|
||||
storedLocation.set(data.location);
|
||||
});
|
||||
|
||||
let params = $state({ ...defaultParameters });
|
||||
$effect(() => {
|
||||
params.temperature_unit = $storedUnits.temperature_unit;
|
||||
params.wind_speed_unit = $storedUnits.wind_speed_unit;
|
||||
params.precipitation_unit = $storedUnits.precipitation_unit;
|
||||
});
|
||||
|
||||
// Only the variables the outlook actually renders: every extra one costs a
|
||||
// full member set (50+ series) over half a year of days.
|
||||
const SEASONAL_VARS = [
|
||||
'temperature_2m_max',
|
||||
'temperature_2m_min',
|
||||
'temperature_2m_mean',
|
||||
'precipitation_sum'
|
||||
];
|
||||
|
||||
// ─── Display state (no refetch) ─────────────────────────────────────────────
|
||||
const RANGES = [
|
||||
{ label: '3 months', days: 92 },
|
||||
{ label: '6 months', days: 183 },
|
||||
{ label: 'Full range', days: Infinity }
|
||||
];
|
||||
let rangeIndex = $state(1);
|
||||
let showLegend = $state(true);
|
||||
let chartComponents: CanvasChart[] = $state([]);
|
||||
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
||||
|
||||
// ─── Fetch state ────────────────────────────────────────────────────────────
|
||||
let mounted = $state(false);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let requestVersion = 0;
|
||||
|
||||
let result = $state<SeasonalForecastResult | null>(null);
|
||||
let normals = $state<ClimateNormals | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
// The full horizon is fetched once per location/units; the range buttons only
|
||||
// reslice it.
|
||||
$effect(() => {
|
||||
const loc = location;
|
||||
const tempUnit = params.temperature_unit;
|
||||
const precipUnit = params.precipitation_unit;
|
||||
if (!mounted || !$isSupporter || !loc) return;
|
||||
|
||||
const version = ++requestVersion;
|
||||
loading = true;
|
||||
loadError = null;
|
||||
|
||||
fetchSeasonalForecast({
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
dailyVariables: SEASONAL_VARS,
|
||||
temperature_unit: tempUnit as 'celsius' | 'fahrenheit',
|
||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||
precipitation_unit: precipUnit as 'mm' | 'inch',
|
||||
timezone: loc.timezone
|
||||
})
|
||||
.then((r) => {
|
||||
if (version !== requestVersion) return;
|
||||
result = r;
|
||||
loading = false;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (version !== requestVersion) return;
|
||||
loadError = err instanceof Error ? err.message : String(err);
|
||||
loading = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Normals are the whole point of a seasonal outlook (everything is shown as a
|
||||
// departure from them), but a failure only drops the comparison.
|
||||
let normalsKey = $derived(
|
||||
`${location?.latitude},${location?.longitude},${params.temperature_unit},${params.precipitation_unit}`
|
||||
);
|
||||
let normalsVersion = 0;
|
||||
$effect(() => {
|
||||
const key = normalsKey;
|
||||
const loc = location;
|
||||
if (!mounted || !$isSupporter || !loc) return;
|
||||
|
||||
const version = ++normalsVersion;
|
||||
normals = null;
|
||||
fetchClimateNormals({
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch'
|
||||
})
|
||||
.then((n) => {
|
||||
if (version === normalsVersion) normals = n;
|
||||
})
|
||||
.catch(() => {
|
||||
if (version === normalsVersion) normals = null;
|
||||
});
|
||||
void key;
|
||||
});
|
||||
|
||||
// ─── Derived view ───────────────────────────────────────────────────────────
|
||||
let visible = $derived(result ? sliceSeasonal(result, RANGES[rangeIndex].days) : null);
|
||||
|
||||
// 1 mm and 0.04 in are the same "measurable rain" threshold in either unit.
|
||||
let wetDayThreshold = $derived(getPrecipUnit(params) === 'in' ? 0.04 : 1);
|
||||
|
||||
let months = $derived(visible ? buildMonthOutlooks(visible, normals, { wetDayThreshold }) : []);
|
||||
|
||||
let horizonDays = $derived(result?.timestamps.length ?? 0);
|
||||
let lastDay = $derived(
|
||||
visible && visible.dailyDates.length > 0
|
||||
? visible.dailyDates[visible.dailyDates.length - 1]
|
||||
: null
|
||||
);
|
||||
// dailyDates are local wall time held as UTC instants (see the service), so
|
||||
// the label has to be formatted in UTC to read back the local date.
|
||||
let lastDayLabel = $derived(
|
||||
lastDay
|
||||
? lastDay.toLocaleDateString(undefined, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
})
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Drizz.li | Seasonal forecast</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Multi-month seasonal outlook: monthly temperature and precipitation trends against the 1991-2020 climate normal"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Page hero -->
|
||||
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<img
|
||||
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
||||
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||
alt={location.country ?? ''}
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
||||
{location.name}
|
||||
</h1>
|
||||
<p class="truncate text-sm text-muted-foreground">
|
||||
<span class="lg:hidden"
|
||||
>{#if location.admin1}{location.admin1},
|
||||
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
||||
>Seasonal outlook
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if result}
|
||||
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
|
||||
<div
|
||||
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
|
||||
role="group"
|
||||
aria-label="Outlook range"
|
||||
>
|
||||
{#each RANGES as range, i (range.label)}
|
||||
{@const disabled = range.days !== Infinity && range.days > horizonDays}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
|
||||
i
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'} {disabled
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: ''}"
|
||||
aria-pressed={rangeIndex === i}
|
||||
{disabled}
|
||||
onclick={() => (rangeIndex = i)}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- What a seasonal forecast is (and is not): without this the daily-looking
|
||||
charts invite over-reading. -->
|
||||
<div
|
||||
class="mb-4 flex items-start gap-2.5 rounded-md border border-border bg-muted/40 px-3.5 py-2.5 text-sm"
|
||||
>
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 16v-4m0-4h.01" />
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
<p class="text-muted-foreground">
|
||||
A seasonal forecast shows how a whole month is likely to
|
||||
<strong class="font-semibold text-foreground">depart from its climate normal</strong> - not the
|
||||
weather on any given day. Read the monthly trend and the ensemble agreement, not the daily
|
||||
wiggles.
|
||||
{#if lastDayLabel}
|
||||
This outlook runs to <strong class="font-semibold text-foreground">{lastDayLabel}</strong>.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PaywallGate feature="The seasonal outlook">
|
||||
{#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 the seasonal outlook: {loadError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if visible && months.length > 0}
|
||||
<SeasonalMonths {months} units={params} {normals} />
|
||||
|
||||
<div class="mt-6">
|
||||
<SeasonalCharts
|
||||
result={visible}
|
||||
{normals}
|
||||
units={params}
|
||||
{loading}
|
||||
{showLegend}
|
||||
bind:charts={chartComponents}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={liveCharts} fileName="seasonal-outlook">
|
||||
{#snippet controls()}
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||
<Label for="show_legend" class="cursor-pointer text-base leading-none"
|
||||
>Show legend</Label
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
</div>
|
||||
{:else if !loadError}
|
||||
<div transition:fade={{ duration: 200 }} class="grid gap-3">
|
||||
<div class="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each [0, 1, 2, 3, 4, 5] as i (i)}
|
||||
<div class="h-44 animate-pulse rounded-xl border border-border/70 bg-card"></div>
|
||||
{/each}
|
||||
</div>
|
||||
<ChartContainer loading chartCount={2} chartHeight={300} bleed={false} />
|
||||
</div>
|
||||
{/if}
|
||||
</PaywallGate>
|
||||
Reference in New Issue
Block a user