From bac47596622cce40f1d46ddd39a355cb24d1003a Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Wed, 22 Jul 2026 19:28:57 +0200 Subject: [PATCH] modular meteograms --- src/lib/charts/CanvasChart.svelte | 313 ++++++++++-- src/lib/services/weather.ts | 67 ++- src/lib/stores/settings.ts | 18 + .../weather/compare/[location]/+page.svelte | 9 +- src/routes/weather/maps/+page.svelte | 3 +- .../weather/week/[location]/+page.svelte | 32 +- .../week/[location]/ChartCustomizer.svelte | 315 +++++++++++++ .../week/[location]/MeteogramCharts.svelte | 300 +++++------- .../week/[location]/VariableSidebar.svelte | 33 +- .../weather/week/[location]/variables.ts | 446 ++++++++++++++++++ 10 files changed, 1239 insertions(+), 297 deletions(-) create mode 100644 src/routes/weather/week/[location]/ChartCustomizer.svelte create mode 100644 src/routes/weather/week/[location]/variables.ts diff --git a/src/lib/charts/CanvasChart.svelte b/src/lib/charts/CanvasChart.svelte index cf0b02c..0962550 100644 --- a/src/lib/charts/CanvasChart.svelte +++ b/src/lib/charts/CanvasChart.svelte @@ -46,6 +46,18 @@ showInLegend?: boolean; /** Custom tooltip value formatter */ format?: (value: number, index: number) => string; + /** Short name used in the tooltip / legend when the full name is long */ + shortName?: string; + /** Colour each line segment by value (e.g. a temperature colour scale) */ + segmentColor?: (value: number, index: number) => string; + /** Draw a contrasting halo (black in light mode, white in dark) under the line */ + outline?: boolean; + /** Annotate local minima / maxima with their value */ + labelExtrema?: boolean; + /** Formatter for extrema labels (defaults to the tooltip format) */ + labelFormat?: (value: number) => string; + /** Render as a puffy cloud band hanging from the top instead of a line */ + cloudBand?: boolean; } interface GroupState { @@ -95,6 +107,8 @@ series: ChartSeries[]; /** Background bands (epoch seconds), e.g. daylight */ bands?: { start: number; end: number }[]; + /** Weather pictograms drawn across the top (t in epoch seconds) */ + pictograms?: { t: number; icon: string }[]; /** Highlighted time range (epoch seconds), e.g. the selected day */ highlight?: { start: number; end: number }; /** Unit label for the left y axis (also used in tooltip values) */ @@ -109,6 +123,8 @@ yMin?: number; /** Fixed left-axis maximum */ yMax?: number; + /** Force the derived left axis to include zero (default true) */ + zeroBaseLeft?: boolean; /** Fixed right-axis minimum (default 0) */ yMinRight?: number; /** Fixed right-axis maximum (default 100) */ @@ -134,6 +150,7 @@ timezone, series, bands = [], + pictograms = [], highlight, unit = '', unitRight, @@ -141,6 +158,7 @@ group, yMin, yMax, + zeroBaseLeft = true, yMinRight, yMaxRight, invertRight = false, @@ -154,10 +172,11 @@ // ─── Constants ────────────────────────────────────────────────────────────── - const PAD_LEFT = 60; const PAD_BOTTOM = 34; const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours const HOUR = 3600; + // Puffy cloud band: 100% cover hangs 40px from the top of the plot + const CLOUD_BAND_MAX = 40; // ─── State ────────────────────────────────────────────────────────────────── @@ -203,11 +222,18 @@ let zoomed = $derived(viewRange !== null && viewEnd - viewStart < tMax - tMin); let visibleSeries = $derived(series.filter((s) => !s.hidden && !legendHidden.has(s.name))); - let hasRightAxis = $derived(series.some((s) => s.axis === 'right')); + // Cloud-band series draw a decorative top band and are excluded from the + // axis scale and from the normal line/bar drawing. + let plottedSeries = $derived(visibleSeries.filter((s) => !s.cloudBand)); + 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); let padRight = $derived(hasRightAxis ? 56 : 20); - let padTop = $derived(title ? (subtitle ? 66 : 46) : 28); - let plotW = $derived(Math.max(1, width - PAD_LEFT - padRight)); + // Reserve a slim row at the very top for weather pictograms when present + let padTop = $derived((title ? (subtitle ? 66 : 46) : 28) + (pictograms.length > 0 ? 22 : 0)); + let plotW = $derived(Math.max(1, width - padLeft - padRight)); let plotH = $derived(Math.max(1, height - padTop - PAD_BOTTOM)); interface Scale { @@ -228,10 +254,10 @@ return nice * 10 ** exp; } - function dataExtent(axis: 'left' | 'right'): [number, number] { + function dataExtent(axis: 'left' | 'right', includeZero = true): [number, number] { let lo = Infinity; let hi = -Infinity; - for (const s of visibleSeries) { + for (const s of plottedSeries) { if ((s.axis ?? 'left') !== axis) continue; for (const v of s.data) { if (v === null || !isFinite(v)) continue; @@ -241,9 +267,12 @@ } if (!isFinite(lo)) return [0, 1]; // Match the previous ECharts behavior (value axis without `scale`): always - // include zero in the axis extent. - lo = Math.min(lo, 0); - hi = Math.max(hi, 0); + // include zero in the axis extent. Skipped for derived secondary axes + // (e.g. pressure) where zero would flatten the series. + if (includeZero) { + lo = Math.min(lo, 0); + hi = Math.max(hi, 0); + } if (lo === hi) hi = lo + 1; return [lo, hi]; } @@ -256,22 +285,26 @@ } let leftScale = $derived.by((): Scale => { - const [dLo, dHi] = dataExtent('left'); + const [dLo, dHi] = dataExtent('left', zeroBaseLeft); return buildScale(yMin ?? dLo, yMax ?? dHi, yMin !== undefined, yMax !== undefined); }); let rightScale = $derived.by((): Scale => { - const lo = yMinRight ?? 0; - const hi = yMaxRight ?? 100; - return buildScale(lo, hi, true, true); + // Use explicit bounds when given; otherwise derive from the right-axis + // data so arbitrary variables (pressure, wind, …) can share a panel. + const loFixed = yMinRight !== undefined; + const hiFixed = yMaxRight !== undefined; + if (loFixed && hiFixed) return buildScale(yMinRight!, yMaxRight!, true, true); + const [dLo, dHi] = dataExtent('right', false); + return buildScale(yMinRight ?? dLo, yMaxRight ?? dHi, loFixed, hiFixed); }); function xPix(t: number): number { - return PAD_LEFT + ((t - viewStart) / (viewEnd - viewStart)) * plotW; + return padLeft + ((t - viewStart) / (viewEnd - viewStart)) * plotW; } function pixToTime(x: number): number { - return viewStart + ((x - PAD_LEFT) / plotW) * (viewEnd - viewStart); + return viewStart + ((x - padLeft) / plotW) * (viewEnd - viewStart); } function yPix(v: number, axis: 'left' | 'right'): number { @@ -364,7 +397,7 @@ const value = s.format ? s.format(v, hoverIdx) : `${v.toFixed(1)}${axisUnit ? ' ' + axisUnit : ''}`; - rows.push({ name: s.name, color: s.color, value }); + rows.push({ name: s.shortName ?? s.name, color: s.color, value }); } return rows; }); @@ -373,6 +406,58 @@ let tooltipX = $derived(hoverIdx >= 0 ? xPix(timestamps[hoverIdx]) : 0); let tooltipFlip = $derived(tooltipX > width * 0.55); + // ─── Pictograms (DOM overlay across the top) ───────────────────────────────── + + // Thin the icons so they never crowd: keep ≥ 34px apart within the view. + let visiblePictograms = $derived.by((): { x: number; icon: string }[] => { + if (pictograms.length === 0 || width <= 0) return []; + const out: { x: number; icon: string }[] = []; + let lastX = -Infinity; + for (const p of pictograms) { + if (p.t < viewStart || p.t > viewEnd) continue; + const x = xPix(p.t); + if (x - lastX < 34) continue; + out.push({ x, icon: p.icon }); + lastX = x; + } + return out; + }); + + // ─── Local minima / maxima (for value labels) ──────────────────────────────── + + function findExtrema(data: (number | null)[]): { i: number; type: 'min' | 'max' }[] { + const res: { i: number; type: 'min' | 'max' }[] = []; + const W = 3; + let lastLabeled = -Infinity; + for (let i = 0; i < data.length; i++) { + const v = data[i]; + if (v === null || !isFinite(v)) continue; + let noGreater = true; + let noLess = true; + let someLess = false; + let someGreater = false; + for (let j = Math.max(0, i - W); j <= Math.min(data.length - 1, i + W); j++) { + if (j === i) continue; + const u = data[j]; + if (u === null || !isFinite(u)) continue; + if (u > v) { + noGreater = false; + someGreater = true; + } else if (u < v) { + noLess = false; + someLess = true; + } + } + const isMax = noGreater && someLess; + const isMin = noLess && someGreater; + if ((isMax || isMin) && i - lastLabeled >= W) { + res.push({ i, type: isMax ? 'max' : 'min' }); + lastLabeled = i; + } + } + return res; + } + // ─── X axis ticks ─────────────────────────────────────────────────────────── interface XTick { @@ -435,8 +520,11 @@ const textColor = cssColor('--muted-foreground', '#6b7280'); const strongColor = cssColor('--foreground', '#374151'); const gridColor = cssColor('--border', 'rgba(0, 0, 0, 0.1)'); + const bgColor = cssColor('--card', '#ffffff'); + const dark = document.documentElement.classList.contains('dark'); + const outlineColor = dark ? '#ffffff' : '#000000'; - const plotRight = PAD_LEFT + plotW; + const plotRight = padLeft + plotW; const plotBottom = padTop + plotH; const font = '11px system-ui, sans-serif'; @@ -444,7 +532,7 @@ ctx.fillStyle = CHART_COLORS.daylight; for (const band of bands) { if (band.end < viewStart || band.start > viewEnd) continue; - const x1 = Math.max(PAD_LEFT, xPix(band.start)); + const x1 = Math.max(padLeft, xPix(band.start)); const x2 = Math.min(plotRight, xPix(band.end)); if (x2 > x1) ctx.fillRect(x1, padTop, x2 - x1, plotH); } @@ -452,7 +540,7 @@ // Selected-day highlight: soft tint + dashed edge lines if (highlight && highlight.end > viewStart && highlight.start < viewEnd) { const accent = cssColor('--primary', '#e08a3c'); - const x1 = Math.max(PAD_LEFT, xPix(highlight.start)); + const x1 = Math.max(padLeft, xPix(highlight.start)); const x2 = Math.min(plotRight, xPix(highlight.end)); if (x2 > x1) { ctx.save(); @@ -466,7 +554,7 @@ ctx.beginPath(); for (const edge of [highlight.start, highlight.end]) { const x = xPix(edge); - if (x >= PAD_LEFT && x <= plotRight) { + if (x >= padLeft && x <= plotRight) { ctx.moveTo(x, padTop); ctx.lineTo(x, plotBottom); } @@ -485,11 +573,11 @@ ctx.strokeStyle = gridColor; ctx.lineWidth = 1; ctx.beginPath(); - ctx.moveTo(PAD_LEFT, y); + ctx.moveTo(padLeft, y); ctx.lineTo(plotRight, y); ctx.stroke(); ctx.fillStyle = textColor; - ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), PAD_LEFT - 8, y); + ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y); } // Right axis labels (only when a unit is provided) @@ -534,15 +622,37 @@ // Series (clipped to the plot area) ctx.save(); ctx.beginPath(); - ctx.rect(PAD_LEFT, padTop, plotW, plotH); + ctx.rect(padLeft, padTop, plotW, plotH); ctx.clip(); - const barSeries = visibleSeries.filter((s) => s.type === 'bar'); const interval = timestamps.length > 1 ? timestamps[1] - timestamps[0] : HOUR; + + // Puffy cloud band: overlapping circles hang from the top of the plot, + // each reaching down by (cover/100) × CLOUD_BAND_MAX. Neighbouring puffs + // merge into a soft, rounded silhouette. + for (const s of cloudBandSeries) { + ctx.save(); + ctx.fillStyle = s.color; + ctx.globalAlpha = 0.5; + for (let i = 0; i < timestamps.length; i++) { + const v = s.data[i]; + if (v === null || v === undefined || !isFinite(v) || v <= 0) continue; + const t = timestamps[i]; + if (t < viewStart - interval || t > viewEnd + interval) continue; + const r = (Math.min(100, v) / 100) * CLOUD_BAND_MAX; + if (r < 1) continue; + ctx.beginPath(); + ctx.arc(xPix(t), padTop, r, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + + const barSeries = plottedSeries.filter((s) => s.type === 'bar'); const slot = plotW / ((viewEnd - viewStart) / interval); const barWidth = Math.min(8, Math.max(1, (slot * 0.7) / Math.max(1, barSeries.length))); - for (const s of visibleSeries) { + for (const s of plottedSeries) { const axis = s.axis ?? 'left'; const baseline = Math.min(plotBottom, Math.max(padTop, yPix(0, axis))); @@ -564,9 +674,9 @@ // Line series: draw fill and stroke per contiguous non-null run // (points outside the view are handled by the clip rect). Each point - // is [x, y, yBand] — yBand only used when s.bandTo is set. - const runs: Array> = []; - let run: Array<[number, number, number]> = []; + // is [x, y, yBand, sourceIndex] — yBand only used when s.bandTo is set. + const runs: Array> = []; + let run: Array<[number, number, number, number]> = []; for (let i = 0; i < timestamps.length; i++) { const v = s.data[i]; const b = s.bandTo?.[i]; @@ -576,7 +686,7 @@ run = []; continue; } - run.push([xPix(timestamps[i]), yPix(v, axis), s.bandTo ? yPix(b as number, axis) : 0]); + run.push([xPix(timestamps[i]), yPix(v, axis), s.bandTo ? yPix(b as number, axis) : 0, i]); } if (run.length > 0) runs.push(run); @@ -603,18 +713,65 @@ const lineWidth = s.width ?? 2; if (lineWidth > 0) { - ctx.beginPath(); - ctx.moveTo(points[0][0], points[0][1]); - for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]); - ctx.strokeStyle = s.color; - ctx.lineWidth = lineWidth; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.setLineDash(s.dashed ? [6, 4] : []); - ctx.stroke(); + + // Contrasting halo drawn under the line so a multi-colour line + // stays legible over any background. + if (s.outline && points.length > 1) { + ctx.strokeStyle = outlineColor; + ctx.lineWidth = lineWidth + 2.5; + ctx.beginPath(); + ctx.moveTo(points[0][0], points[0][1]); + for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]); + ctx.stroke(); + } + + ctx.lineWidth = lineWidth; + if (s.segmentColor) { + // Colour each segment by its value (temperature colour scale). + // `idx[i]` maps a run point back to its source data index. + for (let i = 1; i < points.length; i++) { + const v = s.data[points[i][3]]; + ctx.strokeStyle = s.segmentColor(v as number, points[i][3]); + ctx.beginPath(); + ctx.moveTo(points[i - 1][0], points[i - 1][1]); + ctx.lineTo(points[i][0], points[i][1]); + ctx.stroke(); + } + } else { + ctx.beginPath(); + ctx.moveTo(points[0][0], points[0][1]); + for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]); + ctx.strokeStyle = s.color; + ctx.stroke(); + } ctx.setLineDash([]); } } + + // Local minima / maxima value labels + if (s.labelExtrema) { + const fmt = s.labelFormat ?? ((v: number) => v.toFixed(0)); + ctx.font = 'bold 11px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'alphabetic'; + ctx.lineWidth = 3; + ctx.strokeStyle = bgColor; + ctx.fillStyle = strongColor; + for (const ext of findExtrema(s.data)) { + const t = timestamps[ext.i]; + if (t < viewStart || t > viewEnd) continue; + const v = s.data[ext.i] as number; + const x = xPix(t); + const y = yPix(v, axis); + const label = fmt(v); + const ly = ext.type === 'max' ? y - 8 : y + 15; + ctx.strokeText(label, x, ly); + ctx.fillText(label, x, ly); + } + } } // Current time marker @@ -655,7 +812,7 @@ if (unit) { ctx.textAlign = 'right'; ctx.fillStyle = textColor; - ctx.fillText(unit, PAD_LEFT - 4, padTop - 8); + ctx.fillText(unit, padLeft - 4, padTop - 8); } if (hasRightAxis && unitRight) { ctx.textAlign = 'left'; @@ -730,6 +887,11 @@ const pointers = new SvelteMap(); let panStart: { x: number; start: number; end: number } | null = null; let pinchStart: { dist: number; start: number; end: number } | null = null; + // Touch gesture intent. On touch we defer pointer capture until we know + // the finger is moving horizontally; a vertical drag is left to the page + // so the meteograms don't hijack scrolling (and don't flash the tooltip). + let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' = 'none'; + let touchStart: { x: number; y: number; start: number; end: number } | null = null; const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left; @@ -739,16 +901,27 @@ }; const onPointerDown = (e: PointerEvent): void => { - el.setPointerCapture(e.pointerId); pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); - if (pointers.size === 1) { - panStart = { x: e.clientX, start: viewStart, end: viewEnd }; - pinchStart = null; - } else if (pointers.size === 2) { + + if (pointers.size === 2) { + el.setPointerCapture(e.pointerId); const [a, b] = [...pointers.values()]; pinchStart = { dist: Math.max(10, Math.abs(a.x - b.x)), start: viewStart, end: viewEnd }; panStart = null; + gesture = 'pinch'; setHover(null); + return; + } + + if (e.pointerType === 'mouse') { + el.setPointerCapture(e.pointerId); + panStart = { x: e.clientX, start: viewStart, end: viewEnd }; + pinchStart = null; + gesture = zoomed ? 'pan' : 'inspect'; + } else { + // Touch: wait for the first move to reveal scroll vs inspect intent. + touchStart = { x: e.clientX, y: e.clientY, start: viewStart, end: viewEnd }; + gesture = 'none'; } }; @@ -757,30 +930,53 @@ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); } - if (pinchStart && pointers.size === 2) { + if (gesture === 'pinch' && pointers.size === 2) { const [a, b] = [...pointers.values()]; const dist = Math.max(10, Math.abs(a.x - b.x)); - const scale = pinchStart.dist / dist; - const span = pinchStart.end - pinchStart.start; - const center = (pinchStart.start + pinchStart.end) / 2; + const scale = pinchStart!.dist / dist; + const span = pinchStart!.end - pinchStart!.start; + const center = (pinchStart!.start + pinchStart!.end) / 2; const newSpan = span * scale; applyRange(center - newSpan / 2, center + newSpan / 2); return; } - if (panStart && pointers.size === 1 && zoomed) { + // Resolve touch intent from the initial drag direction + if (e.pointerType !== 'mouse' && gesture === 'none' && touchStart && pointers.size === 1) { + const dx = Math.abs(e.clientX - touchStart.x); + const dy = Math.abs(e.clientY - touchStart.y); + if (dx < 6 && dy < 6) return; + if (dy > dx) { + // Vertical: let the page scroll, never capture or hover + gesture = 'scroll'; + return; + } + gesture = zoomed ? 'pan' : 'inspect'; + el.setPointerCapture(e.pointerId); + if (gesture === 'pan') { + panStart = { x: e.clientX, start: touchStart.start, end: touchStart.end }; + } + } + + if (gesture === 'scroll') return; + + if (gesture === 'pan' && panStart && pointers.size === 1 && zoomed) { const dt = ((panStart.x - e.clientX) / plotW) * (panStart.end - panStart.start); applyRange(panStart.start + dt, panStart.end + dt); return; } - updateHover(e); + if (pointers.size <= 1) updateHover(e); }; const onPointerUp = (e: PointerEvent): void => { pointers.delete(e.pointerId); if (pointers.size < 2) pinchStart = null; - if (pointers.size < 1) panStart = null; + if (pointers.size < 1) { + panStart = null; + touchStart = null; + gesture = 'none'; + } if (e.pointerType !== 'mouse') setHover(null); }; @@ -838,6 +1034,23 @@ style:touch-action="pan-y" > + + {#if visiblePictograms.length > 0} +
+ {#each visiblePictograms as p (p.x)} + + + + {/each} +
+ {/if} + {#if zoomHintVisible}
- {s.name} + {width > 0 && width < 520 ? (s.shortName ?? s.name) : s.name} {/each}
diff --git a/src/lib/services/weather.ts b/src/lib/services/weather.ts index 5c9b1c2..e6df4a9 100644 --- a/src/lib/services/weather.ts +++ b/src/lib/services/weather.ts @@ -165,6 +165,8 @@ export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams { model?: string; forecast_days?: number; past_days?: number; + /** Hourly API variables to request; defaults to the full core set */ + hourlyVariables?: string[]; } export interface WeekHourlyData { @@ -178,6 +180,19 @@ export interface WeekHourlyData { relative_humidity_2m: number[]; apparent_temperature: number[]; dew_point_2m: number[]; + // Additional popular variables available for the customizable meteograms + wind_gusts_10m: number[]; + pressure_msl: number[]; + surface_pressure: number[]; + rain: number[]; + showers: number[]; + snowfall: number[]; + cloud_cover_low: number[]; + cloud_cover_mid: number[]; + cloud_cover_high: number[]; + uv_index: number[]; + visibility: number[]; + cape: number[]; } export interface WeekDailyData { @@ -259,6 +274,9 @@ export interface EnsembleForecastResult { // ─── Week Forecast Fetch ──────────────────────────────────────────────────────── +// Fallback set when the caller does not specify which hourly variables it +// needs. Callers normally pass an explicit list so only shown variables are +// requested. const WEEK_HOURLY_VARS = [ 'temperature_2m', 'precipitation', @@ -294,10 +312,16 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise 0 + ? [...new Set(params.hourlyVariables)] + : [...WEEK_HOURLY_VARS]; + const apiParams: Record = { latitude: params.latitude, longitude: params.longitude, - hourly: WEEK_HOURLY_VARS.join(','), + hourly: hourlyVars.join(','), daily: WEEK_DAILY_VARS.join(','), temperature_unit: params.temperature_unit ?? 'celsius', wind_speed_unit: params.wind_speed_unit ?? 'kmh', @@ -328,17 +352,38 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise new Date(t)); + // Values come back in the requested order; index them by API name so + // variables that were not requested resolve to empty arrays. + const byName: Record = {}; + hourlyVars.forEach((name, i) => { + const variable = hourlyBlock.variables(i); + byName[name] = variable ? getValues(variable) : []; + }); + const g = (name: string): number[] => byName[name] ?? []; + const hourly: WeekHourlyData = { - temperature_2m: getValues(hourlyBlock.variables(0)!), - precipitation: getValues(hourlyBlock.variables(1)!), - precipitation_probability: getValues(hourlyBlock.variables(2)!), - weather_code: getValues(hourlyBlock.variables(3)!), - windspeed_10m: getValues(hourlyBlock.variables(4)!), - winddirection_10m: getValues(hourlyBlock.variables(5)!), - cloud_cover: getValues(hourlyBlock.variables(6)!), - relative_humidity_2m: getValues(hourlyBlock.variables(7)!), - apparent_temperature: getValues(hourlyBlock.variables(8)!), - dew_point_2m: getValues(hourlyBlock.variables(9)!) + temperature_2m: g('temperature_2m'), + precipitation: g('precipitation'), + precipitation_probability: g('precipitation_probability'), + weather_code: g('weather_code'), + windspeed_10m: g('wind_speed_10m'), + winddirection_10m: g('wind_direction_10m'), + cloud_cover: g('cloud_cover'), + relative_humidity_2m: g('relative_humidity_2m'), + apparent_temperature: g('apparent_temperature'), + dew_point_2m: g('dew_point_2m'), + wind_gusts_10m: g('wind_gusts_10m'), + pressure_msl: g('pressure_msl'), + surface_pressure: g('surface_pressure'), + rain: g('rain'), + showers: g('showers'), + snowfall: g('snowfall'), + cloud_cover_low: g('cloud_cover_low'), + cloud_cover_mid: g('cloud_cover_mid'), + cloud_cover_high: g('cloud_cover_high'), + uv_index: g('uv_index'), + visibility: g('visibility'), + cape: g('cape') }; // Daily: variables are in the same order as WEEK_DAILY_VARS diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index 284e7ed..283ba54 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -79,5 +79,23 @@ export const defaultVariablePrefs: VariablePrefs = { export const storedVariablePrefs = persisted('variable_prefs', defaultVariablePrefs); +/** + * Meteogram layout: an ordered list of chart panels, each holding an ordered + * list of variable keys (see the chart variable registry). Users drag + * variables between panels to fully customise the meteograms. + */ +export interface ChartPanel { + id: string; + variables: string[]; +} + +export const defaultChartLayout: ChartPanel[] = [ + { id: 'panel-1', variables: ['temperature', 'cloud_cover'] }, + { id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] }, + { id: 'panel-3', variables: ['wind', 'humidity'] } +]; + +export const storedChartLayout = persisted('chart_layout_v1', defaultChartLayout); + /** Selected ensemble model for the 14-day spread forecast. */ export const storedEnsembleModel = persisted('ensemble_model', 'ncep_gefs_seamless'); diff --git a/src/routes/weather/compare/[location]/+page.svelte b/src/routes/weather/compare/[location]/+page.svelte index f851000..96a45a1 100644 --- a/src/routes/weather/compare/[location]/+page.svelte +++ b/src/routes/weather/compare/[location]/+page.svelte @@ -25,7 +25,7 @@ fetchModelComparison } from '$lib/services/weather'; - import { hourly, modelGroups } from '../../options'; + import { findModel, hourly, modelGroups } from '../../options'; import { defaultParameters } from '../../options'; import ModelPictogramTimeline from './ModelPictogramTimeline.svelte'; @@ -174,8 +174,11 @@ if (model === 'time') continue; if (!model.startsWith(variable)) continue; + // strip the variable prefix and use the concise model label so the + // tooltip/legend stay readable (model ids are very long) + const modelId = model.slice(variable.length + 1); series.push({ - name: model, + name: findModel(modelId)?.label ?? modelId, type: isColumn ? 'bar' : 'line', color: SERIES_COLORS[modelIndex % SERIES_COLORS.length], data: values as (number | null)[], @@ -186,7 +189,7 @@ const { average } = calculateAverage(hourlyData, variable, timeLength); series.push({ - name: variable + '_average', + name: 'Average', type: isColumn ? 'bar' : 'line', color: CHART_COLORS.average, data: average, diff --git a/src/routes/weather/maps/+page.svelte b/src/routes/weather/maps/+page.svelte index 5a44e79..29070d6 100644 --- a/src/routes/weather/maps/+page.svelte +++ b/src/routes/weather/maps/+page.svelte @@ -21,7 +21,8 @@ // Local maps dev server (open-meteo/maps); production: https://maps.open-meteo.com // Run drizzli on a different port so the map keeps 5173 to itself. - const MAPS_ORIGIN = 'http://localhost:5173'; + // const MAPS_ORIGIN = 'http://localhost:5173'; + const MAPS_ORIGIN = 'https://maps.open-meteo.com'; const MAP_HASH_RE = /^#\d+(\.\d+)?\/-?\d+(\.\d+)?\/-?\d+(\.\d+)?/; diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index de77553..98b45e5 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -3,7 +3,12 @@ import { SvelteDate } from 'svelte/reactivity'; import { get } from 'svelte/store'; - import { storedLocation, storedModel, storedVariablePrefs } from '$lib/stores/settings'; + import { + storedChartLayout, + storedLocation, + storedModel, + storedVariablePrefs + } from '$lib/stores/settings'; import { ChartContainer } from '$lib/components/charts'; @@ -15,6 +20,7 @@ import MeteogramCharts from './MeteogramCharts.svelte'; import ModelSelector from './ModelSelector.svelte'; import VariableSidebar from './VariableSidebar.svelte'; + import { neededHourlyApiVars } from './variables'; import type { PageData } from './$types'; import type { FetchedDaily, FetchedHourly } from './types'; @@ -28,16 +34,18 @@ let variableSidebarOpen = $state(false); - // Number of meteogram chart panels currently enabled: used to reserve the - // exact chart area height before data arrives (no layout shift) - let enabledChartCount = $derived.by(() => { - const on = (key: string) => $storedVariablePrefs.charts?.[key] ?? true; - return ( - (on('temperature') || on('cloud_cover') ? 1 : 0) + - (on('precipitation') || on('precipitation_probability') ? 1 : 0) + - (on('wind') || on('humidity') ? 1 : 0) - ); - }); + // Number of meteogram panels: reserves the chart area height before data + // arrives (no layout shift) + let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length); + + // Request only the hourly variables the table rows and meteograms actually + // show, so unused variables are never fetched. + let hourlyVars = $derived( + neededHourlyApiVars( + $storedVariablePrefs.table, + $storedChartLayout.flatMap((p) => p.variables) + ) + ); // the URL is the source of truth: location comes from the load function, // which is also correct on hydrated prerendered pages. The persisted store @@ -72,6 +80,7 @@ $effect(() => { const loc = location; const modelList = params.models; + const requestVars = hourlyVars; if (!mounted || !loc || !modelList?.length) return; @@ -84,6 +93,7 @@ latitude: loc.latitude!, longitude: loc.longitude!, model: modelList[0], + hourlyVariables: requestVars, temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit', wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn', precipitation_unit: params.precipitation_unit as 'mm' | 'inch', diff --git a/src/routes/weather/week/[location]/ChartCustomizer.svelte b/src/routes/weather/week/[location]/ChartCustomizer.svelte new file mode 100644 index 0000000..9170cf2 --- /dev/null +++ b/src/routes/weather/week/[location]/ChartCustomizer.svelte @@ -0,0 +1,315 @@ + + + { + if (e.key === 'Escape' && open && dragKey === null) onClose(); + }} +/> + +{#if open} +
+
dragKey === null && onClose()} + onkeydown={onClose} + role="presentation" + >
+ +
+
+
+

Customize meteograms

+

+ Drag variables between charts to build your own layout. +

+
+ +
+ +
+ {#each $storedChartLayout as panel, i (panel.id)} +
+
+ Chart {i + 1} + +
+
+ {#each panel.variables as key (key)} + {@const def = VARIABLE_BY_KEY.get(key)} + {#if def} +
beginDrag(e, key)} + onpointermove={onDragMove} + onpointerup={endDrag} + onpointercancel={endDrag} + > + + {def.label} + +
+ {/if} + {/each} + {#if panel.variables.length === 0} + Drop variables here + {/if} +
+
+ {/each} + + + +
+

+ Available variables +

+
+ {#each availableVars as def (def.key)} +
beginDrag(e, def.key)} + onpointermove={onDragMove} + onpointerup={endDrag} + onpointercancel={endDrag} + > + + {def.label} +
+ {/each} + {#if availableVars.length === 0} + All variables are in use + {/if} +
+
+
+ +
+ + +
+
+
+ + + {#if dragKey !== null && started} +
+ + {dragLabel} +
+ {/if} +{/if} diff --git a/src/routes/weather/week/[location]/MeteogramCharts.svelte b/src/routes/weather/week/[location]/MeteogramCharts.svelte index b55577d..2307744 100644 --- a/src/routes/weather/week/[location]/MeteogramCharts.svelte +++ b/src/routes/weather/week/[location]/MeteogramCharts.svelte @@ -1,22 +1,18 @@

- Meteogram + Meteograms – {formatZoned(selectedDay, data.timezone, 'EEEE')}{getRelativeDayLabel( selectedDay, @@ -266,42 +149,73 @@ {/each}

+ - {#if chartDefs.length > 0} - - {#each chartDefs as def, i (def.title)} - + {#if renderPanels.length === 0} +
+ No meteograms configured — . +
+ {:else} +
+ {#each renderPanels as panel, i (panel.id)} +
+
+

+ + {panel.titleShort} +

+
+ + + +
{/each} - +
- {:else} -
- All chart variables are hidden — enable some under “Variables”. -
{/if}
+ + (customizerOpen = false)} /> diff --git a/src/routes/weather/week/[location]/VariableSidebar.svelte b/src/routes/weather/week/[location]/VariableSidebar.svelte index f342795..effd820 100644 --- a/src/routes/weather/week/[location]/VariableSidebar.svelte +++ b/src/routes/weather/week/[location]/VariableSidebar.svelte @@ -23,15 +23,6 @@ { key: 'precipitation', label: 'Precipitation' } ]; - const chartVariables = [ - { key: 'temperature', label: 'Temperature' }, - { key: 'cloud_cover', label: 'Cloud cover' }, - { key: 'precipitation', label: 'Precipitation' }, - { key: 'precipitation_probability', label: 'Precipitation probability' }, - { key: 'wind', label: 'Wind speed' }, - { key: 'humidity', label: 'Humidity' } - ]; - function toggle(section: 'table' | 'charts', key: string) { storedVariablePrefs.update((prefs) => { const current = { ...defaultVariablePrefs[section], ...prefs[section] }; @@ -107,26 +98,10 @@ -
-

- Meteogram charts -

-
- {#each chartVariables as variable (variable.key)} -
- toggle('charts', variable.key)} - /> - -
- {/each} -
-
+

+ Meteogram variables are configured with the Customize + button above the charts. +

diff --git a/src/routes/weather/week/[location]/variables.ts b/src/routes/weather/week/[location]/variables.ts new file mode 100644 index 0000000..0fd14e3 --- /dev/null +++ b/src/routes/weather/week/[location]/variables.ts @@ -0,0 +1,446 @@ +/** + * Registry of every variable that can be plotted on the customizable + * meteograms. Each entry carries the metadata needed to build a chart series + * (data field, render style, colour, unit family) so the panels can be + * assembled dynamically from a user-defined layout. + */ +import { getColor } from '../../utils/colors'; +import { + type WeatherUnits, + getPrecipUnit, + getTempUnit, + getWindDirectionLabel, + getWindUnit +} from './types'; + +import type { ChartSeries } from '$lib/charts'; +import type { WeekHourlyData } from '$lib/services/weather'; + +/** Families of variables that share a y-axis and unit. */ +export type UnitKind = + 'temp' | 'precip' | 'snow' | 'wind' | 'percent' | 'pressure' | 'uv' | 'distance' | 'energy'; + +export interface ChartVariableDef { + /** Stable id used in the persisted layout */ + key: string; + label: string; + /** Short label for the tooltip / legend */ + short: string; + /** Data array on the hourly response */ + field: keyof WeekHourlyData; + /** Open-Meteo API variable name (defaults to `field` when identical) */ + api?: string; + type: 'line' | 'bar'; + kind: UnitKind; + color: string; + dashed?: boolean; + fill?: boolean; + fillOpacity?: number; + width?: number; + /** Stroke the line coloured by the temperature scale */ + colorScale?: boolean; + /** Draw a contrasting halo under the line */ + outline?: boolean; + /** Annotate local minima / maxima with their value */ + extrema?: boolean; + /** Draw weather-code pictograms across the top of the chart */ + pictograms?: boolean; + /** Render as a puffy cloud band hanging from the top (0-100 → 0-40px) */ + cloudBand?: boolean; + /** Transform raw values before plotting (e.g. m → km) */ + transform?: (v: number) => number; + /** Preset range to use when this variable lands on a shared right axis */ + rightPreset?: { min: number; max: number; invert?: boolean }; +} + +export const CHART_VARIABLES: ChartVariableDef[] = [ + { + key: 'temperature', + label: 'Temperature', + short: 'Temp', + field: 'temperature_2m', + type: 'line', + kind: 'temp', + color: '#ef6c00', + width: 4, + fill: true, + fillOpacity: 0.12, + colorScale: true, + outline: true, + extrema: true, + pictograms: true + }, + { + key: 'apparent_temperature', + label: 'Apparent Temp', + short: 'Feels', + field: 'apparent_temperature', + type: 'line', + kind: 'temp', + color: '#c2410c', + width: 2, + dashed: true + }, + { + key: 'dew_point', + label: 'Dew Point', + short: 'Dew', + field: 'dew_point_2m', + type: 'line', + kind: 'temp', + color: '#0e7490', + width: 2 + }, + { + key: 'cloud_cover', + label: 'Cloud Cover', + short: 'Cloud', + field: 'cloud_cover', + type: 'line', + kind: 'percent', + color: 'rgb(150, 155, 165)', + cloudBand: true + }, + { + key: 'cloud_cover_low', + label: 'Cloud Cover Low', + short: 'Low', + field: 'cloud_cover_low', + type: 'line', + kind: 'percent', + color: '#94a3b8', + width: 2 + }, + { + key: 'cloud_cover_mid', + label: 'Cloud Cover Mid', + short: 'Mid', + field: 'cloud_cover_mid', + type: 'line', + kind: 'percent', + color: '#64748b', + width: 2 + }, + { + key: 'cloud_cover_high', + label: 'Cloud Cover High', + short: 'High', + field: 'cloud_cover_high', + type: 'line', + kind: 'percent', + color: '#cbd5e1', + width: 2 + }, + { + key: 'precipitation', + label: 'Precipitation', + short: 'Precip', + field: 'precipitation', + type: 'bar', + kind: 'precip', + color: 'rgba(30, 136, 229, 0.8)' + }, + { + key: 'precipitation_probability', + label: 'Precip. Probability', + short: 'PoP', + field: 'precipitation_probability', + type: 'line', + kind: 'percent', + color: '#5c6bc0', + width: 2, + dashed: true + }, + { + key: 'rain', + label: 'Rain', + short: 'Rain', + field: 'rain', + type: 'bar', + kind: 'precip', + color: 'rgba(37, 99, 235, 0.75)' + }, + { + key: 'showers', + label: 'Showers', + short: 'Shwr', + field: 'showers', + type: 'bar', + kind: 'precip', + color: 'rgba(6, 182, 212, 0.75)' + }, + { + key: 'snowfall', + label: 'Snowfall', + short: 'Snow', + field: 'snowfall', + type: 'bar', + kind: 'snow', + color: 'rgba(147, 197, 253, 0.9)' + }, + { + key: 'wind', + label: 'Wind Speed', + short: 'Wind', + field: 'windspeed_10m', + api: 'wind_speed_10m', + type: 'line', + kind: 'wind', + color: '#26a69a', + width: 2, + fill: true, + fillOpacity: 0.15 + }, + { + key: 'wind_gusts', + label: 'Wind Gusts', + short: 'Gusts', + field: 'wind_gusts_10m', + type: 'line', + kind: 'wind', + color: '#0d9488', + width: 2, + dashed: true + }, + { + key: 'humidity', + label: 'Humidity', + short: 'RH', + field: 'relative_humidity_2m', + type: 'line', + kind: 'percent', + color: '#8d6e63', + width: 2, + dashed: true + }, + { + key: 'pressure_msl', + label: 'Pressure (MSL)', + short: 'MSLP', + field: 'pressure_msl', + type: 'line', + kind: 'pressure', + color: '#7c3aed', + width: 2 + }, + { + key: 'surface_pressure', + label: 'Surface Pressure', + short: 'Psfc', + field: 'surface_pressure', + type: 'line', + kind: 'pressure', + color: '#a855f7', + width: 2, + dashed: true + }, + { + key: 'uv_index', + label: 'UV Index', + short: 'UV', + field: 'uv_index', + type: 'line', + kind: 'uv', + color: '#eab308', + width: 2, + fill: true, + fillOpacity: 0.15 + }, + { + key: 'visibility', + label: 'Visibility', + short: 'Vis', + field: 'visibility', + type: 'line', + kind: 'distance', + color: '#0891b2', + width: 2, + transform: (v) => v / 1000 + }, + { + key: 'cape', + label: 'CAPE', + short: 'CAPE', + field: 'cape', + type: 'line', + kind: 'energy', + color: '#dc2626', + width: 2, + fill: true, + fillOpacity: 0.12 + } +]; + +export const VARIABLE_BY_KEY: Map = new Map( + CHART_VARIABLES.map((v) => [v.key, v]) +); + +/** Open-Meteo API variable name for a registry entry. */ +export function apiNameOf(def: ChartVariableDef): string { + return def.api ?? def.field; +} + +/** + * The set of API hourly variables needed to render the current table rows and + * chart layout, so the fetch requests only what is actually shown. + */ +export function neededHourlyApiVars( + tablePrefs: Record | undefined, + layoutKeys: string[] +): string[] { + const on = (key: string): boolean => tablePrefs?.[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('wind')) { + s.add('wind_speed_10m'); + s.add('wind_direction_10m'); + } + if (on('humidity')) s.add('relative_humidity_2m'); + if (on('clouds')) s.add('cloud_cover'); + if (on('precipitation')) { + s.add('precipitation'); + s.add('precipitation_probability'); + } + + // Meteogram variables + for (const key of layoutKeys) { + const def = VARIABLE_BY_KEY.get(key); + if (!def) continue; + s.add(apiNameOf(def)); + if (def.pictograms) s.add('weather_code'); + if (def.key === 'wind') s.add('wind_direction_10m'); + } + + return [...s]; +} + +/** Unit label for a variable family, honouring the user's unit settings. */ +export function unitForKind(kind: UnitKind, units: WeatherUnits): string { + switch (kind) { + case 'temp': + return getTempUnit(units); + case 'precip': + return getPrecipUnit(units); + case 'snow': + return 'cm'; + case 'wind': + return getWindUnit(units); + case 'percent': + return '%'; + case 'pressure': + return 'hPa'; + case 'uv': + return ''; + case 'distance': + return 'km'; + case 'energy': + return 'J/kg'; + } +} + +/** Families whose axis should always start at zero. */ +export function isZeroBased(kind: UnitKind): boolean { + return kind !== 'temp' && kind !== 'pressure'; +} + +/** Sensible decimal places for tooltip / label formatting. */ +export function decimalsForKind(kind: UnitKind): number { + switch (kind) { + case 'precip': + case 'snow': + case 'uv': + case 'distance': + return 1; + default: + return 0; + } +} + +export interface PanelDef { + series: ChartSeries[]; + unit: string; + unitRight?: string; + yMin?: number; + yMinRight?: number; + yMaxRight?: number; + /** Whether the left axis should include zero (false for pressure) */ + zeroBaseLeft: boolean; + hasPictograms: boolean; +} + +/** + * Builds a chart definition for one panel: turns its ordered variable keys into + * series and works out the left / right axis units. The first variable's family + * owns the left axis; the first differing family gets the right axis. + */ +export function buildPanelDef( + variableKeys: string[], + hourly: WeekHourlyData, + units: WeatherUnits +): PanelDef { + const defs = variableKeys + .map((k) => VARIABLE_BY_KEY.get(k)) + .filter((d): d is ChartVariableDef => d != null); + + // Cloud-band variables float above the plot and don't claim an axis. + const axisDefs = defs.filter((d) => !d.cloudBand); + const kinds: UnitKind[] = []; + for (const d of axisDefs) if (!kinds.includes(d.kind)) kinds.push(d.kind); + const leftKind = kinds[0]; + const rightKind = kinds.find((k) => k !== leftKind); + + const series: ChartSeries[] = defs.map((d) => { + const raw = (hourly[d.field] as number[]) ?? []; + const data: (number | null)[] = d.transform + ? raw.map((v) => (v == null || !isFinite(v) ? null : d.transform!(v))) + : raw; + const kindUnit = unitForKind(d.kind, units); + const dec = decimalsForKind(d.kind); + const axis: 'left' | 'right' = d.cloudBand || d.kind === leftKind ? 'left' : 'right'; + + return { + name: d.label, + shortName: d.short, + type: d.type, + color: d.color, + data, + width: d.width, + fill: d.fill, + fillOpacity: d.fillOpacity, + dashed: d.dashed, + axis, + cloudBand: d.cloudBand, + segmentColor: d.colorScale ? (v: number) => getColor(v, units.temperature_unit) : undefined, + outline: d.outline, + labelExtrema: d.extrema, + labelFormat: d.extrema + ? (v: number) => (d.kind === 'temp' ? `${v.toFixed(0)}°` : `${v.toFixed(dec)}${kindUnit}`) + : undefined, + format: + d.key === 'wind' + ? (v: number, i: number) => { + const dir = hourly.winddirection_10m?.[i]; + const dl = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : ''; + return `${v.toFixed(dec)} ${kindUnit}${dl}`; + } + : (v: number) => `${v.toFixed(dec)}${kindUnit ? ' ' + kindUnit : ''}` + } satisfies ChartSeries; + }); + + const rightZero = rightKind ? isZeroBased(rightKind) : false; + return { + series, + unit: leftKind ? unitForKind(leftKind, units) : '', + unitRight: rightKind ? unitForKind(rightKind, units) : undefined, + yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined, + // pressure sits far from zero, so its axis is derived from the data + zeroBaseLeft: leftKind !== 'pressure', + yMinRight: rightKind && rightZero ? 0 : undefined, + yMaxRight: rightKind === 'percent' ? 100 : undefined, + hasPictograms: defs.some((d) => d.pictograms) + }; +}