Files
gods-eye-view/src/layers/wind/model.js
T
Gustavo Beneduzi b7033d86c2 fix(wind): make the particle overlay actually visible
Live browser QA of the Wind layer surfaced several rendering defects that
unit tests with fakes did not catch:

- The trail fade used a translucent black fill (source-over), which raises
  alpha instead of erasing; combined with additive strokes the overlay
  saturated into a solid white disk. Fade with destination-out instead, and
  draw strokes with normal blending.
- Particles were seeded uniformly over the whole globe, so at regional zoom
  almost none fell on screen. Seed inside the camera's visible rectangle.
- A non-finite particle position (polar divide-by-zero) reached Cesium,
  which threw and aborted the entire frame before any stroke. Guard finite
  coordinates per particle and per projection.
- Exaggerate advection in proportion to camera height, so the same visual
  speed is legible at street level and at hemispheric zoom.
- Brighter colour ramp (the old ramp started near-invisible dark grey) and a
  defensive frame try/catch so one bad frame cannot kill the loop.

Verified: the wind field/colour/particle model renders the real GFS 06z field
correctly in an offline render, and the wind unit tests pass (8/8).
2026-09-14 14:02:48 -03:00

61 lines
2.4 KiB
JavaScript

/** Normalize a longitude into the half-open world interval. */
export function normalizeLongitude(lon) {
return ((lon + 180) % 360 + 360) % 360 - 180;
}
/** Sample a north-to-south, seam-wrapped wind grid bilinearly. */
export function sampleWind(field, lon, lat) {
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return { u: 0, v: 0 };
const { u, v, nx, ny, lo1, la1, dx, dy } = field;
const x = ((normalizeLongitude(lon - lo1) + 360) % 360) / dx;
const y = (la1 - lat) / dy;
const x0 = Math.floor(x);
const tx = x - x0;
const yClamped = Math.max(0, Math.min(ny - 1, y));
const y0 = Math.floor(yClamped);
const y1 = Math.min(ny - 1, y0 + 1);
const ty = yClamped - y0;
const column = (index) => ((index % nx) + nx) % nx;
const value = (array) => {
const a = array[y0 * nx + column(x0)];
const b = array[y0 * nx + column(x0 + 1)];
const c = array[y1 * nx + column(x0)];
const d = array[y1 * nx + column(x0 + 1)];
return (a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
};
return { u: value(u), v: value(v) };
}
/** Return metres represented by one longitude degree at a latitude. */
export function metersPerDegreeLon(lat) {
return 111320 * Math.cos((lat * Math.PI) / 180);
}
/** Calculate wind magnitude. */
export function windSpeed(u, v) {
return Math.hypot(u, v);
}
/** Map wind magnitude to a fixed CSS colour ramp (bright from calm upward). */
export function windColor(speed, { max = 30 } = {}) {
const stops = ['#38bdf8', '#22d3ee', '#34d399', '#fbbf24', '#f87171'];
const t = Math.max(0, Math.min(1, speed / max));
const position = t * (stops.length - 1);
const index = Math.min(stops.length - 2, Math.floor(position));
const fraction = position - index;
if (t === 1) return stops.at(-1);
const parse = (color) => color.match(/\w\w/g).map((part) => parseInt(part, 16));
const a = parse(stops[index]);
const b = parse(stops[index + 1]);
return `rgb(${a.map((value, i) => Math.round(value + (b[i] - value) * fraction)).join(', ')})`;
}
/** Advance a particle by a wind vector, mutating and returning it. */
export function advectParticle(particle, wind, dtSeconds, { speedScale = 1 } = {}) {
particle.lon = normalizeLongitude(
particle.lon + (wind.u * dtSeconds * speedScale) / metersPerDegreeLon(particle.lat),
);
particle.lat = Math.max(-89, Math.min(89, particle.lat + (wind.v * dtSeconds * speedScale) / 111320));
return particle;
}