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 + @@ -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