From c7924bb0ae761741c496f3cc0090173a2b135e47 Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Sat, 25 Jul 2026 13:51:14 +0200 Subject: [PATCH] visual updates --- .gitignore | 3 + .mcp.json | 0 README.md | 2 +- src/lib/charts/CanvasChart.svelte | 311 ++++++++++++++---- .../components/charts/ChartContainer.svelte | 6 +- src/lib/components/charts/ChartToolbar.svelte | 6 +- .../components/charts/downloadChartsPng.ts | 54 +-- src/lib/components/charts/index.ts | 2 +- src/lib/components/navigation/header.svelte | 2 +- .../components/navigation/weather-nav.svelte | 4 +- src/lib/components/unit-selector.svelte | 3 +- src/routes/+layout.svelte | 2 +- src/routes/page.svelte.spec.ts | 2 +- .../weather/14-day/[location]/+page.svelte | 57 ++-- .../weather/compare/[location]/+page.svelte | 59 ++-- .../weather/week/[location]/+page.svelte | 2 +- .../weather/week/[location]/DailyCards.svelte | 118 ++++--- .../week/[location]/HourlyTable.svelte | 30 +- .../week/[location]/MeteogramCharts.svelte | 23 +- 19 files changed, 485 insertions(+), 201 deletions(-) create mode 100644 .mcp.json diff --git a/.gitignore b/.gitignore index 934dca1..cceef01 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* AGENTS.md + +# Local agent/editor config +.claude/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 60e0d0c..6e7136a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Drizzli +# Drizz.li An open-source, high-performance weather forecast website built with SvelteKit and powered by the [Open-Meteo APIs](https://open-meteo.com/). diff --git a/src/lib/charts/CanvasChart.svelte b/src/lib/charts/CanvasChart.svelte index a6ff9de..f0a834a 100644 --- a/src/lib/charts/CanvasChart.svelte +++ b/src/lib/charts/CanvasChart.svelte @@ -250,32 +250,6 @@ return color; } - // Multiply an rgb/hex colour toward black by `amount` (0 = unchanged, 1 = black). - function darken(color: string, amount: number): string { - const f = 1 - amount; - const m = color.match(/rgba?\(([^)]+)\)/); - if (m) { - const [r, g, b, a] = m[1].split(',').map((p) => parseFloat(p)); - const alpha = isNaN(a) ? 1 : a; - return `rgba(${Math.round(r * f)}, ${Math.round(g * f)}, ${Math.round(b * f)}, ${alpha})`; - } - if (color[0] === '#') { - const h = color.slice(1); - const n = - h.length === 3 - ? h - .split('') - .map((c) => c + c) - .join('') - : h; - const r = Math.round(parseInt(n.slice(0, 2), 16) * f); - const g = Math.round(parseInt(n.slice(2, 4), 16) * f); - const b = Math.round(parseInt(n.slice(4, 6), 16) * f); - return `rgb(${r}, ${g}, ${b})`; - } - return color; - } - // ─── State ────────────────────────────────────────────────────────────────── let containerEl: HTMLDivElement | undefined = $state(); @@ -323,17 +297,20 @@ 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 ? (isNarrow ? 34 : 56) : isNarrow ? 6 : 20 - ); + // has a right axis, so a stacked row of charts share the same plot width. On + // narrow (mobile) screens we don't reserve it — the graph uses the full width + // and the right-axis labels are drawn overlaid on top instead (see below). + let padRight = $derived(isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 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)); let iconRows = $derived(Math.max(ownIconRows, reserveTopRows)); - let padTop = $derived((title ? (subtitle ? 66 : 46) : 28) + iconRows * ICON_ROW_H); + // tighter top/bottom gutters on mobile so charts don't waste vertical space + const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H); + let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH); + let padBottom = $derived(isNarrow ? 24 : PAD_BOTTOM); let plotW = $derived(Math.max(1, width - padLeft - padRight)); - let plotH = $derived(Math.max(1, height - padTop - PAD_BOTTOM)); + let plotH = $derived(Math.max(1, height - padTop - padBottom)); interface Scale { min: number; @@ -452,6 +429,153 @@ return canvasEl ? canvasEl.toDataURL('image/png') : null; } + // ─── Full export (title + icon bands + legend composited onto one canvas) ──── + // The canvas alone omits the DOM overlays (weather/wind icons), the panel + // title (rendered by the parent), and the legend (an HTML row). This builds a + // standalone canvas that includes all of them so downloads look like the page. + const iconImageCache = new Map>(); + function loadColoredIcon(name: string, color: string): Promise { + const key = `${name}|${color}`; + let p = iconImageCache.get(key); + if (!p) { + p = fetch(`/images/weather-icons/${name}.svg`) + .then((r) => r.text()) + .then( + (svg) => + new Promise((resolve, reject) => { + // the paths carry no fill, so a root fill tints the whole glyph + const colored = svg.replace(/ { + URL.revokeObjectURL(url); + resolve(img); + }; + img.onerror = (e) => { + URL.revokeObjectURL(url); + reject(e); + }; + img.src = url; + }) + ); + iconImageCache.set(key, p); + } + return p; + } + + /** + * Composite the chart, its icon bands, an optional title, and the legend onto + * a fresh canvas for export. Async because the icon SVGs are rasterized. + */ + export async function getExportImage(opts?: { + title?: string; + }): Promise { + if (!canvasEl || !containerEl || width <= 0) return null; + const dpr = window.devicePixelRatio || 1; + const styles = getComputedStyle(containerEl); + const cssVar = (n: string, f: string) => styles.getPropertyValue(n).trim() || f; + const strong = cssVar('--foreground', '#374151'); + const muted = cssVar('--muted-foreground', '#6b7280'); + const bg = cssVar('--card', '#ffffff'); + + const title = opts?.title; + const titleH = title ? 30 : 0; + const legendItems = showLegend ? series.filter((s) => s.showInLegend !== false) : []; + const legendH = legendItems.length > 0 ? 28 : 0; + const totalH = titleH + height + legendH; + + const out = document.createElement('canvas'); + out.width = Math.round(width * dpr); + out.height = Math.round(totalH * dpr); + const ctx = out.getContext('2d'); + if (!ctx) return null; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + ctx.fillStyle = bg; + ctx.fillRect(0, 0, width, totalH); + + if (title) { + ctx.fillStyle = strong; + ctx.font = '600 14px system-ui, -apple-system, sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(title, 4, titleH / 2 + 2); + } + + // the plot itself (canvasEl is a dpr-scaled bitmap of the css-sized chart) + ctx.drawImage(canvasEl, 0, titleH, width, height); + + // weather pictograms + for (const p of visiblePictograms) { + try { + const img = await loadColoredIcon(p.icon, strong); + const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, p.x)); + ctx.drawImage( + img, + iconBandLeft + cx - ICON_PX / 2, + titleH + pictoRowTop + (ICON_BAND_H - ICON_PX) / 2, + ICON_PX, + ICON_PX + ); + } catch { + /* skip an icon that failed to rasterize */ + } + } + + // wind-direction arrows, rotated about their centre + for (const a of visibleWindArrows) { + try { + const img = await loadColoredIcon('wi-direction-down', strong); + const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, a.x)); + ctx.save(); + ctx.translate(iconBandLeft + cx, titleH + windRowTop + ICON_BAND_H / 2); + ctx.rotate((a.deg * Math.PI) / 180); + ctx.globalAlpha = 0.8; + ctx.drawImage(img, -ARROW_PX / 2, -ARROW_PX / 2, ARROW_PX, ARROW_PX); + ctx.restore(); + } catch { + /* skip */ + } + } + + // legend row, centred like the on-screen one + if (legendItems.length > 0) { + ctx.font = '12px system-ui, -apple-system, sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + const gap = 12; + const dot = 9; + const dotGap = 6; + const widths = legendItems.map((s) => dot + dotGap + ctx.measureText(s.name).width); + const totalW = widths.reduce((a, b) => a + b, 0) + gap * (legendItems.length - 1); + let x = Math.max(4, (width - totalW) / 2); + const y = titleH + height + legendH / 2; + for (let i = 0; i < legendItems.length; i++) { + const s = legendItems[i]; + ctx.fillStyle = s.color; + ctx.beginPath(); + ctx.arc(x + dot / 2, y, dot / 2, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = muted; + ctx.fillText(s.name, x + dot + dotGap, y); + x += widths[i] + gap; + } + } + + // credit hugging the bottom-right corner of the export + if (showCredit) { + ctx.font = '10px system-ui, -apple-system, sans-serif'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = muted; + ctx.globalAlpha = 0.8; + ctx.fillText('Weather data by Open-Meteo · visualisation by Drizz.li', width - 6, totalH - 5); + ctx.globalAlpha = 1; + } + + return out; + } + /** Zooms the x axis to the given epoch-second range (clamped to the data). */ export function setRange(startEpoch: number, endEpoch: number): void { applyRange(startEpoch, endEpoch); @@ -551,8 +675,8 @@ // Vertical offset (px from container top) of each icon row's top edge, anchored // just above the plot. When both rows are present, pictograms sit above the // wind arrows (which stay closest to the plot). - let pictoRowTop = $derived(padTop - (windArrows.length > 0 ? 2 : 1) * ICON_ROW_H + 2); - let windRowTop = $derived(padTop - ICON_ROW_H + 2); + let pictoRowTop = $derived(padTop - (windArrows.length > 0 ? 2 : 1) * iconRowH + 2); + let windRowTop = $derived(padTop - iconRowH + 2); // ─── Local minima / maxima (for value labels) ──────────────────────────────── @@ -608,13 +732,25 @@ break; } } + // A "EEE d" day label needs ~46px of room; when days are packed tighter + // (long ranges on a narrow screen) keep every day's gridline but only + // label every Nth one so the dates never overlap. + const pxPerDay = 24 * pxPerHour; + const dayStride = pxPerDay >= 46 ? 1 : Math.max(1, Math.ceil(46 / pxPerDay)); const ticks: XTick[] = []; const first = Math.ceil(viewStart / HOUR) * HOUR; + let dayCount = 0; for (let t = first; t <= viewEnd; t += HOUR) { const date = new Date(t * 1000); const hour = getZonedHour(date, timezone); if (hour === 0) { - ticks.push({ t, label: formatZoned(date, timezone, 'EEE d'), isDay: true }); + const showLabel = dayCount % dayStride === 0; + dayCount++; + ticks.push({ + t, + label: showLabel ? formatZoned(date, timezone, 'EEE d') : '', + isDay: true + }); } else if (step < 24 && hour % step === 0) { ticks.push({ t, label: formatZoned(date, timezone, 'HH:mm'), isDay: false }); } @@ -661,14 +797,16 @@ const plotBottom = padTop + plotH; const font = '11px system-ui, sans-serif'; - // Daylight bands + // Daylight bands (kept subtle so they don't compete with the data) ctx.fillStyle = CHART_COLORS.daylight; + ctx.globalAlpha = 0.65; for (const band of bands) { if (band.end < viewStart || band.start > viewEnd) continue; 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); } + ctx.globalAlpha = 1; // Selected-day highlight: soft tint + dashed edge lines if (highlight && highlight.end > viewStart && highlight.start < viewEnd) { @@ -713,8 +851,10 @@ ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y); } - // Right axis labels (only when a unit is provided) - if (hasRightAxis && unitRight !== undefined) { + // Right axis labels in the gutter (desktop only). On mobile there is no + // gutter — the labels are drawn on top of the graph with halos after the + // series (see drawOverlaidAxisLabels below), so the data can't cover them. + if (hasRightAxis && unitRight !== undefined && !isNarrow) { ctx.textAlign = 'left'; ctx.fillStyle = textColor; for ( @@ -843,11 +983,11 @@ // anchor = opaque end (far-from-zero extreme); fade = transparent end const anchorY = goUp ? minYp : maxYp; const nearY = goUp ? maxYp : minYp; - // fade runs a good stretch past the near extreme, but stops short of - // the plot edge (~60% of the way there) + // fade runs a good stretch past the near extreme, flowing most of the + // way to the plot edge (~78% of the way there) const fadeY = goUp - ? Math.max(padTop, maxYp - (maxYp - padTop) * 0.6) - : Math.min(plotBottom, minYp + (plotBottom - minYp) * 0.6); + ? Math.max(padTop, maxYp - (maxYp - padTop) * 0.78) + : Math.min(plotBottom, minYp + (plotBottom - minYp) * 0.78); const anchorV = goUp ? minV : maxV; const nearV = goUp ? maxV : minV; const span = fadeY - anchorY; @@ -876,7 +1016,11 @@ ctx.lineTo(points[0][0], fadeY); ctx.closePath(); ctx.fillStyle = grad; + // the bright temperature colours glow over a dark background, so + // knock the whole fill back to 75% in dark mode + ctx.globalAlpha = dark ? 0.75 : 1; ctx.fill(); + ctx.globalAlpha = 1; } if (s.fill && points.length > 1) { @@ -946,16 +1090,15 @@ if (t <= prevT) t = prevT + 1e-6; // keep stops strictly increasing if (t > 1) t = 1; prevT = t; - // a touch darker than the fill so the line reads as its edge - grad.addColorStop( - t, - darken(s.segmentColor(s.data[points[i][3]] as number, points[i][3]), 0.05) - ); + // exact same colour as the fill so the line and gradient match + grad.addColorStop(t, s.segmentColor(s.data[points[i][3]] as number, points[i][3])); } ctx.strokeStyle = grad; } else { ctx.strokeStyle = strongColor; } + // keep the line crisp (full opacity) as a clean edge; only the + // large fill area is dimmed in dark mode ctx.stroke(); } else { ctx.beginPath(); @@ -1001,6 +1144,28 @@ } } + // Mobile: right-axis labels overlaid on top of the data with a halo (no + // gutter is reserved on narrow screens, so the graph runs full width). + if (isNarrow && hasRightAxis && unitRight !== undefined) { + ctx.font = font; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.lineWidth = 3; + ctx.lineJoin = 'round'; + for ( + let v = rightScale.min; + v <= rightScale.max + rightScale.step / 2; + v += rightScale.step + ) { + const y = yPix(v, 'right'); + const label = v.toFixed(tickDecimals(rightScale.step)); + ctx.strokeStyle = bgColor; + ctx.strokeText(label, plotRight - 2, y); + ctx.fillStyle = textColor; + ctx.fillText(label, plotRight - 2, y); + } + } + // Current time marker if (showNow) { const now = Date.now() / 1000; @@ -1042,9 +1207,20 @@ ctx.fillText(unit, padLeft - 4, padTop - 8); } if (hasRightAxis && unitRight) { - ctx.textAlign = 'left'; - ctx.fillStyle = textColor; - ctx.fillText(unitRight, plotRight + 4, padTop - 8); + if (isNarrow) { + // overlaid inside the plot's top-right corner (no right gutter) with a halo + ctx.textAlign = 'right'; + ctx.lineWidth = 3; + ctx.lineJoin = 'round'; + ctx.strokeStyle = bgColor; + ctx.strokeText(unitRight, plotRight - 2, padTop - 8); + ctx.fillStyle = textColor; + ctx.fillText(unitRight, plotRight - 2, padTop - 8); + } else { + ctx.textAlign = 'left'; + ctx.fillStyle = textColor; + ctx.fillText(unitRight, plotRight + 4, padTop - 8); + } } // Title / subtitle @@ -1060,15 +1236,8 @@ } } - // Credit watermark - if (showCredit) { - ctx.textAlign = 'right'; - ctx.font = '10px system-ui, sans-serif'; - ctx.globalAlpha = 0.4; - ctx.fillStyle = strongColor; - ctx.fillText('Open-Meteo.com', width - 10, height - 6); - ctx.globalAlpha = 1; - } + // Credit is a DOM overlay (below) so its two sources can be links; the + // export path redraws it onto the exported canvas in getExportImage(). } $effect(() => { @@ -1369,7 +1538,9 @@ {#if showLegend && series.length > 0} -
+
{#each series.filter((s) => s.showInLegend !== false) as s (s.name)}
diff --git a/src/lib/components/charts/ChartContainer.svelte b/src/lib/components/charts/ChartContainer.svelte index 2ead30a..cba3d75 100644 --- a/src/lib/components/charts/ChartContainer.svelte +++ b/src/lib/components/charts/ChartContainer.svelte @@ -124,9 +124,9 @@ min-width: var(--chart-min-width); } - /* Mobile: fit the chart to the viewport instead of forcing a min-width + /* Below lg: 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) { + @media (max-width: 1023px) { .chart-container { min-width: 0; } @@ -135,7 +135,7 @@ } } - @media (min-width: 768px) { + @media (min-width: 1024px) { .chart-bleed { margin-left: -1.5rem; margin-right: -1.5rem; diff --git a/src/lib/components/charts/ChartToolbar.svelte b/src/lib/components/charts/ChartToolbar.svelte index ee3d951..742f3ab 100644 --- a/src/lib/components/charts/ChartToolbar.svelte +++ b/src/lib/components/charts/ChartToolbar.svelte @@ -19,11 +19,11 @@ --> - Drizzli | Weather + Drizz.li | Weather diff --git a/src/routes/weather/week/[location]/DailyCards.svelte b/src/routes/weather/week/[location]/DailyCards.svelte index d1df85d..ada330f 100644 --- a/src/routes/weather/week/[location]/DailyCards.svelte +++ b/src/routes/weather/week/[location]/DailyCards.svelte @@ -57,8 +57,8 @@ // 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; + // with the page hero), leaving the "past days" button fully off to the left + el.scrollLeft += wrap.getBoundingClientRect().left - el.getBoundingClientRect().left; }); }); @@ -119,13 +119,17 @@ -
- +
+
@@ -133,7 +137,7 @@ {#if canExtendPast && onExtendPast} {/if} -
+ "past days" button, letting it scroll out of view even on wide + screens --> +
{#each daily.dailyDates as time, index (index)} {@const selected = isSameDayInZone(time, selectedDay, daily.timezone)} {@const tempMax = daily.daily.temperature_2m_max[index]} @@ -324,36 +323,67 @@ {/if} {/each} -
- {#if canExtend && onExtend} - - {/if} + + + + + + Load
15 days +
+ + {/if} +
{/if}
+ + + {#if daily && canExtend && onExtend} + + {/if}