From d49d322f6b250abc5d3c87018ac2754821e1b3ae Mon Sep 17 00:00:00 2001 From: John Carmack Date: Fri, 26 Jun 2026 21:49:26 -0700 Subject: [PATCH] Add colormap legends with units to the raster layers The wind / precip-forecast / storm-potential overlays were color-only with no scale, so their values were unreadable. Lift the colormap stops into shared JS (rasterShared) and generate BOTH the GLSL ramp and a panel legend from them, so the bar always matches the map. Each visible raster layer now shows a gradient legend with low/mid/high ticks and units: wind m/s, precip dBZ, CAPE J/kg. The precip legend appears only in forecast mode (live radar uses its own palette). --- web/src/layers/cape.tsx | 3 + web/src/layers/legend.tsx | 44 ++++++++++ web/src/layers/precip.tsx | 5 ++ web/src/layers/rasterShared.ts | 142 +++++++++++++++++++++------------ web/src/layers/wind.tsx | 7 ++ 5 files changed, 148 insertions(+), 53 deletions(-) create mode 100644 web/src/layers/legend.tsx diff --git a/web/src/layers/cape.tsx b/web/src/layers/cape.tsx index aa83a53..5d4cc55 100644 --- a/web/src/layers/cape.tsx +++ b/web/src/layers/cape.tsx @@ -3,6 +3,8 @@ import { WEATHER_BASE } from '../config'; import type { CapeTexIndex } from '../generated/weather'; import { nearestStep } from '../Timeline'; import { age } from '../weather'; +import { RasterLegend } from './legend'; +import { CAPE_DOMAIN, CAPE_STOPS } from './rasterShared'; import { CapeRasterLayer } from './scalarRasterLayer'; import { Swatch } from './swatch'; import type { WeatherLayer } from './types'; @@ -41,6 +43,7 @@ export const cape: WeatherLayer = {
CAPE · GFS · {age(idx?.snapshotMs)}
+ { + const pct = ((i / (stops.length - 1)) * 100).toFixed(0); + const [r, g, b] = c.map((v) => Math.round(v * 255)); + return `rgb(${r}, ${g}, ${b}) ${pct}%`; + }); + return `linear-gradient(to right, ${parts.join(', ')})`; +} + +/** + * A compact colormap legend for a raster layer: the same color stops the shader + * uses (so the bar matches the map), with low / mid / high value ticks and a + * unit — turning the color-only overlays into readable scales. + */ +export function RasterLegend({ + stops, + domain, + unit, +}: { + stops: readonly Rgb[]; + domain: readonly [number, number]; + unit: string; +}) { + const [lo, hi] = domain; + const mid = Math.round((lo + hi) / 2); + return ( +
+
+
+ {lo} + {mid} + + {hi} {unit} + +
+
+ ); +} diff --git a/web/src/layers/precip.tsx b/web/src/layers/precip.tsx index 75c54dc..97581fa 100644 --- a/web/src/layers/precip.tsx +++ b/web/src/layers/precip.tsx @@ -6,6 +6,8 @@ import { WEATHER_BASE } from '../config'; import type { CityTileIndex, RefcTexIndex } from '../generated/weather'; import { nearestStep } from '../Timeline'; import { age } from '../weather'; +import { RasterLegend } from './legend'; +import { REFC_DOMAIN, REFC_STOPS } from './rasterShared'; import { RefcRasterLayer } from './scalarRasterLayer'; import { Swatch } from './swatch'; import type { WeatherLayer } from './types'; @@ -109,6 +111,9 @@ export const precip: WeatherLayer = { ? `GFS forecast · ${age(data?.refc?.snapshotMs)}` : 'live · RainViewer / IEM'}
+ {forecast && ( + + )} (Number.isInteger(x) ? x.toFixed(1) : String(x)); + const decls = stops + .map( + (c, i) => + ` const vec3 c${i} = vec3(${g(c[0])}, ${g(c[1])}, ${g(c[2])});`, + ) + .join('\n'); + const branches = stops + .slice(0, -1) + .map( + (_, i) => + ` if (s < ${i + 1}.0) return mix(c${i}, c${i + 1}, s - ${i}.0);`, + ) + .join('\n'); + return ` +vec3 ${fn}(float ${param}) { +${decls} + float s = ${mapExpr}; +${branches} + return c${stops.length - 1}; }`; +} -/** Surface-CAPE (J/kg) colormap: green → yellow → orange → red → magenta across - * ~500→4500 J/kg, the severe-weather instability scale. The storm-potential - * layer fades out stable/weak air (< ~250 J/kg) so the overlay only paints where - * the atmosphere is primed for convection. */ -export const CAPE_RAMP_GLSL = /* glsl */ ` -vec3 capeRamp(float cape) { - const vec3 c0 = vec3(0.30, 0.66, 0.36); // ~500 green (weak) - const vec3 c1 = vec3(0.93, 0.86, 0.32); // ~1500 yellow (moderate) - const vec3 c2 = vec3(0.95, 0.55, 0.20); // ~2500 orange (strong) - const vec3 c3 = vec3(0.86, 0.24, 0.24); // ~3500 red (severe) - const vec3 c4 = vec3(0.72, 0.26, 0.66); // ~4500 magenta (extreme) - float s = clamp((cape - 500.0) / 1000.0, 0.0, 4.0); - if (s < 1.0) return mix(c0, c1, s); - if (s < 2.0) return mix(c1, c2, s - 1.0); - if (s < 3.0) return mix(c2, c3, s - 2.0); - return mix(c3, c4, s - 3.0); -}`; +/** Wind-speed colormap stops: calm blue → teal → green → yellow → orange → red → + * magenta. The ramp input is speed normalized over [0, `WIND_COLOR_MAX`]. */ +export const WIND_STOPS: readonly Rgb[] = [ + [0.16, 0.22, 0.45], // calm + [0.2, 0.55, 0.7], // teal + [0.3, 0.74, 0.45], // green + [0.93, 0.86, 0.32], // yellow + [0.95, 0.55, 0.2], // orange + [0.86, 0.24, 0.24], // red + [0.72, 0.26, 0.66], // magenta (extreme) +]; +/** m/s at which the wind colormap saturates (magenta) — the legend's high end. */ +export const WIND_COLOR_MAX = 28; +export const WIND_RAMP_GLSL = buildRampGlsl( + 'windRamp', + 't', + WIND_STOPS, + 'clamp(t, 0.0, 1.0) * 6.0', +); + +/** Composite-reflectivity (dBZ) colormap stops + domain — the conventional radar + * scale, so the forecast precip matches the live radar's look. The precip layer + * renders everything below its display threshold transparent, so this only + * colors actual echo. */ +export const REFC_STOPS: readonly Rgb[] = [ + [0.26, 0.71, 0.42], // green + [0.93, 0.86, 0.32], // yellow + [0.95, 0.55, 0.2], // orange + [0.86, 0.24, 0.24], // red + [0.72, 0.26, 0.66], // magenta (extreme) +]; +export const REFC_DOMAIN: readonly [number, number] = [15, 65]; +export const REFC_RAMP_GLSL = buildRampGlsl( + 'refcRamp', + 'dbz', + REFC_STOPS, + 'clamp((dbz - 15.0) / 12.5, 0.0, 4.0)', +); + +/** Surface-CAPE (J/kg) colormap stops + domain — the severe-weather instability + * scale. The storm-potential layer fades out stable/weak air (< ~250 J/kg) so + * the overlay only paints where the atmosphere is primed for convection. */ +export const CAPE_STOPS: readonly Rgb[] = [ + [0.3, 0.66, 0.36], // weak (green) + [0.93, 0.86, 0.32], // moderate (yellow) + [0.95, 0.55, 0.2], // strong (orange) + [0.86, 0.24, 0.24], // severe (red) + [0.72, 0.26, 0.66], // extreme (magenta) +]; +export const CAPE_DOMAIN: readonly [number, number] = [500, 4500]; +export const CAPE_RAMP_GLSL = buildRampGlsl( + 'capeRamp', + 'cape', + CAPE_STOPS, + 'clamp((cape - 500.0) / 1000.0, 0.0, 4.0)', +); diff --git a/web/src/layers/wind.tsx b/web/src/layers/wind.tsx index ba382c4..f0b14fb 100644 --- a/web/src/layers/wind.tsx +++ b/web/src/layers/wind.tsx @@ -5,6 +5,8 @@ import { WEATHER_BASE } from '../config'; import type { WindTexIndex } from '../generated/weather'; import { nearestStep } from '../Timeline'; import { age } from '../weather'; +import { RasterLegend } from './legend'; +import { WIND_COLOR_MAX, WIND_STOPS } from './rasterShared'; import { Swatch } from './swatch'; import type { WeatherLayer } from './types'; import { WindRasterLayer } from './windRasterLayer'; @@ -57,6 +59,11 @@ export const wind: WeatherLayer = { controls: (ctx, idx) => (
GFS · {age(idx?.snapshotMs)}
+