mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
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).
This commit is contained in:
1 parent
deb34ebe35
commit
b7033d86c2
3 files changed
+108
-39
No files matched your search
@@ -36,9 +36,9 @@ export function windSpeed(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 } = {}) {
|
||||
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 position = t * (stops.length - 1);
|
||||
const index = Math.min(stops.length - 2, Math.floor(position));
|
||||
|
||||
+105
-37
@@ -19,6 +19,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
|
||||
let lastTime = 0;
|
||||
let cssWidth = 1;
|
||||
let cssHeight = 1;
|
||||
let warned = false;
|
||||
|
||||
const randomParticle = () => ({
|
||||
lon: Math.random() * 360 - 180,
|
||||
@@ -26,13 +27,46 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
|
||||
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 = () =>
|
||||
Math.max(3000, Math.min(8000, Math.floor((cssWidth * cssHeight) / 1000)));
|
||||
Math.max(4000, Math.min(12000, Math.floor((cssWidth * cssHeight) / 150)));
|
||||
|
||||
const seed = () => {
|
||||
particles = [];
|
||||
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) => {
|
||||
@@ -49,6 +83,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
|
||||
|
||||
/** Project lon/lat to CSS pixels, or null when hidden by the globe. */
|
||||
const project = (scene, occluder, lon, lat) => {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
const cartesian = cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
||||
if (occluder && !occluder.isPointVisible(cartesian)) return null;
|
||||
return cesium.SceneTransforms.worldToWindowCoordinates(scene, cartesian);
|
||||
@@ -59,42 +94,74 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
|
||||
if (!running) return;
|
||||
const viewer = getViewer?.();
|
||||
if (!viewer || viewer.isDestroyed?.()) return;
|
||||
resize(viewer);
|
||||
const dt = lastTime ? Math.min(1, Math.max(0, (time - lastTime) / 1000)) : 0.016;
|
||||
lastTime = time;
|
||||
context.fillStyle = 'rgba(0, 0, 0, 0.08)';
|
||||
context.fillRect(0, 0, cssWidth, cssHeight);
|
||||
if (!field) {
|
||||
frame = globalThis.requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
const scene = viewer.scene;
|
||||
const occluder = cesium.EllipsoidalOccluder
|
||||
? new cesium.EllipsoidalOccluder(cesium.Ellipsoid.WGS84, scene.camera.positionWC)
|
||||
: null;
|
||||
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: 8 });
|
||||
particle.age += dt;
|
||||
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, randomParticle());
|
||||
continue;
|
||||
try {
|
||||
resize(viewer);
|
||||
const dt = lastTime ? Math.min(1, Math.max(0, (time - lastTime) / 1000)) : 0.016;
|
||||
lastTime = time;
|
||||
// Fade previous trails by ERASING them (destination-out). A translucent
|
||||
// black fill would instead accumulate alpha and saturate the overlay.
|
||||
context.globalCompositeOperation = 'destination-out';
|
||||
context.fillStyle = 'rgba(0, 0, 0, 0.02)';
|
||||
context.fillRect(0, 0, cssWidth, cssHeight);
|
||||
if (!field) {
|
||||
frame = globalThis.requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
const scene = viewer.scene;
|
||||
// Real wind (m/s) is invisible at globe scale, so the advection is
|
||||
// exaggerated in proportion to camera height: the same visual speed is
|
||||
// legible whether the camera is at street level or over a whole hemisphere.
|
||||
const cameraHeight = scene.camera?.positionCartographic?.height ?? 1e6;
|
||||
const speedScale = Math.max(1, Math.min(4000, cameraHeight / 400));
|
||||
let occluder = null;
|
||||
try {
|
||||
if (cesium.EllipsoidalOccluder && cesium.Ellipsoid && scene.camera?.positionWC)
|
||||
occluder = new cesium.EllipsoidalOccluder(cesium.Ellipsoid.WGS84, scene.camera.positionWC);
|
||||
} catch {
|
||||
occluder = null; // fall back to frustum-only culling
|
||||
}
|
||||
context.lineWidth = 1.8;
|
||||
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);
|
||||
};
|
||||
@@ -104,6 +171,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
|
||||
attach() {
|
||||
if (canvas) return;
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.dataset.gevWind = '1';
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.inset = '0';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
|
||||
@@ -19,6 +19,7 @@ function harness({ occluded = false } = {}) {
|
||||
};
|
||||
const canvas = {
|
||||
style: {},
|
||||
dataset: {},
|
||||
width: 0,
|
||||
height: 0,
|
||||
clientWidth: 800,
|
||||
|
||||
Reference in new issue
Block a user