diff --git a/src/lib/charts/CanvasChart.svelte b/src/lib/charts/CanvasChart.svelte
index 9c190d1..a6ff9de 100644
--- a/src/lib/charts/CanvasChart.svelte
+++ b/src/lib/charts/CanvasChart.svelte
@@ -31,6 +31,9 @@
width?: number;
/** Draw a low-alpha area fill below (or above, on inverted axes) the line */
fill?: boolean;
+ /** Area fill coloured by the value scale (segmentColor), fading to
+ * transparent ~20px below the series minimum. */
+ gradientFill?: boolean;
/** With `fill`, fill the area between this line and another data array
* instead of the baseline (e.g. an ensemble min-max band) */
bandTo?: (number | null)[];
@@ -50,6 +53,9 @@
shortName?: string;
/** Colour each line segment by value (e.g. a temperature colour scale) */
segmentColor?: (value: number, index: number) => string;
+ /** Draw the line itself in the theme foreground (black/white), ignoring
+ * segmentColor for the stroke (segmentColor still colours any fill) */
+ foregroundLine?: boolean;
/** Draw a contrasting halo (black in light mode, white in dark) under the line */
outline?: boolean;
/** Annotate local minima / maxima with their value */
@@ -101,6 +107,14 @@
export function groupRange(name: string): { start: number; end: number } | null {
return groups[name]?.range ?? null;
}
+
+ /**
+ * Current shared crosshair time of a group (epoch seconds, or null), reactive.
+ * Lets outside UI (e.g. the hourly table) mirror the chart's hovered timestep.
+ */
+ export function groupHover(name: string): number | null {
+ return groups[name]?.hover ?? null;
+ }
{#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
@@ -324,7 +408,7 @@
@@ -440,7 +524,7 @@
{#each cellData as cell, i (cell.idx)}
{@const wCode = hourly.weather_code[cell.idx]}
{formatTemp(temp)}
@@ -471,13 +555,30 @@
{/if}
-
+
{#if showRow('feels')}
{@render rowHeader(undefined, tempUnit, 'Feels')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.apparent_temperature[cell.idx]}
-
+
+ {formatTemp(temp)}
+
+ {/each}
+
+ {/if}
+
+
+ {#if showRow('dew_point')}
+
+ {@render rowHeader('wi-raindrop', tempUnit, 'Dew')}
+ {#each cellData as cell (cell.idx)}
+ {@const temp = hourly.dew_point_2m?.[cell.idx]}
+
{formatTemp(temp)}
{/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]}
-
+
{#if windDir != null && !isNaN(windDir)}
- {@render weatherIcon('wi-direction-down', 22)}
+ {@render weatherIcon('wi-direction-down', 40)}
{/if}
-
+
{formatValue(wind)}
@@ -508,13 +609,30 @@
{/if}
-
+
+ {#if showRow('gusts')}
+
+ {@render rowHeader('wi-strong-wind', windUnit, 'Gusts')}
+ {#each cellData as cell (cell.idx)}
+ {@const v = hourly.wind_gusts_10m?.[cell.idx]}
+
+ {formatValue(v)}
+
+ {/each}
+
+ {/if}
+
+
{#if showRow('humidity')}
{@render rowHeader('wi-humidity', '%', 'Humidity')}
{#each cellData as cell (cell.idx)}
{@const hum = hourly.relative_humidity_2m[cell.idx]}
-
+
{formatValue(hum)}
{/each}
@@ -528,7 +646,7 @@
{#each cellData as cell (cell.idx)}
{@const cloud = hourly.cloud_cover[cell.idx]}
{formatValue(cloud)}
@@ -537,6 +655,51 @@
{/if}
+
+ {#if showRow('pressure')}
+
+ {@render rowHeader('wi-barometer', 'hPa', 'Pressure')}
+ {#each cellData as cell (cell.idx)}
+ {@const v = hourly.pressure_msl?.[cell.idx]}
+
+ {v != null && !isNaN(v) ? v.toFixed(0) : '-'}
+
+ {/each}
+
+ {/if}
+
+
+ {#if showRow('uv')}
+
+ {@render rowHeader('wi-day-sunny', 'UV', 'UV')}
+ {#each cellData as cell (cell.idx)}
+ {@const v = hourly.uv_index?.[cell.idx]}
+
+ {v != null && !isNaN(v) ? v.toFixed(0) : '-'}
+
+ {/each}
+
+ {/if}
+
+
+ {#if showRow('visibility')}
+
+ {@render rowHeader('wi-fog', 'km', 'Visibility')}
+ {#each cellData as cell (cell.idx)}
+ {@const v = hourly.visibility?.[cell.idx]}
+
+ {v != null && !isNaN(v) ? (v / 1000).toFixed(0) : '-'}
+
+ {/each}
+
+ {/if}
+
{#if showRow('precipitation')}
@@ -545,7 +708,7 @@
{@const precip = hourly.precipitation[cell.idx]}
{@const prob = hourly.precipitation_probability[cell.idx]}
@@ -559,9 +722,31 @@
{/each}
{/if}
+
+
+ {#if showRow('snowfall')}
+
+ {@render rowHeader('wi-snow', 'cm', 'Snow')}
+ {#each cellData as cell (cell.idx)}
+ {@const v = hourly.snowfall?.[cell.idx]}
+
+ {v != null && !isNaN(v) && v > 0 ? v.toFixed(1) : '-'}
+
+ {/each}
+
+ {/if}
+
+ {#if hoveredCol >= 0 && tableWidth > 0}
+ {@const colWidth = (tableWidth - headerColWidth) / cellData.length}
+
+ {/if}
+
{#if nowLeftPx != null}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
diff --git a/src/routes/weather/week/[location]/MeteogramCharts.svelte b/src/routes/weather/week/[location]/MeteogramCharts.svelte
index ed86ba3..b0d1d43 100644
--- a/src/routes/weather/week/[location]/MeteogramCharts.svelte
+++ b/src/routes/weather/week/[location]/MeteogramCharts.svelte
@@ -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 {
+ 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 @@
Customize
+
+ {#if downloadingPng}
+
+
+
+ {:else}
+
+
+
+
+
+ {/if}
+ PNG
+
@@ -286,10 +331,6 @@
{/each}
-
-
-
-
{/if}
diff --git a/src/routes/weather/week/[location]/VariableSidebar.svelte b/src/routes/weather/week/[location]/VariableSidebar.svelte
index effd820..23555bf 100644
--- a/src/routes/weather/week/[location]/VariableSidebar.svelte
+++ b/src/routes/weather/week/[location]/VariableSidebar.svelte
@@ -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) {
diff --git a/src/routes/weather/week/[location]/variables.ts b/src/routes/weather/week/[location]/variables.ts
index 42dd10f..31bca07 100644
--- a/src/routes/weather/week/[location]/variables.ts
+++ b/src/routes/weather/week/[location]/variables.ts
@@ -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 | 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();
// 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