diff --git a/src/lib/charts/CanvasChart.svelte b/src/lib/charts/CanvasChart.svelte index 2ca7f11..77dafe5 100644 --- a/src/lib/charts/CanvasChart.svelte +++ b/src/lib/charts/CanvasChart.svelte @@ -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(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(); } diff --git a/src/routes/weather/week/[location]/DailyStripSticky.svelte b/src/routes/weather/week/[location]/DailyStripSticky.svelte index fcc37c5..c2c335c 100644 --- a/src/routes/weather/week/[location]/DailyStripSticky.svelte +++ b/src/routes/weather/week/[location]/DailyStripSticky.svelte @@ -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. --> -
+
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,