Compare commits
2
Commits
638d06df43
...
1d7d9d3a53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d7d9d3a53 | ||
|
|
c7924bb0ae |
@@ -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/
|
||||||
|
|||||||
@@ -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/).
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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'}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user