temperature chart and table alignments

This commit is contained in:
Vincent van der Wal
2026-07-25 12:43:41 +02:00
parent d78ac84a12
commit cd98b7a5cc
16 changed files with 621 additions and 133 deletions
+179 -15
View File
@@ -31,6 +31,9 @@
width?: number; width?: number;
/** Draw a low-alpha area fill below (or above, on inverted axes) the line */ /** Draw a low-alpha area fill below (or above, on inverted axes) the line */
fill?: boolean; fill?: boolean;
/** Area fill coloured by the value scale (segmentColor), fading to
* transparent ~20px below the series minimum. */
gradientFill?: boolean;
/** With `fill`, fill the area between this line and another data array /** With `fill`, fill the area between this line and another data array
* instead of the baseline (e.g. an ensemble min-max band) */ * instead of the baseline (e.g. an ensemble min-max band) */
bandTo?: (number | null)[]; bandTo?: (number | null)[];
@@ -50,6 +53,9 @@
shortName?: string; shortName?: string;
/** Colour each line segment by value (e.g. a temperature colour scale) */ /** Colour each line segment by value (e.g. a temperature colour scale) */
segmentColor?: (value: number, index: number) => string; segmentColor?: (value: number, index: number) => string;
/** Draw the line itself in the theme foreground (black/white), ignoring
* segmentColor for the stroke (segmentColor still colours any fill) */
foregroundLine?: boolean;
/** Draw a contrasting halo (black in light mode, white in dark) under the line */ /** Draw a contrasting halo (black in light mode, white in dark) under the line */
outline?: boolean; outline?: boolean;
/** Annotate local minima / maxima with their value */ /** Annotate local minima / maxima with their value */
@@ -101,6 +107,14 @@
export function groupRange(name: string): { start: number; end: number } | null { export function groupRange(name: string): { start: number; end: number } | null {
return groups[name]?.range ?? null; return groups[name]?.range ?? null;
} }
/**
* Current shared crosshair time of a group (epoch seconds, or null), reactive.
* Lets outside UI (e.g. the hourly table) mirror the chart's hovered timestep.
*/
export function groupHover(name: string): number | null {
return groups[name]?.hover ?? null;
}
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -202,12 +216,66 @@
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
const HOUR = 3600; const HOUR = 3600;
// Top icon rows (weather pictograms / wind arrows) // Top icon rows (weather pictograms / wind arrows)
const ICON_ROW_H = 30; // reserved height per icon row const ICON_ROW_H = 40; // reserved height per icon row
const ICON_BAND_H = 28; // visible band height const ICON_BAND_H = 38; // visible band height
const ICON_PX = 26; // pictogram size const ICON_PX = 25; // pictogram size (a touch smaller than the arrows)
const ARROW_PX = 36; // wind-direction arrow size
// edge inset used for BOTH rows so pictograms and arrows clamp to the same
// centre and stay aligned with each other
const ICON_EDGE = ARROW_PX / 2;
// Puffy cloud band: 100% cover hangs 40px from the top of the plot // Puffy cloud band: 100% cover hangs 40px from the top of the plot
const CLOUD_BAND_MAX = 40; const CLOUD_BAND_MAX = 40;
/** Return a colour string with the given alpha (handles rgb/rgba/#hex). */
function withAlpha(color: string, alpha: number): string {
const m = color.match(/rgba?\(([^)]+)\)/);
if (m) {
const [r, g, b] = m[1].split(',').map((p) => parseFloat(p));
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
if (color[0] === '#') {
const h = color.slice(1);
const n =
h.length === 3
? h
.split('')
.map((c) => c + c)
.join('')
: h;
const r = parseInt(n.slice(0, 2), 16);
const g = parseInt(n.slice(2, 4), 16);
const b = parseInt(n.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
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();
@@ -455,6 +523,8 @@
for (const p of pictograms) { for (const p of pictograms) {
if (p.t < viewStart || p.t > viewEnd) continue; if (p.t < viewStart || p.t > viewEnd) continue;
const x = iconBandX(p.t); const x = iconBandX(p.t);
// keep clear of the band edges so the first/last never bunch or clip
if (x < ICON_EDGE || x > iconBandWidth - ICON_EDGE) continue;
if (x - lastX < 40) continue; if (x - lastX < 40) continue;
out.push({ x, icon: p.icon }); out.push({ x, icon: p.icon });
lastX = x; lastX = x;
@@ -470,6 +540,7 @@
for (const a of windArrows) { for (const a of windArrows) {
if (a.t < viewStart || a.t > viewEnd) continue; if (a.t < viewStart || a.t > viewEnd) continue;
const x = iconBandX(a.t); const x = iconBandX(a.t);
if (x < ICON_EDGE || x > iconBandWidth - ICON_EDGE) continue;
if (x - lastX < 40) continue; if (x - lastX < 40) continue;
out.push({ x, deg: a.deg }); out.push({ x, deg: a.deg });
lastX = x; lastX = x;
@@ -582,7 +653,9 @@
const gridColor = cssColor('--border', 'rgba(0, 0, 0, 0.1)'); const gridColor = cssColor('--border', 'rgba(0, 0, 0, 0.1)');
const bgColor = cssColor('--card', '#ffffff'); const bgColor = cssColor('--card', '#ffffff');
const dark = document.documentElement.classList.contains('dark'); const dark = document.documentElement.classList.contains('dark');
const outlineColor = dark ? '#ffffff' : '#000000'; // halo matches the background (white in light mode, dark in dark mode) so a
// line reads clearly where it crosses others
const outlineColor = bgColor;
const plotRight = padLeft + plotW; const plotRight = padLeft + plotW;
const plotBottom = padTop + plotH; const plotBottom = padTop + plotH;
@@ -753,6 +826,59 @@
for (const points of runs) { for (const points of runs) {
if (points.length === 0) continue; if (points.length === 0) continue;
if (s.gradientFill && points.length > 1) {
// Coloured fill anchored at the curve, fading out towards zero: down
// for positive values, up for all-negative values. Full opacity near
// the far-from-zero extreme, fading ~30px past the near-zero extreme.
let minV = Infinity;
let maxV = -Infinity;
for (const p of points) {
const val = s.data[p[3]] as number;
if (val < minV) minV = val;
if (val > maxV) maxV = val;
}
const goUp = maxV <= 0; // all non-positive → fill toward zero (upward)
const maxYp = yPix(maxV, axis);
const minYp = yPix(minV, axis);
// anchor = opaque end (far-from-zero extreme); fade = transparent end
const anchorY = goUp ? minYp : maxYp;
const nearY = goUp ? maxYp : minYp;
// fade runs a good stretch past the near extreme, but stops short of
// the plot edge (~60% of the way there)
const fadeY = goUp
? Math.max(padTop, maxYp - (maxYp - padTop) * 0.6)
: Math.min(plotBottom, minYp + (plotBottom - minYp) * 0.6);
const anchorV = goUp ? minV : maxV;
const nearV = goUp ? maxV : minV;
const span = fadeY - anchorY;
// begin the fade a touch (~10px) BEFORE the near-zero extreme
const fadeStartY = nearY - Math.sign(nearY - anchorY) * 10;
const fadeStart =
span !== 0 ? Math.max(0, Math.min(0.95, (fadeStartY - anchorY) / span)) : 0.6;
const colorFn = s.segmentColor ?? (() => s.color);
const FULL = 0.8;
const grad = ctx.createLinearGradient(0, anchorY, 0, fadeY);
// full colour from the curve down to just before the near extreme…
const STOPS = 6;
for (let k = 0; k <= STOPS; k++) {
const t = k / STOPS;
const val = anchorV + t * (nearV - anchorV);
grad.addColorStop(t * fadeStart, withAlpha(colorFn(val, 0), FULL));
}
// …then a long, gentle fade out to the plot edge
grad.addColorStop(fadeStart, withAlpha(colorFn(nearV, 0), FULL));
grad.addColorStop(1, withAlpha(colorFn(nearV, 0), 0));
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
ctx.lineTo(points[points.length - 1][0], fadeY);
ctx.lineTo(points[0][0], fadeY);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
}
if (s.fill && points.length > 1) { if (s.fill && points.length > 1) {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]); ctx.moveTo(points[0][0], points[0][1]);
@@ -775,7 +901,7 @@
if (lineWidth > 0) { if (lineWidth > 0) {
ctx.lineJoin = 'round'; ctx.lineJoin = 'round';
ctx.lineCap = 'round'; ctx.lineCap = 'round';
ctx.setLineDash(s.dashed ? [6, 4] : []); ctx.setLineDash(s.dashed ? [8, 8] : []);
// Contrasting halo drawn under the line so a multi-colour line // Contrasting halo drawn under the line so a multi-colour line
// stays legible over any background. // stays legible over any background.
@@ -789,7 +915,7 @@
} }
ctx.lineWidth = lineWidth; ctx.lineWidth = lineWidth;
if (s.segmentColor) { if (s.segmentColor && !s.foregroundLine) {
// Colour each segment by its value (temperature colour scale). // Colour each segment by its value (temperature colour scale).
// `idx[i]` maps a run point back to its source data index. // `idx[i]` maps a run point back to its source data index.
for (let i = 1; i < points.length; i++) { for (let i = 1; i < points.length; i++) {
@@ -800,6 +926,37 @@
ctx.lineTo(points[i][0], points[i][1]); ctx.lineTo(points[i][0], points[i][1]);
ctx.stroke(); ctx.stroke();
} }
} else if (s.foregroundLine) {
// Foreground line drawn on top of its own fill. Stroke the whole
// path once with a horizontal gradient sampled from the value
// colour scale, so the colour flows smoothly along the line
// instead of stepping at each data point.
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
if (s.segmentColor && points.length > 1) {
const x0 = points[0][0];
const x1 = points[points.length - 1][0];
const span = x1 - x0 || 1;
const grad = ctx.createLinearGradient(x0, 0, x1, 0);
let prevT = -1;
for (let i = 0; i < points.length; i++) {
let t = (points[i][0] - x0) / span;
t = t < 0 ? 0 : t > 1 ? 1 : t;
if (t <= prevT) t = prevT + 1e-6; // keep stops strictly increasing
if (t > 1) t = 1;
prevT = t;
// a touch darker than the fill so the line reads as its edge
grad.addColorStop(
t,
darken(s.segmentColor(s.data[points[i][3]] as number, points[i][3]), 0.05)
);
}
ctx.strokeStyle = grad;
} else {
ctx.strokeStyle = strongColor;
}
ctx.stroke();
} else { } else {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]); ctx.moveTo(points[0][0], points[0][1]);
@@ -829,7 +986,12 @@
const x = xPix(t); const x = xPix(t);
const y = yPix(v, axis); const y = yPix(v, axis);
const label = fmt(v); const label = fmt(v);
const ly = ext.type === 'max' ? y - off : y + off + 8; // keep the label inside the plot vertically (never under the icon
// band above or clipped at the bottom)
const ly = Math.max(
padTop + 11,
Math.min(plotBottom - 3, ext.type === 'max' ? y - off : y + off + 8)
);
// keep the centred label fully inside the plot so it never clips // keep the centred label fully inside the plot so it never clips
const halfW = ctx.measureText(label).width / 2 + 2; const halfW = ctx.measureText(label).width / 2 + 2;
const lx = Math.max(padLeft + halfW, Math.min(plotRight - halfW, x)); const lx = Math.max(padLeft + halfW, Math.min(plotRight - halfW, x));
@@ -1142,11 +1304,12 @@
style:height="{ICON_BAND_H}px" style:height="{ICON_BAND_H}px"
> >
{#each visiblePictograms as p (p.x)} {#each visiblePictograms as p (p.x)}
{@const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, p.x))}
<svg <svg
class="absolute top-px fill-foreground" class="absolute top-1/2 -translate-y-1/2 fill-foreground"
width={ICON_PX} width={ICON_PX}
height={ICON_PX} height={ICON_PX}
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, p.x - ICON_PX / 2))}px" style:left="{cx - ICON_PX / 2}px"
> >
<use xlink:href="/images/weather-icons/{p.icon}.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/{p.icon}.svg#Layer_1"></use>
</svg> </svg>
@@ -1164,14 +1327,15 @@
style:height="{ICON_BAND_H}px" style:height="{ICON_BAND_H}px"
> >
{#each visibleWindArrows as a (a.x)} {#each visibleWindArrows as a (a.x)}
{@const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, a.x))}
<span <span
class="absolute top-px inline-flex items-center justify-center" class="absolute top-1/2 inline-flex items-center justify-center"
style:width="{ICON_PX}px" style:width="{ARROW_PX}px"
style:height="{ICON_PX}px" style:height="{ARROW_PX}px"
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, a.x - ICON_PX / 2))}px" style:left="{cx - ARROW_PX / 2}px"
style:transform="rotate({a.deg}deg)" style:transform="translateY(-50%) rotate({a.deg}deg)"
> >
<svg class="fill-foreground/80" width="22" height="22"> <svg class="fill-foreground/80" width={ARROW_PX} height={ARROW_PX}>
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
</svg> </svg>
</span> </span>
+6 -1
View File
@@ -5,7 +5,12 @@
* import { CanvasChart, buildDaylightBands, SERIES_COLORS } from '$lib/charts'; * import { CanvasChart, buildDaylightBands, SERIES_COLORS } from '$lib/charts';
*/ */
export { default as CanvasChart, setGroupHover, groupRange } from './CanvasChart.svelte'; export {
default as CanvasChart,
setGroupHover,
groupRange,
groupHover
} from './CanvasChart.svelte';
export type { ChartSeries } from './CanvasChart.svelte'; export type { ChartSeries } from './CanvasChart.svelte';
export { buildDaylightBands } from './bands'; export { buildDaylightBands } from './bands';
@@ -106,12 +106,18 @@
column on md+ (main has 2rem padding) for extra readability. */ column on md+ (main has 2rem padding) for extra readability. */
margin-left: -0.75rem; margin-left: -0.75rem;
margin-right: -0.75rem; margin-right: -0.75rem;
/* overflow-y is pinned (never `visible`): a bare `overflow-x: auto`
makes the browser compute overflow-y as `auto` too, which turns the
chart into a 1-2px vertical micro-scroller that swallows page scroll. */
overflow-x: auto; overflow-x: auto;
overflow-y: hidden;
} }
.chart-bleed.no-bleed { .chart-bleed.no-bleed {
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
/* content fits the column, so no horizontal scroller is needed */
overflow-x: hidden;
} }
.chart-container { .chart-container {
+4 -64
View File
@@ -19,13 +19,12 @@
</ChartToolbar> </ChartToolbar>
--> -->
<script module lang="ts"> <script module lang="ts">
/** Anything that can export itself as a PNG data URL (e.g. a CanvasChart). */ export type { DownloadableChart } from './downloadChartsPng';
export interface DownloadableChart {
getPngDataUrl(): string | null;
}
</script> </script>
<script lang="ts"> <script lang="ts">
import { type DownloadableChart, downloadChartsPng } from './downloadChartsPng';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
// ─── Props ────────────────────────────────────────────────────────────────── // ─── Props ──────────────────────────────────────────────────────────────────
@@ -58,70 +57,11 @@
// ─── Download ─────────────────────────────────────────────────────────────── // ─── Download ───────────────────────────────────────────────────────────────
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(img);
img.src = src;
});
}
/** Resolves the page background so exports match the current theme. */
function exportBackground(): string {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
return bg || '#ffffff';
}
function triggerDownload(url: string, name: string): void {
const link = document.createElement('a');
link.href = url;
link.download = name;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
requestAnimationFrame(() => {
document.body.removeChild(link);
});
}
async function handleDownload(): Promise<void> { async function handleDownload(): Promise<void> {
if (!hasCharts || downloading) return; if (!hasCharts || downloading) return;
downloading = true; downloading = true;
try { try {
const dataUrls = charts await downloadChartsPng(charts, fileName);
.filter((chart): chart is DownloadableChart => chart != null)
.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
);
if (images.length === 0) return;
const maxWidth = Math.max(...images.map((img) => img.naturalWidth));
const totalHeight = images.reduce((sum, img) => sum + img.naturalHeight, 0);
const canvas = document.createElement('canvas');
canvas.width = maxWidth;
canvas.height = totalHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = exportBackground();
ctx.fillRect(0, 0, maxWidth, totalHeight);
let y = 0;
for (const img of images) {
ctx.drawImage(img, 0, y);
y += img.naturalHeight;
}
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
} finally { } finally {
setTimeout(() => { setTimeout(() => {
downloading = false; downloading = false;
@@ -0,0 +1,78 @@
/**
* Shared PNG export for charts.
*
* Stitches one or more chart images vertically onto a single canvas (over the
* current theme background) and triggers a download. Used by both the standalone
* ChartToolbar and the inline toolbar buttons.
*/
/** Anything that can export itself as a PNG data URL (e.g. a CanvasChart). */
export interface DownloadableChart {
getPngDataUrl(): string | null;
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(img);
img.src = src;
});
}
/** Resolves the page background so exports match the current theme. */
function exportBackground(): string {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
return bg || '#ffffff';
}
function triggerDownload(url: string, name: string): void {
const link = document.createElement('a');
link.href = url;
link.download = name;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
requestAnimationFrame(() => {
document.body.removeChild(link);
});
}
/**
* 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).
*/
export async function downloadChartsPng(
charts: Array<DownloadableChart | undefined | null>,
fileName: string
): Promise<void> {
const dataUrls = charts
.filter((chart): chart is DownloadableChart => chart != null)
.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);
if (images.length === 0) return;
const maxWidth = Math.max(...images.map((img) => img.naturalWidth));
const totalHeight = images.reduce((sum, img) => sum + img.naturalHeight, 0);
const canvas = document.createElement('canvas');
canvas.width = maxWidth;
canvas.height = totalHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = exportBackground();
ctx.fillRect(0, 0, maxWidth, totalHeight);
let y = 0;
for (const img of images) {
ctx.drawImage(img, 0, y);
y += img.naturalHeight;
}
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
}
+1
View File
@@ -9,3 +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';
+14 -4
View File
@@ -65,7 +65,14 @@ export const defaultVariablePrefs: VariablePrefs = {
wind: true, wind: true,
humidity: true, humidity: true,
clouds: true, clouds: true,
precipitation: true precipitation: true,
// extra rows, off by default
dew_point: false,
gusts: false,
pressure: false,
uv: false,
visibility: false,
snowfall: false
}, },
charts: { charts: {
temperature: true, temperature: true,
@@ -79,6 +86,9 @@ export const defaultVariablePrefs: VariablePrefs = {
export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs); export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs);
/** Hourly table interval: 3-hourly (default) or 1-hourly. */
export const storedHourlyInterval = persisted<1 | 3>('hourly_interval', 3);
/** /**
* Meteogram layout: an ordered list of chart panels, each holding an ordered * Meteogram layout: an ordered list of chart panels, each holding an ordered
* list of variable keys (see the chart variable registry). Users drag * list of variable keys (see the chart variable registry). Users drag
@@ -90,9 +100,9 @@ export interface ChartPanel {
} }
export const defaultChartLayout: ChartPanel[] = [ export const defaultChartLayout: ChartPanel[] = [
{ id: 'panel-1', variables: ['weather_icons', 'temperature', 'cloud_cover'] }, { id: 'panel-1', variables: ['temperature', 'weather_icons'] },
{ id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] }, { id: 'panel-2', variables: ['precipitation', 'precipitation_probability', 'cloud_cover'] },
{ id: 'panel-3', variables: ['wind', 'wind_direction', 'humidity'] } { id: 'panel-3', variables: ['wind', 'wind_gusts', 'wind_direction'] }
]; ];
export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout); export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout);
+3 -2
View File
@@ -88,8 +88,9 @@
{#if fullBleed} {#if fullBleed}
{@render children()} {@render children()}
{:else} {:else}
<!-- cap the content width on very large screens --> <!-- cap the content width on very large screens; generous bottom room
<div class="mx-auto w-full max-w-[1536px]"> so the last chart/table never sits flush against the viewport edge -->
<div class="mx-auto w-full max-w-[1536px] pb-80">
{@render children()} {@render children()}
</div> </div>
{/if} {/if}
@@ -165,6 +165,21 @@
unit: string; unit: string;
showCredit: boolean; showCredit: boolean;
series: ChartSeries[]; series: ChartSeries[];
zeroBaseLeft?: boolean;
yMin?: number;
yMax?: number;
}
// Sensible axis behaviour per variable so the y-scale stays readable (e.g.
// pressure never anchored to zero; percentages pinned to 0-100).
function axisForVar(v: string): { zeroBaseLeft: boolean; yMin?: number; yMax?: number } {
if (['pressure_msl', 'surface_pressure', 'temperature_2m', 'dew_point_2m'].includes(v)) {
return { zeroBaseLeft: false };
}
if (['relative_humidity_2m', 'cloud_cover', 'precipitation_probability'].includes(v)) {
return { zeroBaseLeft: true, yMin: 0, yMax: 100 };
}
return { zeroBaseLeft: true };
} }
const BAND_COLOR = 'rgba(115, 192, 222, 0.9)'; const BAND_COLOR = 'rgba(115, 192, 222, 0.9)';
@@ -202,33 +217,40 @@
const isColumn = isColumnUnit(unit); const isColumn = isColumnUnit(unit);
const memberCount = varData.members.length; const memberCount = varData.members.length;
// Trim to the valid horizon so the axis scale ignores the trailing
// zeros the service pads past the model's range.
const vMax = varData.max.slice(0, validLength);
const vMin = varData.min.slice(0, validLength);
const vAvg = varData.average.slice(0, validLength);
// Min/max spread band + mean, instead of every individual member // Min/max spread band + mean, instead of every individual member
const series: ChartSeries[] = [ const series: ChartSeries[] = [
{ {
name: 'Max', name: 'Max',
type: 'line', type: 'line',
color: BAND_COLOR, color: BAND_COLOR,
data: varData.max, data: vMax,
width: 1, width: 1,
fill: true, fill: true,
fillOpacity: 0.25, fillOpacity: 0.25,
bandTo: varData.min, bandTo: vMin,
format: (v) => `${v.toFixed(1)} ${unit}` format: (v) => `${v.toFixed(1)} ${unit}`
}, },
{ {
name: 'Mean', name: 'Mean',
type: isColumn ? 'bar' : 'line', type: isColumn ? 'bar' : 'line',
color: CHART_COLORS.average, color: CHART_COLORS.average,
data: varData.average, data: vAvg,
width: 3, width: 3.5,
dashed: !isColumn, dashed: !isColumn,
outline: !isColumn,
format: (v) => `${v.toFixed(1)} ${unit}` format: (v) => `${v.toFixed(1)} ${unit}`
}, },
{ {
name: 'Min', name: 'Min',
type: 'line', type: 'line',
color: BAND_COLOR, color: BAND_COLOR,
data: varData.min, data: vMin,
width: 1, width: 1,
format: (v) => `${v.toFixed(1)} ${unit}` format: (v) => `${v.toFixed(1)} ${unit}`
} }
@@ -237,6 +259,7 @@
const isFirst = vi === 0; const isFirst = vi === 0;
const isLast = vi === variables.length - 1; const isLast = vi === variables.length - 1;
const axis = axisForVar(variable);
defs.push({ defs.push({
// each chart is labelled so the variable is obvious at a glance // each chart is labelled so the variable is obvious at a glance
title: `${varLabel(variable)}${isColumn ? '' : ' spread'}`, title: `${varLabel(variable)}${isColumn ? '' : ' spread'}`,
@@ -245,7 +268,10 @@
: `min · mean · max (${unit})`, : `min · mean · max (${unit})`,
unit, unit,
showCredit: isLast, showCredit: isLast,
series series,
zeroBaseLeft: axis.zeroBaseLeft,
yMin: axis.yMin,
yMax: axis.yMax
}); });
} }
@@ -255,7 +281,7 @@
<!-- ─── Page hero: location + ensemble model selection ─────────────────────── --> <!-- ─── Page hero: location + ensemble model selection ─────────────────────── -->
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3"> <div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
<div class="flex min-w-0 items-center gap-3"> <div class="flex min-w-0 items-center gap-3">
<img <img
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border" class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
@@ -275,7 +301,7 @@
</div> </div>
</div> </div>
<div class="flex w-full items-center gap-3 sm:w-auto"> <div class="lg:absolute lg:right-0 lg:top-0 flex w-full items-center gap-3 sm:w-auto">
<ModelSelector <ModelSelector
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'} selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
groups={ensembleModelGroups} groups={ensembleModelGroups}
@@ -322,7 +348,7 @@
</div> </div>
{/if} {/if}
<ChartContainer {loading} chartCount={params.hourly?.length || 1} chartHeight={300}> <ChartContainer {loading} chartCount={params.hourly?.length || 1} chartHeight={300} bleed={false}>
{#if fetchedData} {#if fetchedData}
{#each chartDefs as def, i (i)} {#each chartDefs as def, i (i)}
<CanvasChart <CanvasChart
@@ -335,6 +361,9 @@
title={def.title} title={def.title}
subtitle={def.subtitle} subtitle={def.subtitle}
showCredit={def.showCredit} showCredit={def.showCredit}
zeroBaseLeft={def.zeroBaseLeft ?? true}
yMin={def.yMin}
yMax={def.yMax}
{showLegend} {showLegend}
height={300} height={300}
group={CHART_GROUP} group={CHART_GROUP}
@@ -257,7 +257,8 @@
color: CHART_COLORS.average, color: CHART_COLORS.average,
data: average, data: average,
width: 4, width: 4,
dashed: !isColumn dashed: !isColumn,
outline: !isColumn
}); });
const isFirst = vi === 0; const isFirst = vi === 0;
@@ -281,7 +282,7 @@
<!-- ─── Page hero: location (matches the other forecast pages) ──────────────── --> <!-- ─── Page hero: location (matches the other forecast pages) ──────────────── -->
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3"> <div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
<div class="flex min-w-0 items-center gap-3"> <div class="flex min-w-0 items-center gap-3">
<img <img
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border" class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
@@ -302,7 +303,7 @@
</div> </div>
<!-- Range / zoom controls, aligned with the title like the other pages --> <!-- Range / zoom controls, aligned with the title like the other pages -->
<div class="flex flex-wrap items-center gap-3"> <div class="lg:absolute lg:right-0 lg:top-20 z-40 flex flex-wrap items-center gap-3">
<span class="hidden text-xs text-muted-foreground lg:inline"> <span class="hidden text-xs text-muted-foreground lg:inline">
drag or drag or
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd <kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd
@@ -362,6 +363,7 @@
{loading} {loading}
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1} chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
chartHeight={300} chartHeight={300}
bleed={false}
> >
{#if fetchedData} {#if fetchedData}
{#each chartDefs as def, i (i)} {#each chartDefs as def, i (i)}
@@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { import {
storedChartLayout, storedChartLayout,
@@ -152,7 +153,7 @@
<div class="week-page"> <div class="week-page">
<div class="weather-content" style="min-height: 50vh"> <div class="weather-content" style="min-height: 50vh">
<!-- Page hero: prominent location + weather model selection --> <!-- Page hero: prominent location + weather model selection -->
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3"> <div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
<div class="flex min-w-0 items-center gap-3"> <div class="flex min-w-0 items-center gap-3">
<img <img
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border" class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
@@ -174,7 +175,7 @@
</div> </div>
</div> </div>
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto"> <div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector <ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'} selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => { onModelChange={(model) => {
@@ -220,14 +221,17 @@
/> />
{:else} {:else}
<!-- placeholder with the table's approximate height: no layout shift --> <!-- placeholder with the table's approximate height: no layout shift -->
<div class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"></div> <div
transition:fade={{ duration: 200 }}
class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"
></div>
{/if} {/if}
{#if fetchedHourly} {#if fetchedHourly}
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} /> <MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
{:else} {:else}
<!-- reserve the exact chart area height before the first fetch resolves --> <!-- reserve the exact chart area height before the first fetch resolves -->
<section class="mt-8"> <section class="mt-8" transition:fade={{ duration: 200 }}>
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} /> <ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
</section> </section>
{/if} {/if}
@@ -119,13 +119,13 @@
</defs> </defs>
</svg> </svg>
<div transition:fade={{ duration: 200 }} class="mb-6 min-h-[260px]"> <div transition:fade={{ duration: 200 }} class="mb-1 min-h-47.5 md:mb-6 md:min-h-65">
<!-- negative margin + matching padding: the scroll box gains room so a <!-- negative margin + matching padding: the scroll box gains room so a
lifted/scaled/shadowed card is never clipped, while the first card still lifted/scaled/shadowed card is never clipped, while the first card still
lines up with the page content edge --> 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-5 pb-11" class="day-scroll -mx-3 flex gap-2 overflow-x-auto px-3 pt-2 pb-3 md:pt-5 md:pb-11"
class:scrolling class:scrolling
onscroll={onScroll} onscroll={onScroll}
> >
@@ -187,7 +187,7 @@
<button <button
class="day-card group relative flex w-35 shrink-0 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200 ease-out will-change-transform motion-reduce:transition-none class="day-card group relative flex w-35 shrink-0 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200 ease-out will-change-transform motion-reduce:transition-none
{selected {selected
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-xl ring-2 ring-primary/60' ? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-[0_5px_14px_-4px_rgba(0,0,0,0.4)] ring-2 ring-primary/60 md:shadow-xl'
: 'border-border/60 bg-card shadow-xs hover:z-10 hover:-translate-y-1 hover:scale-[1.02] hover:border-border hover:shadow-lg'}" : 'border-border/60 bg-card shadow-xs hover:z-10 hover:-translate-y-1 hover:scale-[1.02] hover:border-border hover:shadow-lg'}"
aria-pressed={selected} aria-pressed={selected}
onclick={() => onSelectDay(time, index)} onclick={() => onSelectDay(time, index)}
@@ -1,11 +1,15 @@
<script lang="ts"> <script lang="ts">
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { storedVariablePrefs } from '$lib/stores/settings'; import {
defaultVariablePrefs,
storedHourlyInterval,
storedVariablePrefs
} from '$lib/stores/settings';
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date'; import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
import { setGroupHover } from '$lib/charts'; import { groupHover, setGroupHover } from '$lib/charts';
import { getTempStyle } from '../../utils/colors'; import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherIconName } from '../../utils/weather-codes';
@@ -53,11 +57,15 @@
setGroupHover(METEOGRAM_GROUP, null); setGroupHover(METEOGRAM_GROUP, null);
} }
let hourlyInterval = $state<1 | 3>(3); // persisted 1h / 3h preference
let hourlyInterval = $derived($storedHourlyInterval);
// Row visibility, controlled from the Variables sidebar (missing keys // Row visibility, controlled from the Variables sidebar (missing keys
// from older stored prefs default to visible) // from older stored prefs default to visible)
let showRow = $derived((key: string): boolean => $storedVariablePrefs.table?.[key] ?? true); let showRow = $derived(
(key: string): boolean =>
$storedVariablePrefs.table?.[key] ?? defaultVariablePrefs.table[key] ?? true
);
const today = new Date(); const today = new Date();
const tempUnit = $derived(getTempUnit(units)); const tempUnit = $derived(getTempUnit(units));
@@ -152,6 +160,67 @@
return `rgba(0, 180, 200, ${hum ** 3.5 / 10 ** 7.8})`; return `rgba(0, 180, 200, ${hum ** 3.5 / 10 ** 7.8})`;
} }
// ─── Maps-project colour ramps (open-meteo/maps) ────────────────────────────
// Same breakpoint colours the weather maps use, applied as cell backgrounds.
function hexRgb(hex: string): [number, number, number] {
const h = hex.replace('#', '');
const n =
h.length === 3
? h
.split('')
.map((c) => c + c)
.join('')
: h;
return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
}
function mixRgb(
a: [number, number, number],
b: [number, number, number],
f: number
): [number, number, number] {
return [
Math.round(a[0] + (b[0] - a[0]) * f),
Math.round(a[1] + (b[1] - a[1]) * f),
Math.round(a[2] + (b[2] - a[2]) * f)
];
}
// Pressure: 940 hPa blue → 1010 white → 1060 red.
const PRESSURE_LOW = hexRgb('#4444ff');
const PRESSURE_MID = hexRgb('#ffffff');
const PRESSURE_HIGH = hexRgb('#ff4444');
function getPressureBg(hpa: number | null): string {
if (hpa == null || isNaN(hpa)) return 'transparent';
const v = Math.max(940, Math.min(1060, hpa));
const c =
v <= 1010
? mixRgb(PRESSURE_LOW, PRESSURE_MID, (v - 940) / 70)
: mixRgb(PRESSURE_MID, PRESSURE_HIGH, (v - 1010) / 50);
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0.5)`;
}
// UV index: 0 → 12 across the maps' teal→green→yellow→orange→pink ramp. The
// opacity tracks the value so low/night hours stay faint instead of tinting
// the whole row.
const UV_STOPS = [
'#009392',
'#39b185',
'#9ccb86',
'#e9e29c',
'#eeb479',
'#e88471',
'#cf597e'
].map(hexRgb);
function getUvBg(uv: number | null): string {
if (uv == null || isNaN(uv) || uv <= 0) return 'transparent';
const v = Math.min(12, uv);
const p = (v / 12) * (UV_STOPS.length - 1);
const i = Math.min(UV_STOPS.length - 2, Math.floor(p));
const c = mixRgb(UV_STOPS[i], UV_STOPS[i + 1], p - i);
const alpha = 0.18 + (v / 12) * 0.5;
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${alpha.toFixed(3)})`;
}
function formatPrecipTooltip(precip: number | null, prob: number | null): string { function formatPrecipTooltip(precip: number | null, prob: number | null): string {
const parts: string[] = []; const parts: string[] = [];
if (prob != null && prob > 0) parts.push(`Probability: ${prob}%`); if (prob != null && prob > 0) parts.push(`Probability: ${prob}%`);
@@ -211,6 +280,21 @@
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth) ? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
: null : null
); );
// ─── Chart-hover mirror ─────────────────────────────────────────────────────
// 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).
let chartHoverTime = $derived(groupHover(METEOGRAM_GROUP)); // epoch seconds, or null
let hoveredCol = $derived.by((): number => {
if (chartHoverTime == null || cellData.length === 0) return -1;
const stepMs = (is3h ? 3 : 1) * 3600 * 1000;
const tMs = chartHoverTime * 1000;
for (let i = 0; i < cellData.length; i++) {
const start = cellData[i].date.getTime();
if (tMs >= start && tMs < start + stepMs) return i;
}
return -1;
});
</script> </script>
{#snippet weatherIcon(name: string, size: number = 16)} {#snippet weatherIcon(name: string, size: number = 16)}
@@ -292,7 +376,7 @@
? 'bg-background text-foreground shadow-sm' ? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}" : 'text-muted-foreground hover:text-foreground'}"
aria-pressed={hourlyInterval === interval} aria-pressed={hourlyInterval === interval}
onclick={() => (hourlyInterval = interval as 1 | 3)} onclick={() => storedHourlyInterval.set(interval as 1 | 3)}
> >
{interval}h {interval}h
</button> </button>
@@ -324,7 +408,7 @@
</th> </th>
<td <td
colspan={cellData.length} colspan={cellData.length}
class="relative h-11 overflow-visible p-0" class="relative h-12 overflow-visible p-0"
onmousemove={hoverTimeRow} onmousemove={hoverTimeRow}
onmouseleave={clearTimeRowHover} onmouseleave={clearTimeRowHover}
> >
@@ -440,7 +524,7 @@
{#each cellData as cell, i (cell.idx)} {#each cellData as cell, i (cell.idx)}
{@const wCode = hourly.weather_code[cell.idx]} {@const wCode = hourly.weather_code[cell.idx]}
<td <td
class="cell leading-0 {is3h ? 'h-14' : 'h-11'}" class="cell h-12 leading-0"
class:icon-day={cell.isDaytime} class:icon-day={cell.isDaytime}
class:icon-night={!cell.isDaytime} class:icon-night={!cell.isDaytime}
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime} class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
@@ -462,7 +546,7 @@
{@const temp = hourly.temperature_2m[cell.idx]} {@const temp = hourly.temperature_2m[cell.idx]}
{@const style = getTempStyle(temp, String(units.temperature_unit))} {@const style = getTempStyle(temp, String(units.temperature_unit))}
<td <td
class="cell h-10 font-bold {is3h ? 'text-lg' : 'text-[15px]'}" class="cell h-12 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
style="background-color:{style.bg};color:{style.fg}" style="background-color:{style.bg};color:{style.fg}"
> >
{formatTemp(temp)} {formatTemp(temp)}
@@ -471,13 +555,30 @@
</tr> </tr>
{/if} {/if}
<!-- Feels Like --> <!-- Feels Like (short row) -->
{#if showRow('feels')} {#if showRow('feels')}
<tr class="row"> <tr class="row">
{@render rowHeader(undefined, tempUnit, 'Feels')} {@render rowHeader(undefined, tempUnit, 'Feels')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const temp = hourly.apparent_temperature[cell.idx]} {@const temp = hourly.apparent_temperature[cell.idx]}
<td class="cell h-8 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"> <td
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
>
{formatTemp(temp)}
</td>
{/each}
</tr>
{/if}
<!-- Dew Point (short row; off by default) -->
{#if showRow('dew_point')}
<tr class="row">
{@render rowHeader('wi-raindrop', tempUnit, 'Dew')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.dew_point_2m?.[cell.idx]}
<td
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
>
{formatTemp(temp)} {formatTemp(temp)}
</td> </td>
{/each} {/each}
@@ -491,16 +592,16 @@
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const wind = hourly.windspeed_10m[cell.idx]} {@const wind = hourly.windspeed_10m[cell.idx]}
{@const windDir = hourly.winddirection_10m[cell.idx]} {@const windDir = hourly.winddirection_10m[cell.idx]}
<td class="cell h-13 align-middle leading-tight"> <td class="relative cell h-12 align-middle leading-none">
{#if windDir != null && !isNaN(windDir)} {#if windDir != null && !isNaN(windDir)}
<span <span
class="inline-block leading-0" class="absolute top-0 left-1/2 inline-block origin-center leading-0"
style="transform:{getWindArrowRotation(windDir)}" style="transform: translateX(-50%) {getWindArrowRotation(windDir)}"
> >
{@render weatherIcon('wi-direction-down', 22)} {@render weatherIcon('wi-direction-down', 40)}
</span> </span>
{/if} {/if}
<span class="block font-semibold {is3h ? 'text-sm' : 'text-xs'}"> <span class="mt-5 block font-semibold {is3h ? 'text-[13px]' : 'text-[11px]'}">
{formatValue(wind)} {formatValue(wind)}
</span> </span>
</td> </td>
@@ -508,13 +609,30 @@
</tr> </tr>
{/if} {/if}
<!-- Humidity --> <!-- Wind Gusts (off by default) -->
{#if showRow('gusts')}
<tr class="row">
{@render rowHeader('wi-strong-wind', windUnit, 'Gusts')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.wind_gusts_10m?.[cell.idx]}
<td
class="cell h-12 font-semibold text-foreground/80 {is3h
? 'text-sm'
: 'text-xs'}"
>
{formatValue(v)}
</td>
{/each}
</tr>
{/if}
<!-- Humidity (short row) -->
{#if showRow('humidity')} {#if showRow('humidity')}
<tr class="row"> <tr class="row">
{@render rowHeader('wi-humidity', '%', 'Humidity')} {@render rowHeader('wi-humidity', '%', 'Humidity')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const hum = hourly.relative_humidity_2m[cell.idx]} {@const hum = hourly.relative_humidity_2m[cell.idx]}
<td class="cell h-8" style="background:{getHumidityBg(hum ?? 0)}"> <td class="cell h-12" style="background:{getHumidityBg(hum ?? 0)}">
{formatValue(hum)} {formatValue(hum)}
</td> </td>
{/each} {/each}
@@ -528,7 +646,7 @@
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const cloud = hourly.cloud_cover[cell.idx]} {@const cloud = hourly.cloud_cover[cell.idx]}
<td <td
class="cell h-8" class="cell h-12"
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})" style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
> >
{formatValue(cloud)} {formatValue(cloud)}
@@ -537,6 +655,51 @@
</tr> </tr>
{/if} {/if}
<!-- Pressure (off by default) -->
{#if showRow('pressure')}
<tr class="row">
{@render rowHeader('wi-barometer', 'hPa', 'Pressure')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.pressure_msl?.[cell.idx]}
<td
class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"
style="background:{getPressureBg(v ?? null)}"
>
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
</td>
{/each}
</tr>
{/if}
<!-- UV Index (off by default) -->
{#if showRow('uv')}
<tr class="row">
{@render rowHeader('wi-day-sunny', 'UV', 'UV')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.uv_index?.[cell.idx]}
<td
class="cell h-12 font-semibold {is3h ? 'text-sm' : 'text-xs'}"
style="background:{getUvBg(v ?? null)}"
>
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
</td>
{/each}
</tr>
{/if}
<!-- Visibility (off by default) -->
{#if showRow('visibility')}
<tr class="row">
{@render rowHeader('wi-fog', 'km', 'Visibility')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.visibility?.[cell.idx]}
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
{v != null && !isNaN(v) ? (v / 1000).toFixed(0) : '-'}
</td>
{/each}
</tr>
{/if}
<!-- Precipitation --> <!-- Precipitation -->
{#if showRow('precipitation')} {#if showRow('precipitation')}
<tr class="row"> <tr class="row">
@@ -545,7 +708,7 @@
{@const precip = hourly.precipitation[cell.idx]} {@const precip = hourly.precipitation[cell.idx]}
{@const prob = hourly.precipitation_probability[cell.idx]} {@const prob = hourly.precipitation_probability[cell.idx]}
<td <td
class="cell precip-cell {is3h ? 'h-14' : 'h-11'}" class="cell precip-cell h-12"
style="background:{getPrecipProbBg(prob ?? 0)}" style="background:{getPrecipProbBg(prob ?? 0)}"
title={formatPrecipTooltip(precip, prob)} title={formatPrecipTooltip(precip, prob)}
> >
@@ -559,9 +722,31 @@
{/each} {/each}
</tr> </tr>
{/if} {/if}
<!-- Snowfall (off by default) -->
{#if showRow('snowfall')}
<tr class="row">
{@render rowHeader('wi-snow', 'cm', 'Snow')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.snowfall?.[cell.idx]}
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
{v != null && !isNaN(v) && v > 0 ? v.toFixed(1) : '-'}
</td>
{/each}
</tr>
{/if}
</tbody> </tbody>
</table> </table>
<!-- Hovered-column highlight, mirroring the meteogram crosshair -->
{#if hoveredCol >= 0 && tableWidth > 0}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
<div
class="pointer-events-none absolute inset-y-0 z-10 border-x border-primary/40 bg-primary/10"
style="left:{headerColWidth + hoveredCol * colWidth}px;width:{colWidth}px"
></div>
{/if}
<!-- "Now" column highlight + exact-time line --> <!-- "Now" column highlight + exact-time line -->
{#if nowLeftPx != null} {#if nowLeftPx != null}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length} {@const colWidth = (tableWidth - headerColWidth) / cellData.length}
@@ -5,7 +5,7 @@
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date'; import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
import { ChartContainer, ChartToolbar } from '$lib/components/charts'; import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
import { CanvasChart, groupRange } from '$lib/charts'; import { CanvasChart, groupRange } from '$lib/charts';
@@ -29,6 +29,17 @@
const CHART_HEIGHT = 300; const CHART_HEIGHT = 300;
let customizerOpen = $state(false); let customizerOpen = $state(false);
let downloadingPng = $state(false);
async function downloadPng(): Promise<void> {
if (liveCharts.length === 0 || downloadingPng) return;
downloadingPng = true;
try {
await downloadChartsPng(liveCharts, 'week-forecast');
} finally {
setTimeout(() => (downloadingPng = false), 500);
}
}
// Charts persist across data refetches; entries are null while unmounted. // Charts persist across data refetches; entries are null while unmounted.
let chartComponents: (CanvasChart | null)[] = $state([]); let chartComponents: (CanvasChart | null)[] = $state([]);
@@ -224,6 +235,40 @@
</svg> </svg>
Customize Customize
</button> </button>
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
disabled={liveCharts.length === 0 || downloadingPng}
onclick={downloadPng}
title="Download meteogram as PNG image"
>
{#if downloadingPng}
<svg
class="h-3.5 w-3.5 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
{:else}
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
{/if}
PNG
</button>
</div> </div>
</div> </div>
@@ -286,10 +331,6 @@
</div> </div>
{/each} {/each}
</div> </div>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
</div>
{/if} {/if}
</section> </section>
@@ -17,10 +17,16 @@
{ key: 'icons', label: 'Weather icons' }, { key: 'icons', label: 'Weather icons' },
{ key: 'temperature', label: 'Temperature' }, { key: 'temperature', label: 'Temperature' },
{ key: 'feels', label: 'Feels like' }, { key: 'feels', label: 'Feels like' },
{ key: 'dew_point', label: 'Dew point' },
{ key: 'wind', label: 'Wind' }, { key: 'wind', label: 'Wind' },
{ key: 'gusts', label: 'Wind gusts' },
{ key: 'humidity', label: 'Humidity' }, { key: 'humidity', label: 'Humidity' },
{ key: 'clouds', label: 'Cloud cover' }, { key: 'clouds', label: 'Cloud cover' },
{ key: 'precipitation', label: 'Precipitation' } { key: 'pressure', label: 'Pressure' },
{ key: 'uv', label: 'UV index' },
{ key: 'visibility', label: 'Visibility' },
{ key: 'precipitation', label: 'Precipitation' },
{ key: 'snowfall', label: 'Snowfall' }
]; ];
function toggle(section: 'table' | 'charts', key: string) { function toggle(section: 'table' | 'charts', key: string) {
@@ -4,6 +4,8 @@
* (data field, render style, colour, unit family) so the panels can be * (data field, render style, colour, unit family) so the panels can be
* assembled dynamically from a user-defined layout. * assembled dynamically from a user-defined layout.
*/ */
import { defaultVariablePrefs } from '$lib/stores/settings';
import { getColor } from '../../utils/colors'; import { getColor } from '../../utils/colors';
import { import {
type WeatherUnits, type WeatherUnits,
@@ -36,9 +38,14 @@ export interface ChartVariableDef {
dashed?: boolean; dashed?: boolean;
fill?: boolean; fill?: boolean;
fillOpacity?: number; fillOpacity?: number;
/** Area fill coloured by the value scale, fading out below the minimum */
gradientFill?: boolean;
width?: number; width?: number;
/** Stroke the line coloured by the temperature scale */ /** Stroke the line coloured by the temperature scale */
colorScale?: boolean; colorScale?: boolean;
/** Draw the line in the theme foreground (black/white), while colorScale still
* drives the gradient fill */
foregroundLine?: boolean;
/** Draw a contrasting halo under the line */ /** Draw a contrasting halo under the line */
outline?: boolean; outline?: boolean;
/** Annotate local minima / maxima with their value */ /** Annotate local minima / maxima with their value */
@@ -66,9 +73,10 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
type: 'line', type: 'line',
kind: 'temp', kind: 'temp',
color: '#ef6c00', color: '#ef6c00',
width: 9, width: 5.6,
colorScale: true, colorScale: true,
outline: true, foregroundLine: true,
gradientFill: true,
extrema: true extrema: true
}, },
{ {
@@ -312,23 +320,29 @@ export function neededHourlyApiVars(
tablePrefs: Record<string, boolean> | undefined, tablePrefs: Record<string, boolean> | undefined,
layoutKeys: string[] layoutKeys: string[]
): string[] { ): string[] {
const on = (key: string): boolean => tablePrefs?.[key] ?? true; const on = (key: string): boolean => tablePrefs?.[key] ?? defaultVariablePrefs.table[key] ?? true;
const s = new Set<string>(); const s = new Set<string>();
// Hourly table rows // Hourly table rows
if (on('icons')) s.add('weather_code'); if (on('icons')) s.add('weather_code');
if (on('temperature')) s.add('temperature_2m'); if (on('temperature')) s.add('temperature_2m');
if (on('feels')) s.add('apparent_temperature'); if (on('feels')) s.add('apparent_temperature');
if (on('dew_point')) s.add('dew_point_2m');
if (on('wind')) { if (on('wind')) {
s.add('wind_speed_10m'); s.add('wind_speed_10m');
s.add('wind_direction_10m'); s.add('wind_direction_10m');
} }
if (on('gusts')) s.add('wind_gusts_10m');
if (on('humidity')) s.add('relative_humidity_2m'); if (on('humidity')) s.add('relative_humidity_2m');
if (on('clouds')) s.add('cloud_cover'); if (on('clouds')) s.add('cloud_cover');
if (on('pressure')) s.add('pressure_msl');
if (on('uv')) s.add('uv_index');
if (on('visibility')) s.add('visibility');
if (on('precipitation')) { if (on('precipitation')) {
s.add('precipitation'); s.add('precipitation');
s.add('precipitation_probability'); s.add('precipitation_probability');
} }
if (on('snowfall')) s.add('snowfall');
// Meteogram variables // Meteogram variables
for (const key of layoutKeys) { for (const key of layoutKeys) {
@@ -454,10 +468,12 @@ export function buildPanelDef(
width: d.width, width: d.width,
fill: d.fill, fill: d.fill,
fillOpacity: d.fillOpacity, fillOpacity: d.fillOpacity,
gradientFill: d.gradientFill,
dashed: d.dashed, dashed: d.dashed,
axis, axis,
cloudBand: d.cloudBand, cloudBand: d.cloudBand,
segmentColor: d.colorScale ? (v: number) => getColor(v, units.temperature_unit) : undefined, segmentColor: d.colorScale ? (v: number) => getColor(v, units.temperature_unit) : undefined,
foregroundLine: d.foregroundLine,
outline: d.outline, outline: d.outline,
labelExtrema: d.extrema, labelExtrema: d.extrema,
labelFormat: d.extrema labelFormat: d.extrema