merge: wind overlay visibility fixes from feat/wind-gfs

This commit is contained in:
Gustavo Beneduzi committed 2026-09-14 14:03:11 -03:00
commit a2d38391e6
3 files changed
+108 -39

No files matched your search

+2 -2
View File
@@ -36,9 +36,9 @@ export function windSpeed(u, v) {
return Math.hypot(u, v); return Math.hypot(u, v);
} }
/** Map wind magnitude to a fixed CSS colour ramp. */ /** Map wind magnitude to a fixed CSS colour ramp (bright from calm upward). */
export function windColor(speed, { max = 30 } = {}) { export function windColor(speed, { max = 30 } = {}) {
const stops = ['#4a5568', '#7fe3ff', '#7dffb0', '#ffd166', '#ff7a59']; const stops = ['#38bdf8', '#22d3ee', '#34d399', '#fbbf24', '#f87171'];
const t = Math.max(0, Math.min(1, speed / max)); const t = Math.max(0, Math.min(1, speed / max));
const position = t * (stops.length - 1); const position = t * (stops.length - 1);
const index = Math.min(stops.length - 2, Math.floor(position)); const index = Math.min(stops.length - 2, Math.floor(position));
+105 -37
View File
@@ -19,6 +19,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
let lastTime = 0; let lastTime = 0;
let cssWidth = 1; let cssWidth = 1;
let cssHeight = 1; let cssHeight = 1;
let warned = false;
const randomParticle = () => ({ const randomParticle = () => ({
lon: Math.random() * 360 - 180, lon: Math.random() * 360 - 180,
@@ -26,13 +27,46 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
age: Math.random() * 180, age: Math.random() * 180,
}); });
/**
* Spawn a particle inside the current view. Prefers the camera's visible
* rectangle (robust at any zoom), then ellipsoid picking, then a whole-globe
* fallback. Seeding globally would leave almost nothing on screen at regional
* zoom, which reads as "the layer is broken".
*/
const spawnParticle = (scene) => {
const camera = scene?.camera;
if (camera?.computeViewRectangle && cesium.Ellipsoid && cesium.Math) {
try {
const rect = camera.computeViewRectangle(cesium.Ellipsoid.WGS84);
if (rect) {
const west = cesium.Math.toDegrees(rect.west);
let east = cesium.Math.toDegrees(rect.east);
const south = cesium.Math.toDegrees(rect.south);
const north = cesium.Math.toDegrees(rect.north);
if (east < west) east += 360; // rectangle crosses the antimeridian
const lon = west + Math.random() * (east - west);
const lat = south + Math.random() * (north - south);
return {
lon: ((lon + 180) % 360 + 360) % 360 - 180,
lat: Math.max(-89, Math.min(89, lat)),
age: Math.random() * 30,
};
}
} catch {
/* fall through to the global fallback */
}
}
return randomParticle();
};
const budget = () => const budget = () =>
Math.max(3000, Math.min(8000, Math.floor((cssWidth * cssHeight) / 1000))); Math.max(4000, Math.min(12000, Math.floor((cssWidth * cssHeight) / 150)));
const seed = () => { const seed = () => {
particles = []; particles = [];
const limit = budget(); const limit = budget();
for (let i = 0; i < limit; i += 1) particles.push(randomParticle()); const scene = getViewer?.()?.scene;
for (let i = 0; i < limit; i += 1) particles.push(spawnParticle(scene));
}; };
const resize = (viewer) => { const resize = (viewer) => {
@@ -49,6 +83,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
/** Project lon/lat to CSS pixels, or null when hidden by the globe. */ /** Project lon/lat to CSS pixels, or null when hidden by the globe. */
const project = (scene, occluder, lon, lat) => { const project = (scene, occluder, lon, lat) => {
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
const cartesian = cesium.Cartesian3.fromDegrees(lon, lat, 0); const cartesian = cesium.Cartesian3.fromDegrees(lon, lat, 0);
if (occluder && !occluder.isPointVisible(cartesian)) return null; if (occluder && !occluder.isPointVisible(cartesian)) return null;
return cesium.SceneTransforms.worldToWindowCoordinates(scene, cartesian); return cesium.SceneTransforms.worldToWindowCoordinates(scene, cartesian);
@@ -59,42 +94,74 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
if (!running) return; if (!running) return;
const viewer = getViewer?.(); const viewer = getViewer?.();
if (!viewer || viewer.isDestroyed?.()) return; if (!viewer || viewer.isDestroyed?.()) return;
resize(viewer); try {
const dt = lastTime ? Math.min(1, Math.max(0, (time - lastTime) / 1000)) : 0.016; resize(viewer);
lastTime = time; const dt = lastTime ? Math.min(1, Math.max(0, (time - lastTime) / 1000)) : 0.016;
context.fillStyle = 'rgba(0, 0, 0, 0.08)'; lastTime = time;
context.fillRect(0, 0, cssWidth, cssHeight); // Fade previous trails by ERASING them (destination-out). A translucent
if (!field) { // black fill would instead accumulate alpha and saturate the overlay.
frame = globalThis.requestAnimationFrame(draw); context.globalCompositeOperation = 'destination-out';
return; context.fillStyle = 'rgba(0, 0, 0, 0.02)';
} context.fillRect(0, 0, cssWidth, cssHeight);
const scene = viewer.scene; if (!field) {
const occluder = cesium.EllipsoidalOccluder frame = globalThis.requestAnimationFrame(draw);
? new cesium.EllipsoidalOccluder(cesium.Ellipsoid.WGS84, scene.camera.positionWC) return;
: null; }
for (const particle of particles) { const scene = viewer.scene;
const wind = sampleWind(field, particle.lon, particle.lat); // Real wind (m/s) is invisible at globe scale, so the advection is
const before = { lon: particle.lon, lat: particle.lat }; // exaggerated in proportion to camera height: the same visual speed is
advectParticle(particle, wind, dt, { speedScale: 8 }); // legible whether the camera is at street level or over a whole hemisphere.
particle.age += dt; const cameraHeight = scene.camera?.positionCartographic?.height ?? 1e6;
const point = project(scene, occluder, particle.lon, particle.lat); const speedScale = Math.max(1, Math.min(4000, cameraHeight / 400));
const offscreen = let occluder = null;
!point || try {
point.x < 0 || if (cesium.EllipsoidalOccluder && cesium.Ellipsoid && scene.camera?.positionWC)
point.y < 0 || occluder = new cesium.EllipsoidalOccluder(cesium.Ellipsoid.WGS84, scene.camera.positionWC);
point.x > cssWidth || } catch {
point.y > cssHeight; occluder = null; // fall back to frustum-only culling
if (particle.age > 240 || offscreen) { }
Object.assign(particle, randomParticle()); context.lineWidth = 1.8;
continue; context.globalAlpha = 0.9;
// Normal (non-additive) blending: additive strokes saturate to white when
// many trails overlap over a dark globe.
context.globalCompositeOperation = 'source-over';
for (const particle of particles) {
const wind = sampleWind(field, particle.lon, particle.lat);
const before = { lon: particle.lon, lat: particle.lat };
advectParticle(particle, wind, dt, { speedScale });
particle.age += dt;
// A NaN/Infinity position (polar divide-by-zero or a bad grid value)
// must never reach Cesium: it throws and would abort the whole frame.
if (!Number.isFinite(particle.lon) || !Number.isFinite(particle.lat)) {
Object.assign(particle, spawnParticle(scene));
continue;
}
const point = project(scene, occluder, particle.lon, particle.lat);
const offscreen =
!point ||
point.x < 0 ||
point.y < 0 ||
point.x > cssWidth ||
point.y > cssHeight;
if (particle.age > 240 || offscreen) {
Object.assign(particle, spawnParticle(scene));
continue;
}
const previous = project(scene, occluder, before.lon, before.lat);
if (!previous) continue;
const speed = windSpeed(wind.u, wind.v);
context.strokeStyle = windColor(speed);
context.beginPath();
context.moveTo(previous.x, previous.y);
context.lineTo(point.x, point.y);
context.stroke();
}
context.globalAlpha = 1;
} catch (error) {
if (!warned) {
console.warn('[Data:Wind] frame failed:', error?.message || error);
warned = true;
} }
const previous = project(scene, occluder, before.lon, before.lat);
if (!previous) continue;
context.strokeStyle = windColor(windSpeed(wind.u, wind.v));
context.beginPath();
context.moveTo(previous.x, previous.y);
context.lineTo(point.x, point.y);
context.stroke();
} }
frame = globalThis.requestAnimationFrame(draw); frame = globalThis.requestAnimationFrame(draw);
}; };
@@ -104,6 +171,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
attach() { attach() {
if (canvas) return; if (canvas) return;
canvas = document.createElement('canvas'); canvas = document.createElement('canvas');
canvas.dataset.gevWind = '1';
canvas.style.position = 'absolute'; canvas.style.position = 'absolute';
canvas.style.inset = '0'; canvas.style.inset = '0';
canvas.style.pointerEvents = 'none'; canvas.style.pointerEvents = 'none';
+1
View File
@@ -19,6 +19,7 @@ function harness({ occluded = false } = {}) {
}; };
const canvas = { const canvas = {
style: {}, style: {},
dataset: {},
width: 0, width: 0,
height: 0, height: 0,
clientWidth: 800, clientWidth: 800,