Move Directions routes and active camera flights between globes

This commit is contained in:
German Khokhlov committed 2026-11-03 03:44:07 +04:00
1 parent 5cea3e8cbb
commit 605b9490f3
12 files changed
+568 -63

No files matched your search

+1
View File
@@ -3678,6 +3678,7 @@
"src/layers/infrastructure/render/navara.js",
"src/layers/shared/render/navaraStems.js",
"src/layers/submarineCables/render/navara.js",
"src/layers/directions/render/navara.js",
"src/layers/weather/render/navara.js",
"src/layers/weather/render/navaraSurface.js",
"src/layers/wind/render/navara.js",
+165
View File
@@ -0,0 +1,165 @@
import puppeteer from 'puppeteer';
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=55.3241&lon=10.7995&alt=25000&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]);
});
let routeReads = 0,
release;
await p.setRequestInterception(true);
p.on('request', async (req) => {
const u = new URL(req.url());
const json = (v) =>
req.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify(v),
});
if (u.pathname === '/api/terrain/heights') {
const cells = (u.searchParams.get('points') ?? '').split(';');
return json({ results: cells.map(() => ({ ellipsoid: 34 })) });
}
if (u.pathname !== '/api/route') return req.continue();
routeReads++;
const [a, b] = u.searchParams
.get('coords')
.split(';')
.map((p) => p.split(',').map(Number));
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
if (routeReads === 1)
await new Promise((resolve) => {
release = resolve;
});
return json({
ok: true,
geometry: [a, mid, b],
distanceM: 9000,
durationS: 800,
steps: [a, mid, b].map(([lon, lat], i) => ({
lon,
lat,
instruction: 'Route step ' + i,
distanceM: 3000,
durationS: 120,
})),
});
});
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.qaRoute = dataManager.layers.get('directions').module;
await dataManager.setEnabled('directions', true, { origin: 'user' });
});
for (const [letter, x] of [
['a', 480],
['b', 645],
]) {
await p.evaluate((l) => window.qaRoute.setParams({ arm: l }), letter);
await p.mouse.click(x, 440);
await new Promise((resolve) => setTimeout(resolve, 450));
}
assert.equal(routeReads, 1);
await p.select('#globe-engine', 'cesium');
await ready('cesium');
release();
await p.waitForFunction(() => window.qaRoute.getStats().count === 3);
await p.waitForFunction(() => {
window.qaRoute.setParams({ step: 1 });
return window.qaRoute.getRowControls().list.items[1]?.active;
});
const transfer = async (kind) => {
await p.select('#globe-engine', kind);
await ready(kind);
assert.equal(
await p.evaluate(
() =>
window.__godsEyeView.dataManager.layers.get('directions').module ===
window.qaRoute,
),
true,
);
assert.equal(routeReads, 1);
};
await transfer('navara');
assert.equal(
await p.evaluate(
() => window.qaRoute.getRowControls().list.items[1].active,
),
true,
);
await p.evaluate(() => window.qaRoute.setParams({ fly: true }));
await p.waitForFunction(
() =>
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')?.active,
);
const pos = () =>
p.evaluate(() => window.__godsEyeView.engine.camera.pose().position);
const d = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
await new Promise((resolve) => setTimeout(resolve, 1800));
let progress = await p.evaluate(() => window.qaRoute.getFlightProgress());
for (const kind of ['cesium', 'navara']) {
const before = await pos();
await transfer(kind);
await p.waitForFunction(
() =>
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
?.active,
);
const at = await pos();
await new Promise((resolve) => setTimeout(resolve, 1500));
const next = await pos();
const resumed = await p.evaluate(() => window.qaRoute.getFlightProgress());
assert.equal(resumed.motionId, progress.motionId);
assert.ok(resumed.traveledM > progress.traveledM);
progress = resumed;
console.log(kind, 'moved', d(at, next), 'handoff', d(at, before));
assert.ok(d(at, next) > 1, 'dolly advances after switch to ' + kind);
}
await p.evaluate(() => window.qaRoute.setParams({ clear: true }));
assert.equal(await p.evaluate(() => window.qaRoute.getStats().count), 0);
assert.deepEqual(errors, []);
console.log(
'PASS route delivery during handoff, A/B clicks, step selection, ongoing FLY, clear and single route request',
);
} finally {
await b.close();
}
+1 -1
View File
@@ -255,7 +255,7 @@ export function createApplicationCatalog({
],
[
'directions',
() => createApplicationDirections({ source: sources.directions }),
() => sessionModel('directions', () => createApplicationDirections({ source: sources.directions, surface })),
],
[
'recent-imagery',
+4 -2
View File
@@ -1,6 +1,8 @@
import { createApplicationDirectionsLayer } from '../../data/directions.js';
import { rendererFor } from './renderers.js';
/** Construct Directions with application scene owners and an explicit route source. */
export function createApplicationDirections({ source }) {
return createApplicationDirectionsLayer({ source });
export function createApplicationDirections({ source, surface }) {
return createApplicationDirectionsLayer({ source, groundFloor: surface?.groundFloor, createNativeRenderer: (engine) => rendererFor('directions', engine) });
}
+1
View File
@@ -58,6 +58,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'radio',
'transit',
'bikeshare',
'directions',
'recent-imagery',
'ais-live-vessels',
'military-installations',
+4
View File
@@ -120,6 +120,10 @@ export async function registerNavaraLayerRenderers() {
return (engine, options) => createWeatherNavaraRenderer(engine, navaraViews.get(engine), options);
},
])),
directions: async () => {
const { createDirectionsNavaraRenderer } = await import('../layers/directions/render/navara.js');
return (engine) => createDirectionsNavaraRenderer(engine, navaraViews.get(engine));
},
'telegeography-submarine-cables': async () => {
const { createNavaraCables } = await import('../layers/submarineCables/render/navara.js');
return (engine, { overlayHost }) => createNavaraCables(engine, navaraViews.get(engine), overlayHost);
+1 -1
View File
@@ -231,7 +231,7 @@ test('validated voice camera destinations share the UI navigation authority faca
const route = body(
cameraVerbs,
/export function flyRoute\(\s*annoList,\s*args = \{\},\s*floorFn = null,\s*runNavigation = null,\s*warmFn = null,?\s*\) \{([\s\S]*?)\n\}/,
/export function flyRoute\(\s*annoList,\s*args = \{\},\s*floorFn = null,\s*runNavigation = null,\s*warmFn = null,\s*engine = null,?\s*\) \{([\s\S]*?)\n\}/,
'fly route',
);
ordered(route, [
+66 -4
View File
@@ -960,6 +960,57 @@ let _active = null; // { kind, motion, direction, speed, mode, motionId, ...stat
let _tickRemover = null;
let _trackingRemover = null;
let _inputRemovers = [];
// A Directions dolly can move between engine instances. Geometry and flight time
// stay with the motion. Input and frame listeners belong only to the active canvas.
let routeEngine = null;
let removeRouteFrame = null;
function unbindRouteEngine() {
removeRouteFrame?.();
removeRouteFrame = null;
routeEngine = null;
}
function bindRouteEngine(engine) {
unbindRouteEngine();
routeEngine = engine;
const canvas = engine.host.canvas;
const cancel = () => interruptCameraMotion('manual-input');
const events = ['pointerdown', 'touchstart', 'wheel'];
for (const event of events) canvas.addEventListener(event, cancel, { passive: true });
let last = performance.now();
const remove = engine.frames.preRender.addEventListener(() => {
if (_active?.kind !== 'route') return;
const now = performance.now();
const frame = advanceRouteFlight(_active, (now - last) / 1000);
last = now;
if (!frame.arming) engine.camera.setView({
destination: frame.eye, orientation: { direction: frame.direction, up: frame.up },
});
if (frame.finished) interruptCameraMotion('route-complete');
else engine.render.request('route-flight');
});
removeRouteFrame = () => {
remove();
for (const event of events) canvas.removeEventListener(event, cancel);
};
engine.render.request('route-flight');
}
export function suspendRouteFlight(id) {
if (!id || _active?.motionId !== id || !routeEngine) return null;
const state = _active;
_active = null;
unbindRouteEngine();
releaseContinuousRender('camera-verb');
return state;
}
export function resumeRouteFlight(state, engine) {
if (!state || !engine || _active) return false;
_active = state;
bindRouteEngine(engine);
holdContinuousRender('camera-verb');
return true;
}
/** Monotonic id for the motion currently running; 0 means "no motion". */
let _motionSeq = 0;
@@ -1048,12 +1099,21 @@ export function interruptCameraMotion(reason = 'interrupt') {
if (
wasActive &&
!tracking &&
!routeEngine &&
(_active.kind === 'orbit' || _active.kind === 'route')
)
clearLookAt();
// Instant, not eased: the user grabbed the camera, or the flight is over.
// Either way the next thing they hold must be a level horizon.
const leveled = wasRoute && !tracking ? levelCameraRoll() : false;
let leveled = false;
if (wasRoute && routeEngine) {
const p = routeEngine.camera.pose();
if (Math.abs(geo.math.negativePiToPi(p.roll)) > 1e-5) {
routeEngine.camera.setView({ destination: p.position, orientation: { heading: p.heading, pitch: p.pitch, roll: 0 } });
leveled = true;
}
unbindRouteEngine();
} else if (wasRoute && !tracking) leveled = levelCameraRoll();
_active = null;
releaseContinuousRender('camera-verb');
return { wasActive, reason, leveled };
@@ -1084,7 +1144,7 @@ export function getActiveCameraMotion() {
}
function onTick() {
if (!_active || !_viewer) return;
if (!_active || !_viewer || routeEngine) return;
// Wall-clock dt: clock.currentTime FREEZES when the app clock isn't
// animating, which starved motion to the 1 ms floor (harness catch).
const nowMs = performance.now();
@@ -1750,6 +1810,7 @@ export function flyRoute(
floorFn = null,
runNavigation = null,
warmFn = null,
engine = null,
) {
const speed = ROUTE_M_S[String(args.speed || 'normal').toLowerCase()]
? String(args.speed || 'normal').toLowerCase()
@@ -1816,6 +1877,7 @@ export function flyRoute(
}
const start = () => {
interruptCameraMotion('replaced');
if (engine) bindRouteEngine(engine);
setActiveMotion(
createRouteFlight({
pts,
@@ -1823,8 +1885,8 @@ export function flyRoute(
speed,
floorFn,
warmFn,
probeFn: (cells) => probeMeshFloorM(_viewer?.scene, cells),
cameraHeightM: _viewer?.camera?.positionCartographic?.height,
probeFn: (cells) => probeMeshFloorM(engine ? routeEngine?.surface : _viewer?.scene, cells),
cameraHeightM: engine?.camera.pose().cartographic.height ?? _viewer?.camera?.positionCartographic?.height,
reducedMotion: prefersReducedMotion(),
}),
);
+3 -1
View File
@@ -40,8 +40,10 @@ const directionsServices = Object.freeze({
/** Construct one Directions layer over the application scene owners. */
export function createApplicationDirectionsLayer({
source = createDirectionsSource(),
createNativeRenderer,
groundFloor = ground,
} = {}) {
return createDirectionsLayer({ services: directionsServices, source });
return createDirectionsLayer({ services: { ...directionsServices, ground: groundFloor }, source, createNativeRenderer });
}
export {
+111 -54
View File
@@ -859,7 +859,7 @@ export function normalizeRoutePayload(payload, mode) {
* @param {{services: object, source: {getRoute: Function}}} options
* @returns {object} The layer module the data manager registers.
*/
export function createDirectionsLayer({ services, source }) {
export function createDirectionsLayer({ services, source, createNativeRenderer }) {
if (typeof source?.getRoute !== 'function')
throw new TypeError('Directions require a route source');
/**
@@ -876,6 +876,9 @@ export function createDirectionsLayer({ services, source }) {
// --- Module state ---
let _viewer = null;
let _engine = null;
let _native = null;
let _suspendedFlight = null;
let _enabled = false;
let _mode = DEFAULT_DIRECTIONS_MODE;
/** @type {null|'a'|'b'} which endpoint the next globe click places */
@@ -967,7 +970,7 @@ export function createDirectionsLayer({ services, source }) {
}
function pickGround(screenPosition) {
const cartesian = services.scenePick.pickGroundPosition(
const cartesian = _native ? _engine.picker.pickGlobe(screenPosition) : services.scenePick.pickGroundPosition(
_viewer,
screenPosition,
);
@@ -981,6 +984,7 @@ export function createDirectionsLayer({ services, source }) {
}
function markerEntity(letter, point, color) {
if (_native) return _native.marker(letter, point, color.toCssColorString());
return _viewer.entities.add({
id: `directions:marker:${letter}`,
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat, 0),
@@ -1007,12 +1011,13 @@ export function createDirectionsLayer({ services, source }) {
}
function removeEntity(entity) {
if (_native) { entity?.destroy(); return; }
if (entity && _viewer && !_viewer.isDestroyed?.())
_viewer.entities.remove(entity);
}
function placeMarker(letter, point) {
if (!_viewer) return;
if (!_viewer && !_native) return;
if (letter === 'a') {
removeEntity(_markerA);
_markerA = markerEntity('A', point, MARKER_A_COLOR);
@@ -1023,7 +1028,7 @@ export function createDirectionsLayer({ services, source }) {
}
function clearRouteGraphics() {
services.credits?.hideOsmCredit?.(_viewer, 'directions');
services.credits?.hideOsmCredit?.(_viewer ?? _engine, 'directions');
_clearStepSelection();
stopStepAnchoring();
removeEntity(_routeEntity);
@@ -1072,6 +1077,7 @@ export function createDirectionsLayer({ services, source }) {
);
point.show = true;
}
_native?.steps.sync();
// A card already open on a step that has just been anchored moves with it.
if (_selectedStep !== null) refreshStepCard(_selectedStep);
services.render.governorRequestRender('directions-anchor');
@@ -1092,23 +1098,26 @@ export function createDirectionsLayer({ services, source }) {
function drawRoute(route) {
clearRouteGraphics();
if (!_viewer) return;
services.annotations.ensureFlowFabricRegistered();
const positions = Cesium.Cartesian3.fromDegreesArray(route.geometry.flat());
_routeEntity = _viewer.entities.add({
id: 'directions:route',
polyline: {
positions,
width: 9,
material: new services.annotations.FlowMaterialProperty(
DIRECTIONS_ROUTE_COLOR,
),
clampToGround: true,
// BOTH: drape on 3D tiles when they are up and on terrain when they are
// not, so the keyless globe shows the route too.
classificationType: Cesium.ClassificationType.BOTH,
},
});
if (!_viewer && !_native) return;
if (_native) _routeEntity = _native.route(route.geometry, DIRECTIONS_ROUTE_COLOR);
else {
services.annotations.ensureFlowFabricRegistered();
const positions = Cesium.Cartesian3.fromDegreesArray(route.geometry.flat());
_routeEntity = _viewer.entities.add({
id: 'directions:route',
polyline: {
positions,
width: 9,
material: new services.annotations.FlowMaterialProperty(
DIRECTIONS_ROUTE_COLOR,
),
clampToGround: true,
// BOTH: drape on 3D tiles when they are up and on terrain when they are
// not, so the keyless globe shows the route too.
classificationType: Cesium.ClassificationType.BOTH,
},
});
}
// One dot per decision; A and B already mark departure and arrival. The dots
// start hidden and appear as anchorStepPoints resolves each ground cell.
const cells = [];
@@ -1129,13 +1138,13 @@ export function createDirectionsLayer({ services, source }) {
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
}
services.credits?.showOsmCredit?.(_viewer, 'directions');
services.credits?.showOsmCredit?.(_viewer ?? _engine, 'directions');
services.ground.warmGroundFloor(cells);
_anchorAttempts = 0;
_anchorStartedAt = Date.now();
anchorStepPoints();
syncRenderHold();
services.sprites.restoreSpriteOrder(_viewer);
if (_viewer) services.sprites.restoreSpriteOrder(_viewer);
services.render.governorRequestRender('directions-route');
}
@@ -1338,6 +1347,7 @@ export function createDirectionsLayer({ services, source }) {
* @returns {boolean} Whether a flight of ours was stopped.
*/
function cancelOwnedFlight(reason) {
_suspendedFlight = null;
stopFlightProgress();
if (!_flightId) return false;
const { wasActive } = services.camera.interruptCameraMotionIfActive(
@@ -1378,9 +1388,9 @@ export function createDirectionsLayer({ services, source }) {
}
function flyCurrentRoute() {
if (!_route || !_viewer) return false;
if (!_route || (!_viewer && !_engine)) return false;
cancelOwnedFlight('directions-refly');
services.camera.initCameraVerbs(_viewer);
if (_viewer) services.camera.initCameraVerbs(_viewer);
const seams = _shellSeams;
const result = services.camera.flyRoute(
[
@@ -1401,6 +1411,7 @@ export function createDirectionsLayer({ services, source }) {
// runs, it just has nothing to take the camera from.
typeof seams?.runNavigation === 'function' ? seams.runNavigation : null,
typeof seams?.warmFn === 'function' ? seams.warmFn : null,
_engine,
);
if (result?.ok !== true) {
console.warn('[Data:Directions] fly refused:', result?.error || result);
@@ -1418,6 +1429,7 @@ export function createDirectionsLayer({ services, source }) {
if (_selectedStep !== null && _stepPoints) {
const point = findStepPoint(_selectedStep);
if (point) point.pixelSize = STEP_PIXEL_SIZE;
_native?.steps.sync();
}
_selectedStep = null;
_overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID);
@@ -1496,6 +1508,7 @@ export function createDirectionsLayer({ services, source }) {
_selectedStep = index;
const point = findStepPoint(index);
if (point) point.pixelSize = STEP_SELECTED_PIXEL_SIZE;
_native?.steps.sync();
services.render.governorRequestRender('directions-select');
return true;
}
@@ -1522,8 +1535,7 @@ export function createDirectionsLayer({ services, source }) {
function _installClickHandler(viewer) {
if (_clickHandler) return;
_clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
_clickHandler.setInputAction((click) => {
const handle = (click) => {
if (!_enabled) return;
// Placement acts only while this layer actually HOLDS the pointer, and the
// maneuver-dot selection below is an ambient handler like any other: it
@@ -1539,7 +1551,7 @@ export function createDirectionsLayer({ services, source }) {
if (!services.input.isPointerFree()) return;
let picked = null;
try {
picked = viewer.scene.pick(click.position);
picked = _native ? click.picked : viewer.scene.pick(click.position);
} catch {
picked = null;
}
@@ -1549,7 +1561,14 @@ export function createDirectionsLayer({ services, source }) {
return;
}
if (_selectedStep !== null) _clearStepSelection();
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
};
if (_native) {
const off = _engine.input.on('click', event => handle({ position: event.position, picked: event.picked }), { pick: true });
_clickHandler = { destroy: off };
} else {
_clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
_clickHandler.setInputAction(handle, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
if (typeof document !== 'undefined')
document.addEventListener('keydown', _onKeyDown);
}
@@ -1579,26 +1598,55 @@ export function createDirectionsLayer({ services, source }) {
* Create the maneuver-dot collection. Called once at bootstrap.
* @param {Cesium.Viewer} viewer
*/
init(viewer) {
init(viewer, options) {
return this.attachRenderer(viewer, options);
},
attachRenderer(viewer, { engine = null } = {}) {
_viewer = viewer;
_stepPoints = new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
viewer.scene.primitives.add(_stepPoints);
services.sprites.registerSpriteCollection('directions', _stepPoints);
_stepPoints.show = false;
_enabled = false;
_mode = DEFAULT_DIRECTIONS_MODE;
disarm();
_a = null;
_b = null;
_route = null;
_status = 'idle';
_error = null;
_flightStep = null;
_engine = engine;
if (engine?.kind === 'navara') {
_native = createNativeRenderer(engine);
_stepPoints = _native.steps;
} else {
_stepPoints = new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
viewer.scene.primitives.add(_stepPoints);
services.sprites.registerSpriteCollection('directions', _stepPoints);
services.sprites.restoreSpriteOrder(viewer);
}
_stepPoints.show = _enabled;
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, _enabled);
if (!_enabled) return;
const step = _selectedStep;
this.enable(viewer);
if (_a) placeMarker('a', _a);
if (_b) placeMarker('b', _b);
if (_route) drawRoute(_route);
if (step !== null) _selectStep(step);
},
suspendCamera() {
if (!_flightId || _suspendedFlight) return;
_suspendedFlight = services.camera.suspendRouteFlight?.(_flightId);
if (_suspendedFlight) stopFlightProgress();
},
detachRenderer() {
if (!_suspendedFlight) cancelOwnedFlight('directions-detach');
const selected = _selectedStep;
clearRouteGraphics();
_selectedStep = selected;
removeEntity(_markerA);
removeEntity(_markerB);
_markerA = _markerB = null;
_removeClickHandler();
services.picking.unregisterPickOwner('directions');
if (_native) _native.destroy();
else if (_stepPoints && _viewer) {
services.sprites.unregisterSpriteCollection('directions', _stepPoints);
_viewer.scene.primitives.remove(_stepPoints);
}
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false);
services.sprites.restoreSpriteOrder(viewer);
console.log('[Data:Directions] Initialized');
_stepPoints = _viewer = _engine = _native = null;
},
/**
@@ -1608,7 +1656,7 @@ export function createDirectionsLayer({ services, source }) {
enable(viewer) {
_enabled = true;
_pointerBlocked = false;
_stepPoints.show = true;
if (_stepPoints) _stepPoints.show = true;
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, true);
_installClickHandler(viewer);
services.picking.registerPickOwner(
@@ -1617,7 +1665,7 @@ export function createDirectionsLayer({ services, source }) {
typeof pickedId === 'string' && pickedId.startsWith('directions:'),
);
syncRenderHold();
services.sprites.restoreSpriteOrder(viewer);
if (viewer) services.sprites.restoreSpriteOrder(viewer);
},
/**
@@ -1695,6 +1743,10 @@ export function createDirectionsLayer({ services, source }) {
* Keep a manager handle so placement and routing can repaint the row.
* @param {object} dataManager DataLayerManager instance.
*/
getFlightProgress() {
const motion = services.camera.getActiveCameraMotion();
return motion?.motionId === _flightId ? motion : null;
},
attachDataManager(dataManager) {
_dataManager = dataManager;
},
@@ -1712,6 +1764,15 @@ export function createDirectionsLayer({ services, source }) {
attachShellServices(services) {
_shellSeams = services || null;
},
resumeCamera() {
if (_shellSeams && _suspendedFlight && _engine) {
_suspendedFlight.floorFn = _shellSeams.floorFn;
_suspendedFlight.warmFn = _shellSeams.warmFn;
if (services.camera.resumeRouteFlight(_suspendedFlight, _engine)) trackFlightProgress();
else { _flightId = 0; _flightStep = null; }
_suspendedFlight = null;
}
},
/**
* Tear down entirely.
@@ -1728,11 +1789,7 @@ export function createDirectionsLayer({ services, source }) {
_shellSeams = null;
_dataManager = null;
_rowControlsListener = null;
if (_stepPoints) {
services.sprites.unregisterSpriteCollection('directions', _stepPoints);
viewer.scene.primitives.remove(_stepPoints);
_stepPoints = null;
}
this.detachRenderer();
_overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID);
_viewer = null;
},
+209
View File
@@ -0,0 +1,209 @@
import * as geo from '../../../geo/index.js';
function endpointAtlas(letter, css) {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.beginPath();
ctx.arc(32, 32, 10, 0, 2 * Math.PI);
ctx.fillStyle = css;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'black';
ctx.stroke();
ctx.font = 'bold 17px monospace';
ctx.textAlign = 'center';
ctx.lineWidth = 3;
ctx.strokeText(letter, 32, 17);
ctx.fillStyle = 'white';
ctx.fillText(letter, 32, 17);
return { canvas, cells: { pin: [0, 0, 1, 1] } };
}
// Bake the route into bounded pieces once. Each keeps every original turn.
// Features share one clamped vector layer. Only their opacity changes per tick.
function flowFeatures(path) {
const vertices = path.map(([lon, lat]) =>
geo.cartesian3.fromDegrees(lon, lat),
);
const arc = [0];
for (let i = 1; i < vertices.length; i++)
arc.push(
arc[i - 1] + geo.cartesian3.distance(vertices[i - 1], vertices[i]),
);
const total = arc.at(-1),
features = [];
if (!total) return features;
const n = 64 * 6;
let start = path[0],
i = 1;
for (let j = 1; j <= n; j++) {
const endAt = (total * j) / n,
points = [start];
while (i < path.length - 1 && arc[i] < endAt) points.push(path[i++]);
const f = Math.max(
0,
Math.min(1, (endAt - arc[i - 1]) / (arc[i] - arc[i - 1] || 1)),
);
const [lon, lat] = path[i - 1],
[nextLon, nextLat] = path[i];
const delta = ((nextLon - lon + 540) % 360) - 180;
start =
j === n
? path.at(-1)
: [((lon + f * delta + 540) % 360) - 180, lat + f * (nextLat - lat)];
points.push(start);
features.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: points },
properties: { phase: (j - 0.5) / 6 },
});
}
return features;
}
export function createDirectionsNavaraRenderer(engine, { view }) {
const assets = new Map();
let steps = [],
bank,
capacity = 0;
const syncSteps = () => {
if (steps.length > capacity) {
bank?.destroy();
capacity = steps.length;
bank = engine.movers.createSet({
id: 'directions-steps',
kind: 'point',
capacity,
depthTest: 'always',
pickable: true,
point: { sizePx: 8, outlinePx: 2, outlineColor: [0.22, 0.82, 1, 1] },
});
}
if (!bank) return;
steps.forEach((p, i) =>
bank.set(i, {
position: p.position,
show: p.show,
scale: p.pixelSize / 8,
color: [1, 1, 1, 0.95],
pickId: p.id,
}),
);
bank.setCount(steps.length);
bank.commit();
engine.render.request('directions-steps');
};
return {
steps: {
get length() {
return steps.length;
},
get: (i) => steps[i],
add(p) {
steps.push(p);
return p;
},
removeAll() {
steps = [];
syncSteps();
},
sync: syncSteps,
},
marker(letter, point, css) {
const bank = engine.movers.createSet({
id: 'directions-marker-' + letter,
kind: 'sprite',
capacity: 1,
depthTest: 'always',
pickable: false,
sprite: { sizePx: 48, atlas: endpointAtlas(letter, css) },
});
const carto = geo.cartographic.fromDegrees(point.lon, point.lat);
const place = () => {
const h = engine.surface.terrainHeight(carto) ?? 0;
bank.set(0, {
position: geo.cartesian3.fromDegrees(point.lon, point.lat, h),
cell: 'pin',
color: [1, 1, 1, 1],
show: true,
});
bank.setCount(1);
bank.commit();
};
let remove;
try {
place();
remove = engine.camera.moveEnd.addEventListener(place);
} catch (e) {
bank.destroy();
throw e;
}
const handle = {
destroy() {
remove();
bank.destroy();
assets.delete(handle);
},
};
assets.set(handle, true);
return handle;
},
route(path, css) {
const features = flowFeatures(path);
const source = view.addSource({
type: 'geojson',
data: { type: 'FeatureCollection', features },
});
let layer, remove;
try {
layer = view.addLayer({
type: 'vector',
source,
polyline: {
color: parseInt(css.slice(1), 16),
width: 9,
lit: false,
transparent: true,
clampToGround: true,
},
});
layer.on('featureUpdated', ({ evaluator }) => {
const t = (performance.now() / 1000) * 0.55;
evaluator.evaluate(({ properties }) => {
const x = (properties.phase - t) % 1;
return { opacity: (x + 1) % 1 < 0.46 ? 0.95 : 0.18 };
});
});
let frame = -1;
remove = engine.frames.preRender.addEventListener(() => {
const n = Math.floor(performance.now() / 60);
if (n !== frame) {
frame = n;
layer.forceUpdate();
}
});
} catch (e) {
layer?.delete();
source.delete();
throw e;
}
const handle = {
destroy() {
remove?.();
layer.delete();
source.delete();
assets.delete(handle);
},
};
assets.set(handle, true);
return handle;
},
destroy() {
for (const h of assets.keys()) h.destroy();
bank?.destroy();
bank = null;
capacity = 0;
steps = [];
},
};
}
+2
View File
@@ -37,6 +37,7 @@ async function start(next, engineSwitch = false, cockpit = null) {
if (engineSwitch) {
const style = controls.styleManager;
await style.initialRestorePromise;
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.
const target = style.getAircraftTrackingTarget();
@@ -137,6 +138,7 @@ async function reconcile(remember = false) {
id, { entry: entry.module.attachRenderer ? entry : null, enabled: manager.isEffectivelyEnabled(id) },
]))
: null;
if (layerSession.handoffLayers) manager.layers.get('directions')?.module.suspendCamera?.();
renderers.retain(kind);
await application.destroy();
await start(pageEngineKind(), true, cockpit);