diff --git a/src/layers/wind/model.js b/src/layers/wind/model.js index bdc9cd413..b7e5c8667 100644 --- a/src/layers/wind/model.js +++ b/src/layers/wind/model.js @@ -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)); diff --git a/src/layers/wind/rendering.js b/src/layers/wind/rendering.js index 646ee6b77..9e556c6e6 100644 --- a/src/layers/wind/rendering.js +++ b/src/layers/wind/rendering.js @@ -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'; diff --git a/src/layers/wind/rendering.test.mjs b/src/layers/wind/rendering.test.mjs index 179b58d86..dade321db 100644 --- a/src/layers/wind/rendering.test.mjs +++ b/src/layers/wind/rendering.test.mjs @@ -19,6 +19,7 @@ function harness({ occluded = false } = {}) { }; const canvas = { style: {}, + dataset: {}, width: 0, height: 0, clientWidth: 800,