Files
gods-eye-view/src/layers/wind/rendering.js
T

250 lines
8.8 KiB
JavaScript

import { advectParticle, sampleWind, windColor, windSpeed } from './model.js';
/**
* Canvas particle renderer for a global wind field.
*
* Particles are advected in lon/lat by the sampled field and projected to the
* screen through Cesium each frame. Particles hidden by the globe are skipped
* with an `EllipsoidalOccluder`, and trails fade into a translucent backdrop.
*
* @param {{cesium: object, container: HTMLElement, getViewer: Function}} options
*/
export function createWindRendering({ cesium, container, getViewer } = {}) {
let canvas = null;
let context = null;
let field = null;
let particles = [];
let frame = null;
let running = false;
let lastTime = 0;
let cssWidth = 1;
let cssHeight = 1;
let warned = false;
let cameraSignature = null;
const randomParticle = () => ({
lon: Math.random() * 360 - 180,
lat: Math.random() * 178 - 89,
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(200, Math.min(4000, Math.floor((cssWidth * cssHeight) / 300)));
const seed = () => {
particles = [];
const limit = budget();
const scene = getViewer?.()?.scene;
for (let i = 0; i < limit; i += 1) particles.push(spawnParticle(scene));
};
const resize = (viewer) => {
const ratio = Math.min(2, globalThis.devicePixelRatio || 1);
const sceneCanvas = viewer?.scene?.canvas;
cssWidth = sceneCanvas?.clientWidth || container?.clientWidth || 1;
cssHeight = sceneCanvas?.clientHeight || container?.clientHeight || 1;
const width = Math.floor(cssWidth * ratio);
const height = Math.floor(cssHeight * ratio);
// Assigning either dimension clears a real canvas, even if unchanged.
if (canvas.width === width && canvas.height === height) return;
canvas.width = width;
canvas.height = height;
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
context.setTransform?.(ratio, 0, 0, ratio, 0, 0);
};
/** 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);
};
const draw = (time) => {
frame = null;
if (!running) return;
const viewer = getViewer?.();
if (!viewer || viewer.isDestroyed?.()) return;
try {
resize(viewer);
if (particles.length !== budget()) seed();
const camera = viewer.scene.camera;
const signature = [
camera?.positionWC?.x,
camera?.positionWC?.y,
camera?.positionWC?.z,
camera?.heading,
camera?.pitch,
camera?.roll,
].join(',');
if (signature !== cameraSignature) {
context.clearRect(0, 0, cssWidth, cssHeight);
cameraSignature = signature;
}
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) 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;
}
}
frame = globalThis.requestAnimationFrame(draw);
};
return {
/** Create and append the overlay canvas. */
attach() {
if (canvas) return;
canvas = document.createElement('canvas');
canvas.dataset.gevWind = '1';
canvas.style.position = 'absolute';
canvas.style.inset = '0';
canvas.style.pointerEvents = 'none';
canvas.style.zIndex = '1';
context = canvas.getContext('2d');
container.appendChild(canvas);
},
/** Install a wind field and (re)seed particles. */
setField(next) {
field = next?.grid ? { ...next.grid, u: next.u, v: next.v } : next;
if (canvas) {
resize(getViewer?.());
seed();
if (running && frame === null)
frame = globalThis.requestAnimationFrame(draw);
}
},
/** Start the animation loop. Safe to call before a field is installed. */
start() {
if (running || !canvas) return;
running = true;
lastTime = 0;
if (field) frame = globalThis.requestAnimationFrame(draw);
},
/** Stop the animation loop. */
stop() {
running = false;
if (frame !== null) globalThis.cancelAnimationFrame(frame);
frame = null;
},
/** Clear the canvas and particles without removing the canvas. */
clear() {
field = null;
particles = [];
context?.clearRect(0, 0, cssWidth, cssHeight);
},
/** Stop and remove the overlay canvas. Idempotent. */
destroy() {
this.stop();
this.clear();
canvas?.remove();
canvas = null;
context = null;
},
/** Test seam: the current particle count. */
getParticleCount() {
return particles.length;
},
};
}