344 lines
12 KiB
Svelte
344 lines
12 KiB
Svelte
<script lang="ts">
|
||
import { fade } from 'svelte/transition';
|
||
|
||
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
|
||
|
||
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||
|
||
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
|
||
|
||
import { CanvasChart, groupRange } from '$lib/charts';
|
||
|
||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||
import ChartCustomizer from './ChartCustomizer.svelte';
|
||
import { type FetchedHourly, type WeatherUnits } from './types';
|
||
import { VARIABLE_BY_KEY, buildPanelDef } from './variables';
|
||
|
||
interface Props {
|
||
data: FetchedHourly;
|
||
selectedDay: Date;
|
||
units: WeatherUnits;
|
||
loading: boolean;
|
||
onResetZoom?: () => void;
|
||
}
|
||
|
||
let { data, selectedDay, units, loading, onResetZoom }: Props = $props();
|
||
|
||
const CHART_GROUP = 'week-meteogram';
|
||
const SECONDS_PER_DAY = 24 * 3600;
|
||
const CHART_HEIGHT = 300;
|
||
|
||
let customizerOpen = $state(false);
|
||
let downloadingPng = $state(false);
|
||
|
||
async function downloadPng(): Promise<void> {
|
||
if (liveCharts.length === 0 || downloadingPng) return;
|
||
downloadingPng = true;
|
||
try {
|
||
// pair each chart with its panel title so the export is labelled
|
||
const items = renderPanels.map((p, i) => ({ chart: chartComponents[i], title: p.title }));
|
||
await downloadChartsPng(items, 'week-forecast');
|
||
} finally {
|
||
setTimeout(() => (downloadingPng = false), 500);
|
||
}
|
||
}
|
||
|
||
// Charts persist across data refetches; entries are null while unmounted.
|
||
let chartComponents: (CanvasChart | null)[] = $state([]);
|
||
let liveCharts = $derived(chartComponents.filter((chart): chart is CanvasChart => chart != null));
|
||
|
||
// Only render panels that hold at least one known variable.
|
||
let panels = $derived(
|
||
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
|
||
);
|
||
|
||
// When an extended range runs past the model's horizon the service pads with
|
||
// zeros; trim the axis to the last hour that actually has data so the charts
|
||
// cut off instead of flat-lining to zero.
|
||
let validLength = $derived.by((): number => {
|
||
const temp = data.hourly.temperature_2m ?? [];
|
||
const n = data.timestamps.length;
|
||
if (temp.length === 0) return n;
|
||
let last = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
if (temp[i] != null && !isNaN(temp[i]) && temp[i] !== 0) last = i + 1;
|
||
}
|
||
return last || n;
|
||
});
|
||
|
||
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
|
||
let timestampsSec = $derived(data.timestamps.slice(0, validLength).map((t) => t / 1000));
|
||
|
||
function dayStartSec(day: Date): number | null {
|
||
if (!data) return null;
|
||
const tz = data.timezone;
|
||
const targetDayStr = formatZoned(day, tz, 'yyyy-MM-dd');
|
||
const firstHourIdx = data.hourlyDates.findIndex(
|
||
(d) => formatZoned(d, tz, 'yyyy-MM-dd') === targetDayStr
|
||
);
|
||
return firstHourIdx === -1 ? null : data.timestamps[firstHourIdx] / 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();
|
||
onResetZoom?.();
|
||
}
|
||
|
||
const now = new Date();
|
||
// "Selected day" is redundant while today is the selected day, so hide it then
|
||
let rangePresets = $derived.by(() => {
|
||
const isToday = isSameDayInZone(now, selectedDay, data.timezone);
|
||
return [
|
||
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
|
||
...(isToday ? [] : [{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) }]),
|
||
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
|
||
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
|
||
{ label: 'All', apply: () => resetZoom() }
|
||
];
|
||
});
|
||
|
||
// Soft band drawn on every chart marking the currently selected day
|
||
let selectedDayHighlight = $derived.by(() => {
|
||
const start = dayStartSec(selectedDay);
|
||
return start == null ? undefined : { start, end: start + SECONDS_PER_DAY };
|
||
});
|
||
|
||
// ─── Pictograms (weather icons across the top) ──────────────────────────────
|
||
|
||
function isDaytime(tSec: number): boolean {
|
||
return data.daylightBands.some((b) => tSec >= b.start && tSec < b.end);
|
||
}
|
||
|
||
let pictograms = $derived.by((): { t: number; icon: string }[] => {
|
||
const codes = data.hourly.weather_code ?? [];
|
||
const out: { t: number; icon: string }[] = [];
|
||
for (let i = 0; i < timestampsSec.length; i++) {
|
||
const code = codes[i];
|
||
if (code == null || !isFinite(code)) continue;
|
||
const t = timestampsSec[i];
|
||
out.push({ t, icon: getWeatherIconName(code, isDaytime(t)) });
|
||
}
|
||
return out;
|
||
});
|
||
|
||
// Wind-direction arrows for panels showing wind (deg = direction from North).
|
||
let windArrowMarks = $derived.by((): { t: number; deg: number }[] => {
|
||
const dirs = data.hourly.winddirection_10m ?? [];
|
||
const out: { t: number; deg: number }[] = [];
|
||
for (let i = 0; i < timestampsSec.length; i++) {
|
||
const d = dirs[i];
|
||
if (d == null || !isFinite(d)) continue;
|
||
out.push({ t: timestampsSec[i], deg: d });
|
||
}
|
||
return out;
|
||
});
|
||
|
||
// True while the shared group is zoomed in (not the full range).
|
||
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
|
||
|
||
// ─── Panel definitions ──────────────────────────────────────────────────────
|
||
|
||
interface RenderPanel extends ChartPanel {
|
||
def: ReturnType<typeof buildPanelDef>;
|
||
title: string;
|
||
titleShort: string;
|
||
}
|
||
|
||
let renderPanels = $derived.by((): RenderPanel[] =>
|
||
panels.map((p) => {
|
||
const def = buildPanelDef(p.variables, data.hourly, units);
|
||
const title = def.series.map((s) => s.name).join(' · ');
|
||
const titleShort = def.series.map((s) => s.shortName ?? s.name).join(' · ');
|
||
return { ...p, def, title, titleShort };
|
||
})
|
||
);
|
||
|
||
// Uniform sizing across every panel: reserve the right-axis gutter and the
|
||
// tallest icon-row count so all meteograms share one plot rectangle.
|
||
let anyRightAxis = $derived(renderPanels.some((p) => p.def.unitRight != null));
|
||
let maxTopRows = $derived(
|
||
Math.max(
|
||
0,
|
||
...renderPanels.map((p) => (p.def.hasPictograms ? 1 : 0) + (p.def.hasWindArrows ? 1 : 0))
|
||
)
|
||
);
|
||
</script>
|
||
|
||
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||
<h3 class="text-lg font-bold">
|
||
Meteograms
|
||
<span class="font-semibold text-muted-foreground">
|
||
– {formatZoned(selectedDay, data.timezone, 'EEEE')}{getRelativeDayLabel(
|
||
selectedDay,
|
||
data.timezone
|
||
) !== formatZoned(selectedDay, data.timezone, 'EEEE')
|
||
? ` (${getRelativeDayLabel(selectedDay, data.timezone)})`
|
||
: ''}
|
||
</span>
|
||
</h3>
|
||
<div class="flex flex-wrap items-center 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>
|
||
<button
|
||
type="button"
|
||
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||
onclick={() => (customizerOpen = true)}
|
||
>
|
||
<svg
|
||
class="h-3.5 w-3.5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
stroke-width="2"
|
||
>
|
||
<path stroke-linecap="round" d="M4 6h16M4 12h16M4 18h16M8 4v4m8 2v4M6 16v4" />
|
||
</svg>
|
||
Customize
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||
disabled={liveCharts.length === 0 || downloadingPng}
|
||
onclick={downloadPng}
|
||
title="Download meteogram as PNG image"
|
||
>
|
||
{#if downloadingPng}
|
||
<svg
|
||
class="h-3.5 w-3.5 animate-spin"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
stroke-width="2"
|
||
>
|
||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||
</svg>
|
||
{:else}
|
||
<svg
|
||
class="h-3.5 w-3.5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
stroke-width="2"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||
<polyline points="7 10 12 15 17 10" />
|
||
<line x1="12" y1="15" x2="12" y2="3" />
|
||
</svg>
|
||
{/if}
|
||
PNG
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{#if renderPanels.length === 0}
|
||
<div
|
||
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||
>
|
||
No meteograms configured, <button
|
||
class="cursor-pointer font-semibold text-primary underline-offset-2 hover:underline"
|
||
onclick={() => (customizerOpen = true)}>add some variables</button
|
||
>.
|
||
</div>
|
||
{:else}
|
||
<!-- one full-bleed card until lg / contained card on lg+, graphs stacked
|
||
tightly so they read as one fluent meteogram -->
|
||
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||
{#each renderPanels as panel, i (panel.id)}
|
||
<div
|
||
class="px-0 pt-0.5 pb-0 lg:px-4 lg:pt-2 lg:pb-1 {i > 0
|
||
? 'border-t border-border/50'
|
||
: 'lg:pt-4'} {i === renderPanels.length - 1 ? 'pb-1 lg:pb-4' : ''}"
|
||
>
|
||
<div class="mb-0 flex items-center justify-between px-3 lg:mb-0.5 lg:px-0">
|
||
<h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase">
|
||
<span class="hidden lg:inline">{panel.title}</span>
|
||
<span class="lg:hidden">{panel.titleShort}</span>
|
||
</h4>
|
||
</div>
|
||
<ChartContainer
|
||
{loading}
|
||
chartCount={1}
|
||
chartHeight={CHART_HEIGHT}
|
||
minWidth={520}
|
||
bleed={false}
|
||
>
|
||
<CanvasChart
|
||
bind:this={chartComponents[i]}
|
||
timestamps={timestampsSec}
|
||
timezone={data.timezone}
|
||
series={panel.def.series}
|
||
bands={data.daylightBands}
|
||
pictograms={panel.def.hasPictograms ? pictograms : []}
|
||
windArrows={panel.def.hasWindArrows ? windArrowMarks : []}
|
||
reserveRightAxis={anyRightAxis}
|
||
reserveTopRows={maxTopRows}
|
||
highlight={selectedDayHighlight}
|
||
unit={panel.def.unit}
|
||
unitRight={panel.def.unitRight}
|
||
yMin={panel.def.yMin}
|
||
zeroBaseLeft={panel.def.zeroBaseLeft}
|
||
yMinRight={panel.def.yMinRight}
|
||
yMaxRight={panel.def.yMaxRight}
|
||
showCredit={i === renderPanels.length - 1}
|
||
showLegend
|
||
height={CHART_HEIGHT}
|
||
group={CHART_GROUP}
|
||
/>
|
||
</ChartContainer>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
|
||
<ChartCustomizer open={customizerOpen} onClose={() => (customizerOpen = false)} />
|