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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user