mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Move Directions routes and active camera flights between globes
This commit is contained in:
1 parent
5cea3e8cbb
commit
605b9490f3
12 files changed
+568
-63
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -255,7 +255,7 @@ export function createApplicationCatalog({
|
||||
],
|
||||
[
|
||||
'directions',
|
||||
() => createApplicationDirections({ source: sources.directions }),
|
||||
() => sessionModel('directions', () => createApplicationDirections({ source: sources.directions, surface })),
|
||||
],
|
||||
[
|
||||
'recent-imagery',
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -58,6 +58,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
||||
'radio',
|
||||
'transit',
|
||||
'bikeshare',
|
||||
'directions',
|
||||
'recent-imagery',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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 = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user