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>