modular meteograms
This commit is contained in:
@@ -1,22 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { storedVariablePrefs } from '$lib/stores/settings';
|
||||
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
|
||||
|
||||
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
||||
|
||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||
|
||||
import { CanvasChart, type ChartSeries } from '$lib/charts';
|
||||
import { CanvasChart } from '$lib/charts';
|
||||
|
||||
import {
|
||||
type FetchedHourly,
|
||||
type WeatherUnits,
|
||||
getPrecipUnit,
|
||||
getTempUnit,
|
||||
getWindDirectionLabel,
|
||||
getWindUnit
|
||||
} from './types';
|
||||
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;
|
||||
@@ -30,14 +26,22 @@
|
||||
|
||||
const CHART_GROUP = 'week-meteogram';
|
||||
const SECONDS_PER_DAY = 24 * 3600;
|
||||
const CHART_HEIGHT = 300;
|
||||
|
||||
let chartComponents: CanvasChart[] = $state([]);
|
||||
let customizerOpen = $state(false);
|
||||
|
||||
// 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)))
|
||||
);
|
||||
|
||||
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
|
||||
let timestampsSec = $derived(data.timestamps.map((t) => t / 1000));
|
||||
|
||||
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
||||
|
||||
function dayStartSec(day: Date): number | null {
|
||||
if (!data) return null;
|
||||
const tz = data.timezone;
|
||||
@@ -60,8 +64,6 @@
|
||||
onResetZoom?.();
|
||||
}
|
||||
|
||||
// Range presets: charts show the full week by default, these (or
|
||||
// Ctrl+scroll) narrow the window.
|
||||
const rangePresets = [
|
||||
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
|
||||
{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) },
|
||||
@@ -76,165 +78,46 @@
|
||||
return start == null ? undefined : { start, end: start + SECONDS_PER_DAY };
|
||||
});
|
||||
|
||||
// ─── Series Building ────────────────────────────────────────────────────────
|
||||
// ─── Pictograms (weather icons across the top) ──────────────────────────────
|
||||
|
||||
let tempUnit = $derived(getTempUnit(units));
|
||||
let precipUnit = $derived(getPrecipUnit(units));
|
||||
let windUnit = $derived(getWindUnit(units));
|
||||
|
||||
interface ChartDef {
|
||||
title: string;
|
||||
unit: string;
|
||||
unitRight?: string;
|
||||
yMin?: number;
|
||||
yMinRight?: number;
|
||||
yMaxRight?: number;
|
||||
invertRight?: boolean;
|
||||
showCredit?: boolean;
|
||||
series: ChartSeries[];
|
||||
function isDaytime(tSec: number): boolean {
|
||||
return data.daylightBands.some((b) => tSec >= b.start && tSec < b.end);
|
||||
}
|
||||
|
||||
let chartDefs = $derived.by((): ChartDef[] => {
|
||||
if (!data) return [];
|
||||
|
||||
const { hourly } = data;
|
||||
// Variables the user disabled in the sidebar are left out entirely
|
||||
const on = (key: string): boolean => $storedVariablePrefs.charts?.[key] ?? true;
|
||||
const defs: ChartDef[] = [];
|
||||
|
||||
if (on('temperature') || on('cloud_cover')) {
|
||||
const series: ChartSeries[] = [];
|
||||
if (on('cloud_cover')) {
|
||||
series.push({
|
||||
name: 'Cloud Cover',
|
||||
type: 'line',
|
||||
color: 'rgb(150, 150, 150)',
|
||||
data: hourly.cloud_cover,
|
||||
width: 0,
|
||||
fill: true,
|
||||
fillOpacity: 0.25,
|
||||
axis: 'right',
|
||||
format: (v) => `${v.toFixed(0)}%`
|
||||
});
|
||||
}
|
||||
if (on('temperature')) {
|
||||
series.push({
|
||||
name: 'Temperature',
|
||||
type: 'line',
|
||||
color: '#ef6c00',
|
||||
data: hourly.temperature_2m,
|
||||
width: 3,
|
||||
fill: true,
|
||||
fillOpacity: 0.2,
|
||||
format: (v) => `${v.toFixed(1)} ${tempUnit}`
|
||||
});
|
||||
}
|
||||
defs.push({
|
||||
title: [on('temperature') && 'Temperature', on('cloud_cover') && 'Cloud Cover']
|
||||
.filter(Boolean)
|
||||
.join(' & '),
|
||||
unit: tempUnit,
|
||||
// Hidden inverted right axis (0-250) so cloud cover hangs from the top,
|
||||
// occupying at most the upper 40% of the plot
|
||||
yMinRight: 0,
|
||||
yMaxRight: 250,
|
||||
invertRight: true,
|
||||
series
|
||||
});
|
||||
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)) });
|
||||
}
|
||||
|
||||
if (on('precipitation') || on('precipitation_probability')) {
|
||||
const series: ChartSeries[] = [];
|
||||
if (on('precipitation')) {
|
||||
series.push({
|
||||
name: 'Precipitation',
|
||||
type: 'bar',
|
||||
color: 'rgba(30, 136, 229, 0.8)',
|
||||
data: hourly.precipitation,
|
||||
format: (v) => `${v.toFixed(1)} ${precipUnit}`
|
||||
});
|
||||
}
|
||||
if (on('precipitation_probability')) {
|
||||
series.push({
|
||||
name: 'Precip. Probability',
|
||||
type: 'line',
|
||||
color: '#5c6bc0',
|
||||
data: hourly.precipitation_probability,
|
||||
width: 2,
|
||||
dashed: true,
|
||||
axis: 'right',
|
||||
format: (v) => `${v.toFixed(0)}%`
|
||||
});
|
||||
}
|
||||
defs.push({
|
||||
title: [
|
||||
on('precipitation') && 'Precipitation',
|
||||
on('precipitation_probability') && 'Probability'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' & '),
|
||||
unit: precipUnit,
|
||||
unitRight: on('precipitation_probability') ? '%' : undefined,
|
||||
yMin: 0,
|
||||
yMinRight: 0,
|
||||
yMaxRight: 100,
|
||||
series
|
||||
});
|
||||
}
|
||||
|
||||
if (on('wind') || on('humidity')) {
|
||||
const series: ChartSeries[] = [];
|
||||
if (on('wind')) {
|
||||
series.push({
|
||||
name: 'Wind Speed',
|
||||
type: 'line',
|
||||
color: '#26a69a',
|
||||
data: hourly.windspeed_10m,
|
||||
width: 2,
|
||||
fill: true,
|
||||
fillOpacity: 0.15,
|
||||
format: (v, i) => {
|
||||
const dir = hourly.winddirection_10m[i];
|
||||
const dirLabel = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : '';
|
||||
return `${v.toFixed(0)} ${windUnit}${dirLabel}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (on('humidity')) {
|
||||
series.push({
|
||||
name: 'Humidity',
|
||||
type: 'line',
|
||||
color: '#8d6e63',
|
||||
data: hourly.relative_humidity_2m,
|
||||
width: 2,
|
||||
dashed: true,
|
||||
axis: 'right',
|
||||
format: (v) => `${v.toFixed(0)}%`
|
||||
});
|
||||
}
|
||||
defs.push({
|
||||
title: [on('wind') && 'Wind Speed', on('humidity') && 'Humidity']
|
||||
.filter(Boolean)
|
||||
.join(' & '),
|
||||
unit: windUnit,
|
||||
unitRight: on('humidity') ? '%' : undefined,
|
||||
yMin: 0,
|
||||
yMinRight: 0,
|
||||
yMaxRight: 100,
|
||||
series
|
||||
});
|
||||
}
|
||||
|
||||
if (defs.length > 0) defs[defs.length - 1].showCredit = true;
|
||||
|
||||
return defs;
|
||||
return out;
|
||||
});
|
||||
|
||||
// ─── 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 };
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="mt-8" in:fade={{ duration: 200 }}>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="text-lg font-bold">
|
||||
Meteogram
|
||||
Meteograms
|
||||
<span class="font-semibold text-muted-foreground">
|
||||
– {formatZoned(selectedDay, data.timezone, 'EEEE')}{getRelativeDayLabel(
|
||||
selectedDay,
|
||||
@@ -266,42 +149,73 @@
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if chartDefs.length > 0}
|
||||
<ChartContainer {loading} chartCount={chartDefs.length} chartHeight={300}>
|
||||
{#each chartDefs as def, i (def.title)}
|
||||
<CanvasChart
|
||||
bind:this={chartComponents[i]}
|
||||
timestamps={timestampsSec}
|
||||
timezone={data.timezone}
|
||||
series={def.series}
|
||||
bands={data.daylightBands}
|
||||
highlight={selectedDayHighlight}
|
||||
unit={def.unit}
|
||||
unitRight={def.unitRight}
|
||||
yMin={def.yMin}
|
||||
yMinRight={def.yMinRight}
|
||||
yMaxRight={def.yMaxRight}
|
||||
invertRight={def.invertRight}
|
||||
title={def.title}
|
||||
showCredit={def.showCredit}
|
||||
showLegend
|
||||
height={300}
|
||||
group={CHART_GROUP}
|
||||
/>
|
||||
{#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}
|
||||
<div class="flex flex-col gap-6">
|
||||
{#each renderPanels as panel, i (panel.id)}
|
||||
<div class="rounded-2xl border border-border/70 bg-card p-3 shadow-sm md:p-4">
|
||||
<div class="mb-1 flex items-center justify-between px-1">
|
||||
<h4 class="truncate text-sm font-bold text-muted-foreground">
|
||||
<span class="hidden md:inline">{panel.title}</span>
|
||||
<span class="md:hidden">{panel.titleShort}</span>
|
||||
</h4>
|
||||
</div>
|
||||
<ChartContainer {loading} chartCount={1} chartHeight={CHART_HEIGHT} minWidth={520}>
|
||||
<CanvasChart
|
||||
bind:this={chartComponents[i]}
|
||||
timestamps={timestampsSec}
|
||||
timezone={data.timezone}
|
||||
series={panel.def.series}
|
||||
bands={data.daylightBands}
|
||||
pictograms={panel.def.hasPictograms ? pictograms : []}
|
||||
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}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
All chart variables are hidden — enable some under “Variables”.
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<ChartCustomizer open={customizerOpen} onClose={() => (customizerOpen = false)} />
|
||||
|
||||
Reference in New Issue
Block a user