cloud cover

This commit is contained in:
Vincent van der Wal
2026-08-01 12:55:32 +02:00
parent 5cc9a5428c
commit d3b223cbb5
3 changed files with 231 additions and 29 deletions
+181 -15
View File
@@ -62,8 +62,14 @@
labelExtrema?: boolean;
/** Formatter for extrema labels (defaults to the tooltip format) */
labelFormat?: (value: number) => string;
/** Render as a puffy cloud band hanging from the top instead of a line */
/** Render as a soft cloud band instead of a line (see `cloudLayer`) */
cloudBand?: boolean;
/**
* Which slot a cloud band occupies. Omitted (total cover) hangs from the
* top of the plot; the named layers stack in their real vertical order,
* each in its own slot, so a layered chart reads like the sky itself.
*/
cloudLayer?: 'high' | 'mid' | 'low';
}
interface GroupState {
@@ -229,8 +235,13 @@
// 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;
// Total cloud cover: 100% reaches this far down from the top of the plot.
const CLOUD_BAND_MAX = 48;
// Layered cover (high / mid / low): each layer owns a slot of this height and
// grows symmetrically out of the centre line of that slot.
const CLOUD_LAYER_H = 42;
const CLOUD_LAYER_GAP = 6;
const CLOUD_LAYER_ORDER = ['high', 'mid', 'low'] as const;
/** Return a colour string with the given alpha (handles rgb/rgba/#hex). */
function withAlpha(color: string, alpha: number): string {
@@ -256,6 +267,83 @@
return color;
}
/**
* Light [1 2 1] smoothing pass. Cloud cover is noisy hour to hour; smoothing
* the values before interpolating gives the band a slow, rolling silhouette
* instead of one that tracks every single sample.
*/
function smoothCover(values: number[], passes = 2): number[] {
let out = values;
for (let p = 0; p < passes; p++) {
const next = new Array<number>(out.length);
for (let i = 0; i < out.length; i++) {
const prev = out[i - 1] ?? out[i];
const nxt = out[i + 1] ?? out[i];
next[i] = (prev + 2 * out[i] + nxt) / 4;
}
out = next;
}
return out;
}
/** Vertical offset of a cloud series' slot from the top of the plot. */
function cloudSlotOffset(s: ChartSeries): number {
if (!s.cloudLayer) return 0;
return CLOUD_LAYER_ORDER.indexOf(s.cloudLayer) * (CLOUD_LAYER_H + CLOUD_LAYER_GAP);
}
/**
* Traces a monotone cubic (Fritsch-Carlson) curve through the points. Unlike a
* plain Catmull-Rom spline it never overshoots the data, so a cloud band can't
* bulge past 0% or 100% between two samples.
*/
function traceMonotone(
ctx: CanvasRenderingContext2D,
pts: Array<[number, number]>,
continuePath = false
): void {
const n = pts.length;
if (!continuePath) ctx.moveTo(pts[0][0], pts[0][1]);
if (n === 2) {
ctx.lineTo(pts[1][0], pts[1][1]);
return;
}
// secant slopes, then tangents averaged from the neighbouring secants
const slope: number[] = [];
for (let i = 0; i < n - 1; i++) {
const dx = pts[i + 1][0] - pts[i][0];
slope.push(dx === 0 ? 0 : (pts[i + 1][1] - pts[i][1]) / dx);
}
const m: number[] = [slope[0]];
for (let i = 1; i < n - 1; i++) m.push((slope[i - 1] + slope[i]) / 2);
m.push(slope[n - 2]);
// clamp the tangents back onto the monotone circle of radius 3
for (let i = 0; i < n - 1; i++) {
if (slope[i] === 0) {
m[i] = 0;
m[i + 1] = 0;
continue;
}
const a = m[i] / slope[i];
const b = m[i + 1] / slope[i];
const h = a * a + b * b;
if (h > 9) {
const t = 3 / Math.sqrt(h);
m[i] = t * a * slope[i];
m[i + 1] = t * b * slope[i];
}
}
for (let i = 0; i < n - 1; i++) {
const [x0, y0] = pts[i];
const [x1, y1] = pts[i + 1];
const dx = (x1 - x0) / 3;
ctx.bezierCurveTo(x0 + dx, y0 + m[i] * dx, x1 - dx, y1 - m[i + 1] * dx, x1, y1);
}
}
// ─── State ──────────────────────────────────────────────────────────────────
let containerEl: HTMLDivElement | undefined = $state();
@@ -934,23 +1022,101 @@
const interval = timestamps.length > 1 ? timestamps[1] - timestamps[0] : HOUR;
// Puffy cloud band: overlapping circles hang from the top of the plot,
// each reaching down by (cover/100) × CLOUD_BAND_MAX. Neighbouring puffs
// merge into a soft, rounded silhouette.
// Cloud bands: cover drives how far the band reaches into its slot, traced
// as a monotone-interpolated curve so the silhouette flows instead of
// stepping hour to hour. Both gradients run from near-transparent at the
// anchor to solid at full cover, so a 10% sky barely registers while an
// overcast one is unmistakable.
//
// * total cover hangs from the top of the plot
// * high / mid / low each grow symmetrically out of their slot's centre
// line, stacked in the order the layers actually sit in the sky
for (const s of cloudBandSeries) {
ctx.save();
ctx.fillStyle = s.color;
ctx.globalAlpha = 0.5;
const slotTop = padTop + cloudSlotOffset(s);
const layered = s.cloudLayer != null;
const slotH = layered ? CLOUD_LAYER_H : CLOUD_BAND_MAX;
const centre = slotTop + slotH / 2;
// Samples just outside the viewport are kept so the curve enters and
// leaves the plot at the right slope.
const xs: number[] = [];
const covers: number[] = [];
for (let i = 0; i < timestamps.length; i++) {
const v = s.data[i];
if (v === null || v === undefined || !isFinite(v) || v <= 0) continue;
const t = timestamps[i];
if (t < viewStart - interval || t > viewEnd + interval) continue;
const r = (Math.min(100, v) / 100) * CLOUD_BAND_MAX;
if (r < 1) continue;
if (t < viewStart - interval * 2 || t > viewEnd + interval * 2) continue;
const v = s.data[i];
xs.push(xPix(t));
covers.push(
v === null || v === undefined || !isFinite(v) ? 0 : Math.min(100, Math.max(0, v))
);
}
if (xs.length < 2) continue;
const smoothed = smoothCover(covers);
const top: Array<[number, number]> = [];
const bottom: Array<[number, number]> = [];
for (let i = 0; i < xs.length; i++) {
const frac = smoothed[i] / 100;
if (layered) {
const half = frac * (slotH / 2);
top.push([xs[i], centre - half]);
bottom.push([xs[i], centre + half]);
} else {
top.push([xs[i], slotTop]);
bottom.push([xs[i], slotTop + frac * slotH]);
}
}
// The gradient runs ALONG the series rather than top-to-bottom: every
// sample contributes a stop at its own x, so the band's density tracks
// the cover itself - a clear spell dissolves, an overcast one goes
// solid - and the fade always lines up with the silhouette above it.
const first = xs[0];
const span = xs[xs.length - 1] - first || 1;
const base = layered ? 0.08 : 0.05;
const peak = layered ? 0.92 : 0.68;
// Total cover ramps up late: a broken sky stays nearly clear on the
// plot and the density only really builds as it closes over.
const curve = layered ? 1 : 2.2;
const fill = ctx.createLinearGradient(first, 0, xs[xs.length - 1], 0);
// The edge follows the same values but starts later still: below a
// fifth of the sky there is no silhouette to draw at all.
const EDGE_FLOOR = 20;
const edge = layered ? null : ctx.createLinearGradient(first, 0, xs[xs.length - 1], 0);
for (let i = 0; i < xs.length; i++) {
const pos = Math.max(0, Math.min(1, (xs[i] - first) / span));
const cover = smoothed[i];
fill.addColorStop(pos, withAlpha(s.color, base + (peak - base) * Math.pow(cover / 100, curve)));
if (edge) {
const above = Math.max(0, (cover - EDGE_FLOOR) / (100 - EDGE_FLOOR));
edge.addColorStop(pos, withAlpha(s.color, 0.85 * Math.pow(above, 1.6)));
}
}
ctx.save();
ctx.beginPath();
ctx.arc(xPix(t), padTop, r, 0, Math.PI * 2);
traceMonotone(ctx, bottom);
if (layered) {
// back along the mirrored upper edge
ctx.lineTo(top[top.length - 1][0], top[top.length - 1][1]);
traceMonotone(ctx, [...top].reverse(), true);
} else {
ctx.lineTo(bottom[bottom.length - 1][0], slotTop);
ctx.lineTo(bottom[0][0], slotTop);
}
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
// The layers are gradient only - an outline would fight the soft mass
// they are meant to look like.
if (edge) {
ctx.beginPath();
traceMonotone(ctx, bottom);
ctx.strokeStyle = edge;
ctx.lineWidth = 1.25;
ctx.lineJoin = 'round';
ctx.stroke();
}
ctx.restore();
}
@@ -240,7 +240,7 @@
Both stay perfectly centered when compact (the night icon melts
away and the day icon re-centers on its own); when full, small
k-driven nudges line each temp up under its icon. -->
<div class="flex w-full items-center justify-center">
<div class="icon-row flex w-full items-center justify-center">
<div class="icon-wrap">
<svg class="day-icon fill-foreground">
<use
@@ -413,9 +413,11 @@
.sentinel,
.daystrip {
--cell-w-full: 76px;
--cell-w-min: 58px;
--cell-h-full: 140px;
--cell-h-min: 64px;
/* collapsed cells are square: the width follows the compact height, so the
docked bar reads as a row of tiles instead of narrow slivers */
--cell-w-min: var(--cell-h-min);
--icon-full: 44px;
--icon-min: 21px;
--pt-full: 7px;
@@ -439,9 +441,18 @@
/* full state only: nudge each temp to sit under "its" icon */
--tmax-nudge: 0px;
--tmin-nudge: 5px;
/* cell bottom padding (roomy when full, tight when compact) */
/* full state only: gap above the icons, and the day / night icon offsets
that tighten the pair around the cell centre */
--icon-gap-full: 2px;
--day-icon-nudge: 2px;
--night-icon-nudge: 4px;
/* compact only: signed shift that lands the lone icon dead centre in the
square tile (measured against the tile's mid-line) */
--icon-lift: -1px;
/* cell bottom padding (roomy when full, a little breathing room under the
temps when compact so they don't sit on the tile edge) */
--pb-full: 6px;
--pb-min: 6px;
--pb-min: 8px;
/* extra scrub distance beyond the height difference — slows the collapse
down; the surplus just slides content under the (opaque) bar */
--collapse-extra: 0px;
@@ -460,7 +471,6 @@
.daystrip {
--cell-w-full: 120px;
--cell-h-full: 208px;
--cell-w-min: 64px;
--cell-h-min: 56px;
--icon-full: 80px;
--icon-min: 22px;
@@ -476,8 +486,11 @@
--tmax-padx-full: 12px;
--tmax-nudge: 0px;
--tmin-nudge: 8px;
--icon-gap-full: 6px;
--day-icon-nudge: 3px;
--night-icon-nudge: 6px;
--pb-full: 10px;
--pb-min: 2px;
--pb-min: 5px;
--collapse-extra: 60px;
}
.daystrip {
@@ -652,6 +665,10 @@
width: var(--cell-w);
padding-top: var(--pt);
padding-bottom: calc(var(--pb-min) + (var(--pb-full) - var(--pb-min)) * var(--k));
/* The collapsed rows that shrink to zero height still paid for their flex
gap, which pushed the temps onto the tile's bottom edge. Fading the gap
out with the collapse gives that space back as real bottom padding. */
row-gap: calc(2px * var(--k));
}
/* Side buttons keep the compact width in BOTH states, so almost nothing to
the left of the first day changes size during the collapse — the first
@@ -659,6 +676,13 @@
.strip-side {
width: var(--cell-w-min);
}
.icon-row {
/* full: keep the icons off the weekday / date block above them.
compact: the icon is the only thing between the two text rows, so lift
it to sit optically dead centre in the square tile. */
margin-top: calc(var(--icon-gap-full) * var(--k));
transform: translateY(calc(-1 * var(--icon-lift) * (1 - var(--k))));
}
.icon-wrap {
/* tuck the icon into its line box: the glyphs carry generous built-in
padding, so pulling the neighbours in keeps the cells tight */
@@ -668,6 +692,8 @@
display: block;
width: var(--icon);
height: var(--icon);
/* full only: settle the pair a touch right of centre */
transform: translateX(calc(var(--day-icon-nudge) * var(--k)));
}
/* The night icon sits beside the day icon (slightly low, like a companion)
and melts away completely when compact so the day icon re-centers. */
@@ -678,6 +704,7 @@
opacity: var(--rel);
margin-left: calc(-4px * var(--rel));
margin-bottom: calc(6px * var(--rel));
transform: translateX(calc(-1 * var(--night-icon-nudge) * var(--k)));
}
/* weekday centered when full, pushed to the edges when compact */
.dow-row {
@@ -56,8 +56,10 @@ export interface ChartVariableDef {
windArrows?: boolean;
/** Marker-only variable (icons / arrows): contributes no plotted series */
marker?: boolean;
/** Render as a puffy cloud band hanging from the top (0-100 → 0-40px) */
/** Render as a soft cloud band instead of a line */
cloudBand?: boolean;
/** Which slot the band occupies; omitted hangs from the top (total cover) */
cloudLayer?: 'high' | 'mid' | 'low';
/** Transform raw values before plotting (e.g. m → km) */
transform?: (v: number) => number;
/** Preset range to use when this variable lands on a shared right axis */
@@ -121,6 +123,9 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
color: 'rgb(150, 155, 165)',
cloudBand: true
},
// The three layers stack in their real vertical order (high at the top of the
// plot, low at the bottom of the band group) and darken towards the ground,
// the way the layers actually look from below.
{
key: 'cloud_cover_low',
label: 'Cloud Cover Low',
@@ -128,8 +133,9 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
field: 'cloud_cover_low',
type: 'line',
kind: 'percent',
color: '#94a3b8',
width: 2
color: 'rgb(110, 118, 132)',
cloudBand: true,
cloudLayer: 'low'
},
{
key: 'cloud_cover_mid',
@@ -138,8 +144,9 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
field: 'cloud_cover_mid',
type: 'line',
kind: 'percent',
color: '#64748b',
width: 2
color: 'rgb(148, 156, 170)',
cloudBand: true,
cloudLayer: 'mid'
},
{
key: 'cloud_cover_high',
@@ -148,8 +155,9 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
field: 'cloud_cover_high',
type: 'line',
kind: 'percent',
color: '#cbd5e1',
width: 2
color: 'rgb(186, 194, 208)',
cloudBand: true,
cloudLayer: 'high'
},
{
key: 'precipitation',
@@ -475,6 +483,7 @@ export function buildPanelDef(
dashed: d.dashed,
axis,
cloudBand: d.cloudBand,
cloudLayer: d.cloudLayer,
segmentColor: d.colorScale ? (v: number) => getColor(v, units.temperature_unit) : undefined,
foregroundLine: d.foregroundLine,
outline: d.outline,