cloud cover
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user