temperature chart and table alignments

This commit is contained in:
Vincent van der Wal
2026-07-25 12:43:41 +02:00
parent d78ac84a12
commit cd98b7a5cc
16 changed files with 621 additions and 133 deletions
@@ -165,6 +165,21 @@
unit: string;
showCredit: boolean;
series: ChartSeries[];
zeroBaseLeft?: boolean;
yMin?: number;
yMax?: number;
}
// Sensible axis behaviour per variable so the y-scale stays readable (e.g.
// pressure never anchored to zero; percentages pinned to 0-100).
function axisForVar(v: string): { zeroBaseLeft: boolean; yMin?: number; yMax?: number } {
if (['pressure_msl', 'surface_pressure', 'temperature_2m', 'dew_point_2m'].includes(v)) {
return { zeroBaseLeft: false };
}
if (['relative_humidity_2m', 'cloud_cover', 'precipitation_probability'].includes(v)) {
return { zeroBaseLeft: true, yMin: 0, yMax: 100 };
}
return { zeroBaseLeft: true };
}
const BAND_COLOR = 'rgba(115, 192, 222, 0.9)';
@@ -202,33 +217,40 @@
const isColumn = isColumnUnit(unit);
const memberCount = varData.members.length;
// Trim to the valid horizon so the axis scale ignores the trailing
// zeros the service pads past the model's range.
const vMax = varData.max.slice(0, validLength);
const vMin = varData.min.slice(0, validLength);
const vAvg = varData.average.slice(0, validLength);
// Min/max spread band + mean, instead of every individual member
const series: ChartSeries[] = [
{
name: 'Max',
type: 'line',
color: BAND_COLOR,
data: varData.max,
data: vMax,
width: 1,
fill: true,
fillOpacity: 0.25,
bandTo: varData.min,
bandTo: vMin,
format: (v) => `${v.toFixed(1)} ${unit}`
},
{
name: 'Mean',
type: isColumn ? 'bar' : 'line',
color: CHART_COLORS.average,
data: varData.average,
width: 3,
data: vAvg,
width: 3.5,
dashed: !isColumn,
outline: !isColumn,
format: (v) => `${v.toFixed(1)} ${unit}`
},
{
name: 'Min',
type: 'line',
color: BAND_COLOR,
data: varData.min,
data: vMin,
width: 1,
format: (v) => `${v.toFixed(1)} ${unit}`
}
@@ -237,6 +259,7 @@
const isFirst = vi === 0;
const isLast = vi === variables.length - 1;
const axis = axisForVar(variable);
defs.push({
// each chart is labelled so the variable is obvious at a glance
title: `${varLabel(variable)}${isColumn ? '' : ' spread'}`,
@@ -245,7 +268,10 @@
: `min · mean · max (${unit})`,
unit,
showCredit: isLast,
series
series,
zeroBaseLeft: axis.zeroBaseLeft,
yMin: axis.yMin,
yMax: axis.yMax
});
}
@@ -255,7 +281,7 @@
<!-- ─── Page hero: location + ensemble model selection ─────────────────────── -->
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<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"
@@ -275,7 +301,7 @@
</div>
</div>
<div class="flex w-full items-center gap-3 sm:w-auto">
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
groups={ensembleModelGroups}
@@ -322,7 +348,7 @@
</div>
{/if}
<ChartContainer {loading} chartCount={params.hourly?.length || 1} chartHeight={300}>
<ChartContainer {loading} chartCount={params.hourly?.length || 1} chartHeight={300} bleed={false}>
{#if fetchedData}
{#each chartDefs as def, i (i)}
<CanvasChart
@@ -335,6 +361,9 @@
title={def.title}
subtitle={def.subtitle}
showCredit={def.showCredit}
zeroBaseLeft={def.zeroBaseLeft ?? true}
yMin={def.yMin}
yMax={def.yMax}
{showLegend}
height={300}
group={CHART_GROUP}
@@ -257,7 +257,8 @@
color: CHART_COLORS.average,
data: average,
width: 4,
dashed: !isColumn
dashed: !isColumn,
outline: !isColumn
});
const isFirst = vi === 0;
@@ -281,7 +282,7 @@
<!-- ─── Page hero: location (matches the other forecast pages) ──────────────── -->
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<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"
@@ -302,7 +303,7 @@
</div>
<!-- Range / zoom controls, aligned with the title like the other pages -->
<div class="flex flex-wrap items-center gap-3">
<div class="lg:absolute lg:right-0 lg:top-20 z-40 flex flex-wrap items-center gap-3">
<span class="hidden text-xs text-muted-foreground lg:inline">
drag or
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd
@@ -362,6 +363,7 @@
{loading}
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
chartHeight={300}
bleed={false}
>
{#if fetchedData}
{#each chartDefs as def, i (i)}
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import {
storedChartLayout,
@@ -152,7 +153,7 @@
<div class="week-page">
<div class="weather-content" style="min-height: 50vh">
<!-- Page hero: prominent location + weather model selection -->
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<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"
@@ -174,7 +175,7 @@
</div>
</div>
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto">
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
@@ -220,14 +221,17 @@
/>
{:else}
<!-- placeholder with the table's approximate height: no layout shift -->
<div class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"></div>
<div
transition:fade={{ duration: 200 }}
class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"
></div>
{/if}
{#if fetchedHourly}
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
{:else}
<!-- reserve the exact chart area height before the first fetch resolves -->
<section class="mt-8">
<section class="mt-8" transition:fade={{ duration: 200 }}>
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
</section>
{/if}
@@ -119,13 +119,13 @@
</defs>
</svg>
<div transition:fade={{ duration: 200 }} class="mb-6 min-h-[260px]">
<div transition:fade={{ duration: 200 }} class="mb-1 min-h-47.5 md:mb-6 md:min-h-65">
<!-- negative margin + matching padding: the scroll box gains room so a
lifted/scaled/shadowed card is never clipped, while the first card still
lines up with the page content edge -->
<div
bind:this={scrollEl}
class="day-scroll -mx-3 flex gap-2 overflow-x-auto px-3 pt-5 pb-11"
class="day-scroll -mx-3 flex gap-2 overflow-x-auto px-3 pt-2 pb-3 md:pt-5 md:pb-11"
class:scrolling
onscroll={onScroll}
>
@@ -187,7 +187,7 @@
<button
class="day-card group relative flex w-35 shrink-0 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200 ease-out will-change-transform motion-reduce:transition-none
{selected
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-xl ring-2 ring-primary/60'
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-[0_5px_14px_-4px_rgba(0,0,0,0.4)] ring-2 ring-primary/60 md:shadow-xl'
: 'border-border/60 bg-card shadow-xs hover:z-10 hover:-translate-y-1 hover:scale-[1.02] hover:border-border hover:shadow-lg'}"
aria-pressed={selected}
onclick={() => onSelectDay(time, index)}
@@ -1,11 +1,15 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { storedVariablePrefs } from '$lib/stores/settings';
import {
defaultVariablePrefs,
storedHourlyInterval,
storedVariablePrefs
} from '$lib/stores/settings';
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
import { setGroupHover } from '$lib/charts';
import { groupHover, setGroupHover } from '$lib/charts';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
@@ -53,11 +57,15 @@
setGroupHover(METEOGRAM_GROUP, null);
}
let hourlyInterval = $state<1 | 3>(3);
// persisted 1h / 3h preference
let hourlyInterval = $derived($storedHourlyInterval);
// Row visibility, controlled from the Variables sidebar (missing keys
// from older stored prefs default to visible)
let showRow = $derived((key: string): boolean => $storedVariablePrefs.table?.[key] ?? true);
let showRow = $derived(
(key: string): boolean =>
$storedVariablePrefs.table?.[key] ?? defaultVariablePrefs.table[key] ?? true
);
const today = new Date();
const tempUnit = $derived(getTempUnit(units));
@@ -152,6 +160,67 @@
return `rgba(0, 180, 200, ${hum ** 3.5 / 10 ** 7.8})`;
}
// ─── Maps-project colour ramps (open-meteo/maps) ────────────────────────────
// Same breakpoint colours the weather maps use, applied as cell backgrounds.
function hexRgb(hex: string): [number, number, number] {
const h = hex.replace('#', '');
const n =
h.length === 3
? h
.split('')
.map((c) => c + c)
.join('')
: h;
return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
}
function mixRgb(
a: [number, number, number],
b: [number, number, number],
f: number
): [number, number, number] {
return [
Math.round(a[0] + (b[0] - a[0]) * f),
Math.round(a[1] + (b[1] - a[1]) * f),
Math.round(a[2] + (b[2] - a[2]) * f)
];
}
// Pressure: 940 hPa blue → 1010 white → 1060 red.
const PRESSURE_LOW = hexRgb('#4444ff');
const PRESSURE_MID = hexRgb('#ffffff');
const PRESSURE_HIGH = hexRgb('#ff4444');
function getPressureBg(hpa: number | null): string {
if (hpa == null || isNaN(hpa)) return 'transparent';
const v = Math.max(940, Math.min(1060, hpa));
const c =
v <= 1010
? mixRgb(PRESSURE_LOW, PRESSURE_MID, (v - 940) / 70)
: mixRgb(PRESSURE_MID, PRESSURE_HIGH, (v - 1010) / 50);
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0.5)`;
}
// UV index: 0 → 12 across the maps' teal→green→yellow→orange→pink ramp. The
// opacity tracks the value so low/night hours stay faint instead of tinting
// the whole row.
const UV_STOPS = [
'#009392',
'#39b185',
'#9ccb86',
'#e9e29c',
'#eeb479',
'#e88471',
'#cf597e'
].map(hexRgb);
function getUvBg(uv: number | null): string {
if (uv == null || isNaN(uv) || uv <= 0) return 'transparent';
const v = Math.min(12, uv);
const p = (v / 12) * (UV_STOPS.length - 1);
const i = Math.min(UV_STOPS.length - 2, Math.floor(p));
const c = mixRgb(UV_STOPS[i], UV_STOPS[i + 1], p - i);
const alpha = 0.18 + (v / 12) * 0.5;
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${alpha.toFixed(3)})`;
}
function formatPrecipTooltip(precip: number | null, prob: number | null): string {
const parts: string[] = [];
if (prob != null && prob > 0) parts.push(`Probability: ${prob}%`);
@@ -211,6 +280,21 @@
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
: null
);
// ─── Chart-hover mirror ─────────────────────────────────────────────────────
// When the shared meteogram is hovered, highlight the matching table column
// (only if the hovered time falls on the day the table is currently showing).
let chartHoverTime = $derived(groupHover(METEOGRAM_GROUP)); // epoch seconds, or null
let hoveredCol = $derived.by((): number => {
if (chartHoverTime == null || cellData.length === 0) return -1;
const stepMs = (is3h ? 3 : 1) * 3600 * 1000;
const tMs = chartHoverTime * 1000;
for (let i = 0; i < cellData.length; i++) {
const start = cellData[i].date.getTime();
if (tMs >= start && tMs < start + stepMs) return i;
}
return -1;
});
</script>
{#snippet weatherIcon(name: string, size: number = 16)}
@@ -292,7 +376,7 @@
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={hourlyInterval === interval}
onclick={() => (hourlyInterval = interval as 1 | 3)}
onclick={() => storedHourlyInterval.set(interval as 1 | 3)}
>
{interval}h
</button>
@@ -324,7 +408,7 @@
</th>
<td
colspan={cellData.length}
class="relative h-11 overflow-visible p-0"
class="relative h-12 overflow-visible p-0"
onmousemove={hoverTimeRow}
onmouseleave={clearTimeRowHover}
>
@@ -440,7 +524,7 @@
{#each cellData as cell, i (cell.idx)}
{@const wCode = hourly.weather_code[cell.idx]}
<td
class="cell leading-0 {is3h ? 'h-14' : 'h-11'}"
class="cell h-12 leading-0"
class:icon-day={cell.isDaytime}
class:icon-night={!cell.isDaytime}
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
@@ -462,7 +546,7 @@
{@const temp = hourly.temperature_2m[cell.idx]}
{@const style = getTempStyle(temp, String(units.temperature_unit))}
<td
class="cell h-10 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
class="cell h-12 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
style="background-color:{style.bg};color:{style.fg}"
>
{formatTemp(temp)}
@@ -471,13 +555,30 @@
</tr>
{/if}
<!-- Feels Like -->
<!-- Feels Like (short row) -->
{#if showRow('feels')}
<tr class="row">
{@render rowHeader(undefined, tempUnit, 'Feels')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.apparent_temperature[cell.idx]}
<td class="cell h-8 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}">
<td
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
>
{formatTemp(temp)}
</td>
{/each}
</tr>
{/if}
<!-- Dew Point (short row; off by default) -->
{#if showRow('dew_point')}
<tr class="row">
{@render rowHeader('wi-raindrop', tempUnit, 'Dew')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.dew_point_2m?.[cell.idx]}
<td
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
>
{formatTemp(temp)}
</td>
{/each}
@@ -491,16 +592,16 @@
{#each cellData as cell (cell.idx)}
{@const wind = hourly.windspeed_10m[cell.idx]}
{@const windDir = hourly.winddirection_10m[cell.idx]}
<td class="cell h-13 align-middle leading-tight">
<td class="relative cell h-12 align-middle leading-none">
{#if windDir != null && !isNaN(windDir)}
<span
class="inline-block leading-0"
style="transform:{getWindArrowRotation(windDir)}"
class="absolute top-0 left-1/2 inline-block origin-center leading-0"
style="transform: translateX(-50%) {getWindArrowRotation(windDir)}"
>
{@render weatherIcon('wi-direction-down', 22)}
{@render weatherIcon('wi-direction-down', 40)}
</span>
{/if}
<span class="block font-semibold {is3h ? 'text-sm' : 'text-xs'}">
<span class="mt-5 block font-semibold {is3h ? 'text-[13px]' : 'text-[11px]'}">
{formatValue(wind)}
</span>
</td>
@@ -508,13 +609,30 @@
</tr>
{/if}
<!-- Humidity -->
<!-- Wind Gusts (off by default) -->
{#if showRow('gusts')}
<tr class="row">
{@render rowHeader('wi-strong-wind', windUnit, 'Gusts')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.wind_gusts_10m?.[cell.idx]}
<td
class="cell h-12 font-semibold text-foreground/80 {is3h
? 'text-sm'
: 'text-xs'}"
>
{formatValue(v)}
</td>
{/each}
</tr>
{/if}
<!-- Humidity (short row) -->
{#if showRow('humidity')}
<tr class="row">
{@render rowHeader('wi-humidity', '%', 'Humidity')}
{#each cellData as cell (cell.idx)}
{@const hum = hourly.relative_humidity_2m[cell.idx]}
<td class="cell h-8" style="background:{getHumidityBg(hum ?? 0)}">
<td class="cell h-12" style="background:{getHumidityBg(hum ?? 0)}">
{formatValue(hum)}
</td>
{/each}
@@ -528,7 +646,7 @@
{#each cellData as cell (cell.idx)}
{@const cloud = hourly.cloud_cover[cell.idx]}
<td
class="cell h-8"
class="cell h-12"
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
>
{formatValue(cloud)}
@@ -537,6 +655,51 @@
</tr>
{/if}
<!-- Pressure (off by default) -->
{#if showRow('pressure')}
<tr class="row">
{@render rowHeader('wi-barometer', 'hPa', 'Pressure')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.pressure_msl?.[cell.idx]}
<td
class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"
style="background:{getPressureBg(v ?? null)}"
>
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
</td>
{/each}
</tr>
{/if}
<!-- UV Index (off by default) -->
{#if showRow('uv')}
<tr class="row">
{@render rowHeader('wi-day-sunny', 'UV', 'UV')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.uv_index?.[cell.idx]}
<td
class="cell h-12 font-semibold {is3h ? 'text-sm' : 'text-xs'}"
style="background:{getUvBg(v ?? null)}"
>
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
</td>
{/each}
</tr>
{/if}
<!-- Visibility (off by default) -->
{#if showRow('visibility')}
<tr class="row">
{@render rowHeader('wi-fog', 'km', 'Visibility')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.visibility?.[cell.idx]}
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
{v != null && !isNaN(v) ? (v / 1000).toFixed(0) : '-'}
</td>
{/each}
</tr>
{/if}
<!-- Precipitation -->
{#if showRow('precipitation')}
<tr class="row">
@@ -545,7 +708,7 @@
{@const precip = hourly.precipitation[cell.idx]}
{@const prob = hourly.precipitation_probability[cell.idx]}
<td
class="cell precip-cell {is3h ? 'h-14' : 'h-11'}"
class="cell precip-cell h-12"
style="background:{getPrecipProbBg(prob ?? 0)}"
title={formatPrecipTooltip(precip, prob)}
>
@@ -559,9 +722,31 @@
{/each}
</tr>
{/if}
<!-- Snowfall (off by default) -->
{#if showRow('snowfall')}
<tr class="row">
{@render rowHeader('wi-snow', 'cm', 'Snow')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.snowfall?.[cell.idx]}
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
{v != null && !isNaN(v) && v > 0 ? v.toFixed(1) : '-'}
</td>
{/each}
</tr>
{/if}
</tbody>
</table>
<!-- Hovered-column highlight, mirroring the meteogram crosshair -->
{#if hoveredCol >= 0 && tableWidth > 0}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
<div
class="pointer-events-none absolute inset-y-0 z-10 border-x border-primary/40 bg-primary/10"
style="left:{headerColWidth + hoveredCol * colWidth}px;width:{colWidth}px"
></div>
{/if}
<!-- "Now" column highlight + exact-time line -->
{#if nowLeftPx != null}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
@@ -5,7 +5,7 @@
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
import { CanvasChart, groupRange } from '$lib/charts';
@@ -29,6 +29,17 @@
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 {
await downloadChartsPng(liveCharts, 'week-forecast');
} finally {
setTimeout(() => (downloadingPng = false), 500);
}
}
// Charts persist across data refetches; entries are null while unmounted.
let chartComponents: (CanvasChart | null)[] = $state([]);
@@ -224,6 +235,40 @@
</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>
@@ -286,10 +331,6 @@
</div>
{/each}
</div>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
</div>
{/if}
</section>
@@ -17,10 +17,16 @@
{ key: 'icons', label: 'Weather icons' },
{ key: 'temperature', label: 'Temperature' },
{ key: 'feels', label: 'Feels like' },
{ key: 'dew_point', label: 'Dew point' },
{ key: 'wind', label: 'Wind' },
{ key: 'gusts', label: 'Wind gusts' },
{ key: 'humidity', label: 'Humidity' },
{ key: 'clouds', label: 'Cloud cover' },
{ key: 'precipitation', label: 'Precipitation' }
{ key: 'pressure', label: 'Pressure' },
{ key: 'uv', label: 'UV index' },
{ key: 'visibility', label: 'Visibility' },
{ key: 'precipitation', label: 'Precipitation' },
{ key: 'snowfall', label: 'Snowfall' }
];
function toggle(section: 'table' | 'charts', key: string) {
@@ -4,6 +4,8 @@
* (data field, render style, colour, unit family) so the panels can be
* assembled dynamically from a user-defined layout.
*/
import { defaultVariablePrefs } from '$lib/stores/settings';
import { getColor } from '../../utils/colors';
import {
type WeatherUnits,
@@ -36,9 +38,14 @@ export interface ChartVariableDef {
dashed?: boolean;
fill?: boolean;
fillOpacity?: number;
/** Area fill coloured by the value scale, fading out below the minimum */
gradientFill?: boolean;
width?: number;
/** Stroke the line coloured by the temperature scale */
colorScale?: boolean;
/** Draw the line in the theme foreground (black/white), while colorScale still
* drives the gradient fill */
foregroundLine?: boolean;
/** Draw a contrasting halo under the line */
outline?: boolean;
/** Annotate local minima / maxima with their value */
@@ -66,9 +73,10 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
type: 'line',
kind: 'temp',
color: '#ef6c00',
width: 9,
width: 5.6,
colorScale: true,
outline: true,
foregroundLine: true,
gradientFill: true,
extrema: true
},
{
@@ -312,23 +320,29 @@ export function neededHourlyApiVars(
tablePrefs: Record<string, boolean> | undefined,
layoutKeys: string[]
): string[] {
const on = (key: string): boolean => tablePrefs?.[key] ?? true;
const on = (key: string): boolean => tablePrefs?.[key] ?? defaultVariablePrefs.table[key] ?? true;
const s = new Set<string>();
// Hourly table rows
if (on('icons')) s.add('weather_code');
if (on('temperature')) s.add('temperature_2m');
if (on('feels')) s.add('apparent_temperature');
if (on('dew_point')) s.add('dew_point_2m');
if (on('wind')) {
s.add('wind_speed_10m');
s.add('wind_direction_10m');
}
if (on('gusts')) s.add('wind_gusts_10m');
if (on('humidity')) s.add('relative_humidity_2m');
if (on('clouds')) s.add('cloud_cover');
if (on('pressure')) s.add('pressure_msl');
if (on('uv')) s.add('uv_index');
if (on('visibility')) s.add('visibility');
if (on('precipitation')) {
s.add('precipitation');
s.add('precipitation_probability');
}
if (on('snowfall')) s.add('snowfall');
// Meteogram variables
for (const key of layoutKeys) {
@@ -454,10 +468,12 @@ export function buildPanelDef(
width: d.width,
fill: d.fill,
fillOpacity: d.fillOpacity,
gradientFill: d.gradientFill,
dashed: d.dashed,
axis,
cloudBand: d.cloudBand,
segmentColor: d.colorScale ? (v: number) => getColor(v, units.temperature_unit) : undefined,
foregroundLine: d.foregroundLine,
outline: d.outline,
labelExtrema: d.extrema,
labelFormat: d.extrema