diff --git a/src/lib/charts/CanvasChart.svelte b/src/lib/charts/CanvasChart.svelte index 50aac1d..9c190d1 100644 --- a/src/lib/charts/CanvasChart.svelte +++ b/src/lib/charts/CanvasChart.svelte @@ -250,11 +250,15 @@ let cloudBandSeries = $derived(visibleSeries.filter((s) => s.cloudBand)); let hasRightAxis = $derived(plottedSeries.some((s) => s.axis === 'right')); - // Tighter left gutter on narrow screens so axis labels sit near the edge - let padLeft = $derived(width > 0 && width < 520 ? 38 : 60); + // Minimal gutters on narrow screens so the plot uses nearly the full width + // (just enough to keep the axis tick labels legible). + let isNarrow = $derived(width > 0 && width < 520); + let padLeft = $derived(isNarrow ? 26 : 60); // Reserve the right gutter when this chart (or a sibling, via reserveRightAxis) // has a right axis, so a stacked row of charts share the same plot width. - let padRight = $derived(hasRightAxis || reserveRightAxis ? 56 : 20); + let padRight = $derived( + hasRightAxis || reserveRightAxis ? (isNarrow ? 34 : 56) : isNarrow ? 6 : 20 + ); // Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps // a stacked row of charts the same height even if some have fewer icon rows. let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0)); @@ -1131,7 +1135,7 @@ {#if visiblePictograms.length > 0}
{#if visibleWindArrows.length > 0}
- · {#if location.admin1}{location.admin1}, + · {#if location.admin1}{location.admin1},  {/if}{location.country ?? ''} {/if} diff --git a/src/routes/weather/14-day/[location]/+page.svelte b/src/routes/weather/14-day/[location]/+page.svelte index 873b0c4..88ac784 100644 --- a/src/routes/weather/14-day/[location]/+page.svelte +++ b/src/routes/weather/14-day/[location]/+page.svelte @@ -154,6 +154,11 @@ fetchedData ? fetchedData.timestamps.slice(0, validLength).map((t) => t / 1000) : [] ); + // Surface a note when the chosen model's ensemble stops short of the request. + let fullHours = $derived.by(() => (fetchedData ? fetchedData.timestamps.length : 0)); + let validDays = $derived(Math.max(0, Math.round(validLength / 24))); + let isTrimmed = $derived(fetchedData != null && validLength > 0 && validLength < fullHours - 1); + interface ChartDef { title?: string; subtitle?: string; @@ -210,14 +215,6 @@ bandTo: varData.min, format: (v) => `${v.toFixed(1)} ${unit}` }, - { - name: 'Min', - type: 'line', - color: BAND_COLOR, - data: varData.min, - width: 1, - format: (v) => `${v.toFixed(1)} ${unit}` - }, { name: 'Mean', type: isColumn ? 'bar' : 'line', @@ -226,6 +223,14 @@ width: 3, dashed: !isColumn, format: (v) => `${v.toFixed(1)} ${unit}` + }, + { + name: 'Min', + type: 'line', + color: BAND_COLOR, + data: varData.min, + width: 1, + format: (v) => `${v.toFixed(1)} ${unit}` } ]; @@ -285,6 +290,30 @@ +{#if isTrimmed} +
+ + + + + This model's ensemble only reaches about {validDays} days ahead — the spread is + trimmed to its available range. + +
+{/if} + {#if loadError}
chart != null)); + // ─── Zoom range controls (mirrors the 7-day meteograms) ───────────────────── + + const SECONDS_PER_DAY = 24 * 3600; + + function dayStartSec(day: Date): number | null { + if (!fetchedData) return null; + const tz = fetchedData.timezone; + const target = formatZoned(day, tz, 'yyyy-MM-dd'); + const idx = fetchedData.timestamps.findIndex( + (t) => formatZoned(new Date(t), tz, 'yyyy-MM-dd') === target + ); + return idx === -1 ? null : fetchedData.timestamps[idx] / 1000; + } + + function setRangeDays(from: Date, days: number): void { + const start = dayStartSec(from); + if (start == null || liveCharts.length === 0) return; + // charts share the group, so setting the range on one syncs all of them + liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY); + } + + function resetZoom(): void { + liveCharts[0]?.resetRange(); + } + + const rangePresets = [ + { label: 'Today', apply: () => setRangeDays(new Date(), 1) }, + { label: '3 days', apply: () => setRangeDays(new Date(), 3) }, + { label: '5 days', apply: () => setRangeDays(new Date(), 5) }, + { label: 'All', apply: () => resetZoom() } + ]; + + let zoomActive = $derived(groupRange(CHART_GROUP) != null); + // ─── Data Fetching (only when params.hourly or params.models change) ─────── $effect(() => { @@ -161,6 +198,24 @@ series: ChartSeries[]; } + // Human labels for the compared variables (API names → readable title) + const VAR_LABELS: Record = { + temperature_2m: 'Temperature', + apparent_temperature: 'Feels like', + dew_point_2m: 'Dew point', + precipitation: 'Precipitation', + rain: 'Rain', + showers: 'Showers', + 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)' + }; + 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 []; @@ -209,10 +264,11 @@ const isLast = vi === variableCount - 1; defs.push({ - title: isFirst ? 'Model Compare' : undefined, + // label every chart with its variable so each is identifiable + title: varLabel(variable), subtitle: isFirst - ? `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}` - : undefined, + ? `${params.models?.length ?? 0} models · dashed = average` + : `across ${params.models?.length ?? 0} models`, unit, showCredit: isLast, series @@ -223,6 +279,27 @@ }); + + +
+ {location.country +
+

+ {location.name} +

+

+ {#if location.admin1}{location.admin1}, + {/if}{location.country ?? ''}·Model comparison +

+
+
+ {#if loadError} @@ -233,6 +310,49 @@
{/if} + +
+ + {#if zoomActive} + + {/if} +
+ {#each rangePresets as preset (preset.label)} + + {/each} +
+
+ (); - let pastBtnEl = $state(); + let cardsWrapEl = $state(); let hiddenForRef: FetchedDaily | null = null; + // Show the scrollbar only briefly while actively scrolling (a constant thin + // gutter is reserved so revealing the thumb never shifts layout). + let scrolling = $state(false); + let scrollHideTimer: ReturnType | undefined; + function onScroll() { + scrolling = true; + clearTimeout(scrollHideTimer); + scrollHideTimer = setTimeout(() => (scrolling = false), 700); + } + $effect(() => { const d = daily; - if (!d || !canExtendPast || !scrollEl || !pastBtnEl || hiddenForRef === d) return; + if (!d || !canExtendPast || !scrollEl || !cardsWrapEl || hiddenForRef === d) return; hiddenForRef = d; - const btn = pastBtnEl.getBoundingClientRect(); - const cont = scrollEl.getBoundingClientRect(); - // scroll so the button's right edge sits just past the left edge (a small - // gap of extra margin keeps the first day card from hugging the edge) - scrollEl.scrollLeft += btn.right - cont.left + 6; + const el = scrollEl; + const wrap = cardsWrapEl; + // defer to after layout so the measured positions and scroll width are final + requestAnimationFrame(() => { + // scroll so the first day card sits exactly at the content edge (aligned + // with the page hero), leaving the "past days" button off to the left + el.scrollLeft += wrap.getBoundingClientRect().left - el.getBoundingClientRect().left - 12; + }); }); function getDaylightSeconds(index: number): number { @@ -112,13 +125,13 @@ lines up with the page content edge -->
{#if daily} {#if canExtendPast && onExtendPast} {/if} - {#each daily.dailyDates as time, index (index)} - {@const selected = isSameDayInZone(time, selectedDay, daily.timezone)} - {@const tempMax = daily.daily.temperature_2m_max[index]} - {@const tempMin = daily.daily.temperature_2m_min[index]} - {@const wCode = daily.daily.weather_code[index]} - {@const sunDuration = daily.daily.sunshine_duration[index]} - {@const daylightSec = getDaylightSeconds(index)} - {@const sunColor = getSunshineColor(sunDuration, daylightSec)} - {@const sunPct = getSunshinePercent(sunDuration, daylightSec)} - {@const precipSum = daily.daily.precipitation_sum[index]} - {@const windMax = daily.daily.windspeed_10m_max[index]} - {@const gustMax = daily.daily.windgusts_10m_max[index]} - {@const windDir = daily.daily.winddirection_10m_dominant[index]} - {@const unit = String(units.temperature_unit)} - {@const maxStyle = getTempStyle(tempMax, unit)} - {@const lowSun = !sunIsSignificant(sunDuration, daylightSec)} - {@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))} - {@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))} - {#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)} - - {/if} - {/each} + + {/if} + {/each} +
{#if canExtend && onExtend}