past weather
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
|
||||
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
|
||||
|
||||
import { formatZoned } from '$lib/utils/date';
|
||||
|
||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
@@ -17,6 +19,7 @@
|
||||
SERIES_COLORS,
|
||||
calculateAverage,
|
||||
findUnit,
|
||||
groupRange,
|
||||
isColumnUnit
|
||||
} from '$lib/charts';
|
||||
import {
|
||||
@@ -99,6 +102,40 @@
|
||||
// components persist across refetches; entries are null while unmounted
|
||||
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
||||
|
||||
// ─── Zoom range controls (mirrors the 7-day meteograms) ─────────────────────
|
||||
|
||||
const SECONDS_PER_DAY = 24 * 3600;
|
||||
|
||||
function dayStartSec(day: Date): number | null {
|
||||
if (!fetchedData) return null;
|
||||
const tz = fetchedData.timezone;
|
||||
const target = formatZoned(day, tz, 'yyyy-MM-dd');
|
||||
const idx = fetchedData.timestamps.findIndex(
|
||||
(t) => formatZoned(new Date(t), tz, 'yyyy-MM-dd') === target
|
||||
);
|
||||
return idx === -1 ? null : fetchedData.timestamps[idx] / 1000;
|
||||
}
|
||||
|
||||
function setRangeDays(from: Date, days: number): void {
|
||||
const start = dayStartSec(from);
|
||||
if (start == null || liveCharts.length === 0) return;
|
||||
// charts share the group, so setting the range on one syncs all of them
|
||||
liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY);
|
||||
}
|
||||
|
||||
function resetZoom(): void {
|
||||
liveCharts[0]?.resetRange();
|
||||
}
|
||||
|
||||
const rangePresets = [
|
||||
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
|
||||
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
|
||||
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
|
||||
{ label: 'All', apply: () => resetZoom() }
|
||||
];
|
||||
|
||||
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
|
||||
|
||||
// ─── Data Fetching (only when params.hourly or params.models change) ───────
|
||||
|
||||
$effect(() => {
|
||||
@@ -161,6 +198,24 @@
|
||||
series: ChartSeries[];
|
||||
}
|
||||
|
||||
// Human labels for the compared variables (API names → readable title)
|
||||
const VAR_LABELS: Record<string, string> = {
|
||||
temperature_2m: 'Temperature',
|
||||
apparent_temperature: 'Feels like',
|
||||
dew_point_2m: 'Dew point',
|
||||
precipitation: 'Precipitation',
|
||||
rain: 'Rain',
|
||||
showers: 'Showers',
|
||||
snowfall: 'Snowfall',
|
||||
wind_speed_10m: 'Wind speed',
|
||||
wind_gusts_10m: 'Wind gusts',
|
||||
relative_humidity_2m: 'Relative humidity',
|
||||
cloud_cover: 'Cloud cover',
|
||||
pressure_msl: 'Pressure (MSL)'
|
||||
};
|
||||
const varLabel = (v: string): string =>
|
||||
VAR_LABELS[v] ?? v.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
let chartDefs = $derived.by((): ChartDef[] => {
|
||||
if (!fetchedData) return [];
|
||||
|
||||
@@ -209,10 +264,11 @@
|
||||
const isLast = vi === variableCount - 1;
|
||||
|
||||
defs.push({
|
||||
title: isFirst ? 'Model Compare' : undefined,
|
||||
// label every chart with its variable so each is identifiable
|
||||
title: varLabel(variable),
|
||||
subtitle: isFirst
|
||||
? `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
||||
: undefined,
|
||||
? `${params.models?.length ?? 0} models · dashed = average`
|
||||
: `across ${params.models?.length ?? 0} models`,
|
||||
unit,
|
||||
showCredit: isLast,
|
||||
series
|
||||
@@ -223,6 +279,27 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ─── Page hero: location (matches the other forecast pages) ──────────────── -->
|
||||
|
||||
<div class="mb-5 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
|
||||
>Model comparison
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||
|
||||
{#if loadError}
|
||||
@@ -233,6 +310,49 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Range / zoom controls (same as the 7-day meteograms) -->
|
||||
<div class="mb-3 flex flex-wrap items-center justify-end gap-3">
|
||||
<span class="hidden text-xs text-muted-foreground md:inline">
|
||||
drag or
|
||||
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd>
|
||||
+ scroll to zoom
|
||||
</span>
|
||||
{#if zoomActive}
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
|
||||
onclick={resetZoom}
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
|
||||
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
|
||||
</svg>
|
||||
Reset zoom
|
||||
</button>
|
||||
{/if}
|
||||
<div
|
||||
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
||||
role="group"
|
||||
aria-label="Chart time range"
|
||||
>
|
||||
{#each rangePresets as preset (preset.label)}
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-md px-2.5 py-1 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
|
||||
onclick={preset.apply}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- chart count derives from the selected variables (not the fetched data),
|
||||
so the reserved height is right even before the response arrives -->
|
||||
<ChartContainer
|
||||
|
||||
Reference in New Issue
Block a user