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;
/** Draw a low-alpha area fill below (or above, on inverted axes) the line */
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
* instead of the baseline (e.g. an ensemble min-max band) */
bandTo?: (number | null)[];
@@ -50,6 +53,9 @@
shortName?: string;
/** Colour each line segment by value (e.g. a temperature colour scale) */
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 */
outline?: boolean;
/** Annotate local minima / maxima with their value */
@@ -101,6 +107,14 @@
export function groupRange(name: string): { start: number; end: number } | 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 lang="ts">
@@ -202,12 +216,66 @@
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
const HOUR = 3600;
// Top icon rows (weather pictograms / wind arrows)
const ICON_ROW_H = 30; // reserved height per icon row
const ICON_BAND_H = 28; // visible band height
const ICON_PX = 26; // pictogram size
const ICON_ROW_H = 40; // reserved height per icon row
const ICON_BAND_H = 38; // visible band height
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
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 ──────────────────────────────────────────────────────────────────
let containerEl: HTMLDivElement | undefined = $state();
@@ -455,6 +523,8 @@
for (const p of pictograms) {
if (p.t < viewStart || p.t > viewEnd) continue;
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;
out.push({ x, icon: p.icon });
lastX = x;
@@ -470,6 +540,7 @@
for (const a of windArrows) {
if (a.t < viewStart || a.t > viewEnd) continue;
const x = iconBandX(a.t);
if (x < ICON_EDGE || x > iconBandWidth - ICON_EDGE) continue;
if (x - lastX < 40) continue;
out.push({ x, deg: a.deg });
lastX = x;
@@ -582,7 +653,9 @@
const gridColor = cssColor('--border', 'rgba(0, 0, 0, 0.1)');
const bgColor = cssColor('--card', '#ffffff');
const dark = document.documentElement.classList.contains('dark');
const outlineColor = dark ? '#ffffff' : '#000000';
// 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 plotBottom = padTop + plotH;
@@ -753,6 +826,59 @@
for (const points of runs) {
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) {
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
@@ -775,7 +901,7 @@
if (lineWidth > 0) {
ctx.lineJoin = '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
// stays legible over any background.
@@ -789,7 +915,7 @@
}
ctx.lineWidth = lineWidth;
if (s.segmentColor) {
if (s.segmentColor && !s.foregroundLine) {
// Colour each segment by its value (temperature colour scale).
// `idx[i]` maps a run point back to its source data index.
for (let i = 1; i < points.length; i++) {
@@ -800,6 +926,37 @@
ctx.lineTo(points[i][0], points[i][1]);
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 {
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
@@ -829,7 +986,12 @@
const x = xPix(t);
const y = yPix(v, axis);
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
const halfW = ctx.measureText(label).width / 2 + 2;
const lx = Math.max(padLeft + halfW, Math.min(plotRight - halfW, x));
@@ -1142,11 +1304,12 @@
style:height="{ICON_BAND_H}px"
>
{#each visiblePictograms as p (p.x)}
{@const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, p.x))}
<svg
class="absolute top-px fill-foreground"
class="absolute top-1/2 -translate-y-1/2 fill-foreground"
width={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>
</svg>
@@ -1164,14 +1327,15 @@
style:height="{ICON_BAND_H}px"
>
{#each visibleWindArrows as a (a.x)}
{@const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, a.x))}
<span
class="absolute top-px inline-flex items-center justify-center"
style:width="{ICON_PX}px"
style:height="{ICON_PX}px"
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, a.x - ICON_PX / 2))}px"
style:transform="rotate({a.deg}deg)"
class="absolute top-1/2 inline-flex items-center justify-center"
style:width="{ARROW_PX}px"
style:height="{ARROW_PX}px"
style:left="{cx - ARROW_PX / 2}px"
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>
</svg>
</span>
+6 -1
View File
@@ -5,7 +5,12 @@
* 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 { buildDaylightBands } from './bands';
@@ -106,12 +106,18 @@
column on md+ (main has 2rem padding) for extra readability. */
margin-left: -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-y: hidden;
}
.chart-bleed.no-bleed {
margin-left: 0;
margin-right: 0;
/* content fits the column, so no horizontal scroller is needed */
overflow-x: hidden;
}
.chart-container {
+4 -64
View File
@@ -19,13 +19,12 @@
</ChartToolbar>
-->
<script module lang="ts">
/** Anything that can export itself as a PNG data URL (e.g. a CanvasChart). */
export interface DownloadableChart {
getPngDataUrl(): string | null;
}
export type { DownloadableChart } from './downloadChartsPng';
</script>
<script lang="ts">
import { type DownloadableChart, downloadChartsPng } from './downloadChartsPng';
import type { Snippet } from 'svelte';
// ─── Props ──────────────────────────────────────────────────────────────────
@@ -58,70 +57,11 @@
// ─── 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> {
if (!hasCharts || downloading) return;
downloading = true;
try {
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`);
await downloadChartsPng(charts, fileName);
} finally {
setTimeout(() => {
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 ChartToolbar } from './ChartToolbar.svelte';
export { downloadChartsPng, type DownloadableChart } from './downloadChartsPng';
+14 -4
View File
@@ -65,7 +65,14 @@ export const defaultVariablePrefs: VariablePrefs = {
wind: true,
humidity: 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: {
temperature: true,
@@ -79,6 +86,9 @@ export const defaultVariablePrefs: VariablePrefs = {
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
* list of variable keys (see the chart variable registry). Users drag
@@ -90,9 +100,9 @@ export interface ChartPanel {
}
export const defaultChartLayout: ChartPanel[] = [
{ id: 'panel-1', variables: ['weather_icons', 'temperature', 'cloud_cover'] },
{ id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] },
{ id: 'panel-3', variables: ['wind', 'wind_direction', 'humidity'] }
{ id: 'panel-1', variables: ['temperature', 'weather_icons'] },
{ id: 'panel-2', variables: ['precipitation', 'precipitation_probability', 'cloud_cover'] },
{ id: 'panel-3', variables: ['wind', 'wind_gusts', 'wind_direction'] }
];
export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout);