From b4e509f9cb86442fb38c9589a223b13a85f08a6a Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 25 Jul 2026 09:53:40 +0200 Subject: [PATCH] revamp weather UI: cards, meteograms, units in header, location favorites and range controls --- src/lib/charts/CanvasChart.svelte | 187 +++++++++++--- src/lib/charts/index.ts | 2 +- .../components/charts/ChartContainer.svelte | 34 ++- .../location/location-search.svelte | 174 +++++++++---- src/lib/components/navigation/header.svelte | 23 +- .../components/navigation/weather-nav.svelte | 54 ++-- src/lib/components/unit-selector.svelte | 95 +++++++ src/lib/stores/settings.ts | 26 +- src/lib/utils/location.ts | 5 +- src/routes/+layout.svelte | 8 +- .../weather/14-day/[location]/+page.svelte | 92 +++++-- .../weather/compare/[location]/+page.svelte | 14 +- src/routes/weather/utils/weather-codes.ts | 21 +- .../weather/week/[location]/+page.svelte | 61 +++-- .../weather/week/[location]/DailyCards.svelte | 239 +++++++++++++++--- .../week/[location]/HourlyTable.svelte | 127 +++++++--- .../week/[location]/MeteogramCharts.svelte | 81 +++++- .../weather/week/[location]/variables.ts | 55 +++- 18 files changed, 1023 insertions(+), 275 deletions(-) create mode 100644 src/lib/components/unit-selector.svelte diff --git a/src/lib/charts/CanvasChart.svelte b/src/lib/charts/CanvasChart.svelte index 0962550..50aac1d 100644 --- a/src/lib/charts/CanvasChart.svelte +++ b/src/lib/charts/CanvasChart.svelte @@ -86,6 +86,21 @@ delete groups[name]; } } + + /** + * Drive the shared crosshair of a chart group from the outside (e.g. hovering + * the hourly table). `time` is epoch seconds, or null to clear. No-op if no + * chart in that group is currently mounted. + */ + export function setGroupHover(name: string, time: number | null): void { + const state = groups[name]; + if (state) state.hover = time; + } + + /** Current shared zoom range of a group (null = full range), reactive. */ + export function groupRange(name: string): { start: number; end: number } | null { + return groups[name]?.range ?? null; + } -
+
@@ -100,18 +103,41 @@ .chart-bleed { /* Bleed exactly into the page padding on mobile (main has p-5 = 1.25rem) for edge-to-edge charts, and a bit past the content - column on md+ (main has 2rem padding) for extra readability. - Charts narrower than their min-width scroll sideways. */ + column on md+ (main has 2rem padding) for extra readability. */ margin-left: -1.25rem; margin-right: -1.25rem; overflow-x: auto; } + .chart-bleed.no-bleed { + margin-left: 0; + margin-right: 0; + } + + .chart-container { + min-width: var(--chart-min-width); + } + + /* Mobile: fit the chart to the viewport instead of forcing a min-width + sideways scroll (which fights touch inspection). Pinch to zoom for detail. */ + @media (max-width: 767px) { + .chart-container { + min-width: 0; + } + .chart-bleed { + overflow-x: hidden; + } + } + @media (min-width: 768px) { .chart-bleed { margin-left: -1.5rem; margin-right: -1.5rem; } + .chart-bleed.no-bleed { + margin-left: 0; + margin-right: 0; + } } .chart-content { diff --git a/src/lib/components/location/location-search.svelte b/src/lib/components/location/location-search.svelte index cb70699..f757b68 100644 --- a/src/lib/components/location/location-search.svelte +++ b/src/lib/components/location/location-search.svelte @@ -1,7 +1,12 @@ +{#snippet locationRow(location: GeoLocation)} + {@const fav = favKeys.has(locationKey(location))} +
+ + +
+{/snippet} +
{:then results} - {#if results.results && results.results.length === 0} - {#if searchQuery.length < 2} + {#if searchQuery.length < 2} + {#if $storedFavoriteLocations.length > 0 || recentToShow.length > 0} + {#if $storedFavoriteLocations.length > 0} +
+ Favorites +
+
+ {#each $storedFavoriteLocations as loc (locationKey(loc))} + {@render locationRow(loc)} + {/each} +
+ {/if} + {#if recentToShow.length > 0} +
+ Recent +
+
+ {#each recentToShow as loc (locationKey(loc))} + {@render locationRow(loc)} + {/each} +
+ {/if} + {:else}
@@ -200,62 +301,25 @@ Start typing to search or use GPS to detect your position
- {:else} - - - No locations found for "{searchQuery}". Try a different term. - - {/if} - {:else if !results.results} + {:else if results.results && results.results.length > 0} +
+ {#each results.results as location, i (i)} + {@render locationRow(location)} + {/each} +
+ {:else if results.results} + + + No locations found for "{searchQuery}". Try a different term. + + + {:else} No locations found - {:else} -
- {#each results.results || [] as location, i (i)} - - {/each} -
{/if} {:catch error} diff --git a/src/lib/components/navigation/header.svelte b/src/lib/components/navigation/header.svelte index 38e5279..fb33194 100644 --- a/src/lib/components/navigation/header.svelte +++ b/src/lib/components/navigation/header.svelte @@ -10,6 +10,7 @@ import { buildLocationRoute } from '$lib/utils/location'; import LocationSearch from '$lib/components/location/location-search.svelte'; + import UnitSelector from '$lib/components/unit-selector.svelte'; interface Props { onMenuToggle?: () => void; @@ -68,22 +69,23 @@ {#if location} {/if} @@ -100,6 +102,9 @@ />
+ + + -
+ + {#if onToggle} +
+ +
+ {/if} diff --git a/src/routes/weather/week/[location]/HourlyTable.svelte b/src/routes/weather/week/[location]/HourlyTable.svelte index 05060df..8922269 100644 --- a/src/routes/weather/week/[location]/HourlyTable.svelte +++ b/src/routes/weather/week/[location]/HourlyTable.svelte @@ -3,6 +3,8 @@ import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date'; + import { setGroupHover } from '$lib/charts'; + import { getTempStyle } from '../../utils/colors'; import { getWeatherIconName } from '../../utils/weather-codes'; import { @@ -21,9 +23,33 @@ selectedDay: Date; units: WeatherUnits; locationName: string; + /** Opens the variable-customization sidebar (button lives in the header). */ + onCustomize?: () => void; } - let { data, daily, selectedDay, units, locationName }: Props = $props(); + let { data, daily, selectedDay, units, locationName, onCustomize }: Props = $props(); + + // Must match MeteogramCharts' CHART_GROUP so hovering the time row drives the + // meteogram crosshairs. + const METEOGRAM_GROUP = 'week-meteogram'; + + // Scrubbing the time row moves the shared meteogram cursor to the hovered + // time (interpolated across the row so it feels continuous), and clears it on + // leave. + function hoverTimeRow(e: MouseEvent) { + const el = e.currentTarget as HTMLElement; + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || cellData.length === 0) return; + const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); + const stepMs = (is3h ? 3 : 1) * 3600 * 1000; + const first = cellData[0].date.getTime(); + const last = cellData[cellData.length - 1].date.getTime() + stepMs; + setGroupHover(METEOGRAM_GROUP, (first + frac * (last - first)) / 1000); + } + + function clearTimeRowHover() { + setGroupHover(METEOGRAM_GROUP, null); + } let hourlyInterval = $state<1 | 3>(3); @@ -209,7 +235,7 @@ {#if cellData.length > 0} {@const hourly = data.hourly} - {@const iconPx = is3h ? 38 : 26} + {@const iconPx = is3h ? 38 : 33}
-
- {#each [3, 1] as interval (interval)} +
+ {#if onCustomize} - {/each} + {/if} +
+ {#each [3, 1] as interval (interval)} + + {/each} +
@@ -269,7 +319,12 @@ Time - + {#if sunTimes && sunrisePercent != null && sunsetPercent != null}
{/if} + + {#if isTodaySelected && nowPercent != null} + + Now + + {/if} {#each cellData as cell, i (cell.idx)} {@const leftPct = (i / cellData.length) * 100} {@const widthPct = 100 / cellData.length} {#if is3h} - {formatZoned(cell.date, data.timezone, 'HH')} + + {formatZoned(cell.date, data.timezone, 'HH')} + 00 + {:else} {formatZoned(cell.date, data.timezone, 'HH')} 0000 {/if} @@ -495,16 +569,11 @@ style="left:{headerColWidth + nowIdx * colWidth}px;width:{colWidth}px" >
{/if} +
- - Now - -
+ > {/if} diff --git a/src/routes/weather/week/[location]/MeteogramCharts.svelte b/src/routes/weather/week/[location]/MeteogramCharts.svelte index 2307744..57d11eb 100644 --- a/src/routes/weather/week/[location]/MeteogramCharts.svelte +++ b/src/routes/weather/week/[location]/MeteogramCharts.svelte @@ -7,7 +7,7 @@ import { ChartContainer, ChartToolbar } from '$lib/components/charts'; - import { CanvasChart } from '$lib/charts'; + import { CanvasChart, groupRange } from '$lib/charts'; import { getWeatherIconName } from '../../utils/weather-codes'; import ChartCustomizer from './ChartCustomizer.svelte'; @@ -39,8 +39,22 @@ $storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k))) ); + // When an extended range runs past the model's horizon the service pads with + // zeros; 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 => { + const temp = data.hourly.temperature_2m ?? []; + const n = data.timestamps.length; + if (temp.length === 0) return n; + let last = 0; + for (let i = 0; i < n; i++) { + if (temp[i] != null && !isNaN(temp[i]) && temp[i] !== 0) last = i + 1; + } + return last || n; + }); + // Timestamps from the service are in milliseconds; CanvasChart uses seconds. - let timestampsSec = $derived(data.timestamps.map((t) => t / 1000)); + let timestampsSec = $derived(data.timestamps.slice(0, validLength).map((t) => t / 1000)); function dayStartSec(day: Date): number | null { if (!data) return null; @@ -96,6 +110,21 @@ return out; }); + // Wind-direction arrows for panels showing wind (deg = direction from North). + let windArrowMarks = $derived.by((): { t: number; deg: number }[] => { + const dirs = data.hourly.winddirection_10m ?? []; + const out: { t: number; deg: number }[] = []; + for (let i = 0; i < timestampsSec.length; i++) { + const d = dirs[i]; + if (d == null || !isFinite(d)) continue; + out.push({ t: timestampsSec[i], deg: d }); + } + return out; + }); + + // True while the shared group is zoomed in (not the full range). + let zoomActive = $derived(groupRange(CHART_GROUP) != null); + // ─── Panel definitions ────────────────────────────────────────────────────── interface RenderPanel extends ChartPanel { @@ -112,6 +141,16 @@ return { ...p, def, title, titleShort }; }) ); + + // Uniform sizing across every panel: reserve the right-axis gutter and the + // tallest icon-row count so all meteograms share one plot rectangle. + let anyRightAxis = $derived(renderPanels.some((p) => p.def.unitRight != null)); + let maxTopRows = $derived( + Math.max( + 0, + ...renderPanels.map((p) => (p.def.hasPictograms ? 1 : 0) + (p.def.hasWindArrows ? 1 : 0)) + ) + );
@@ -129,11 +168,31 @@
+ {#if zoomActive} + + {/if}
{#each renderPanels as panel, i (panel.id)} -
-
+ +
+

{panel.titleShort}

- + VARIABLE_BY_KEY.get(k)) .filter((d): d is ChartVariableDef => d != null); + // Marker-only variables (weather icons) render no series, just a top row. + const defs = allDefs.filter((d) => !d.marker); + // Cloud-band variables float above the plot and don't claim an axis. const axisDefs = defs.filter((d) => !d.cloudBand); const kinds: UnitKind[] = []; @@ -400,6 +431,7 @@ export function buildPanelDef( : raw; const kindUnit = unitForKind(d.kind, units); const dec = decimalsForKind(d.kind); + const tipDec = tooltipDecimalsForKind(d.kind); const axis: 'left' | 'right' = d.cloudBand || d.kind === leftKind ? 'left' : 'right'; return { @@ -425,9 +457,9 @@ export function buildPanelDef( ? (v: number, i: number) => { const dir = hourly.winddirection_10m?.[i]; const dl = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : ''; - return `${v.toFixed(dec)} ${kindUnit}${dl}`; + return `${v.toFixed(tipDec)} ${kindUnit}${dl}`; } - : (v: number) => `${v.toFixed(dec)}${kindUnit ? ' ' + kindUnit : ''}` + : (v: number) => `${v.toFixed(tipDec)}${kindUnit ? ' ' + kindUnit : ''}` } satisfies ChartSeries; }); @@ -441,6 +473,7 @@ export function buildPanelDef( zeroBaseLeft: leftKind !== 'pressure', yMinRight: rightKind && rightZero ? 0 : undefined, yMaxRight: rightKind === 'percent' ? 100 : undefined, - hasPictograms: defs.some((d) => d.pictograms) + hasPictograms: allDefs.some((d) => d.pictograms), + hasWindArrows: allDefs.some((d) => d.windArrows) }; }