diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b9d450de8..ca1b88e45 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3679,6 +3679,7 @@ "src/layers/shared/render/navaraStems.js", "src/layers/submarineCables/render/navara.js", "src/layers/directions/render/navara.js", + "src/layers/traffic/render/navara.js", "src/layers/shared/render/navaraStory.js", "src/layers/bhoteKoshi/render/navara.js", "src/layers/weather/render/navara.js", diff --git a/scripts/qa-traffic-engine-switch.mjs b/scripts/qa-traffic-engine-switch.mjs new file mode 100644 index 000000000..75107417a --- /dev/null +++ b/scripts/qa-traffic-engine-switch.mjs @@ -0,0 +1,182 @@ +import { trafficFixtureResponse } from './traffic-fixtures.mjs'; +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=30.2670&lon=-97.7440&alt=1800&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 keyless = process.argv.includes('--simulation'); + const reads = new Map(); + await p.setRequestInterception(true); + p.on('request', async (req) => { + const u = new URL(req.url()); + if ( + u.hostname === 'tiles.openfreemap.org' || + u.pathname.startsWith('/api/tomtom/') + ) { + reads.set(u.href, (reads.get(u.href) || 0) + 1); + if (keyless && u.pathname === '/api/tomtom/status') + return req.respond({ + status: 200, + contentType: 'application/json', + body: '{"hasKey":false}', + }); + const response = trafficFixtureResponse(req); + if (response) return req.respond(response); + throw Error('unrecognized traffic URL: ' + u.pathname); + } + return req.continue(); + }); + p.on('console', (msg) => { + if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text())) + errors.push(msg.text()); + }); + 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 { tileset, dataManager } = window.__godsEyeView; + if (tileset || window.__GOOGLE_MAPS_API_KEY__) + throw Error('Keyless preview required'); + window.qaTraffic = dataManager.layers.get('traffic').module; + window.qaTraffic.setParams({ jamViz: 'both' }); + await dataManager.setEnabled('traffic', true, { origin: 'user' }); + }); + const waitTraffic = async () => { + try { + await p.waitForFunction(() => window.qaTraffic.getStats().count >= 10, { + timeout: 40000, + }); + } catch (e) { + console.log( + 'no traffic', + await p.evaluate(() => window.qaTraffic.getStats()), + ); + throw e; + } + }; + await waitTraffic(); + await new Promise((resolve) => setTimeout(resolve, 2000)); + const ids = await p.evaluate(() => + window.qaTraffic.getDetectableObjects().map((p) => p.id), + ); + const first = await p.evaluate( + () => window.qaTraffic.getStats().motion.rebuilds, + ); + 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 waitTraffic(); + const check = await p.evaluate(() => { + const { dataManager } = window.__godsEyeView; + const mod = dataManager.layers.get('traffic').module; + return { + same: mod === window.qaTraffic, + ...mod.getStats(), + params: mod.getParams(), + ids: mod.getDetectableObjects().map((p) => p.id), + }; + }); + console.log( + kind, + check.mode, + check.count, + check.flowBuckets, + check.heatLines, + check.motion, + ); + assert.equal(check.same, true); + assert.equal(check.params.jamViz, 'both'); + assert.equal(check.error, null); + assert.equal( + check.motion.rebuilds, + first, + 'traffic population was cleared', + ); + assert.ok( + check.ids.some((id) => ids.includes(id)), + 'original moving vehicles survived', + ); + if (!keyless) + assert.ok(check.heatLines > 0, 'congestion corridors are published'); + if (keyless) assert.equal(check.flowBuckets.sim, check.count); + else assert.ok(check.flowBuckets.slow + check.flowBuckets.jam > 0); + } + if (keyless) + assert.ok(![...reads.keys()].some((u) => u.includes('/tomtom/flow/'))); + const pixels = async () => + sharp(await p.screenshot()) + .extract({ left: 360, top: 255, width: 330, height: 260 }) + .removeAlpha() + .raw() + .toBuffer(); + // Source counts can be ready before the incoming renderer has drawn terrain. + // Let the camera and the newly attached road heights settle before comparison. + await new Promise((resolve) => setTimeout(resolve, 2200)); + const shown = await pixels(); + await p.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('traffic', false, { + origin: 'user', + }), + ); + await new Promise((resolve) => setTimeout(resolve, 600)); + const hidden = await pixels(); + let visible = 0; + for (let i = 0; i < shown.length; i += 3) { + if ( + shown[i] + + shown[i + 1] + + shown[i + 2] - + (hidden[i] + hidden[i + 1] + hidden[i + 2]) > + 110 + ) + visible++; + } + console.log('changed traffic pixels', visible); + assert.ok(visible > 15, 'changing the layer changes the rendered streets'); + assert.deepEqual(errors, []); + console.log( + 'PASS retained traffic population and flow, real geography and stable identities across both globes', + ); +} finally { + await b.close(); +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 290f6226b..7fce273cd 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -241,7 +241,9 @@ export function createApplicationCatalog({ ], [ 'traffic', - () => createApplicationTraffic({ source: sources.traffic, surface }), + () => sessionModel('traffic', () => createApplicationTraffic({ + source: sources.traffic, surface, nativeRenderer: engine => rendererFor('traffic',engine), + })), ], ['cctv', () => createApplicationCctv({ surface, source: sources.cctv })], [ diff --git a/src/app/data.js b/src/app/data.js index 60727f4f2..58df1d9cc 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -47,7 +47,7 @@ export function createApplicationData({ throw new TypeError('An application layer catalog is required'); for (const layer of catalog.layers) layer.attachMapStackController?.(mapStackController); - const attached = dataManager.bindCatalog(viewer, engine, catalog); + const attached = dataManager.bindCatalog(viewer, engine, catalog, { waitForCameraRestore: layerSession?.restoringView === true }); for (const layer of catalog.layers) layer.attachDataManager?.(dataManager); if (allowQaRegistration) { diff --git a/src/app/layers/traffic.js b/src/app/layers/traffic.js index c2c371c7e..f4d8d04cc 100644 --- a/src/app/layers/traffic.js +++ b/src/app/layers/traffic.js @@ -4,9 +4,9 @@ import * as ground from '../../data/groundFloor.js'; import * as render from '../../renderGovernor.js'; /** Construct one layer using the application scene owners and a supplied source. */ -export function createApplicationTraffic({ source, surface }) { +export function createApplicationTraffic({ source, surface, nativeRenderer }) { return createTrafficLayer({ source, - services: { credits, render, ground: surface?.groundFloor || ground }, + services: { credits, render, nativeRenderer, ground: surface?.groundFloor || ground }, }); } diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index b99b8c6ee..bac376fd5 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -56,6 +56,7 @@ test('the Navara mode registers migrated renderers once', async () => { 'alpr-cameras', 'satellites', 'rocket-launches', + 'traffic', 'cctv', 'radio', 'transit', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 4453ce41c..09a66aa17 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -126,6 +126,10 @@ export async function registerNavaraLayerRenderers() { const { drawStorySource } = await import('../layers/shared/render/navaraStory.js'); return (engine, { data }) => drawStorySource(engine, navaraViews.get(engine), data); }, + traffic: async () => { + const { createTrafficNavaraRenderer } = await import('../layers/traffic/render/navara.js'); + return engine => createTrafficNavaraRenderer(engine, navaraViews.get(engine)); + }, directions: async () => { const { createDirectionsNavaraRenderer } = await import('../layers/directions/render/navara.js'); return (engine) => createDirectionsNavaraRenderer(engine, navaraViews.get(engine)); diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 2cd179983..919dcbbf4 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -142,7 +142,7 @@ export class LayerLifecycle { } /** Bind the next catalog only after the preceding scene released its entries. */ - bindCatalog(viewer, engine, catalog) { + bindCatalog(viewer, engine, catalog, options = {}) { if (this._registrationsFinalized) throw new Error('Scene already attached'); this.viewer = viewer; this.engine = engine ?? engineFromViewer(viewer) ?? null; @@ -154,7 +154,7 @@ export class LayerLifecycle { else { if (entry.module !== layer) throw new Error(`Layer owner changed: ${layer.id}`); if (entry.initialized) { - const ready = layer.attachRenderer(viewer, this._moduleContext()); + const ready = layer.attachRenderer(viewer, { ...this._moduleContext(), ...options }); if (ready?.then) attachments.push(ready); } } diff --git a/src/layers/traffic/animation.js b/src/layers/traffic/animation.js index 6c58a57a4..91cad5f1a 100644 --- a/src/layers/traffic/animation.js +++ b/src/layers/traffic/animation.js @@ -33,7 +33,8 @@ export function createAnimation({ if (needed) { layerState._lastAnimTime = 0; services.render.holdContinuousRender('traffic'); - layerState._viewer?.scene?.requestRender?.(); + if (layerState._engine) layerState._engine.render.request('traffic'); + else layerState._viewer?.scene?.requestRender?.(); } else services.render.releaseContinuousRender('traffic'); } @@ -318,6 +319,9 @@ export function createAnimation({ // Jam heat-lines throb (~1.6 s period) — one shared material uniform for // the whole jam batch, no geometry rebuild. Null outside live heatline mode. + if (layerState._native && layerState._heatJamPrim) { + layerState._heatJamPrim.setAlpha(HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260)); + } if (layerState._heatJamPrim?.appearance) { layerState._heatJamPrim.appearance.material.uniforms.color.alpha = HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260); @@ -349,7 +353,7 @@ export function createAnimation({ /** Remove all point primitives and reset dot/road arrays and counters. */ function clearDots() { - services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic'); + services.credits?.hideOsmCredit?.(layerState._engine ?? layerState._viewer, 'traffic'); parts.retention.clear(); if (layerState._dots.length) layerState._motion.rebuilds++; if (layerState._pointCollection) layerState._pointCollection.removeAll(); diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js index 9b37178ba..add3a06fa 100644 --- a/src/layers/traffic/flow.js +++ b/src/layers/traffic/flow.js @@ -70,7 +70,7 @@ export function createFlow({ state: layerState, services, parts, source }) { layerState._flowStatusKnown = true; if (layerState._liveMode) { console.log('[Data:Traffic] TomTom key present — live flow mode'); - registerDynamicCredit(layerState._viewer, TOMTOM_CREDIT); + registerDynamicCredit(layerState._engine ?? layerState._viewer, TOMTOM_CREDIT); } }) .catch((e) => { @@ -217,6 +217,10 @@ export function createFlow({ state: layerState, services, parts, source }) { ) { if (generation !== layerState._loadGeneration || signal?.aborted) return false; + if (layerState._waitForScene) { + await layerState._waitForScene; + if (generation !== layerState._loadGeneration || signal?.aborted) return false; + } const revision = (layerState._surfaceRevision || 0) + 1; layerState._surfaceRevision = revision; const current = () => @@ -237,6 +241,8 @@ export function createFlow({ state: layerState, services, parts, source }) { layerState._retainedPreview = true; return true; } + if (roads.length && !majorPreview && !label.includes('refined')) + layerState._surfaceInput = { roads, bounds: clamped }; const preparedRoads = layerState._preparedRoads || (layerState._preparedRoads = new Map()); const refining = label.includes('refined'); @@ -253,7 +259,8 @@ export function createFlow({ state: layerState, services, parts, source }) { parts.rendering.renderRoadsForAltitude([], altitude, label, trace); return true; } - const scene = layerState._viewer.scene; + const scene = layerState._trafficScene?.() ?? layerState._viewer?.scene; + if (!scene) return false; // The enable-time estimate can predate the first local mesh (especially // after an intercontinental jump). Once previews have measured roads, use // that local evidence to select the detail view at its real elevation. @@ -325,7 +332,7 @@ export function createFlow({ state: layerState, services, parts, source }) { const selected = parts.rendering .visibleRoadsForAltitude(roads, altitude) .filter((road) => { - const camera = layerState._viewer.camera?.positionWC; + const camera = (layerState._engine?.camera.livePosition ?? layerState._viewer?.camera?.positionWC); const localHeight = Math.max( 0, altitude - (layerState._viewHeightEstimate || 0), @@ -349,10 +356,7 @@ export function createFlow({ state: layerState, services, parts, source }) { maxX = -Infinity, maxY = -Infinity; for (const point of road.waypoints) { - const screen = Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - point, - ); + const screen = scene.project ? scene.project(point, {}) : Cesium.SceneTransforms.worldToWindowCoordinates(scene, point); if (!screen) continue; minX = Math.min(minX, screen.x); maxX = Math.max(maxX, screen.x); @@ -382,7 +386,7 @@ export function createFlow({ state: layerState, services, parts, source }) { roadDotBudget(altitude), ); const admitted = selected.filter((r, i) => budgets[i] > 0); - const camera = layerState._viewer.camera?.positionWC; + const camera = (layerState._engine?.camera.livePosition ?? layerState._viewer?.camera?.positionWC); if (camera) admitted.sort( (a, b) => @@ -478,7 +482,7 @@ export function createFlow({ state: layerState, services, parts, source }) { }; let prepared = await prepareRoadSurfaces( unresolved, - layerState._viewer.scene, + scene, services.ground, [layerState._pointCollection], signal, @@ -500,7 +504,7 @@ export function createFlow({ state: layerState, services, parts, source }) { if (!current()) return false; prepared = await prepareRoadSurfaces( prepared.pending, - layerState._viewer.scene, + scene, services.ground, [layerState._pointCollection], signal, diff --git a/src/layers/traffic/footprint.js b/src/layers/traffic/footprint.js index 51d0c3022..0b1d59019 100644 --- a/src/layers/traffic/footprint.js +++ b/src/layers/traffic/footprint.js @@ -69,16 +69,18 @@ export function createReticleFootprint() { const hits = Array.from({ length: 25 }, () => geo.cartesian3.create()); const carto = geo.cartographic.create(); const ray = new Cesium.Ray(); - return (viewer, altitude) => { - const { camera, scene } = viewer; - if (!camera.getPickRay) return null; - const nadir = camera.positionCartographic; + return (viewer, altitude, engine) => { + const camera = engine?.camera ?? viewer?.camera; + if (!camera?.getPickRay && !camera?.pickRay) return null; + const pose = engine?.camera.pose(); + const nadir = pose?.cartographic ?? camera.positionCartographic; + const canvas = engine?.host.canvas ?? viewer.scene.canvas; const near = { lat: geo.math.toDegrees(nadir.latitude), lon: geo.math.toDegrees(nadir.longitude), }; - const width = scene.canvas.clientWidth || scene.canvas.width, - height = scene.canvas.clientHeight || scene.canvas.height; + const width = canvas.clientWidth || canvas.width, + height = canvas.clientHeight || canvas.height; const side = Math.min(width, height), left = (width - side) / 2, top = (height - side) / 2; @@ -98,8 +100,9 @@ export function createReticleFootprint() { dlon -= 360 * Math.round(dlon / 360); dx = dlon * 111.195 * Math.cos(nadir.latitude); } else { - camera.getPickRay(p, ray); - const d = ray.direction; + const picked = engine ? camera.pickRay(p) : camera.getPickRay(p, ray) || ray; + if (!picked) continue; + const d = picked.direction; dx = -Math.sin(nadir.longitude) * d.x + Math.cos(nadir.longitude) * d.y; dy = @@ -155,8 +158,8 @@ export function createReticleFootprint() { nadir.latitude, nadir.longitude, nadir.height / 10000, - camera.heading, - camera.pitch, + pose?.heading ?? camera.heading, + pose?.pitch ?? camera.pitch, ] .map((v) => Number(v || 0).toFixed(7)) .join(','), diff --git a/src/layers/traffic/ingestion.js b/src/layers/traffic/ingestion.js index 6a2e68f82..27aa0107e 100644 --- a/src/layers/traffic/ingestion.js +++ b/src/layers/traffic/ingestion.js @@ -270,12 +270,12 @@ export function createIngestion({ layerState._viewHeightEstimate = 0; const centerLat = (clamped.south + clamped.north) / 2; const centerLon = (clamped.west + clamped.east) / 2; - const scene = layerState._viewer?.scene; + const scene = layerState._trafficScene?.() ?? layerState._viewer?.scene; const location = geo.cartographic.fromDegrees(centerLon, centerLat); let estimated = services.ground?.cachedGroundFloor?.(centerLat, centerLon); if (!Number.isFinite(estimated)) { try { - estimated = scene?.globe?.show + estimated = scene?.engine ? scene.sampleHeight(location) : scene?.globe?.show ? scene.globe.getHeight?.(location) : scene?.sampleHeightSupported ? scene.sampleHeight(location, [layerState._pointCollection]) diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index 1c53e3ebe..9970ea41f 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -14,6 +14,51 @@ export function createLifecycle({ }) { const { resetFlowTileCache } = source; + function readyForSurfaceWork() { + const resolve = layerState._sceneReady; + layerState._sceneReady = layerState._waitForScene = null; + resolve?.(); + } + + function bindScene() { + const viewer = layerState._viewer, engine = layerState._engine; + const camera = engine?.camera ?? viewer.camera; + const frames = engine?.frames ?? viewer.scene; + if (viewer) layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene); + layerState._pointCollection.show = true; + layerState._lastAnimTime = 0; + parts.animation.syncRenderDemand(); + layerState._preRenderRemover = frames.preRender.addEventListener(() => { + parts.animation.animate(); + layerState._native?.points.flush(); + }); + if (TRAFFIC_TIMING_ENABLED) { + parts.timing.clearTrafficTimingEntries(); + layerState._trafficTimingCurrentAnchor = null; + layerState._trafficTimingPostRenderRemovers = new Set(); + layerState._trafficTimingMoveEndRemover = camera.moveEnd.addEventListener(parts.timing.markTrafficTimingMoveEnd); + } + layerState._cameraRemover = camera.changed.addEventListener(parts.viewport.onCameraChanged); + layerState._arrivalRemover = camera.moveEnd.addEventListener(() => parts.viewport.onCameraChanged({immediate:true})); + claimCameraSensitivity(engine ?? camera,'traffic',0.05); + } + + function unbindScene() { + const camera = layerState._engine?.camera ?? layerState._viewer?.camera; + layerState._surfaceFrameRemover?.(); + layerState._preRenderRemover?.(); + layerState._trafficTimingMoveEndRemover?.(); + for (const remove of layerState._trafficTimingPostRenderRemovers || []) remove(); + layerState._trafficTimingPostRenderRemovers = null; + layerState._trafficTimingCurrentAnchor = null; + layerState._cameraRemover?.(); + // Some legacy camera events do not return a remover. + if (!layerState._cameraRemover) camera?.changed?.removeEventListener?.(parts.viewport.onCameraChanged); + layerState._arrivalRemover?.(); + layerState._surfaceFrameRemover = layerState._preRenderRemover = layerState._cameraRemover = layerState._arrivalRemover = layerState._trafficTimingMoveEndRemover = null; + releaseCameraSensitivity(layerState._engine ?? camera,'traffic'); + } + const methods = { /** * One-time initialisation. Creates the PointPrimitiveCollection and adds it @@ -22,14 +67,8 @@ export function createLifecycle({ * * @param {Cesium.Viewer} viewer - The Cesium viewer instance. */ - init(viewer) { - layerState._viewer = viewer; - layerState._pointCollection = new Cesium.PointPrimitiveCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - // Add permanently — toggle with .show to avoid destroy-on-remove errors - viewer.scene.primitives.add(layerState._pointCollection); - layerState._pointCollection.show = false; + init(viewer, options) { + this.attachRenderer(viewer, options); layerState._dots = []; layerState._roads = []; layerState._count = 0; @@ -60,9 +99,8 @@ export function createLifecycle({ layerState._stylePreset = document?.documentElement?.dataset?.gevStyle || 'normal'; if (!layerState._styleListenerBound) { - window.addEventListener('gev:style-change', (e) => - parts.style.setStylePreset(e?.detail?.style), - ); + layerState._styleListener = (e) => parts.style.setStylePreset(e?.detail?.style); + window.addEventListener('gev:style-change', layerState._styleListener); layerState._styleListenerBound = true; } } @@ -70,6 +108,66 @@ export function createLifecycle({ console.log('[Data:Traffic] Initialized'); }, + attachRenderer(viewer, {engine=null,waitForCameraRestore=false}={}) { + layerState._restoringViewport = waitForCameraRestore; + layerState._viewer = viewer; + layerState._engine = engine; + layerState._native = !viewer && engine ? services.nativeRenderer(engine) : null; + layerState._heatSupported = null; + const points = layerState._native?.points ?? new Cesium.PointPrimitiveCollection({blendOption: Cesium.BlendOption.TRANSLUCENT}); + if (viewer) viewer.scene.primitives.add(points); + layerState._pointCollection = points; + for(const dot of layerState._dots) dot.point = points.add(dot.point); + points.show = layerState._enabled; + if (!waitForCameraRestore) readyForSurfaceWork(); + if (layerState._enabled) { + bindScene(); + parts.rendering.rebuildHeatLines(layerState._roads); + if (layerState._dots.length && layerState._roads.some(r=>!r.directFlow)) + services.credits?.showOsmCredit?.(engine ?? viewer,'traffic',{openMapTiles:true}); + if (layerState._liveMode) services.credits?.registerDynamicCredit?.(engine ?? viewer, services.credits.TOMTOM_CREDIT); + parts.viewport.onCameraChanged({immediate:true}); + } + }, + resumeViewport() { + if (!layerState._restoringViewport) return; + layerState._restoringViewport = false; + readyForSurfaceWork(); + if (!layerState._enabled) return; + if (layerState._populationPending) parts.retention.resume(); + const previous = layerState._surfaceInput; + if (previous && !layerState._fetching && layerState._lastBounds === previous.bounds) { + void parts.flow.applyFlowThenRender(previous.roads, previous.bounds, layerState._loadGeneration, + parts.viewport.getCameraAltitude(), 'Handoff refined').catch(error => { + if (error?.name !== 'AbortError') console.warn('[Data:Traffic] Surface refresh failed', error); + }); + } + parts.viewport.onCameraChanged({immediate:true}); + }, + detachRenderer() { + if(!layerState._pointCollection)return; + unbindScene(); + clearTimeout(layerState._fetchTimeout); + layerState._surfaceRefineRemove?.(); + layerState._surfaceRefineRemove = null; + layerState._surfaceRevision = (layerState._surfaceRevision || 0)+1; + layerState._preparedRoads?.clear(); + parts.rendering.removeHeatLines(); + services.credits?.hideOsmCredit?.(layerState._engine ?? layerState._viewer,'traffic'); + for(const dot of layerState._dots) { + const p=dot.point; + dot.point={position:Cesium.Cartesian3.clone(p.position),pixelSize:p.pixelSize,color:p.color,show:p.show, + outlineColor:p.outlineColor,outlineWidth:p.outlineWidth,scaleByDistance:p.scaleByDistance, + translucencyByDistance:p.translucencyByDistance,disableDepthTestDistance:0}; + } + layerState._viewer?.scene.primitives.remove(layerState._pointCollection); + layerState._native?.destroy(); + if(layerState._renderHeld)services.render.releaseContinuousRender('traffic'); + layerState._renderHeld=false; + layerState._pointCollection=layerState._viewer=layerState._engine=layerState._native=null; + layerState._waitForScene ??= new Promise(resolve=>{layerState._sceneReady=resolve;}); + }, + /** * Enable the traffic layer. Shows the point collection, subscribes to the * preRender animation loop and camera-change events, and kicks off an @@ -79,35 +177,10 @@ export function createLifecycle({ */ enable(viewer) { if (layerState._enabled) return; + if (viewer) layerState._viewer = viewer; layerState._enabled = true; - layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene); if (layerState._roadMode !== 'tomtom') source.prefetch?.(); - parts.animation.syncRenderDemand(); - layerState._lastAnimTime = 0; - layerState._pointCollection.show = true; - - layerState._preRenderRemover = viewer.scene.preRender.addEventListener( - parts.animation.animate, - ); - if (TRAFFIC_TIMING_ENABLED) { - parts.timing.clearTrafficTimingEntries(); - layerState._trafficTimingCurrentAnchor = null; - layerState._trafficTimingPostRenderRemovers = new Set(); - layerState._trafficTimingMoveEndRemover = - viewer.camera.moveEnd.addEventListener( - parts.timing.markTrafficTimingMoveEnd, - ); - } - - // Share the 5% movement threshold with other camera-driven layers. - viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged); - // Always inspect the final view, even when the last flight step is below - // camera.changed's movement threshold. - layerState._arrivalRemover = viewer.camera.moveEnd.addEventListener(() => - parts.viewport.onCameraChanged({ immediate: true }), - ); - claimCameraSensitivity(viewer.camera, 'traffic', 0.05); - + bindScene(); // Enabling is explicit intent, not a camera gesture that needs settling. parts.viewport.onCameraChanged({ immediate: true }); @@ -142,7 +215,8 @@ export function createLifecycle({ */ disable(viewer) { layerState._enabled = false; - services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic'); + layerState._surfaceInput = null; + services.credits?.hideOsmCredit?.(layerState._engine ?? layerState._viewer, 'traffic'); parts.animation.syncRenderDemand(); clearTimeout(layerState._fetchTimeout); clearInterval(layerState._enableKickTimer); @@ -164,25 +238,7 @@ export function createLifecycle({ layerState._flowStale = false; layerState._flowOldestTileAt = null; - layerState._surfaceFrameRemover?.(); - layerState._surfaceFrameRemover = null; - if (layerState._preRenderRemover) { - layerState._preRenderRemover(); - layerState._preRenderRemover = null; - } - if (TRAFFIC_TIMING_ENABLED) { - layerState._trafficTimingMoveEndRemover?.(); - layerState._trafficTimingMoveEndRemover = null; - for (const remove of layerState._trafficTimingPostRenderRemovers || []) - remove(); - layerState._trafficTimingPostRenderRemovers = null; - layerState._trafficTimingCurrentAnchor = null; - } - - viewer.camera.changed.removeEventListener(parts.viewport.onCameraChanged); - layerState._arrivalRemover?.(); - layerState._arrivalRemover = null; - releaseCameraSensitivity(viewer.camera, 'traffic'); + unbindScene(); if (layerState._pointCollection) layerState._pointCollection.show = false; }, @@ -195,11 +251,19 @@ export function createLifecycle({ destroy(viewer) { this.disable(viewer); if (layerState._pointCollection) { - viewer.scene.primitives.remove(layerState._pointCollection); + layerState._viewer?.scene.primitives.remove(layerState._pointCollection); + layerState._native?.destroy(); + layerState._native = null; layerState._pointCollection = null; } parts.rendering.removeHeatLines(); layerState._tileCache.clear(); + if (layerState._styleListenerBound && typeof window !== 'undefined') { + window.removeEventListener('gev:style-change', layerState._styleListener); + layerState._styleListenerBound = false; + } + layerState._sceneReady?.(); + layerState._waitForScene = layerState._sceneReady = null; resetFlowTileCache(); layerState._count = 0; layerState._lastUpdate = null; diff --git a/src/layers/traffic/render/navara.js b/src/layers/traffic/render/navara.js new file mode 100644 index 000000000..b5f8819f8 --- /dev/null +++ b/src/layers/traffic/render/navara.js @@ -0,0 +1,187 @@ +import * as geo from '../../../geo/index.js'; +import { MAX_DOTS } from '../policy.js'; + +// Point handles keep the same mutable fields the shared traffic motion loop uses. +// The GPU gets two packed draws, including the preset's optional colored outlines. +function createTrafficPoints(engine) { + const points = new Set(), + renderSets = []; + for (const [role, order] of [ + ['outline', 0], + ['fill', 1], + ]) { + renderSets.push( + engine.movers.createSet({ + id: 'traffic-' + role, + kind: 'point', + order, + capacity: MAX_DOTS, + depthTest: 'normal', + pickable: false, + point: { sizePx: 1, outlinePx: 0 }, + }), + ); + } + const color = [0, 0, 0, 0]; + let empty = true; + const range = (p, d) => { + if (!p) return 1; + // The engine's distance scalar interpolates in squared range. + const x = Math.min( + 1, + Math.max( + 0, + (d * d - p.near * p.near) / (p.far * p.far - p.near * p.near), + ), + ); + return p.nearValue + (p.farValue - p.nearValue) * Math.pow(x, 0.2); + }; + return { + show: true, + add(spec) { + const pos = geo.cartesian3.clone(spec.position); + const p = { + ...spec, + show: spec.show ?? true, + get position() { + return pos; + }, + set position(v) { + geo.cartesian3.clone(v, pos); + }, + }; + points.add(p); + return p; + }, + remove(point) { + return points.delete(point); + }, + removeAll() { + points.clear(); + this.flush(); + }, + flush() { + if (!points.size && empty) return; + empty = points.size === 0; + const center = engine.camera.livePosition; + const sizes = [0, 0]; + if (this.show && center) + for (const p of points) { + if ( + !p.show || + p.pixelSize <= 0 || + !engine.projector.horizonVisible(p.position) + ) + continue; + const distance = geo.cartesian3.distance(p.position, center), + scale = range(p.scaleByDistance, distance), + alpha = range(p.translucencyByDistance, distance); + if (alpha <= 0) continue; + for ( + let i = p.outlineWidth > 0 && p.outlineColor ? 0 : 1; + i < 2; + i++ + ) { + const c = i ? p.color : p.outlineColor; + color[0] = c.red; + color[1] = c.green; + color[2] = c.blue; + color[3] = c.alpha * alpha; + renderSets[i].set(sizes[i]++, { + position: p.position, + scale: Math.max( + 0, + (p.pixelSize + (i ? 0 : 2 * p.outlineWidth)) * scale, + ), + color, + show: true, + }); + } + } + for (let i = 0; i < 2; i++) { + renderSets[i].setCount(sizes[i]); + renderSets[i].commit(); + } + }, + destroy() { + for (const set of renderSets) set.destroy(); + points.clear(); + }, + }; +} +export function createTrafficNavaraRenderer(engine, native) { + const { view } = native; + const points = createTrafficPoints(engine); + const surface = { + engine, + canvas: engine.host.canvas, + postRender: engine.frames.postRender, + get sampleHeightSupported() { + return engine.surface.supported; + }, + sampleHeight: (p) => + engine.maps.photorealActive() + ? engine.surface.sampleHeight(p) + : engine.surface.terrainHeight(p), + project: (world, out) => engine.projector.worldToWindow(world, out), + get ready() { + return engine.surface.tilesLoaded(); + }, + get surfaceKey() { + return engine.maps.photorealActive() ? 'tiles' : 'terrain'; + }, + }; + const makeHeat = (roads, tint, width, alpha) => { + if (!roads.length) return null; + const source = view.addSource({ + type: 'geojson', + data: { + type: 'FeatureCollection', + features: roads.map((r) => ({ + type: 'Feature', + properties: {}, + geometry: { type: 'LineString', coordinates: r.coords }, + })), + }, + }); + const css = + (Math.round(tint.red * 255) << 16) | + (Math.round(tint.green * 255) << 8) | + Math.round(tint.blue * 255); + let layer; + try { + layer = view.addLayer({ + type: 'vector', + source, + polyline: { + color: css, + width, + opacity: alpha, + clampToGround: true, + transparent: true, + lit: false, + }, + }); + } catch (e) { + source.delete(); + throw e; + } + let current = alpha; + layer.on('featureUpdated', ({ evaluator }) => + evaluator.evaluate(() => ({ opacity: current })), + ); + return { + setAlpha(a) { + if (Math.abs(a - current) > 0.015) { + current = a; + layer.forceUpdate(); + } + }, + destroy() { + layer.delete(); + source.delete(); + }, + }; + }; + return { points, surface, makeHeat, destroy: () => points.destroy() }; +} diff --git a/src/layers/traffic/rendering.js b/src/layers/traffic/rendering.js index bf0132b98..4296299d8 100644 --- a/src/layers/traffic/rendering.js +++ b/src/layers/traffic/rendering.js @@ -37,6 +37,11 @@ export function createRendering({ /** Remove both heat-line ground primitives from the scene. */ function removeHeatLines() { + if (layerState._native) { + layerState._heatJamPrim?.destroy(); + layerState._heatSlowPrim?.destroy(); + layerState._heatJamPrim = layerState._heatSlowPrim = null; + } if (layerState._heatJamPrim) { layerState._viewer?.scene.groundPrimitives.remove( layerState._heatJamPrim, @@ -68,11 +73,12 @@ export function createRendering({ function rebuildHeatLines(roads) { removeHeatLines(); if ( - !layerState._viewer || + (!layerState._viewer && !layerState._native) || !layerState._liveMode || !parts.style.heatlineOn() ) return; + if (layerState._native) layerState._heatSupported = true; if (layerState._heatSupported === null) { layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported( layerState._viewer.scene, @@ -122,6 +128,14 @@ export function createRendering({ ? new Cesium.Color(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha) : HEAT_SLOW_COLOR; + if (layerState._native) { + const roadsFor = bucket => kept.filter(item=>item.bucket===bucket).map(item=>item.road); + layerState._heatJamPrim = layerState._native.makeHeat(roadsFor('jam'), jamLineColor, HEAT_LINE_JAM_WIDTH, HEAT_JAM_BASE_ALPHA); + layerState._heatSlowPrim = layerState._native.makeHeat(roadsFor('slow'), slowLineColor, HEAT_LINE_SLOW_WIDTH, slowLineColor.alpha); + layerState._heatLineCount = kept.length; + parts.animation.syncRenderDemand(); + return; + } const jamInstances = instancesFor('jam', HEAT_LINE_JAM_WIDTH); if (jamInstances.length) { layerState._heatJamPrim = layerState._viewer.scene.groundPrimitives.add( @@ -202,7 +216,7 @@ export function createRendering({ // nadir view: oblique pitches put the loaded roads many km away even at // low altitude. Probe three roads and stretch the curves accordingly. let areaDist = altitude; - if (layerState._viewer && filteredRoads.length) { + if ((layerState._viewer || layerState._engine) && filteredRoads.length) { const probes = [ filteredRoads[0], filteredRoads[Math.floor(filteredRoads.length / 2)], @@ -214,7 +228,7 @@ export function createRendering({ areaDist = Math.max( areaDist, Cesium.Cartesian3.distance( - layerState._viewer.camera.positionWC, + layerState._engine?.camera.livePosition ?? layerState._viewer.camera.positionWC, wp, ), ); @@ -233,10 +247,10 @@ export function createRendering({ : null; parts.retention.reconcile(filteredRoads, altitude, replace); if (layerState._dots.length && filteredRoads.some((r) => !r.directFlow)) - services.credits?.showOsmCredit?.(layerState._viewer, 'traffic', { + services.credits?.showOsmCredit?.(layerState._engine ?? layerState._viewer, 'traffic', { openMapTiles: true, }); - else services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic'); + else services.credits?.hideOsmCredit?.(layerState._engine ?? layerState._viewer, 'traffic'); parts.model.recolorDotsInPlace(label); const renderMetrics = state diff --git a/src/layers/traffic/retention.js b/src/layers/traffic/retention.js index a987f64b0..5e9552343 100644 --- a/src/layers/traffic/retention.js +++ b/src/layers/traffic/retention.js @@ -17,6 +17,7 @@ export function createRetention({ state, parts }) { const screenTo = new Cesium.Cartesian2(); const eye = new Cesium.Cartesian3(); function projectSafely(scene, point, screen) { + if (scene.project) return Boolean(scene.project(point, screen)); if (scene.camera.viewMatrix) { Cesium.Matrix4.multiplyByPoint(scene.camera.viewMatrix, point, eye); if (eye.z >= -(scene.camera.frustum?.near || 0)) return false; @@ -50,7 +51,7 @@ export function createRetention({ state, parts }) { }; function pump() { timer = null; - if (!state._enabled) return; + if (!state._enabled || !state._pointCollection) return; let budget = DOT_CHANGE_BUDGET; let pending = false; const now = Date.now(); @@ -191,9 +192,9 @@ export function createRetention({ state, parts }) { } function easeHeights(dt) { const blend = Math.min(1, dt * 12); - const scene = state._viewer?.scene; + const scene = state._trafficScene?.() ?? state._viewer?.scene; const canProject = - scene?.canvas?.clientHeight && scene.camera && scene.frameState; + scene?.canvas?.clientHeight && (scene.project || (scene.camera && scene.frameState)); state._heightPending = false; for (let r = 0; r < state._roads.length; r++) { const road = state._roads[r]; @@ -276,5 +277,5 @@ export function createRetention({ state, parts }) { state._populationPending = false; state._heightPending = false; } - return { reconcile, easeHeights, finishHeights, clear, refreshCounts }; + return { reconcile, easeHeights, finishHeights, clear, refreshCounts, resume: schedule }; } diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js index ecd34c9ee..fc0e38dc0 100644 --- a/src/layers/traffic/state.js +++ b/src/layers/traffic/state.js @@ -17,6 +17,10 @@ export function createState({ services }) { /** @type {Cesium.Viewer|null} */ layerState._viewer = null; + layerState._engine = null; + layerState._native = null; + layerState._trafficScene = () => layerState._native?.surface ?? layerState._viewer?.scene; + /** @type {Cesium.PointPrimitiveCollection|null} */ diff --git a/src/layers/traffic/surface.js b/src/layers/traffic/surface.js index 3344faa64..8f9b901f9 100644 --- a/src/layers/traffic/surface.js +++ b/src/layers/traffic/surface.js @@ -50,6 +50,7 @@ export function observeTrafficSurface(scene) { } function currentRenderedFrame(scene) { + if (scene.engine) return {ready:scene.ready}; if (!scene.postRender?.addEventListener || !scene.camera?.viewMatrix) return { ready: surfaceTilesReady(scene) }; const frame = renderedFrames.get(scene); @@ -204,6 +205,7 @@ const surfaceId = (object) => { */ const surfaceKeys = new WeakMap(); export function trafficSurfaceKey(scene) { + if (scene.engine) return scene.surfaceKey; let state = surfaceKeys.get(scene); if (!state) { state = { globe: null, terrain: null, tiles: [], key: '' }; @@ -278,7 +280,7 @@ export async function prepareRoadSurfaces( cache = { surface, heights: new Map() }; sceneCaches.set(scene, cache); } - const cameraCarto = scene.camera?.positionCartographic; + const cameraCarto = scene.engine?.camera.pose().cartographic ?? scene.camera?.positionCartographic; const cameraLon = cameraCarto ? Cesium.Math.toDegrees(cameraCarto.longitude) : 0; @@ -333,7 +335,8 @@ export async function prepareRoadSurfaces( let sampled; // sampleHeight reads the locally rendered mesh, not unresolved/offscreen // tiles. Global tilesLoaded can stay false while this street is usable. - if (scene.globe?.show) sampled = scene.globe.getHeight?.(carto); + if (scene.engine) sampled = scene.sampleHeight(carto); + else if (scene.globe?.show) sampled = scene.globe.getHeight?.(carto); else { const estimate = Cesium.Cartographic.fromCartesian( road.waypoints[i], @@ -342,7 +345,7 @@ export async function prepareRoadSurfaces( if (validHeight(sampled)) metrics.depthHits++; } if ( - !scene.globe?.show && + !scene.engine && !scene.globe?.show && !validHeight(sampled) && scene.sampleHeightSupported ) { diff --git a/src/layers/traffic/viewport.js b/src/layers/traffic/viewport.js index f6ea5147d..739060c08 100644 --- a/src/layers/traffic/viewport.js +++ b/src/layers/traffic/viewport.js @@ -24,7 +24,7 @@ export function createViewport({ state: layerState, services, parts, source }) { */ function getCameraAltitude() { - const carto = layerState._viewer.camera.positionCartographic; + const carto = layerState._engine?.camera.pose().cartographic ?? layerState._viewer?.camera.positionCartographic; return carto ? carto.height : Infinity; } @@ -35,7 +35,7 @@ export function createViewport({ state: layerState, services, parts, source }) { */ function getViewBounds() { - const rect = layerState._viewer.camera.computeViewRectangle(); + const rect = layerState._engine?.camera.viewRectangle() ?? layerState._viewer?.camera.computeViewRectangle(); if (!rect) return null; return { south: geo.math.toDegrees(rect.south), @@ -60,18 +60,18 @@ export function createViewport({ state: layerState, services, parts, source }) { */ function getFetchCenter() { - const carto = layerState._viewer.camera.positionCartographic; + const carto = layerState._engine?.camera.pose().cartographic ?? layerState._viewer?.camera.positionCartographic; if (!carto) return null; const nadirLat = geo.math.toDegrees(carto.latitude); const nadirLon = geo.math.toDegrees(carto.longitude); let hitLat; let hitLon; - const canvas = layerState._viewer.scene.canvas; + const canvas = layerState._engine?.host.canvas ?? layerState._viewer.scene.canvas; const width = canvas.clientWidth || canvas.width; const height = canvas.clientHeight || canvas.height; if (width > 0 && height > 0) { - const hit = layerState._viewer.camera.pickEllipsoid( + const hit = (layerState._engine?.camera ?? layerState._viewer.camera).pickEllipsoid( geo.cartesian2.create(width / 2, height / 2), Cesium.Ellipsoid.WGS84, ); @@ -174,7 +174,7 @@ export function createViewport({ state: layerState, services, parts, source }) { /** Debounce camera events and sample the actual settled view, including final flight steps. */ function onCameraChanged(options) { - if (!layerState._enabled) return; + if (!layerState._enabled || layerState._waitForScene || layerState._restoringViewport) return; clearTimeout(layerState._fetchTimeout); if (getCameraAltitude() > ACTIVATION_ALTITUDE) { clearTimeout(layerState._retryTimer); @@ -197,10 +197,10 @@ export function createViewport({ state: layerState, services, parts, source }) { : null; layerState._fetchTimeout = setTimeout( () => { - if (!layerState._enabled) return; + if (!layerState._enabled || layerState._waitForScene || layerState._restoringViewport) return; const alt = getCameraAltitude(); if (alt > ACTIVATION_ALTITUDE) return onCameraChanged(); - const footprint = reticleFootprint(layerState._viewer, alt); + const footprint = reticleFootprint(layerState._viewer, alt, layerState._engine); const bounds = footprint || getViewBounds(); if (!bounds) return; const center = footprint ? null : getFetchCenter(); diff --git a/src/main.js b/src/main.js index d4c57e7c0..9b53871d4 100644 --- a/src/main.js +++ b/src/main.js @@ -24,6 +24,7 @@ window.addEventListener('pagehide', (event) => { async function start(next, engineSwitch = false, cockpit = null) { kind = next; + layerSession.restoringView = engineSwitch; application = createStandaloneApplication({ googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY, cesiumToken: import.meta.env.CESIUM_ION_TOKEN, @@ -37,6 +38,8 @@ async function start(next, engineSwitch = false, cockpit = null) { if (engineSwitch) { const style = controls.styleManager; await style.initialRestorePromise; + layerSession.restoringView = false; + for (const {module} of layerSession.manager.layers.values()) module.resumeViewport?.(); if (layerSession.handoffLayers) layerSession.manager.layers.get('directions')?.module.resumeCamera?.(); // Only resume the aircraft that actually survived the handoff. A history // entry may select a different subject, and a stale track may disappear.