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
+182 -16
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();
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();
ctx.arc(xPix(t), padTop, r, 0, Math.PI * 2);
ctx.fill();
traceMonotone(ctx, bottom);
ctx.strokeStyle = edge;
ctx.lineWidth = 1.25;
ctx.lineJoin = 'round';
ctx.stroke();
}
ctx.restore();
}