revamp weather UI: cards, meteograms, units in header, location favorites and range controls
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
import { storedEnsembleModel, storedLocation } from '$lib/stores/settings';
|
||||
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
|
||||
|
||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
@@ -46,10 +46,25 @@
|
||||
|
||||
let params = $state({
|
||||
...defaultParameters,
|
||||
hourly: ['temperature_2m'],
|
||||
hourly: [
|
||||
'temperature_2m',
|
||||
'precipitation',
|
||||
'wind_speed_10m',
|
||||
'relative_humidity_2m',
|
||||
'cloud_cover',
|
||||
'pressure_msl'
|
||||
],
|
||||
models: ['ncep_gefs_seamless']
|
||||
});
|
||||
|
||||
// units live in a persisted store; mirror them into params so a change
|
||||
// re-runs the fetch effect (which reads params.*_unit)
|
||||
$effect(() => {
|
||||
params.temperature_unit = $storedUnits.temperature_unit;
|
||||
params.wind_speed_unit = $storedUnits.wind_speed_unit;
|
||||
params.precipitation_unit = $storedUnits.precipitation_unit;
|
||||
});
|
||||
|
||||
// ─── Cached API Response ────────────────────────────────────────────────────
|
||||
|
||||
interface FetchedData {
|
||||
@@ -119,9 +134,24 @@
|
||||
|
||||
// ─── Chart Building (runs when fetchedData or the variable list changes) ────
|
||||
|
||||
// Ensemble members stop at the model's horizon; past it the service collapses
|
||||
// every value to 0 (min = max = mean = 0). 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 => {
|
||||
if (!fetchedData) return 0;
|
||||
const temp = fetchedData.ensembleResult.variables['temperature_2m'];
|
||||
const n = fetchedData.timestamps.length;
|
||||
if (!temp) return n;
|
||||
let last = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (!(temp.max[i] === 0 && temp.min[i] === 0 && temp.average[i] === 0)) last = i + 1;
|
||||
}
|
||||
return last || n;
|
||||
});
|
||||
|
||||
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
|
||||
let timestampsSec = $derived.by(() =>
|
||||
fetchedData ? fetchedData.timestamps.map((t) => t / 1000) : []
|
||||
fetchedData ? fetchedData.timestamps.slice(0, validLength).map((t) => t / 1000) : []
|
||||
);
|
||||
|
||||
interface ChartDef {
|
||||
@@ -134,6 +164,23 @@
|
||||
|
||||
const BAND_COLOR = 'rgba(115, 192, 222, 0.9)';
|
||||
|
||||
// Human labels for the plotted ensemble variables (API names → readable title)
|
||||
const VAR_LABELS: Record<string, string> = {
|
||||
temperature_2m: 'Temperature',
|
||||
apparent_temperature: 'Feels like',
|
||||
precipitation: 'Precipitation',
|
||||
rain: 'Rain',
|
||||
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)',
|
||||
dew_point_2m: 'Dew point'
|
||||
};
|
||||
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 [];
|
||||
|
||||
@@ -186,10 +233,11 @@
|
||||
const isLast = vi === variables.length - 1;
|
||||
|
||||
defs.push({
|
||||
title: isFirst ? 'Ensemble Spread' : undefined,
|
||||
// each chart is labelled so the variable is obvious at a glance
|
||||
title: `${varLabel(variable)}${isColumn ? '' : ' spread'}`,
|
||||
subtitle: isFirst
|
||||
? `${variable} min/mean/max across ${memberCount} members (${params.models?.[0] ?? ''})`
|
||||
: undefined,
|
||||
? `min · mean · max across ${memberCount} ensemble members`
|
||||
: `min · mean · max (${unit})`,
|
||||
unit,
|
||||
showCredit: isLast,
|
||||
series
|
||||
@@ -214,23 +262,25 @@
|
||||
{location.name}
|
||||
</h1>
|
||||
<p class="truncate text-sm text-muted-foreground">
|
||||
{#if location.admin1}{location.admin1},
|
||||
{/if}{location.country ?? ''}
|
||||
<span class="mx-1 opacity-50">·</span>
|
||||
14-day ensemble forecast
|
||||
<span class="lg:hidden"
|
||||
>{#if location.admin1}{location.admin1},
|
||||
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
||||
>14-day ensemble forecast
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
||||
groups={ensembleModelGroups}
|
||||
label="Ensemble model"
|
||||
onModelChange={(model) => {
|
||||
params.models = [model];
|
||||
storedEnsembleModel.set(model);
|
||||
}}
|
||||
/>
|
||||
<div class="flex w-full items-center gap-3 sm:w-auto">
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
||||
groups={ensembleModelGroups}
|
||||
label="Ensemble model"
|
||||
onModelChange={(model) => {
|
||||
params.models = [model];
|
||||
storedEnsembleModel.set(model);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||
@@ -269,9 +319,9 @@
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={liveCharts} fileName="14-day-forecast">
|
||||
{#snippet controls()}
|
||||
<div class="flex gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
|
||||
<Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
|
||||
Reference in New Issue
Block a user