From 2b4955c8171cbf4234a5f4eb1b5be1c3bcc79a68 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 03:34:25 +0400 Subject: [PATCH] Draw and inspect the same wind forecast when switching globes --- scripts/package-boundaries.json | 2 + scripts/qa-wind-engine-switch.mjs | 170 +++++++++++++++++++ src/app/constructCatalog.js | 6 +- src/app/navaraLayers.test.mjs | 1 + src/app/navaraMode.js | 6 + src/layers/weather/render/navaraSurface.js | 4 +- src/layers/wind/index.js | 45 +++-- src/layers/wind/inspectionMarker.js | 30 ++-- src/layers/wind/render/flowMesh.js | 133 +++++++++++++++ src/layers/wind/render/navara.js | 186 +++++++++++++++++++++ src/layers/wind/rendering.js | 2 +- 11 files changed, 555 insertions(+), 30 deletions(-) create mode 100644 scripts/qa-wind-engine-switch.mjs create mode 100644 src/layers/wind/render/flowMesh.js create mode 100644 src/layers/wind/render/navara.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d413f11bb..bea6e46e0 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3677,6 +3677,8 @@ "src/layers/infrastructure/render/navaraStems.js", "src/layers/weather/render/navara.js", "src/layers/weather/render/navaraSurface.js", + "src/layers/wind/render/navara.js", + "src/layers/wind/render/flowMesh.js", "src/layers/ghostMaps/render/navara.js", "src/layers/shared/render/navaraGroundPicks.js", "src/layers/cyclones/render/navara.js", diff --git a/scripts/qa-wind-engine-switch.mjs b/scripts/qa-wind-engine-switch.mjs new file mode 100644 index 000000000..7a2eb724d --- /dev/null +++ b/scripts/qa-wind-engine-switch.mjs @@ -0,0 +1,170 @@ +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +import assert from 'node:assert/strict'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; + +const base = new URL(process.argv[2]); +if ( + !['localhost', '127.0.0.1'].includes(base.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + base.port, + ) +) + throw new Error('Supply an unreserved local keyless preview URL'); +base.search = '?engine=navara&welcome=0&look=cinematic'; +base.hash = + 'v=2&lat=43.6500&lon=-79.3800&alt=2000000&heading=0&pitch=-90&roll=0&map=sentinel2&l='; +const b = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const p = await b.newPage(); + await blockPaidQaMaps(p); + await p.setViewport({ width: 1024, height: 768 }); + const errors = []; + p.on('pageerror', (e) => { + errors.push(e.message); + console.log('error', e.message.split('?')[0]); + }); + const reads = []; + await p.setRequestInterception(true); + p.on('request', (req) => { + const u = new URL(req.url()); + if (!u.pathname.startsWith('/api/wind/')) return req.continue(); + reads.push(u.pathname + u.search); + const model = u.searchParams.get('model'), + kind = u.searchParams.get('overlay'); + const count = 36 * 19; + if (u.pathname.endsWith('/manifest')) + return req.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + model, + overlay: kind ?? 'none', + grid: { nx: 36, ny: 19, lo1: 0, la1: 90, dx: 10, dy: 10 }, + gridUrl: '/api/wind/grid/' + model + '-qa.bin' + u.search, + cycle: { + validIso: '2026-11-02T12:00:00Z', + runIso: '2026-11-02T06:00:00Z', + }, + ...(kind + ? { + scalar: { + kind, + units: kind === 'temperature' ? '°C' : 'hPa', + level: + kind === 'temperature' + ? '2 m above ground' + : 'mean sea level', + }, + } + : {}), + }), + }); + const values = new Float32Array(count * (kind ? 3 : 2)); + values.fill(8, 0, count); + values.fill(6, count, count * 2); + if (kind) values.fill(kind === 'temperature' ? 40 : 1020, count * 2); + return req.respond({ + status: 200, + contentType: 'application/octet-stream', + body: Buffer.from(values.buffer), + }); + }); + p.on('console', (msg) => { + if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text())) { + errors.push(msg.text()); + console.log('GPU', msg.text().slice(0, 2500)); + } + }); + await p.goto(base.href); + const ready = async (kind) => { + await p.waitForFunction( + (k) => + window.__godsEyeView?.engine.kind === k && + !document.querySelector('#globe-engine')?.disabled && + !document.querySelector('.engine-switch-frame') && + document.querySelector('#loading-screen.hidden'), + { timeout: 90000 }, + kind, + ); + await p.evaluate( + () => window.__godsEyeView.styleManager.initialRestorePromise, + ); + }; + await ready('navara'); + await p.evaluate(async () => { + const { dataManager, tileset } = window.__godsEyeView; + if (tileset || window.__GOOGLE_MAPS_API_KEY__) + throw Error('Keyless preview required'); + const mod = dataManager.layers.get('wind').module; + window.qaWind = mod; + mod.setParams({ overlay: 'temperature', paused: true, units: 'm/s' }); + await dataManager.setEnabled('wind', true, { origin: 'user' }); + }); + await p.waitForFunction( + () => + window.qaWind.getDiagnostics().surface?.ready && + window.qaWind.getDiagnostics().gpu?.ready, + ); + await p.evaluate(() => window.qaWind.setParams({ inspect: true })); + const baseline = reads.length; + for (const kind of ['navara', 'cesium', 'navara']) { + if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) { + await p.select('#globe-engine', kind); + await ready(kind); + } + await p.waitForFunction( + () => + window.qaWind.getDiagnostics().gpu?.ready && + window.qaWind.getRowControls().summary.reading?.speed === 10, + { timeout: 90000 }, + ); + await new Promise((resolve) => setTimeout(resolve, 800)); + const check = await p.evaluate(() => { + const { dataManager } = window.__godsEyeView; + const mod = dataManager.layers.get('wind').module; + return { + same: mod === window.qaWind, + params: mod.getParams(), + gpu: mod.getDiagnostics().gpu.pathCount, + reading: mod.getRowControls().summary.reading, + }; + }); + assert.equal(check.same, true); + assert.equal(check.params.paused, true); + assert.ok(check.gpu > 1000); + assert.equal(check.reading.scalarValue, '40.0 °C'); + assert.equal(check.reading.from, 'SW'); + const sample = await sharp(await p.screenshot()) + .extract({ left: 530, top: 390, width: 24, height: 24 }) + .removeAlpha() + .raw() + .toBuffer(); + const [r, g, b] = [0, 1, 2].map((k) => { + let sum = 0; + for (let i = k; i < sample.length; i += 3) sum += sample[i]; + return sum / (sample.length / 3); + }); + console.log(kind, check.gpu, check.reading.wind, [r, g, b]); + assert.ok( + r > g * 1.35 && r > b * 1.35, + kind + ' temperature field visible', + ); + assert.equal(reads.length, baseline); + } + await p.evaluate(() => window.qaWind.setParams({ paused: false })); + await p.waitForFunction(() => window.qaWind.getDiagnostics().gpu.phaseSeconds > 0.2); + await p.evaluate(() => window.qaWind.setParams({ paused: true })); + const frozen = await p.evaluate(() => window.qaWind.getDiagnostics().gpu.phaseSeconds); + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.equal(await p.evaluate(() => window.qaWind.getDiagnostics().gpu.phaseSeconds), frozen); + assert.deepEqual(errors, []); + console.log( + 'PASS retained forecast grid, pause, temperature field and sampled wind on both engines', + ); +} finally { + await b.close(); +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index d8fffe6bb..7591be79c 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -279,7 +279,11 @@ export function createApplicationCatalog({ ], [ 'wind', - () => createWindLayer({ feed: sources.wind, clock: weatherClock }), + () => sessionModel('wind', () => createWindLayer({ + feed: sources.wind, + clock: weatherClock, + createNativeRendering: (engine) => rendererFor('wind', engine), + })), ], ...['weather-radar', 'weather-satellite', 'weather-lightning'].map( (id) => [ diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 8c1712bc5..fb24895bb 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -62,6 +62,7 @@ test('the Navara mode registers migrated renderers once', async () => { 'ais-live-vessels', 'military-installations', 'military-awareness', + 'wind', 'weather-radar', 'weather-satellite', 'weather-lightning', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 4258d85cc..44a7ed255 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -59,6 +59,8 @@ const NAVARA_CREDIT_KEYS = new Set([ 'eccc-aqhi', 'wfigs', 's2-ghostmaps', + 'wind-gfs', + 'ecmwf-ifs', 'weather-noaa', 'weather-lightning', 'weather-cyclones', @@ -117,6 +119,10 @@ export async function registerNavaraLayerRenderers() { return (engine, options) => createWeatherNavaraRenderer(engine, navaraViews.get(engine), options); }, ])), + wind: async () => { + const { createWindNavaraRenderer } = await import('../layers/wind/render/navara.js'); + return (engine) => createWindNavaraRenderer(engine, navaraViews.get(engine)); + }, 'weather-cyclones': async () => { const {createCyclonesNavaraRenderer} = await import('../layers/cyclones/render/navara.js'); return (engine,options) => createCyclonesNavaraRenderer(engine,navaraViews.get(engine).view,options); diff --git a/src/layers/weather/render/navaraSurface.js b/src/layers/weather/render/navaraSurface.js index 790fa32dd..d98a88e64 100644 --- a/src/layers/weather/render/navaraSurface.js +++ b/src/layers/weather/render/navaraSurface.js @@ -14,9 +14,9 @@ import { shellLift } from '../shellRendering.js'; const KEY = 'gevObservedWeather'; const owners = new WeakMap(); -const products = ['Satellite', 'Radar', 'Lightning']; +const products = ['Wind', 'Satellite', 'Radar', 'Lightning']; const slotFor = (p) => - p === 'radar' ? 'Radar' : p === 'lightning' ? 'Lightning' : 'Satellite'; + p === 'wind' ? 'Wind' : p === 'radar' ? 'Radar' : p === 'lightning' ? 'Lightning' : 'Satellite'; const emptyRect = [0, 0, -1, -1]; const rectArray = (r) => (r ? [r.west, r.south, r.east, r.north] : emptyRect); diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index 1494be8e8..1efbfcd16 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -57,10 +57,12 @@ export function createWindLayer({ cesium = Cesium, container, createRendering = createWindRendering, + createNativeRendering, } = {}) { if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Wind requires a snapshot source'); let viewer = null; + let engine = null; let request = null; let enabled = false; let rendering = null; @@ -81,6 +83,10 @@ export function createWindLayer({ inspectionMarker?.clear(); }; const sample = (position) => { + if (!position && engine && !viewer) { + const canvas = engine.host.canvas; + position = engine.camera.pickEllipsoid({ x: canvas.clientWidth / 2, y: canvas.clientHeight / 2 }); + } reading = inspectWindAtCenter(manifest, viewer, cesium, { position, units, @@ -110,9 +116,13 @@ export function createWindLayer({ icon: '🌬', source: 'GFS / ECMWF IFS · FORECAST', updateInterval: 3600_000, - init(nextViewer) { + init(nextViewer, options) { + layer.attachRenderer(nextViewer, options); + }, + attachRenderer(nextViewer, { engine: nextEngine = null } = {}) { viewer = nextViewer; - rendering = createRendering({ + engine = nextEngine; + rendering = engine?.kind === 'navara' ? createNativeRendering(engine) : createRendering({ cesium, container: container ?? nextViewer.container, getViewer: () => viewer, @@ -125,12 +135,24 @@ export function createWindLayer({ }); rendering.attach(); rendering.setOptions?.({ overlay, paused }); - const target = container ?? nextViewer.container; + const target = container ?? nextViewer?.container ?? engine?.host.container; inspectionMarker = createWindInspectionMarker({ container: target, viewer, cesium, + engine, }); + if (enabled && manifest && !manifest.unavailable) { + rendering.setField(manifest); + rendering.start(); + } + if (sampledPosition) inspectionMarker.show(sampledPosition); + }, + detachRenderer() { + inspectionMarker?.destroy(); + inspectionMarker = null; + rendering?.destroy(); + rendering = imageryHost = viewer = engine = null; }, attachShellServices(services) { imageryHost = @@ -182,12 +204,12 @@ export function createWindLayer({ error = null; if (sampledPosition) sample(sampledPosition); if (snapshot.unavailable) { - rendering.stop(); - rendering.clear(); + rendering?.stop(); + rendering?.clear(); } else { - rendering.setOptions?.({ overlay, paused }); - rendering.setField(snapshot); - rendering.start(); + rendering?.setOptions?.({ overlay, paused }); + rendering?.setField(snapshot); + rendering?.start(); } return true; } catch (cause) { @@ -438,13 +460,8 @@ export function createWindLayer({ destroy() { layer.disable(); unsubscribeClock?.(); - rendering?.destroy(); - rendering = null; - imageryHost = null; - viewer = null; + layer.detachRenderer(); rowControlsListener = null; - inspectionMarker?.destroy(); - inspectionMarker = null; }, getStats() { const diagnostic = rendering?.getDiagnostics?.(); diff --git a/src/layers/wind/inspectionMarker.js b/src/layers/wind/inspectionMarker.js index e263eaf45..704e89ea0 100644 --- a/src/layers/wind/inspectionMarker.js +++ b/src/layers/wind/inspectionMarker.js @@ -10,6 +10,7 @@ import { applicationPinia } from '../../ui/vueRuntime.js'; export function createWindInspectionMarker({ container, viewer, + engine, cesium, store = useWindInspectionStore(applicationPinia()), }) { @@ -33,36 +34,40 @@ export function createWindInspectionMarker({ show(position) { clear(); const scene = viewer?.scene; + const canvas = scene?.canvas ?? engine?.host.canvas; + const frames = scene?.postRender ?? engine?.frames.postRender; if ( !position || !container?.ownerDocument || - !scene?.postRender?.addEventListener || - !scene.cartesianToCanvasCoordinates + !frames?.addEventListener || + (!engine?.projector && !scene?.cartesianToCanvasCoordinates) ) return; present({ owner, container, visible: false, left: 0, top: 0 }); - const occluder = new cesium.EllipsoidalOccluder( + const occluder = !engine && new cesium.EllipsoidalOccluder( scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84, viewer.camera.positionWC, ); const render = () => { if (!shown()) return; - occluder.cameraPosition = viewer.camera.positionWC; - const point = scene.cartesianToCanvasCoordinates(position); + if (occluder) occluder.cameraPosition = viewer.camera.positionWC; + const point = engine + ? engine.projector.worldToWindow(position, {}) + : scene.cartesianToCanvasCoordinates(position); const visible = point && point.x >= 0 && point.y >= 0 && - point.x <= scene.canvas.clientWidth && - point.y <= scene.canvas.clientHeight && - (scene.mode !== cesium.SceneMode.SCENE3D || - occluder.isPointVisible(position)); + point.x <= canvas.clientWidth && + point.y <= canvas.clientHeight && + (engine ? engine.projector.horizonVisible(position) : + scene.mode !== cesium.SceneMode.SCENE3D || occluder.isPointVisible(position)); if (!visible) { // A hidden marker keeps its last place, as the element did. if (marker.visible) present({ visible: false }); return; } - const canvasRect = scene.canvas.getBoundingClientRect(); + const canvasRect = canvas.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const left = point.x + canvasRect.left - containerRect.left; const top = point.y + canvasRect.top - containerRect.top; @@ -70,9 +75,10 @@ export function createWindInspectionMarker({ return; present({ visible: true, left, top }); }; - removeRender = scene.postRender.addEventListener(render); + removeRender = frames.addEventListener(render); render(); - scene.requestRender?.(); + if (engine) engine.render.request('wind-inspection'); + else scene.requestRender?.(); }, clear, destroy: clear, diff --git a/src/layers/wind/render/flowMesh.js b/src/layers/wind/render/flowMesh.js new file mode 100644 index 000000000..098edc269 --- /dev/null +++ b/src/layers/wind/render/flowMesh.js @@ -0,0 +1,133 @@ +import { + Mesh, + ShaderMaterial, + InstancedBufferGeometry, + InstancedBufferAttribute, + Float32BufferAttribute, + Vector2, + DoubleSide, +} from 'three'; +import { fromDegrees } from '../../../geo/cartesian3.js'; +import { WIND_DISPLAY_HEIGHT_METERS } from '../streamlines.js'; + +const KEY = 'gevWindFlow'; +const registered = new WeakSet(); +const vertexShader = /* glsl */ ` +#include +#include +attribute vec3 begin, end; +attribute vec3 progress; +uniform vec2 viewport; +varying vec4 flow; +void main() { + vec3 world = mix(begin,end,position.x); + vec4 a = projectionMatrix * viewMatrix * vec4(begin,1.0); + vec4 b = projectionMatrix * viewMatrix * vec4(end,1.0); + vec2 delta = (b.xy/max(1.0,b.w)-a.xy/max(1.0,a.w))*viewport; + vec2 perp = vec2(-delta.y,delta.x)/max(0.000001,length(delta)); + gl_Position = mix(a,b,position.x); + gl_Position.xy += perp * position.y * 2.4 * gl_Position.w/viewport; + flow = vec4(mix(progress.x,progress.y,position.x),progress.z,position.y, + dot(normalize(world),normalize(cameraPosition-world))); + #include +}`; +const fragmentShader = /* glsl */ ` +#include +#include +uniform float seconds,heightFade,ghost; +varying vec4 flow; +void main() { + #include + float behind=fract(seconds*0.16+flow.y-flow.x); + float tail=1.0-smoothstep(0.0,0.32,behind), tip=1.0-smoothstep(0.0,0.045,behind); + float ends=smoothstep(0.0,0.055,flow.x)*(1.0-smoothstep(0.945,1.0,flow.x)); + float edge=1.0-smoothstep(0.52,1.0,abs(flow.z)); + float alpha=(ghost+0.64*tail+0.16*tip)*ends*edge*smoothstep(0.0,0.065,flow.w)*heightFade; + if(alpha<0.001) discard; + gl_FragColor=vec4(mix(vec3(0.50,0.79,0.90),vec3(0.91,0.99,1.0),tip),alpha); +}`; + +export function createWindFlowMesh({ view, MeshDesc }, paths) { + if (!registered.has(view)) { + view.registerMesh( + KEY, + class extends MeshDesc { + constructor(host, ctx, config) { + super(host, ctx, config); + this.flow = config[KEY]; + } + getPassKey() { + return 'transparent'; + } + createMesh() { + return this.flow; + } + onDestroy() { + this.flow.geometry.dispose(); + this.flow.material.dispose(); + super.onDestroy(); + } + }, + ); + registered.add(view); + } + const segments = paths.reduce((sum, p) => sum + p.coordinates.length - 1, 0); + const begin = new Float32Array(segments * 3), + end = new Float32Array(segments * 3), + progress = new Float32Array(segments * 3); + let i = 0; + for (const path of paths) { + let previous = null; + const n = path.coordinates.length - 1; + for (let j = 0; j <= n; j++) { + const [lon, lat] = path.coordinates[j]; + const next = fromDegrees(lon, lat, WIND_DISPLAY_HEIGHT_METERS); + if (previous) { + begin.set([previous.x, previous.y, previous.z], i); + end.set([next.x, next.y, next.z], i); + progress.set([(j - 1) / n, j / n, path.phase], i); + i += 3; + } + previous = next; + } + } + const geometry = new InstancedBufferGeometry(); + geometry.setAttribute( + 'position', + new Float32BufferAttribute([0, -1, 0, 0, 1, 0, 1, -1, 0, 1, 1, 0], 3), + ); + geometry.setIndex([0, 1, 2, 2, 1, 3]); + for (const [key, array] of Object.entries({ begin, end, progress })) + geometry.setAttribute(key, new InstancedBufferAttribute(array, 3)); + geometry.instanceCount = segments; + const uniforms = { + seconds: { value: 0 }, + heightFade: { value: 0 }, + ghost: { value: 0.25 }, + viewport: { value: new Vector2(1, 1) }, + }; + const material = new ShaderMaterial({ + uniforms, + vertexShader, + fragmentShader, + transparent: true, + side: DoubleSide, + depthWrite: false, + toneMapped: false, + }); + material.forceSinglePass = true; + const mesh = new Mesh(geometry, material); + mesh.frustumCulled = false; + const holder = view.addMesh({ [KEY]: mesh }); + return { + update({ time, fade, still, width, height }) { + mesh.visible = fade > 0; + uniforms.seconds.value = time; + uniforms.heightFade.value = fade; + uniforms.ghost.value = still ? 0.34 : 0.25; + uniforms.viewport.value.set(Math.max(1, width), Math.max(1, height)); + }, + destroy: () => holder.delete(), + segments, + }; +} diff --git a/src/layers/wind/render/navara.js b/src/layers/wind/render/navara.js new file mode 100644 index 000000000..1e8082e38 --- /dev/null +++ b/src/layers/wind/render/navara.js @@ -0,0 +1,186 @@ +import { + bakeWindStreamlines, + WIND_NARROW_PATH_LIMIT, + WIND_PATH_LIMIT, +} from '../streamlines.js'; +import { createFieldRaster } from '../fields.js'; +import { fieldUnavailableText, sameWind } from '../rendering.js'; +import { createWeatherSurface } from '../../weather/render/navaraSurface.js'; +import { WEATHER_SHELL_HEIGHTS } from '../../weather/shellRendering.js'; +import { createWindFlowMesh } from './flowMesh.js'; + +const world = { + west: -Math.PI, + south: -Math.PI / 2, + east: Math.PI, + north: Math.PI / 2, +}; +const clamp = (n) => Math.max(0, Math.min(1, n)); +export function createWindNavaraRenderer(engine, native) { + const canvas = engine.host.canvas, + media = matchMedia('(prefers-reduced-motion: reduce)'); + let snapshot, + field, + mesh, + surface, + removeFrame, + lastAlpha, + lastTime, + seconds = 0, + paths = 0, + fade = 0; + let running = false, + paused = false, + overlay = 'none', + error = null, + budget = 0; + const wake = () => engine.render.request('wind'); + const build = () => { + mesh?.destroy(); + mesh = null; + budget = + canvas.clientWidth < 700 ? WIND_NARROW_PATH_LIMIT : WIND_PATH_LIMIT; + const baked = bakeWindStreamlines(field, { count: budget }); + paths = baked.length; + if (paths) mesh = createWindFlowMesh(native, baked); + }; + const paint = () => { + surface?.destroy(); + surface = null; + lastAlpha = null; + error = null; + if (!snapshot || overlay === 'none') return; + const raster = createFieldRaster(snapshot, overlay); + if (!raster) { + error = fieldUnavailableText(overlay); + return; + } + const texture = document.createElement('canvas'); + texture.width = raster.width; + texture.height = raster.height; + texture + .getContext('2d') + .putImageData( + new ImageData(raster.rgba, raster.width, raster.height), + 0, + 0, + ); + surface = createWeatherSurface(engine, native, 'wind', { + rectangle: world, + height: WEATHER_SHELL_HEIGHTS.wind, + onSettled: wake, + }); + surface.setImage(texture); + wake(); + }; + const frame = () => { + if (!running || document.hidden) { + lastTime = null; + return; + } + const h = engine.camera.pose().cartographic.height; + if ( + field && + budget !== + (canvas.clientWidth < 700 ? WIND_NARROW_PATH_LIMIT : WIND_PATH_LIMIT) + ) + build(); + fade = clamp((h - 15000) / 45000); + const still = paused || media.matches; + const now = performance.now(); + if (!still && fade && lastTime != null) + seconds += Math.min(0.1, (now - lastTime) / 1000); + lastTime = !still && fade ? now : null; + mesh?.update({ + time: seconds % 10000, + fade, + still, + width: canvas.clientWidth, + height: canvas.clientHeight, + }); + if (surface) { + let alpha = + (overlay === 'temperature' ? 1 : 0.85) * + clamp(Math.log(Math.max(1, h) / 200000) / Math.log(6)); + if (engine.maps.photorealActive()) alpha = Math.round(alpha * 10) / 10; + if ( + lastAlpha == null || + Math.abs(alpha - lastAlpha) > 0.005 || + (alpha === 0 && lastAlpha !== 0) + ) { + lastAlpha = alpha; + surface.setAlpha(alpha); + surface.setShow(alpha > 0); + } + } + if (mesh && !still && fade) wake(); + }; + const reset = () => { + mesh?.destroy(); + mesh = null; + surface?.destroy(); + surface = null; + field = snapshot = null; + paths = 0; + error = null; + lastAlpha = lastTime = null; + }; + const preferenceChanged = () => { + lastTime = null; + wake(); + }; + return { + attach() { + removeFrame = engine.frames.preRender.addEventListener(frame); + media.addEventListener('change', preferenceChanged); + document.addEventListener('visibilitychange', preferenceChanged); + }, + setField(next) { + const nextField = { ...next.grid, u: next.u, v: next.v }; + const changed = !sameWind(snapshot, next, field, nextField); + snapshot = next; + field = nextField; + if (changed) build(); + paint(); + wake(); + }, + setOptions(next) { + paused = next.paused; + if (overlay !== next.overlay) { + overlay = next.overlay; + paint(); + } + wake(); + }, + start() { + running = true; + wake(); + }, + stop() { + running = false; + lastTime = null; + }, + clear: reset, + destroy() { + running = false; + removeFrame?.(); + media.removeEventListener('change', preferenceChanged); + document.removeEventListener('visibilitychange', preferenceChanged); + reset(); + }, + getParticleCount: () => paths, + getDiagnostics: () => ({ + renderMode: 'gpu-streamlines', + reducedMotion: media.matches, + imageryError: error, + gpu: { + pathCount: paths, + phaseSeconds: seconds, + ready: Boolean(mesh), + heightFade: fade, + vertexCount: (mesh?.segments ?? 0) * 4, + }, + surface: surface?.getDiagnostics() ?? null, + }), + }; +} diff --git a/src/layers/wind/rendering.js b/src/layers/wind/rendering.js index 462e9aca0..58462b9f2 100644 --- a/src/layers/wind/rendering.js +++ b/src/layers/wind/rendering.js @@ -37,7 +37,7 @@ const WIND_FIELD_LOG_RANGE = Math.log( // Snapshots own immutable decoded arrays. Compare their contents, not grid URLs // (which include the scalar overlay) or only cycle IDs (which can be revised). // This bounded scan runs on acquisition, never in the animation loop. -function sameWind(previous, next, previousField, nextField) { +export function sameWind(previous, next, previousField, nextField) { if (!previous || !next || !previousField || !nextField) return false; for (const key of ['model', 'level', 'units']) if (previous[key] !== next[key]) return false;