2 Commits
Author SHA1 Message Date
Vincent van der Wal 1d7d9d3a53 scroll behaviour 2026-07-25 14:14:49 +02:00
Vincent van der Wal c7924bb0ae visual updates 2026-07-25 13:56:16 +02:00
20 changed files with 521 additions and 201 deletions
+3
View File
@@ -23,3 +23,6 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
AGENTS.md AGENTS.md
# Local agent/editor config
.claude/
View File
+1 -1
View File
@@ -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/). An open-source, high-performance weather forecast website built with SvelteKit and powered by the [Open-Meteo APIs](https://open-meteo.com/).
+251 -60
View File
@@ -250,32 +250,6 @@
return color; 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 ────────────────────────────────────────────────────────────────── // ─── State ──────────────────────────────────────────────────────────────────
let containerEl: HTMLDivElement | undefined = $state(); let containerEl: HTMLDivElement | undefined = $state();
@@ -323,17 +297,20 @@
let isNarrow = $derived(width > 0 && width < 520); let isNarrow = $derived(width > 0 && width < 520);
let padLeft = $derived(isNarrow ? 26 : 60); let padLeft = $derived(isNarrow ? 26 : 60);
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis) // 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. // has a right axis, so a stacked row of charts share the same plot width. On
let padRight = $derived( // narrow (mobile) screens we don't reserve it — the graph uses the full width
hasRightAxis || reserveRightAxis ? (isNarrow ? 34 : 56) : isNarrow ? 6 : 20 // 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 // 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. // 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 ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
let iconRows = $derived(Math.max(ownIconRows, reserveTopRows)); 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 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 { interface Scale {
min: number; min: number;
@@ -452,6 +429,153 @@
return canvasEl ? canvasEl.toDataURL('image/png') : null; 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<string, Promise<HTMLImageElement>>();
function loadColoredIcon(name: string, color: string): Promise<HTMLImageElement> {
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<HTMLImageElement>((resolve, reject) => {
// the paths carry no fill, so a root fill tints the whole glyph
const colored = svg.replace(/<svg\b/, `<svg fill="${color}"`);
const url = URL.createObjectURL(new Blob([colored], { type: 'image/svg+xml' }));
const img = new Image();
img.onload = () => {
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<HTMLCanvasElement | null> {
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). */ /** Zooms the x axis to the given epoch-second range (clamped to the data). */
export function setRange(startEpoch: number, endEpoch: number): void { export function setRange(startEpoch: number, endEpoch: number): void {
applyRange(startEpoch, endEpoch); applyRange(startEpoch, endEpoch);
@@ -551,8 +675,8 @@
// Vertical offset (px from container top) of each icon row's top edge, anchored // 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 // just above the plot. When both rows are present, pictograms sit above the
// wind arrows (which stay closest to the plot). // wind arrows (which stay closest to the plot).
let pictoRowTop = $derived(padTop - (windArrows.length > 0 ? 2 : 1) * ICON_ROW_H + 2); let pictoRowTop = $derived(padTop - (windArrows.length > 0 ? 2 : 1) * iconRowH + 2);
let windRowTop = $derived(padTop - ICON_ROW_H + 2); let windRowTop = $derived(padTop - iconRowH + 2);
// ─── Local minima / maxima (for value labels) ──────────────────────────────── // ─── Local minima / maxima (for value labels) ────────────────────────────────
@@ -608,13 +732,25 @@
break; 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 ticks: XTick[] = [];
const first = Math.ceil(viewStart / HOUR) * HOUR; const first = Math.ceil(viewStart / HOUR) * HOUR;
let dayCount = 0;
for (let t = first; t <= viewEnd; t += HOUR) { for (let t = first; t <= viewEnd; t += HOUR) {
const date = new Date(t * 1000); const date = new Date(t * 1000);
const hour = getZonedHour(date, timezone); const hour = getZonedHour(date, timezone);
if (hour === 0) { 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) { } else if (step < 24 && hour % step === 0) {
ticks.push({ t, label: formatZoned(date, timezone, 'HH:mm'), isDay: false }); ticks.push({ t, label: formatZoned(date, timezone, 'HH:mm'), isDay: false });
} }
@@ -661,14 +797,16 @@
const plotBottom = padTop + plotH; const plotBottom = padTop + plotH;
const font = '11px system-ui, sans-serif'; 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.fillStyle = CHART_COLORS.daylight;
ctx.globalAlpha = 0.65;
for (const band of bands) { for (const band of bands) {
if (band.end < viewStart || band.start > viewEnd) continue; if (band.end < viewStart || band.start > viewEnd) continue;
const x1 = Math.max(padLeft, xPix(band.start)); const x1 = Math.max(padLeft, xPix(band.start));
const x2 = Math.min(plotRight, xPix(band.end)); const x2 = Math.min(plotRight, xPix(band.end));
if (x2 > x1) ctx.fillRect(x1, padTop, x2 - x1, plotH); if (x2 > x1) ctx.fillRect(x1, padTop, x2 - x1, plotH);
} }
ctx.globalAlpha = 1;
// Selected-day highlight: soft tint + dashed edge lines // Selected-day highlight: soft tint + dashed edge lines
if (highlight && highlight.end > viewStart && highlight.start < viewEnd) { if (highlight && highlight.end > viewStart && highlight.start < viewEnd) {
@@ -713,8 +851,10 @@
ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y); ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y);
} }
// Right axis labels (only when a unit is provided) // Right axis labels in the gutter (desktop only). On mobile there is no
if (hasRightAxis && unitRight !== undefined) { // 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.textAlign = 'left';
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
for ( for (
@@ -843,11 +983,11 @@
// anchor = opaque end (far-from-zero extreme); fade = transparent end // anchor = opaque end (far-from-zero extreme); fade = transparent end
const anchorY = goUp ? minYp : maxYp; const anchorY = goUp ? minYp : maxYp;
const nearY = goUp ? maxYp : minYp; const nearY = goUp ? maxYp : minYp;
// fade runs a good stretch past the near extreme, but stops short of // fade runs a good stretch past the near extreme, flowing most of the
// the plot edge (~60% of the way there) // way to the plot edge (~78% of the way there)
const fadeY = goUp const fadeY = goUp
? Math.max(padTop, maxYp - (maxYp - padTop) * 0.6) ? Math.max(padTop, maxYp - (maxYp - padTop) * 0.78)
: Math.min(plotBottom, minYp + (plotBottom - minYp) * 0.6); : Math.min(plotBottom, minYp + (plotBottom - minYp) * 0.78);
const anchorV = goUp ? minV : maxV; const anchorV = goUp ? minV : maxV;
const nearV = goUp ? maxV : minV; const nearV = goUp ? maxV : minV;
const span = fadeY - anchorY; const span = fadeY - anchorY;
@@ -876,7 +1016,11 @@
ctx.lineTo(points[0][0], fadeY); ctx.lineTo(points[0][0], fadeY);
ctx.closePath(); ctx.closePath();
ctx.fillStyle = grad; 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.fill();
ctx.globalAlpha = 1;
} }
if (s.fill && points.length > 1) { if (s.fill && points.length > 1) {
@@ -946,16 +1090,15 @@
if (t <= prevT) t = prevT + 1e-6; // keep stops strictly increasing if (t <= prevT) t = prevT + 1e-6; // keep stops strictly increasing
if (t > 1) t = 1; if (t > 1) t = 1;
prevT = t; prevT = t;
// a touch darker than the fill so the line reads as its edge // exact same colour as the fill so the line and gradient match
grad.addColorStop( grad.addColorStop(t, s.segmentColor(s.data[points[i][3]] as number, points[i][3]));
t,
darken(s.segmentColor(s.data[points[i][3]] as number, points[i][3]), 0.05)
);
} }
ctx.strokeStyle = grad; ctx.strokeStyle = grad;
} else { } else {
ctx.strokeStyle = strongColor; 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(); ctx.stroke();
} else { } else {
ctx.beginPath(); 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 // Current time marker
if (showNow) { if (showNow) {
const now = Date.now() / 1000; const now = Date.now() / 1000;
@@ -1042,9 +1207,20 @@
ctx.fillText(unit, padLeft - 4, padTop - 8); ctx.fillText(unit, padLeft - 4, padTop - 8);
} }
if (hasRightAxis && unitRight) { if (hasRightAxis && unitRight) {
ctx.textAlign = 'left'; if (isNarrow) {
ctx.fillStyle = textColor; // overlaid inside the plot's top-right corner (no right gutter) with a halo
ctx.fillText(unitRight, plotRight + 4, padTop - 8); 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 // Title / subtitle
@@ -1060,15 +1236,8 @@
} }
} }
// Credit watermark // Credit is a DOM overlay (below) so its two sources can be links; the
if (showCredit) { // export path redraws it onto the exported canvas in getExportImage().
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;
}
} }
$effect(() => { $effect(() => {
@@ -1369,7 +1538,9 @@
<!-- Legend sits below the graph --> <!-- Legend sits below the graph -->
{#if showLegend && series.length > 0} {#if showLegend && series.length > 0}
<div class="mt-1.5 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 px-1"> <div
class="mt-0.5 flex flex-wrap items-center justify-center gap-x-3 gap-y-0.5 px-1 md:mt-1.5 md:gap-y-1"
>
{#each series.filter((s) => s.showInLegend !== false) as s (s.name)} {#each series.filter((s) => s.showInLegend !== false) as s (s.name)}
<button <button
type="button" type="button"
@@ -1392,4 +1563,24 @@
{/each} {/each}
</div> </div>
{/if} {/if}
<!-- Credit: below the legend, in the bottom-right corner, with linked sources -->
{#if showCredit}
<div
class="px-2 pt-2 pb-1 text-center md:text-right text-[10px] leading-none text-muted-foreground/70"
>
Weather data by <a
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
href="https://open-meteo.com"
target="_blank"
rel="noopener noreferrer">Open-Meteo</a
>, visualisation by
<a
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
href="https://drizz.li"
target="_blank"
rel="noopener noreferrer">Drizz.li</a
>
</div>
{/if}
</div> </div>
@@ -124,9 +124,9 @@
min-width: var(--chart-min-width); 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. */ sideways scroll (which fights touch inspection). Pinch to zoom for detail. */
@media (max-width: 767px) { @media (max-width: 1023px) {
.chart-container { .chart-container {
min-width: 0; min-width: 0;
} }
@@ -135,7 +135,7 @@
} }
} }
@media (min-width: 768px) { @media (min-width: 1024px) {
.chart-bleed { .chart-bleed {
margin-left: -1.5rem; margin-left: -1.5rem;
margin-right: -1.5rem; margin-right: -1.5rem;
@@ -19,11 +19,11 @@
</ChartToolbar> </ChartToolbar>
--> -->
<script module lang="ts"> <script module lang="ts">
export type { DownloadableChart } from './downloadChartsPng'; export type { ExportableChart } from './downloadChartsPng';
</script> </script>
<script lang="ts"> <script lang="ts">
import { type DownloadableChart, downloadChartsPng } from './downloadChartsPng'; import { type ExportableChart, downloadChartsPng } from './downloadChartsPng';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -31,7 +31,7 @@
interface Props { interface Props {
/** Chart components available for download (undefined entries are skipped) */ /** Chart components available for download (undefined entries are skipped) */
charts?: Array<DownloadableChart | undefined | null>; charts?: Array<ExportableChart | undefined | null>;
/** Base file name for downloaded images (without extension) */ /** Base file name for downloaded images (without extension) */
fileName?: string; fileName?: string;
/** Optional CSS class for the outer container */ /** Optional CSS class for the outer container */
+28 -26
View File
@@ -1,23 +1,20 @@
/** /**
* Shared PNG export for charts. * Shared PNG export for charts.
* *
* Stitches one or more chart images vertically onto a single canvas (over the * Each chart composites itself (plot + icon bands + optional title + legend)
* current theme background) and triggers a download. Used by both the standalone * onto a canvas via `getExportImage`; those are stacked vertically over the
* ChartToolbar and the inline toolbar buttons. * current theme background and downloaded as one PNG.
*/ */
/** Anything that can export itself as a PNG data URL (e.g. a CanvasChart). */ /** A chart that can composite itself (plot + icons + legend) for export. */
export interface DownloadableChart { export interface ExportableChart {
getPngDataUrl(): string | null; getExportImage(opts?: { title?: string }): Promise<HTMLCanvasElement | null>;
} }
function loadImage(src: string): Promise<HTMLImageElement> { /** One chart to export, with the title to render above it (e.g. panel name). */
return new Promise((resolve) => { export interface ChartExportItem {
const img = new Image(); chart: ExportableChart | null | undefined;
img.onload = () => resolve(img); title?: string;
img.onerror = () => resolve(img);
img.src = src;
});
} }
/** Resolves the page background so exports match the current theme. */ /** Resolves the page background so exports match the current theme. */
@@ -38,25 +35,30 @@ function triggerDownload(url: string, name: string): void {
}); });
} }
/** Normalise either a bare chart or a {chart, title} item. */
function toItem(x: ChartExportItem | ExportableChart | null | undefined): ChartExportItem {
if (x && 'getExportImage' in x) return { chart: x };
return (x as ChartExportItem) ?? { chart: null };
}
/** /**
* Stitch the given charts into one PNG and download it. Resolves once the * Stitch the given charts into one PNG and download it. Resolves once the
* download has been triggered (or immediately if there is nothing to export). * download has been triggered (or immediately if there is nothing to export).
*/ */
export async function downloadChartsPng( export async function downloadChartsPng(
charts: Array<DownloadableChart | undefined | null>, charts: Array<ChartExportItem | ExportableChart | null | undefined>,
fileName: string fileName: string
): Promise<void> { ): Promise<void> {
const dataUrls = charts const items = charts.map(toItem).filter((it) => it.chart != null);
.filter((chart): chart is DownloadableChart => chart != null) if (items.length === 0) return;
.map((chart) => chart.getPngDataUrl())
.filter((url): url is string => url !== null);
if (dataUrls.length === 0) return;
const images = (await Promise.all(dataUrls.map(loadImage))).filter((img) => img.naturalWidth > 0); const canvases = (
if (images.length === 0) return; await Promise.all(items.map((it) => it.chart!.getExportImage({ title: it.title })))
).filter((c): c is HTMLCanvasElement => c != null && c.width > 0 && c.height > 0);
if (canvases.length === 0) return;
const maxWidth = Math.max(...images.map((img) => img.naturalWidth)); const maxWidth = Math.max(...canvases.map((c) => c.width));
const totalHeight = images.reduce((sum, img) => sum + img.naturalHeight, 0); const totalHeight = canvases.reduce((sum, c) => sum + c.height, 0);
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = maxWidth; canvas.width = maxWidth;
@@ -69,9 +71,9 @@ export async function downloadChartsPng(
ctx.fillRect(0, 0, maxWidth, totalHeight); ctx.fillRect(0, 0, maxWidth, totalHeight);
let y = 0; let y = 0;
for (const img of images) { for (const c of canvases) {
ctx.drawImage(img, 0, y); ctx.drawImage(c, 0, y);
y += img.naturalHeight; y += c.height;
} }
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`); triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
+1 -1
View File
@@ -9,4 +9,4 @@
export { default as ChartContainer } from './ChartContainer.svelte'; export { default as ChartContainer } from './ChartContainer.svelte';
export { default as ChartToolbar } from './ChartToolbar.svelte'; export { default as ChartToolbar } from './ChartToolbar.svelte';
export { downloadChartsPng, type DownloadableChart } from './downloadChartsPng'; export { downloadChartsPng, type ExportableChart, type ChartExportItem } from './downloadChartsPng';
+1 -1
View File
@@ -57,7 +57,7 @@
> >
<!-- Mobile menu toggle --> <!-- Mobile menu toggle -->
<button <button
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors hover:bg-black/5 md:hidden dark:hover:bg-white/10" class="-ms-1 flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:hidden"
onclick={onMenuToggle} onclick={onMenuToggle}
aria-label="Toggle menu" aria-label="Toggle menu"
> >
@@ -87,7 +87,7 @@
</div> </div>
{#if !collapsed} {#if !collapsed}
<span class="text-sm font-bold tracking-tight whitespace-nowrap text-sidebar-foreground"> <span class="text-sm font-bold tracking-tight whitespace-nowrap text-sidebar-foreground">
Drizzli Drizz.li
</span> </span>
{/if} {/if}
</a> </a>
@@ -129,7 +129,7 @@
{#if onToggle} {#if onToggle}
<div class="border-t border-sidebar-border px-2 py-3"> <div class="border-t border-sidebar-border px-2 py-3">
<button <button
class="relative flex w-full items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100" class="relative flex w-full cursor-pointer items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100"
onclick={onToggle} onclick={onToggle}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
> >
+1 -1
View File
@@ -6,7 +6,7 @@
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'; import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
export const buttonVariants = tv({ export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs', default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs',
+1 -2
View File
@@ -44,7 +44,7 @@
<Popover.Root> <Popover.Root>
<Popover.Trigger <Popover.Trigger
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 px-2.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground" class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
aria-label="Choose measurement units" aria-label="Choose measurement units"
title="Units" title="Units"
> >
@@ -63,7 +63,6 @@
/> />
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none" /> <circle cx="12" cy="15" r="1" fill="currentColor" stroke="none" />
</svg> </svg>
<span class="hidden text-xs font-semibold sm:inline">Units</span>
</Popover.Trigger> </Popover.Trigger>
<Popover.Content align="end" class="w-64 border-border"> <Popover.Content align="end" class="w-64 border-border">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
+1 -1
View File
@@ -83,7 +83,7 @@
<Header onMenuToggle={toggleMobileMenu} /> <Header onMenuToggle={toggleMobileMenu} />
<main <main
class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-3 md:px-8 md:py-6'} class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-3 lg:px-8 lg:py-6'}
> >
{#if fullBleed} {#if fullBleed}
{@render children()} {@render children()}
+1 -1
View File
@@ -8,6 +8,6 @@ describe('/+page.svelte', () => {
render(Page); render(Page);
const title = document.querySelector('title'); const title = document.querySelector('title');
expect(title?.textContent).toBe('Drizzli'); expect(title?.textContent).toBe('Drizz.li');
}); });
}); });
@@ -334,7 +334,7 @@
/> />
</svg> </svg>
<span> <span>
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead the spread is This model's ensemble only reaches about <strong>{validDays} days</strong> ahead, the spread is
trimmed to its available range. trimmed to its available range.
</span> </span>
</div> </div>
@@ -348,29 +348,42 @@
</div> </div>
{/if} {/if}
<ChartContainer {loading} chartCount={params.hourly?.length || 1} chartHeight={300} bleed={false}> {#if fetchedData}
{#if fetchedData} <!-- full-bleed graphs until lg / contained card on lg+; titles stay within
the page margins (padded), the graphs bleed to the edges -->
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
{#each chartDefs as def, i (i)} {#each chartDefs as def, i (i)}
<CanvasChart <div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
bind:this={chartComponents[i]} <div class="mb-1 px-3 lg:px-0">
timestamps={timestampsSec} <h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
timezone={fetchedData.timezone} {#if def.subtitle}
series={def.series} <p class="text-xs text-muted-foreground">{def.subtitle}</p>
bands={fetchedData.daylightBands} {/if}
unit={def.unit} </div>
title={def.title} <ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
subtitle={def.subtitle} <CanvasChart
showCredit={def.showCredit} bind:this={chartComponents[i]}
zeroBaseLeft={def.zeroBaseLeft ?? true} timestamps={timestampsSec}
yMin={def.yMin} timezone={fetchedData.timezone}
yMax={def.yMax} series={def.series}
{showLegend} bands={fetchedData.daylightBands}
height={300} unit={def.unit}
group={CHART_GROUP} showCredit={def.showCredit}
/> zeroBaseLeft={def.zeroBaseLeft ?? true}
yMin={def.yMin}
yMax={def.yMax}
{showLegend}
height={300}
group={CHART_GROUP}
/>
</ChartContainer>
</div>
{/each} {/each}
{/if} </div>
</ChartContainer> {:else}
<!-- reserve the chart area height before data arrives (no layout shift) -->
<ChartContainer loading chartCount={params.hourly?.length || 1} chartHeight={340} bleed={false} />
{/if}
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── --> <!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
@@ -359,31 +359,44 @@
<!-- chart count derives from the selected variables (not the fetched data), <!-- chart count derives from the selected variables (not the fetched data),
so the reserved height is right even before the response arrives --> so the reserved height is right even before the response arrives -->
<ChartContainer {#if fetchedData}
{loading} <!-- full-bleed graphs until lg / contained card on lg+; titles stay within
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1} the page margins (padded), the graphs bleed to the edges -->
chartHeight={300} <div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
bleed={false}
>
{#if fetchedData}
{#each chartDefs as def, i (i)} {#each chartDefs as def, i (i)}
<CanvasChart <div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
bind:this={chartComponents[i]} <div class="mb-1 px-3 lg:px-0">
timestamps={timestampsSec} <h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
timezone={fetchedData.timezone} {#if def.subtitle}
series={def.series} <p class="text-xs text-muted-foreground">{def.subtitle}</p>
bands={fetchedData.daylightBands} {/if}
unit={def.unit} </div>
title={def.title} <ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
subtitle={def.subtitle} <CanvasChart
showCredit={def.showCredit} bind:this={chartComponents[i]}
{showLegend} timestamps={timestampsSec}
height={300} timezone={fetchedData.timezone}
group={CHART_GROUP} series={def.series}
/> bands={fetchedData.daylightBands}
unit={def.unit}
showCredit={def.showCredit}
{showLegend}
height={300}
group={CHART_GROUP}
/>
</ChartContainer>
</div>
{/each} {/each}
{/if} </div>
</ChartContainer> {:else}
<!-- reserve the chart area height before data arrives (no layout shift) -->
<ChartContainer
loading
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
chartHeight={340}
bleed={false}
/>
{/if}
{#if fetchedData && !loading} {#if fetchedData && !loading}
<ModelPictogramTimeline <ModelPictogramTimeline
@@ -145,7 +145,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Drizzli | Weather</title> <title>Drizz.li | Weather</title>
<link rel="canonical" href="https://drizz.li/weather/week" /> <link rel="canonical" href="https://drizz.li/weather/week" />
<meta name="description" content="7-day weather forecast with detailed hourly data" /> <meta name="description" content="7-day weather forecast with detailed hourly data" />
</svelte:head> </svelte:head>
@@ -57,7 +57,9 @@
// defer to after layout so the measured positions and scroll width are final // defer to after layout so the measured positions and scroll width are final
requestAnimationFrame(() => { requestAnimationFrame(() => {
// scroll so the first day card sits exactly at the content edge (aligned // 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 // with the page hero), leaving the "past days" button fully off to the
// left. The scroll's pl-3 keeps the lifted/scaled card from being clipped,
// so subtract it here to land the card on the content edge.
el.scrollLeft += wrap.getBoundingClientRect().left - el.getBoundingClientRect().left - 12; el.scrollLeft += wrap.getBoundingClientRect().left - el.getBoundingClientRect().left - 12;
}); });
}); });
@@ -119,13 +121,17 @@
</defs> </defs>
</svg> </svg>
<div transition:fade={{ duration: 200 }} class="mb-1 min-h-47.5 md:mb-6 md:min-h-65"> <div
<!-- negative margin + matching padding: the scroll box gains room so a transition:fade={{ duration: 200 }}
lifted/scaled/shadowed card is never clipped, while the first card still class="-mx-3 mb-1 flex min-h-47.5 items-stretch gap-2 px-3 md:mb-6 md:min-h-65"
lines up with the page content edge --> >
<!-- A horizontally-scrolling rail (past button + day cards) plus a "load more"
card pinned to the right so it's always visible on every screen. The
matching top/bottom padding gives a lifted/scaled card room so it's never
clipped, while the first card still lines up with the page content edge. -->
<div <div
bind:this={scrollEl} bind:this={scrollEl}
class="day-scroll -mx-3 flex gap-2 overflow-x-auto px-3 pt-2 pb-3 md:pt-5 md:pb-11" class="day-scroll -ml-3 flex min-w-0 flex-1 gap-2 overflow-x-auto pt-3 pb-3 pl-3 md:pt-5 md:pb-11"
class:scrolling class:scrolling
onscroll={onScroll} onscroll={onScroll}
> >
@@ -133,7 +139,7 @@
{#if canExtendPast && onExtendPast} {#if canExtendPast && onExtendPast}
<button <button
type="button" type="button"
class="flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground" class="mr-4 flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
onclick={onExtendPast} onclick={onExtendPast}
aria-label="Load recent past days" aria-label="Load recent past days"
> >
@@ -157,14 +163,9 @@
</button> </button>
{/if} {/if}
<!-- the cards fill at least the viewport so the row overflows past the <!-- the cards fill at least the viewport so the row overflows past the
"past days" button (letting it scroll out of view even on wide "past days" button, letting it scroll out of view even on wide
screens). On md+ we also reserve room so the "load more" button stays screens -->
visible; on mobile it's simply reached by scrolling (never clipped). --> <div bind:this={cardsWrapEl} class="cards-fill flex gap-2">
<div
bind:this={cardsWrapEl}
class="cards-fill flex gap-2"
style="--fill-reserve: {canExtend ? '6.5rem' : '0rem'}"
>
{#each daily.dailyDates as time, index (index)} {#each daily.dailyDates as time, index (index)}
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)} {@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
{@const tempMax = daily.daily.temperature_2m_max[index]} {@const tempMax = daily.daily.temperature_2m_max[index]}
@@ -324,36 +325,67 @@
</button> </button>
{/if} {/if}
{/each} {/each}
</div>
{#if canExtend && onExtend} <!-- Mobile: the "load more" card is the last item in the SAME flex row
<button as the cards (reached by scrolling); keeping it in this container
type="button" avoids the zoom/flex mis-position seen when it was a sibling. On
class="flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground" md+ it's hidden here and the pinned sibling below is shown. -->
onclick={onExtend} {#if canExtend && onExtend}
aria-label="Load the longer-range forecast" <button
> type="button"
<svg class="day-card-btn flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:hidden"
class="h-6 w-6" onclick={onExtend}
fill="none" aria-label="Load the longer-range forecast"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
> >
<path <svg
stroke-linecap="round" class="h-6 w-6"
stroke-linejoin="round" fill="none"
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z" stroke="currentColor"
/> viewBox="0 0 24 24"
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" /> stroke-width="1.75"
</svg> >
<span class="text-center text-[11px] leading-tight font-semibold"> <path
Load<br />15 days stroke-linecap="round"
</span> stroke-linejoin="round"
</button> d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
{/if} />
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
</svg>
<span class="text-center text-[11px] leading-tight font-semibold">
Load<br />15 days
</span>
</button>
{/if}
</div>
{/if} {/if}
</div> </div>
<!-- Desktop: "load more" card pinned to the right of the scroll rail (a
sibling, not inside the scroll) so it's always visible and full-height. -->
{#if daily && canExtend && onExtend}
<button
type="button"
class="mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:mt-5 md:mb-11 md:flex md:w-24"
onclick={onExtend}
aria-label="Load the longer-range forecast"
>
<svg
class="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
</svg>
<span class="text-center text-[11px] leading-tight font-semibold"> Load<br />15 days </span>
</button>
{/if}
</div> </div>
<style> <style>
@@ -362,6 +394,10 @@
.day-scroll { .day-scroll {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: transparent transparent; scrollbar-color: transparent transparent;
/* Soft fade at the right edge (only) so cards dissolve into the page
margin instead of being hard-cut as they scroll off. */
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent);
} }
.day-scroll.scrolling { .day-scroll.scrolling {
scrollbar-color: color-mix(in oklab, var(--color-border) 85%, transparent) transparent; scrollbar-color: color-mix(in oklab, var(--color-border) 85%, transparent) transparent;
@@ -281,6 +281,68 @@
: null : null
); );
// ─── Auto-scroll the (overflowing) table on day / interval change ────────────
// • Day change → today lands on the current-hour cell, other days on 06:00.
// • Interval change (3h ↔ 1h) → keep whatever cell is currently on the left.
let tableScrollEl = $state<HTMLDivElement>();
let autoScrolledDay = -1;
let lastInterval = 0;
let viewLeftHour = 6; // zoned hour at the left edge (plain, non-reactive)
function colWidthPx(): number {
return (tableWidth - headerColWidth) / cellData.length;
}
function scrollToIdx(idx: number) {
const el = tableScrollEl;
if (!el) return;
if (el.scrollWidth <= el.clientWidth + 4) {
el.scrollLeft = 0;
return;
}
el.scrollLeft = idx <= 0 ? 0 : Math.max(0, idx * colWidthPx());
}
// track the left-most visible cell's hour so an interval switch can restore it
function onTableScroll() {
const el = tableScrollEl;
if (!el || cellData.length === 0 || tableWidth === 0) return;
const idx = Math.min(
cellData.length - 1,
Math.max(0, Math.round(el.scrollLeft / colWidthPx()))
);
viewLeftHour = getZonedHour(cellData[idx].date, data.timezone);
}
$effect(() => {
const day = selectedDay.getTime();
const interval = is3h ? 3 : 1;
const el = tableScrollEl;
if (!el || cellData.length === 0 || tableWidth === 0 || headerColWidth === 0) return;
const dayChanged = autoScrolledDay !== day;
const intervalChanged = lastInterval !== interval;
if (!dayChanged && !intervalChanged) return;
autoScrolledDay = day;
lastInterval = interval;
let targetIdx: number;
if (dayChanged) {
// today → the cell whose block contains "now"; otherwise → 06:00
const nowMs = today.getTime();
const stepMs = interval * 3600 * 1000;
const nowIdx = cellData.findIndex(
(c) => nowMs >= c.date.getTime() && nowMs < c.date.getTime() + stepMs
);
targetIdx =
nowIdx >= 0 ? nowIdx : cellData.findIndex((c) => getZonedHour(c.date, data.timezone) >= 6);
viewLeftHour = targetIdx >= 0 ? getZonedHour(cellData[targetIdx].date, data.timezone) : 6;
} else {
// interval change only: keep the same cell on the left
targetIdx = cellData.findIndex((c) => getZonedHour(c.date, data.timezone) >= viewLeftHour);
}
requestAnimationFrame(() => scrollToIdx(targetIdx));
});
// ─── Chart-hover mirror ───────────────────────────────────────────────────── // ─── Chart-hover mirror ─────────────────────────────────────────────────────
// When the shared meteogram is hovered, highlight the matching table column // When the shared meteogram is hovered, highlight the matching table column
// (only if the hovered time falls on the day the table is currently showing). // (only if the hovered time falls on the day the table is currently showing).
@@ -387,7 +449,7 @@
<!-- Below the min-width the table scrolls sideways instead of squeezing; <!-- Below the min-width the table scrolls sideways instead of squeezing;
1h needs far more room than 3h (24 vs 8 columns) --> 1h needs far more room than 3h (24 vs 8 columns) -->
<div class="overflow-x-auto"> <div class="overflow-x-auto" bind:this={tableScrollEl} onscroll={onTableScroll}>
<div <div
class="relative {is3h ? 'min-w-[560px]' : 'min-w-[1100px]'}" class="relative {is3h ? 'min-w-[560px]' : 'min-w-[1100px]'}"
bind:clientWidth={tableWidth} bind:clientWidth={tableWidth}
@@ -35,7 +35,9 @@
if (liveCharts.length === 0 || downloadingPng) return; if (liveCharts.length === 0 || downloadingPng) return;
downloadingPng = true; downloadingPng = true;
try { try {
await downloadChartsPng(liveCharts, 'week-forecast'); // pair each chart with its panel title so the export is labelled
const items = renderPanels.map((p, i) => ({ chart: chartComponents[i], title: p.title }));
await downloadChartsPng(items, 'week-forecast');
} finally { } finally {
setTimeout(() => (downloadingPng = false), 500); setTimeout(() => (downloadingPng = false), 500);
} }
@@ -276,26 +278,25 @@
<div <div
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground" class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
> >
No meteograms configured <button No meteograms configured, <button
class="cursor-pointer font-semibold text-primary underline-offset-2 hover:underline" class="cursor-pointer font-semibold text-primary underline-offset-2 hover:underline"
onclick={() => (customizerOpen = true)}>add some variables</button onclick={() => (customizerOpen = true)}>add some variables</button
>. >.
</div> </div>
{:else} {:else}
<!-- one full-bleed card on mobile / contained card on md+, graphs stacked <!-- one full-bleed card until lg / contained card on lg+, graphs stacked
tightly so they read as one fluent meteogram --> tightly so they read as one fluent meteogram -->
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"> <div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
{#each renderPanels as panel, i (panel.id)} {#each renderPanels as panel, i (panel.id)}
<div <div
class="px-0 pt-2 pb-1 md:px-4 {i > 0 ? 'border-t border-border/50' : 'md:pt-4'} {i === class="px-0 pt-0.5 pb-0 lg:px-4 lg:pt-2 lg:pb-1 {i > 0
renderPanels.length - 1 ? 'border-t border-border/50'
? 'pb-3 md:pb-4' : 'lg:pt-4'} {i === renderPanels.length - 1 ? 'pb-1 lg:pb-4' : ''}"
: ''}"
> >
<div class="mb-0.5 flex items-center justify-between px-3 md:px-0"> <div class="mb-0 flex items-center justify-between px-3 lg:mb-0.5 lg:px-0">
<h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase"> <h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase">
<span class="hidden md:inline">{panel.title}</span> <span class="hidden lg:inline">{panel.title}</span>
<span class="md:hidden">{panel.titleShort}</span> <span class="lg:hidden">{panel.titleShort}</span>
</h4> </h4>
</div> </div>
<ChartContainer <ChartContainer