Draw Street Level, traffic and directions through one display each

- Street Level: coverage sequences are ground corridor sets of up to
  120, draped on terrain and tiles as Cesium's ground polylines were;
  the marker and view cones are sprite movers turned to their compass
  bearing on screen and culled 1 km under WGS84. The layer no longer
  loads the Cesium SDK on Navara.
- Traffic: vehicles are two point mover sets (an outline disc under each
  fill) scaled and faded with Cesium's NearFarScalar maths; congestion
  heat lines are ground corridor sets draped on tiles. Heights, depth,
  readiness and the surface key go through the engine, and Navara no
  longer loads the Cesium SDK for traffic.
- Directions: the route is one ground corridor set in the annotation
  route's dash pattern, maneuvers are point movers and the A/B markers a
  disc and a letter sprite; the in-layer Navara branch and the Cesium
  point collection go.

Known gaps against Cesium, to close in the contract: the traffic heat
lines lose their glow and pulse and the route its flowing dashes
(no animated ground fills yet), and dense Street Level coverage on
Navara is costly as ground corridors (about 80 to 190 ms a frame at
6,000 to 14,400 sequences) until corridors are binned set-wide.

Coupling: Navara renderer lines fall from 3349 to 2767, Cesium-importing
files from 51 to 32, `viewer` lines from 1591 to 1373, Cesium SDK loads
on Navara from 6 to 4.
This commit is contained in:
German Khokhlov committed 2026-11-07 08:03:25 +04:00
1 parent 2fbdca62be
commit 1559bd57f4
78 files changed
+4762 -3719

No files matched your search

+3
View File
@@ -34,6 +34,9 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
// cesium only in render/cesium*.js, three and Navara only in // cesium only in render/cesium*.js, three and Navara only in
// render/navara*.js. The list grows with each layer moved onto renderers. // render/navara*.js. The list grows with each layer moved onto renderers.
const RENDERED_LAYER_DIRS = [ const RENDERED_LAYER_DIRS = [
'src/layers/streetLevel/',
'src/layers/traffic/',
'src/layers/directions/',
'src/layers/infrastructure/', 'src/layers/infrastructure/',
'src/layers/installations/', 'src/layers/installations/',
'src/layers/awareness/', 'src/layers/awareness/',
+9 -9
View File
@@ -1,16 +1,16 @@
{ {
"metrics": { "metrics": {
"cesiumLayerRendererLines": 351, "cesiumLayerRendererLines": 351,
"navaraLayerRendererLines": 3349, "navaraLayerRendererLines": 2767,
"cesiumImportFiles": 51, "cesiumImportFiles": 32,
"viewerLines": 1591, "viewerLines": 1373,
"navaraKindChecks": 8, "navaraKindChecks": 7,
"engineSniffCalls": 14, "engineSniffCalls": 14,
"engineExtensionReads": 18, "engineExtensionReads": 15,
"groundEffectPasses": 4, "groundEffectPasses": 4,
"cesiumSdkLoads": 6, "cesiumSdkLoads": 4,
"screenSpaceHandlerFiles": 6, "screenSpaceHandlerFiles": 4,
"engineNameLines": 101 "engineNameLines": 81
}, },
"measuredAt": "after the installations, Global Context and infrastructure displays, 2026-11-07" "measuredAt": "after the Street Level, traffic and directions displays, 2026-11-07"
} }
+10 -1
View File
@@ -1640,5 +1640,14 @@
"src/layers/installations/navigation.test.mjs", "src/layers/installations/navigation.test.mjs",
"src/layers/infrastructure/index.js", "src/layers/infrastructure/index.js",
"src/layers/infrastructure/session.test.mjs", "src/layers/infrastructure/session.test.mjs",
"src/data/localGeojson.test.mjs" "src/data/localGeojson.test.mjs",
"src/layers/directions/render/display.js",
"src/layers/directions/render/display.test.mjs",
"src/layers/directions/render/style.js",
"src/layers/streetLevel/render/display.js",
"src/layers/streetLevel/render/display.test.mjs",
"src/layers/traffic/render/display.js",
"src/layers/traffic/render/display.test.mjs",
"src/layers/directions/edgeCases.test.mjs",
"src/layers/streetLevel/edgeCases.test.mjs"
] ]
+22 -11
View File
@@ -1914,9 +1914,10 @@
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/geo/", "src/geo/",
"src/globe/contract.js", "src/globe/contract.js",
"src/globe/cesium/engineFromViewer.js" "src/globe/cesium/engineFromViewer.js",
"src/layers/traffic/render/display.js"
], ],
"external": ["cesium", "pbf", "@mapbox/vector-tile"] "external": ["pbf", "@mapbox/vector-tile"]
}, },
"bikeshare-layer": { "bikeshare-layer": {
"exports": ["./layers/bikeshare"], "exports": ["./layers/bikeshare"],
@@ -2085,9 +2086,12 @@
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/layers/streetLevel/providers/mapillary/policy.js", "src/layers/streetLevel/providers/mapillary/policy.js",
"src/geo/" "src/geo/",
"src/layers/shared/color.js",
"src/globe/cameraMath.js",
"src/layers/streetLevel/render/display.js"
], ],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] "external": ["pbf", "@mapbox/vector-tile", "mapillary-js"]
}, },
"alpr-cameras": { "alpr-cameras": {
"exports": ["./layers/alpr"], "exports": ["./layers/alpr"],
@@ -3463,9 +3467,6 @@
"src/layers/awareness/render/navaraPulses.js", "src/layers/awareness/render/navaraPulses.js",
"src/layers/shared/render/navaraStems.js", "src/layers/shared/render/navaraStems.js",
"src/layers/shared/render/navaraSceneDepth.js", "src/layers/shared/render/navaraSceneDepth.js",
"src/layers/directions/render/navara.js",
"src/layers/traffic/render/navara.js",
"src/layers/streetLevel/render/navara.js",
"src/scenes/dataPacks/navara.js", "src/scenes/dataPacks/navara.js",
"src/scenes/dataPacks/navaraGeometry.js", "src/scenes/dataPacks/navaraGeometry.js",
"src/layers/shared/render/navaraStory.js", "src/layers/shared/render/navaraStory.js",
@@ -3578,7 +3579,11 @@
"src/layers/awareness/render/display.js", "src/layers/awareness/render/display.js",
"src/layers/installations/render/namedMarkers.js", "src/layers/installations/render/namedMarkers.js",
"src/layers/installations/render/display.js", "src/layers/installations/render/display.js",
"src/layers/infrastructure/render/display.js" "src/layers/infrastructure/render/display.js",
"src/layers/directions/render/style.js",
"src/layers/directions/render/display.js",
"src/layers/streetLevel/render/display.js",
"src/layers/traffic/render/display.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -4315,7 +4320,11 @@
"src/layers/awareness/render/pulseDisc.js", "src/layers/awareness/render/pulseDisc.js",
"src/layers/awareness/render/display.js", "src/layers/awareness/render/display.js",
"src/layers/installations/render/namedMarkers.js", "src/layers/installations/render/namedMarkers.js",
"src/layers/installations/render/display.js" "src/layers/installations/render/display.js",
"src/layers/directions/render/style.js",
"src/layers/directions/render/display.js",
"src/layers/streetLevel/render/display.js",
"src/layers/traffic/render/display.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -4674,9 +4683,11 @@
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/geo/" "src/geo/",
"src/layers/directions/render/display.js",
"src/layers/directions/render/style.js"
], ],
"external": ["cesium"] "external": []
}, },
"radio-browser-source": { "radio-browser-source": {
"exports": ["./sources/radio-browser"], "exports": ["./sources/radio-browser"],
-1
View File
@@ -311,7 +311,6 @@ export function createApplicationCatalog({
createApplicationTraffic({ createApplicationTraffic({
source: sources.traffic, source: sources.traffic,
surface, surface,
nativeRenderer: (engine) => rendererFor('traffic', engine),
}), }),
), ),
], ],
+6 -2
View File
@@ -2,11 +2,15 @@ import { createApplicationDirectionsLayer } from '../../data/directions.js';
import { rendererFor } from './renderers.js'; import { rendererFor } from './renderers.js';
/** Construct Directions with application scene owners and an explicit route source. */ /**
* Construct Directions with application scene owners and an explicit route
* source; it draws with the engine's renderer from the registry
* (render/display.js on every engine).
*/
export function createApplicationDirections({ source, surface }) { export function createApplicationDirections({ source, surface }) {
return createApplicationDirectionsLayer({ return createApplicationDirectionsLayer({
source, source,
groundFloor: surface?.groundFloor, groundFloor: surface?.groundFloor,
createNativeRenderer: (engine) => rendererFor('directions', engine), createRenderer: (engine) => rendererFor('directions', engine),
}); });
} }
+6
View File
@@ -49,6 +49,9 @@ import { createCablesDisplay } from '../../layers/submarineCables/render/display
import { createInfrastructureDisplay } from '../../layers/infrastructure/render/display.js'; import { createInfrastructureDisplay } from '../../layers/infrastructure/render/display.js';
import { createInstallationsDisplay } from '../../layers/installations/render/display.js'; import { createInstallationsDisplay } from '../../layers/installations/render/display.js';
import { createAwarenessDisplay } from '../../layers/awareness/render/display.js'; import { createAwarenessDisplay } from '../../layers/awareness/render/display.js';
import { createStreetLevelDisplay } from '../../layers/streetLevel/render/display.js';
import { createTrafficDisplay } from '../../layers/traffic/render/display.js';
import { createDirectionsDisplay } from '../../layers/directions/render/display.js';
const ENGINE_KINDS = new Set(['cesium', 'navara']); const ENGINE_KINDS = new Set(['cesium', 'navara']);
@@ -133,6 +136,9 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({
cctv: createCctvDisplay, cctv: createCctvDisplay,
'military-installations': createInstallationsDisplay, 'military-installations': createInstallationsDisplay,
'military-awareness': createAwarenessDisplay, 'military-awareness': createAwarenessDisplay,
'street-level': createStreetLevelDisplay,
traffic: createTrafficDisplay,
directions: createDirectionsDisplay,
}); });
// The other application layers still draw themselves on Cesium. // The other application layers still draw themselves on Cesium.
+6
View File
@@ -85,6 +85,9 @@ test('the layers with extracted renderers', () => {
'cctv', 'cctv',
'military-installations', 'military-installations',
'military-awareness', 'military-awareness',
'street-level',
'traffic',
'directions',
]); ]);
}); });
@@ -111,6 +114,9 @@ test('the layers drawn through one display get the same factory on every engine'
'local-datacenters': ['infrastructure', 'createInfrastructureDisplay'], 'local-datacenters': ['infrastructure', 'createInfrastructureDisplay'],
'local-dams': ['infrastructure', 'createInfrastructureDisplay'], 'local-dams': ['infrastructure', 'createInfrastructureDisplay'],
'military-installations': ['installations', 'createInstallationsDisplay'], 'military-installations': ['installations', 'createInstallationsDisplay'],
'street-level': ['streetLevel', 'createStreetLevelDisplay'],
traffic: ['traffic', 'createTrafficDisplay'],
directions: ['directions', 'createDirectionsDisplay'],
'telegeography-submarine-cables': [ 'telegeography-submarine-cables': [
'submarineCables', 'submarineCables',
'createCablesDisplay', 'createCablesDisplay',
+11 -4
View File
@@ -3,7 +3,7 @@ import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapi
import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js'; import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js';
import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/index.js'; import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/index.js';
import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js'; import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js';
import * as sprites from '../../data/spriteOrder.js'; import { rendererFor } from './renderers.js';
import * as picking from '../../data/pickRegistry.js'; import * as picking from '../../data/pickRegistry.js';
import * as input from '../../data/inputOwnership.js'; import * as input from '../../data/inputOwnership.js';
import * as scenePick from '../../data/scenePick.js'; import * as scenePick from '../../data/scenePick.js';
@@ -29,15 +29,22 @@ function defaultProviders(sources) {
/** /**
* Inject providers, or use the defaults. An explicit provider list replaces * Inject providers, or use the defaults. An explicit provider list replaces
* them; an unused sources.mapillary is neither constructed nor validated, * them; an unused sources.mapillary is neither constructed nor validated,
* including when the list is empty. * including when the list is empty. The layer draws with the engine's
* renderer from the registry (render/display.js on every engine).
*/ */
export function createApplicationStreetLevel({ export function createApplicationStreetLevel({
sources = {}, sources = {},
nativeRenderer, nativeRenderer = (engine) => rendererFor('street-level', engine),
providers = defaultProviders(sources), providers = defaultProviders(sources),
}) { }) {
return createStreetLevelLayer({ return createStreetLevelLayer({
providers, providers,
services: { sprites, picking, input, scenePick, render, nativeRenderer }, services: {
picking,
input,
scenePick,
render,
createDisplay: nativeRenderer,
},
}); });
} }
+8 -3
View File
@@ -2,15 +2,20 @@ import { createTrafficLayer } from '../../layers/traffic/index.js';
import * as credits from '../../data/dataCredits.js'; import * as credits from '../../data/dataCredits.js';
import * as ground from '../../data/groundFloor.js'; import * as ground from '../../data/groundFloor.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
import { rendererFor } from './renderers.js';
/** Construct one layer using the application scene owners and a supplied source. */ /**
export function createApplicationTraffic({ source, surface, nativeRenderer }) { * Construct one layer using the application scene owners and a supplied
* source; it draws with the engine's renderer from the registry
* (render/display.js on every engine).
*/
export function createApplicationTraffic({ source, surface }) {
return createTrafficLayer({ return createTrafficLayer({
source, source,
services: { services: {
credits, credits,
render, render,
nativeRenderer, createRenderer: (engine) => rendererFor('traffic', engine),
ground: surface?.groundFloor || ground, ground: surface?.groundFloor || ground,
}, },
}); });
+9 -17
View File
@@ -153,23 +153,15 @@ export async function registerNavaraLayerRenderers() {
return (engine, { data }) => return (engine, { data }) =>
drawStorySource(engine, engine.extensions.navara, data); drawStorySource(engine, engine.extensions.navara, data);
}, },
'street-level': async () => { 'street-level': async () =>
const { createStreetMap } = (await import('../layers/streetLevel/render/display.js'))
await import('../layers/streetLevel/render/navara.js'); .createStreetLevelDisplay,
return (engine) => createStreetMap(engine, engine.extensions.navara); traffic: async () =>
}, (await import('../layers/traffic/render/display.js'))
traffic: async () => { .createTrafficDisplay,
const { createTrafficNavaraRenderer } = directions: async () =>
await import('../layers/traffic/render/navara.js'); (await import('../layers/directions/render/display.js'))
return (engine) => .createDirectionsDisplay,
createTrafficNavaraRenderer(engine, engine.extensions.navara);
},
directions: async () => {
const { createDirectionsNavaraRenderer } =
await import('../layers/directions/render/navara.js');
return (engine) =>
createDirectionsNavaraRenderer(engine, engine.extensions.navara);
},
'telegeography-submarine-cables': async () => 'telegeography-submarine-cables': async () =>
(await import('../layers/submarineCables/render/display.js')) (await import('../layers/submarineCables/render/display.js'))
.createCablesDisplay, .createCablesDisplay,
+8 -12
View File
@@ -3,21 +3,19 @@ import { createDirectionsSource } from '../layers/directions/source.js';
* @module directions * @module directions
* @description Service bundle for the Directions layer. * @description Service bundle for the Directions layer.
* *
* The layer itself lives in `src/layers/directions/`, which imports nothing * The layer itself lives in `src/layers/directions/` and draws through the
* but Cesium and the pure step formatter. This module is the one place that * globe contract (its render/display.js). This module is the one place that
* names the application's shared services — the render governor, sprite order, * names the application's shared services — the render governor, the pick
* the pick registry, the world overlay, the annotation material, the camera * registry, the ground pick, the world overlay, the camera verbs, the shared
* verbs, the shared ground floor and the pointer arbiter — and it re-exports * ground floor and the pointer arbiter — and it re-exports the layer's pure
* the layer's pure helpers for the tests and the share-link code. * helpers for the tests and the share-link code.
*/ */
import * as credits from './dataCredits.js'; import * as credits from './dataCredits.js';
import * as render from '../renderGovernor.js'; import * as render from '../renderGovernor.js';
import * as sprites from './spriteOrder.js';
import * as picking from './pickRegistry.js'; import * as picking from './pickRegistry.js';
import * as scenePick from './scenePick.js'; import * as scenePick from './scenePick.js';
import * as overlays from '../overlays/worldOverlay.js'; import * as overlays from '../overlays/worldOverlay.js';
import * as annotations from '../annotations/worldAnnotationRenderer.js';
import * as camera from '../cameraVerbs.js'; import * as camera from '../cameraVerbs.js';
import * as ground from './groundFloor.js'; import * as ground from './groundFloor.js';
import * as input from './inputOwnership.js'; import * as input from './inputOwnership.js';
@@ -27,11 +25,9 @@ import { createDirectionsLayer } from '../layers/directions/index.js';
const directionsServices = Object.freeze({ const directionsServices = Object.freeze({
credits, credits,
render, render,
sprites,
picking, picking,
scenePick, scenePick,
overlays, overlays,
annotations,
camera, camera,
ground, ground,
input, input,
@@ -40,13 +36,13 @@ const directionsServices = Object.freeze({
/** Construct one Directions layer over the application scene owners. */ /** Construct one Directions layer over the application scene owners. */
export function createApplicationDirectionsLayer({ export function createApplicationDirectionsLayer({
source = createDirectionsSource(), source = createDirectionsSource(),
createNativeRenderer, createRenderer,
groundFloor = ground, groundFloor = ground,
} = {}) { } = {}) {
return createDirectionsLayer({ return createDirectionsLayer({
services: { ...directionsServices, ground: groundFloor }, services: { ...directionsServices, ground: groundFloor },
source, source,
createNativeRenderer, createRenderer,
}); });
} }
+8 -8
View File
@@ -5,7 +5,6 @@ import { showOsmCredit, hideOsmCredit } from './dataCredits.js';
import { createInfrastructureLayers } from './infrastructure.js'; import { createInfrastructureLayers } from './infrastructure.js';
import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js'; import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js';
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js'; import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
import * as annotations from '../annotations/worldAnnotationRenderer.js';
import { createDirectionsLayer } from '../layers/directions/index.js'; import { createDirectionsLayer } from '../layers/directions/index.js';
import { createDirectionsSource } from '../layers/directions/source.js'; import { createDirectionsSource } from '../layers/directions/source.js';
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js'; import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
@@ -69,22 +68,23 @@ test('voice route geometry introduces the shared OSM credit', (t) => {
}); });
test('Directions introduces the shared OSM credit when the requested route draws', async (t) => { test('Directions introduces the shared OSM credit when the requested route draws', async (t) => {
const v = viewer(t);
t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] })); t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] }));
const engine = createFakeEngine();
const layer = createDirectionsLayer({ source: createDirectionsSource(), services: { const layer = createDirectionsLayer({ source: createDirectionsSource(), services: {
credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {}, credits: { showOsmCredit, hideOsmCredit }, input, scenePick: {}, camera: {},
overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop }, overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop }, render: { governorRequestRender: noop },
sprites: { registerSpriteCollection: noop, unregisterSpriteCollection: noop, restoreSpriteOrder: noop },
picking: { registerPickOwner: noop, unregisterPickOwner: noop }, picking: { registerPickOwner: noop, unregisterPickOwner: noop },
ground: { warmGroundFloor: noop }, ground: { warmGroundFloor: noop },
} }); } });
layer.init(v); layer.enable(v); layer.init(null, { engine }); layer.enable();
layer.placeEndpoint('a', { lat: 0, lon: 0 }); layer.placeEndpoint('b', { lat: 0.01, lon: 0.01 }); layer.placeEndpoint('a', { lat: 0, lon: 0 }); layer.placeEndpoint('b', { lat: 0.01, lon: 0.01 });
for (let i = 0; i < 30; i++) await Promise.resolve(); for (let i = 0; i < 30; i++) await Promise.resolve();
assert.equal(layer.getStats().error, null); assert.equal(layer.getStats().error, null);
assertCredit(v); await layer.destroy(v); assert.ok(engine.fake.credits.some((credit) => credit.onScreen && credit.html.includes('>© OpenStreetMap<')));
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0); layer.destroy();
assert.equal(engine.fake.credits.filter((credit) => credit.onScreen).length, 0);
engine.destroy();
}); });
test('Warendorf webcam display introduces the shared OSM credit', (t) => { test('Warendorf webcam display introduces the shared OSM credit', (t) => {
+47 -13
View File
@@ -141,7 +141,6 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
let server; let server;
const cacheDir = mkdtempSync(path.join(tmpdir(), 'gev-traffic-timing-vite-')); const cacheDir = mkdtempSync(path.join(tmpdir(), 'gev-traffic-timing-vite-'));
let trafficLayer; let trafficLayer;
let viewer;
const pendingDebounce = () => { const pendingDebounce = () => {
const matches = [...timeouts].filter(([, timer]) => timer.delay === 320); const matches = [...timeouts].filter(([, timer]) => timer.delay === 320);
@@ -206,15 +205,18 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
const preRender = eventChannel(); const preRender = eventChannel();
const postRender = eventChannel(); const postRender = eventChannel();
const preUpdate = eventChannel();
const moveEnd = eventChannel(); const moveEnd = eventChannel();
const changed = eventChannel(); const changed = eventChannel();
let longitude = -97.74; let longitude = -97.74;
const cartographic = { latitude: Math.PI / 6, longitude: 0, height: 5000 };
const camera = { const camera = {
moveEnd, moveEnd,
changed, changed,
percentageChanged: 0.5, percentageChanged: 0.5,
positionCartographic: { latitude: Math.PI / 6, longitude: 0, height: 5000 }, livePosition: { x: 0, y: 0, z: 0 },
computeViewRectangle() { pose: () => ({ cartographic: { ...cartographic }, heading: 0, pitch: -Math.PI / 2 }),
viewRectangle() {
const west = longitude * Math.PI / 180; const west = longitude * Math.PI / 180;
return { return {
south: 30 * Math.PI / 180, south: 30 * Math.PI / 180,
@@ -223,31 +225,63 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
east: west + 0.02 * Math.PI / 180, east: west + 0.02 * Math.PI / 180,
}; };
}, },
pickEllipsoid: () => undefined,
}; };
const setLongitude = (next) => { const setLongitude = (next) => {
longitude = next; longitude = next;
camera.positionCartographic.longitude = next * Math.PI / 180; cartographic.longitude = next * Math.PI / 180;
}; };
setLongitude(longitude); setLongitude(longitude);
viewer = { const noSet = () => ({
camera, set() {},
scene: { setCount() {},
commit() {},
replace() {},
show() {},
destroy() {},
});
// An engine on a zero-size canvas: no pick ray (no reticle footprint),
// so the fetch is bounded by the view rectangle.
const engine = {
kind: 'cesium',
host: {
canvas: { clientWidth: 0, clientHeight: 0, width: 0, height: 0 }, canvas: { clientWidth: 0, clientHeight: 0, width: 0, height: 0 },
preRender, size: () => ({ width: 0, height: 0 }),
postRender,
primitives: { add: (primitive) => primitive, remove: () => true },
}, },
camera,
frames: { preUpdate, preRender, postRender },
projector: {
worldToWindow: () => undefined,
viewProjection: (result = new Array(16).fill(0)) => result,
horizonVisible: () => true,
},
picker: { pickPositionSupported: false },
surface: {
supported: false,
terrainHeight: () => 0,
sampleHeight: () => undefined,
tilesLoaded: () => true,
},
maps: {
photorealActive: () => false,
current: () => ({ baseImagery: null, photoreal: null }),
},
render: { request() {} },
credits: { addStatic: () => () => {} },
movers: { createSet: noSet },
ground: { createSet: noSet },
destroy() {},
}; };
performance.clearMarks(); performance.clearMarks();
performance.clearMeasures(); performance.clearMeasures();
trafficLayer.init(viewer); trafficLayer.init(null, { engine });
performance.mark('traffic:stale:mark'); performance.mark('traffic:stale:mark');
performance.measure('traffic:stale:measure', { performance.measure('traffic:stale:measure', {
start: 'traffic:stale:mark', start: 'traffic:stale:mark',
end: 'traffic:stale:mark', end: 'traffic:stale:mark',
}); });
trafficLayer.enable(viewer); trafficLayer.enable();
assert.equal(traffic.getTrafficTimingDiagnostics().marksInstalled, 1); assert.equal(traffic.getTrafficTimingDiagnostics().marksInstalled, 1);
assert.equal(moveEnd.listenerCount(), 2, 'one timing listener plus the production arrival check'); assert.equal(moveEnd.listenerCount(), 2, 'one timing listener plus the production arrival check');
assert.equal(performance.getEntriesByName('traffic:stale:mark').length, 0); assert.equal(performance.getEntriesByName('traffic:stale:mark').length, 0);
@@ -325,7 +359,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
entry.detail?.interactionId !== anchorB.interactionId entry.detail?.interactionId !== anchorB.interactionId
)), 'the canceled/stale B load must never emit a correlated trace'); )), 'the canceled/stale B load must never emit a correlated trace');
} finally { } finally {
trafficLayer?.disable(viewer); trafficLayer?.disable();
await server?.close(); await server?.close();
globalThis.window = originalWindow; globalThis.window = originalWindow;
globalThis.document = originalDocument; globalThis.document = originalDocument;
+69 -98
View File
@@ -1,9 +1,15 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import {
GROUND_HARNESSES,
createFakeGroundHarness,
} from '../../testSupport/groundDisplayEngines.mjs';
import { createApplicationDirectionsLayer } from '../../data/directions.js'; import { createApplicationDirectionsLayer } from '../../data/directions.js';
import { getActiveCameraMotion } from '../../cameraVerbs.js'; import { getActiveCameraMotion } from '../../cameraVerbs.js';
import { createDirectionsNavaraRenderer } from './render/navara.js'; import {
createDirectionsDisplay,
DIRECTIONS_SET_IDS,
} from './render/display.js';
const A = { lat: 60.1699, lon: 24.9384 }; const A = { lat: 60.1699, lon: 24.9384 };
const B = { lat: 60.2055, lon: 24.6559 }; const B = { lat: 60.2055, lon: 24.6559 };
@@ -33,44 +39,7 @@ const payload = (from, to) => ({
], ],
}); });
function installDocument() { function fixture(makeHarness = createFakeGroundHarness) {
const saved = globalThis.document;
const context = () =>
new Proxy(
{},
{ get: (target, key) => (key in target ? target[key] : () => {}) },
);
globalThis.document = {
createElement: () => ({ getContext: context }),
addEventListener() {},
removeEventListener() {},
};
return () => {
if (saved === undefined) delete globalThis.document;
else globalThis.document = saved;
};
}
function nativeView() {
const live = new Set();
return {
live,
view: {
addSource(options) {
const source = {
data: options.data,
delete: () => live.delete(source),
};
live.add(source);
return source;
},
addLayer: () => ({ on() {}, forceUpdate() {}, delete() {} }),
},
};
}
function fixture() {
const restoreDocument = installDocument();
let clock = 0; let clock = 0;
const realNow = performance.now; const realNow = performance.now;
performance.now = () => clock; performance.now = () => clock;
@@ -79,7 +48,7 @@ function fixture() {
getRoute: (request, { signal }) => getRoute: (request, { signal }) =>
new Promise((resolve) => requests.push({ request, signal, resolve })), new Promise((resolve) => requests.push({ request, signal, resolve })),
}; };
const natives = new Map(); const harnesses = new Map();
const layer = createApplicationDirectionsLayer({ const layer = createApplicationDirectionsLayer({
source, source,
groundFloor: { groundFloor: {
@@ -87,38 +56,39 @@ function fixture() {
cachedGroundFloor: () => 0, cachedGroundFloor: () => 0,
warmGroundFloor() {}, warmGroundFloor() {},
}, },
createNativeRenderer(engine) { createRenderer: createDirectionsDisplay,
const native = nativeView();
natives.set(engine, native);
return createDirectionsNavaraRenderer(engine, native);
},
}); });
layer._setOverlayHostForTest({ layer._setOverlayHostForTest({
setEntries() {}, setEntries() {},
setVisible() {}, setVisible() {},
clearSource() {}, clearSource() {},
}); });
const makeEngine = () => const makeEngine = (make = makeHarness) => {
createFakeEngine({ kind: 'navara', canvas: new EventTarget() }); const harness = make();
// The stub Cesium scene has no credit display; the OSM credit goes there.
if (harness.viewer)
harness.viewer.creditDisplay ??= {
addStaticCredit() {},
removeStaticCredit() {},
};
harnesses.set(harness.engine, harness);
return harness.engine;
};
const engine = makeEngine(); const engine = makeEngine();
layer.attachShellServices({}); layer.attachShellServices({});
layer.init(null, { engine }); layer.init(null, { engine });
layer.enable(null); layer.enable();
return { return {
layer, layer,
engine, engine,
makeEngine, makeEngine,
requests, requests,
natives, // The route each engine draws: the first and last point of its route
// The route each engine draws: its live sources' first and last point. // set, once per live set.
drawn(target) { drawn(target) {
return [...natives.get(target).live].map(({ data }) => { const set = harnesses.get(target).set(DIRECTIONS_SET_IDS.route);
const features = data.features; if (!set?.items.length) return [];
return [ return [[set.items[0].positions[0], set.items.at(-1).positions.at(-1)]];
features[0].geometry.coordinates[0],
features.at(-1).geometry.coordinates.at(-1),
];
});
}, },
frames(target, count) { frames(target, count) {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@@ -127,56 +97,57 @@ function fixture() {
} }
}, },
cleanup() { cleanup() {
layer.destroy(null); layer.destroy();
performance.now = realNow; performance.now = realNow;
restoreDocument(); for (const harness of harnesses.values()) harness.destroy();
}, },
}; };
} }
test('edge:directions: a late answer for the route before A/B swapped never replaces the swapped route', async () => { for (const makeHarness of GROUND_HARNESSES)
for (const lateFirst of [false, true]) { test(`edge:directions: a late answer for the route before A/B swapped never replaces the swapped route (${makeHarness().name})`, async () => {
const f = fixture(); for (const lateFirst of [false, true]) {
try { const f = fixture(makeHarness);
f.layer.placeEndpoint('a', A); try {
f.layer.placeEndpoint('b', B); f.layer.placeEndpoint('a', A);
f.layer.setParams({ swap: true }); f.layer.placeEndpoint('b', B);
assert.equal(f.requests.length, 2); f.layer.setParams({ swap: true });
const [before, after] = f.requests; assert.equal(f.requests.length, 2);
assert.equal( const [before, after] = f.requests;
before.signal.aborted,
true,
'the pre-swap request is aborted',
);
assert.deepEqual(after.request.a, B);
assert.deepEqual(after.request.b, A);
// A source that ignores the abort still answers, early or late.
if (lateFirst) {
before.resolve(payload(A, B));
await settle();
assert.equal( assert.equal(
f.drawn(f.engine).length, before.signal.aborted,
0, true,
'the stale route is not drawn', 'the pre-swap request is aborted',
); );
} assert.deepEqual(after.request.a, B);
after.resolve(payload(B, A)); assert.deepEqual(after.request.b, A);
await settle(); // A source that ignores the abort still answers, early or late.
if (!lateFirst) { if (lateFirst) {
before.resolve(payload(A, B)); before.resolve(payload(A, B));
await settle();
assert.equal(
f.drawn(f.engine).length,
0,
'the stale route is not drawn',
);
}
after.resolve(payload(B, A));
await settle(); await settle();
if (!lateFirst) {
before.resolve(payload(A, B));
await settle();
}
assert.deepEqual(f.drawn(f.engine), [
[
[B.lon, B.lat],
[A.lon, A.lat],
],
]);
} finally {
f.cleanup();
} }
assert.deepEqual(f.drawn(f.engine), [
[
[B.lon, B.lat],
[A.lon, A.lat],
],
]);
} finally {
f.cleanup();
} }
} });
});
test('edge:directions: an engine switch mid-FLY continues the same flight on the new engine and the old camera stays still', async () => { test('edge:directions: an engine switch mid-FLY continues the same flight on the new engine and the old camera stays still', async () => {
const f = fixture(); const f = fixture();
+116 -275
View File
@@ -6,8 +6,9 @@
* again). With both ends placed the layer asks the existing `/api/route` * again). With both ends placed the layer asks the existing `/api/route`
* proxy (OSRM on the FOSSGIS servers, © OpenStreetMap contributors) for the * proxy (OSRM on the FOSSGIS servers, © OpenStreetMap contributors) for the
* street-following route with turn-by-turn steps, drapes it on the terrain * street-following route with turn-by-turn steps, drapes it on the terrain
* and 3D tiles with the same flowing dashes the voice "route from A to B" * and 3D tiles in the dashes of the voice "route from A to B" annotation,
* annotation uses, and drops one dot per maneuver. Click a dot for the * and drops one dot per maneuver (render/display.js draws them on the globe
* contract). Click a dot for the
* instruction; "FLY" rides the camera along the route through the shared * instruction; "FLY" rides the camera along the route through the shared
* route-flight cinematic (`flyRoute`), the way voice's `fly_route` does. * route-flight cinematic (`flyRoute`), the way voice's `fly_route` does.
* *
@@ -16,9 +17,9 @@
* route. * route.
*/ */
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { WHITE, rgbaFromCss } from '../shared/color.js'; import { createDirectionsDisplay } from './render/display.js';
import { DIRECTIONS_ROUTE_COLOR } from './render/style.js';
import { roadLabel } from '../../data/routeSteps.js'; import { roadLabel } from '../../data/routeSteps.js';
import { import {
t, t,
@@ -68,16 +69,8 @@ export function presentRoutingFailure(error) {
return error?.message || msg('Routing unavailable'); return error?.message || msg('Routing unavailable');
} }
/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */ /** Route colour: the annotation palette's cyan, so voice routes and Directions match. */
export const DIRECTIONS_ROUTE_COLOR = '#39d0ff'; export { DIRECTIONS_ROUTE_COLOR };
// Engine-neutral colours (src/layers/shared/color.js): a Navara page has no
// Cesium SDK.
const MARKER_A_COLOR = () => rgbaFromCss('#5dff9f');
const MARKER_B_COLOR = () => rgbaFromCss('#ff6b6b');
const STEP_COLOR = () => WHITE.withAlpha(0.95);
const STEP_OUTLINE = () => rgbaFromCss(DIRECTIONS_ROUTE_COLOR);
const STEP_PIXEL_SIZE = 8;
const STEP_SELECTED_PIXEL_SIZE = 13;
/** Route request timeout (ms) — the proxy itself gives OSRM 12 s. */ /** Route request timeout (ms) — the proxy itself gives OSRM 12 s. */
const ROUTE_TIMEOUT_MS = 15_000; const ROUTE_TIMEOUT_MS = 15_000;
@@ -770,7 +763,7 @@ export function directionsStepCopy(steps, index) {
/** /**
* Shared-host card for the selected maneuver. * Shared-host card for the selected maneuver.
* @param {number} index * @param {number} index
* @param {Cesium.Cartesian3} position * @param {{x:number, y:number, z:number}} position src/geo Cartesian3.
* @param {{title:string, details:string[]}} copy * @param {{title:string, details:string[]}} copy
* @returns {object|null} * @returns {object|null}
*/ */
@@ -859,14 +852,17 @@ export function normalizeRoutePayload(payload, mode) {
* Each catalog builds its own, so two applications on a page never share the * Each catalog builds its own, so two applications on a page never share the
* route, the markers, the pointer claim or the flight this layer owns. The * route, the markers, the pointer claim or the flight this layer owns. The
* shared services are injected, which is what keeps this module's own imports * shared services are injected, which is what keeps this module's own imports
* down to Cesium and the pure step formatter. * down to the globe contract display and the pure step formatter.
* @param {{services: object, source: {getRoute: Function}}} options * @param {{services: object, source: {getRoute: Function},
* createRenderer?: (engine: object) => object|null}} options
* `createRenderer` builds the display on an engine (render/display.js by
* default).
* @returns {object} The layer module the data manager registers. * @returns {object} The layer module the data manager registers.
*/ */
export function createDirectionsLayer({ export function createDirectionsLayer({
services, services,
source, source,
createNativeRenderer, createRenderer = createDirectionsDisplay,
}) { }) {
if (typeof source?.getRoute !== 'function') if (typeof source?.getRoute !== 'function')
throw new TypeError('Directions require a route source'); throw new TypeError('Directions require a route source');
@@ -883,9 +879,9 @@ export function createDirectionsLayer({
let _overlayHost = DEFAULT_OVERLAY_HOST; let _overlayHost = DEFAULT_OVERLAY_HOST;
// --- Module state --- // --- Module state ---
let _viewer = null;
let _engine = null; let _engine = null;
let _native = null; /** The display on the bound engine (render/display.js), or null. */
let _display = null;
let _suspendedFlight = null; let _suspendedFlight = null;
let _enabled = false; let _enabled = false;
let _mode = DEFAULT_DIRECTIONS_MODE; let _mode = DEFAULT_DIRECTIONS_MODE;
@@ -903,13 +899,11 @@ export function createDirectionsLayer({
let _routeSeq = 0; let _routeSeq = 0;
let _routeAbort = null; let _routeAbort = null;
let _lastUpdate = null; let _lastUpdate = null;
let _markerA = null; /** Maneuver dot index → its anchored position, or null while unresolved. */
let _markerB = null; const _stepAnchors = new Map();
let _routeEntity = null;
let _stepPoints = null;
let _selectedStep = null; let _selectedStep = null;
let _clickHandler = null; let _listening = false;
let _renderHeld = false; let _removeClick = null;
let _rowControlsListener = null; let _rowControlsListener = null;
let _dataManager = null; let _dataManager = null;
/** Set when arming failed because another tool holds the pointer. */ /** Set when arming failed because another tool holds the pointer. */
@@ -966,21 +960,10 @@ export function createDirectionsLayer({
_dataManager?.refreshLayerStats?.(); _dataManager?.refreshLayerStats?.();
} }
function syncRenderHold() {
const shouldHold = _enabled && Boolean(_routeEntity);
if (shouldHold && !_renderHeld) {
services.render.holdContinuousRender('directions');
_renderHeld = true;
} else if (!shouldHold && _renderHeld) {
services.render.releaseContinuousRender('directions');
_renderHeld = false;
}
}
function pickGround(screenPosition) { function pickGround(screenPosition) {
const cartesian = _native const cartesian = _engine
? _engine.picker.pickGlobe(screenPosition) ? services.scenePick.pickGroundPosition(_engine, screenPosition)
: services.scenePick.pickGroundPosition(_viewer, screenPosition); : null;
if (!cartesian) return null; if (!cartesian) return null;
const carto = geo.cartographic.fromCartesian(cartesian); const carto = geo.cartographic.fromCartesian(cartesian);
if (!carto) return null; if (!carto) return null;
@@ -990,61 +973,27 @@ export function createDirectionsLayer({
}; };
} }
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),
point: {
pixelSize: 14,
color,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
label: {
text: letter,
font: 'bold 13px "JetBrains Mono", "SF Mono", monospace',
fillColor: Cesium.Color.WHITE,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(0, -20),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
}
function removeEntity(entity) {
if (_native) {
entity?.destroy();
return;
}
if (entity && _viewer && !_viewer.isDestroyed?.())
_viewer.entities.remove(entity);
}
function placeMarker(letter, point) { function placeMarker(letter, point) {
if (!_viewer && !_native) return; _display?.setMarker(letter, point);
if (letter === 'a') { }
removeEntity(_markerA);
_markerA = markerEntity('A', point, MARKER_A_COLOR()); /** The maneuver dots as the display draws them: anchored or hidden. */
} else { function pushSteps() {
removeEntity(_markerB); _display?.setSteps(
_markerB = markerEntity('B', point, MARKER_B_COLOR()); stepMarkerIndices(_route?.steps).map((index) => ({
} index,
position: _stepAnchors.get(index) ?? null,
})),
);
} }
function clearRouteGraphics() { function clearRouteGraphics() {
services.credits?.hideOsmCredit?.(_viewer ?? _engine, 'directions'); services.credits?.hideOsmCredit?.(_engine, 'directions');
_clearStepSelection(); _clearStepSelection();
stopStepAnchoring(); stopStepAnchoring();
removeEntity(_routeEntity); _display?.setRoute(null);
_routeEntity = null; _stepAnchors.clear();
_stepPoints?.removeAll(); _display?.setSteps([]);
syncRenderHold();
} }
function stopStepAnchoring() { function stopStepAnchoring() {
@@ -1062,32 +1011,28 @@ export function createDirectionsLayer({
*/ */
function anchorStepPoints() { function anchorStepPoints() {
_anchorTimer = null; _anchorTimer = null;
if (!_stepPoints || !_route || !_enabled) return; if (!_display || !_route || !_enabled) return;
const steps = _route.steps; const steps = _route.steps;
const pending = []; const pending = [];
let unresolved = 0; let unresolved = 0;
for (const index of stepMarkerIndices(steps)) { for (const index of stepMarkerIndices(steps)) {
const step = steps[index]; const step = steps[index];
const point = findStepPoint(index);
if (!point) continue;
const height = stepMarkerHeightM( const height = stepMarkerHeightM(
services.ground.cachedGroundFloor(step.lat, step.lon), services.ground.cachedGroundFloor(step.lat, step.lon),
services.ground.GROUND_FLOOR_LIFT_M, services.ground.GROUND_FLOOR_LIFT_M,
); );
if (height === null) { if (height === null) {
unresolved += 1; unresolved += 1;
point.show = false; _stepAnchors.set(index, null);
pending.push({ lat: step.lat, lon: step.lon }); pending.push({ lat: step.lat, lon: step.lon });
continue; continue;
} }
point.position = Cesium.Cartesian3.fromDegrees( _stepAnchors.set(
step.lon, index,
step.lat, geo.cartesian3.fromDegrees(step.lon, step.lat, height),
height,
); );
point.show = true;
} }
_native?.steps.sync(); pushSteps();
// A card already open on a step that has just been anchored moves with it. // A card already open on a step that has just been anchored moves with it.
if (_selectedStep !== null) refreshStepCard(_selectedStep); if (_selectedStep !== null) refreshStepCard(_selectedStep);
services.render.governorRequestRender('directions-anchor'); services.render.governorRequestRender('directions-anchor');
@@ -1108,56 +1053,22 @@ export function createDirectionsLayer({
function drawRoute(route) { function drawRoute(route) {
clearRouteGraphics(); clearRouteGraphics();
if (!_viewer && !_native) return; if (!_display) return;
if (_native) _display.setRoute(route.geometry);
_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 // One dot per decision; A and B already mark departure and arrival. The dots
// start hidden and appear as anchorStepPoints resolves each ground cell. // start hidden and appear as anchorStepPoints resolves each ground cell.
const cells = []; const cells = [];
for (const index of stepMarkerIndices(route.steps)) { for (const index of stepMarkerIndices(route.steps)) {
const step = route.steps[index]; const step = route.steps[index];
cells.push({ lat: step.lat, lon: step.lon }); cells.push({ lat: step.lat, lon: step.lon });
_stepPoints.add({ _stepAnchors.set(index, null);
id: `directions:step:${index}`,
position: Cesium.Cartesian3.fromDegrees(step.lon, step.lat, 0),
color: STEP_COLOR(),
pixelSize: STEP_PIXEL_SIZE,
outlineColor: STEP_OUTLINE(),
outlineWidth: 2,
show: false,
// The shared ground floor is a coarse cell, and on the photoreal stack
// the rendered mesh can stand above it. Keeping the dot always visible
// is the same choice every other anchored sprite in the app makes.
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
} }
services.credits?.showOsmCredit?.(_viewer ?? _engine, 'directions'); pushSteps();
services.credits?.showOsmCredit?.(_engine, 'directions');
services.ground.warmGroundFloor(cells); services.ground.warmGroundFloor(cells);
_anchorAttempts = 0; _anchorAttempts = 0;
_anchorStartedAt = Date.now(); _anchorStartedAt = Date.now();
anchorStepPoints(); anchorStepPoints();
syncRenderHold();
if (_viewer) services.sprites.restoreSpriteOrder(_viewer);
services.render.governorRequestRender('directions-route'); services.render.governorRequestRender('directions-route');
} }
@@ -1265,8 +1176,8 @@ export function createDirectionsLayer({
* Place one endpoint at a picked ground point and reroute when both are set. * Place one endpoint at a picked ground point and reroute when both are set.
* *
* The globe click handler is the only caller in the running app; it is * The globe click handler is the only caller in the running app; it is
* exported because a ScreenSpaceEventHandler needs a real canvas, and the * exported because the ownership timing this function decides is exactly
* ownership timing this function decides is exactly what has to be tested. * what has to be tested without a click.
* @param {'a'|'b'} which Which endpoint to place. * @param {'a'|'b'} which Which endpoint to place.
* @param {{lat: number, lon: number}} point Ground point in degrees. * @param {{lat: number, lon: number}} point Ground point in degrees.
* @returns {boolean} Whether the endpoint was placed. * @returns {boolean} Whether the endpoint was placed.
@@ -1343,10 +1254,8 @@ export function createDirectionsLayer({
_route = null; _route = null;
_status = 'idle'; _status = 'idle';
_error = null; _error = null;
removeEntity(_markerA); placeMarker('a', null);
removeEntity(_markerB); placeMarker('b', null);
_markerA = null;
_markerB = null;
clearRouteGraphics(); clearRouteGraphics();
services.render.governorRequestRender('directions-clear'); services.render.governorRequestRender('directions-clear');
} }
@@ -1419,7 +1328,7 @@ export function createDirectionsLayer({
typeof seams?.floorFn === 'function' ? seams.floorFn : null, typeof seams?.floorFn === 'function' ? seams.floorFn : null,
// One camera owner: the UI shell's immediate-navigation facade stamps the // One camera owner: the UI shell's immediate-navigation facade stamps the
// navigation generation and releases whatever held the camera before the // navigation generation and releases whatever held the camera before the
// flight starts. Without the shell (tests, a bare viewer) the flight still // flight starts. Without the shell (tests, a bare engine) the flight still
// runs, it just has nothing to take the camera from. // runs, it just has nothing to take the camera from.
typeof seams?.runNavigation === 'function' ? seams.runNavigation : null, typeof seams?.runNavigation === 'function' ? seams.runNavigation : null,
typeof seams?.warmFn === 'function' ? seams.warmFn : null, typeof seams?.warmFn === 'function' ? seams.warmFn : null,
@@ -1438,25 +1347,11 @@ export function createDirectionsLayer({
// --- Step selection --- // --- Step selection ---
function _clearStepSelection() { function _clearStepSelection() {
if (_selectedStep !== null && _stepPoints) { if (_selectedStep !== null) _display?.selectStep(null);
const point = findStepPoint(_selectedStep);
if (point) point.pixelSize = STEP_PIXEL_SIZE;
_native?.steps.sync();
}
_selectedStep = null; _selectedStep = null;
_overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID); _overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID);
} }
function findStepPoint(index) {
if (!_stepPoints) return null;
const id = `directions:step:${index}`;
for (let i = 0; i < _stepPoints.length; i += 1) {
const point = _stepPoints.get(i);
if (point.id === id) return point;
}
return null;
}
/** /**
* Where a step's card should hang, or null while that is not yet known. * Where a step's card should hang, or null while that is not yet known.
* *
@@ -1466,13 +1361,13 @@ export function createDirectionsLayer({
* the same shared floor, and a step whose cell is still cold has no anchor at * the same shared floor, and a step whose cell is still cold has no anchor at
* all rather than one at sea level. * all rather than one at sea level.
* @param {number} index Step index. * @param {number} index Step index.
* @returns {Cesium.Cartesian3|null} * @returns {{x:number, y:number, z:number}|null} src/geo Cartesian3.
*/ */
function stepAnchorPosition(index) { function stepAnchorPosition(index) {
const step = _route?.steps?.[index]; const step = _route?.steps?.[index];
if (!step) return null; if (!step) return null;
const point = findStepPoint(index); const drawn = _display?.stepPosition(index);
if (point?.show) return Cesium.Cartesian3.clone(point.position); if (drawn) return drawn;
const height = stepMarkerHeightM( const height = stepMarkerHeightM(
services.ground.cachedGroundFloor(step.lat, step.lon), services.ground.cachedGroundFloor(step.lat, step.lon),
services.ground.GROUND_FLOOR_LIFT_M, services.ground.GROUND_FLOOR_LIFT_M,
@@ -1481,7 +1376,7 @@ export function createDirectionsLayer({
services.ground.warmGroundFloor([{ lat: step.lat, lon: step.lon }]); services.ground.warmGroundFloor([{ lat: step.lat, lon: step.lon }]);
return null; return null;
} }
return Cesium.Cartesian3.fromDegrees(step.lon, step.lat, height); return geo.cartesian3.fromDegrees(step.lon, step.lat, height);
} }
function refreshStepCard(index) { function refreshStepCard(index) {
@@ -1518,21 +1413,16 @@ export function createDirectionsLayer({
return false; return false;
} }
_selectedStep = index; _selectedStep = index;
const point = findStepPoint(index); _display?.selectStep(index);
if (point) point.pixelSize = STEP_SELECTED_PIXEL_SIZE;
_native?.steps.sync();
services.render.governorRequestRender('directions-select'); services.render.governorRequestRender('directions-select');
return true; return true;
} }
function stepIndexFromPick(picked) { function stepIndexFromPick(picked) {
const candidates = [picked?.primitive?.id, picked?.id]; const match =
for (const candidate of candidates) { typeof picked?.id === 'string' &&
if (typeof candidate !== 'string') continue; /^directions:step:(\d+)$/.exec(picked.id);
const match = /^directions:step:(\d+)$/.exec(candidate); return match ? Number(match[1]) : null;
if (match) return Number(match[1]);
}
return null;
} }
function _onKeyDown(event) { function _onKeyDown(event) {
@@ -1545,58 +1435,40 @@ export function createDirectionsLayer({
} }
} }
function _installClickHandler(viewer) { function _onClick(click) {
if (_clickHandler) return; if (!_enabled) return;
const handle = (click) => { // Placement acts only while this layer actually HOLDS the pointer, and the
if (!_enabled) return; // maneuver-dot selection below is an ambient handler like any other: it
// Placement acts only while this layer actually HOLDS the pointer, and the // yields the moment a tool (this one included) owns the click.
// maneuver-dot selection below is an ambient handler like any other: it // The lease, not the owner name: a superseded instance of this layer must
// yields the moment a tool (this one included) owns the click. // not act on a click its replacement owns.
// The lease, not the owner name: a superseded instance of this layer must if (_armed && services.input.isLeaseCurrent(_pointerClaim)) {
// not act on a click its replacement owns. const point = pickGround(click.position);
if (_armed && services.input.isLeaseCurrent(_pointerClaim)) { if (!point) return;
const point = pickGround(click.position); placeDirectionsEndpoint(_armed, point);
if (!point) return; return;
placeDirectionsEndpoint(_armed, point);
return;
}
if (!services.input.isPointerFree()) return;
let picked = null;
try {
picked = _native ? click.picked : viewer.scene.pick(click.position);
} catch {
picked = null;
}
const index = stepIndexFromPick(picked);
if (index !== null) {
_selectStep(index);
return;
}
if (_selectedStep !== null) _clearStepSelection();
};
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 (!services.input.isPointerFree()) return;
const index = stepIndexFromPick(click.picked);
if (index !== null) {
_selectStep(index);
return;
}
if (_selectedStep !== null) _clearStepSelection();
}
function _installClickHandler() {
if (_listening) return;
_listening = true;
_removeClick = _display?.onClick(_onClick) ?? null;
if (typeof document !== 'undefined') if (typeof document !== 'undefined')
document.addEventListener('keydown', _onKeyDown); document.addEventListener('keydown', _onKeyDown);
} }
function _removeClickHandler() { function _removeClickHandler() {
if (_clickHandler) { _listening = false;
_clickHandler.destroy(); _removeClick?.();
_clickHandler = null; _removeClick = null;
}
// Teardown can outlive the document (page unload, and every headless test). // Teardown can outlive the document (page unload, and every headless test).
if (typeof document !== 'undefined') if (typeof document !== 'undefined')
document.removeEventListener('keydown', _onKeyDown); document.removeEventListener('keydown', _onKeyDown);
@@ -1614,31 +1486,21 @@ export function createDirectionsLayer({
updateInterval: 0, updateInterval: 0,
/** /**
* Create the maneuver-dot collection. Called once at bootstrap. * Bind the engine the display draws on. Called once at bootstrap.
* @param {Cesium.Viewer} viewer * @param {unknown} _scene Unused: the layer draws through `engine`.
* @param {{engine?: object|null}} [options]
*/ */
init(viewer, options) { init(_scene, options) {
return this.attachRenderer(viewer, options); return this.attachRenderer(_scene, options);
}, },
attachRenderer(viewer, { engine = null } = {}) { attachRenderer(_scene, { engine = null } = {}) {
_viewer = viewer;
_engine = engine; _engine = engine;
if (engine?.kind === 'navara') { _display = engine ? createRenderer(engine) : null;
_native = createNativeRenderer(engine); _display?.show(_enabled);
_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); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, _enabled);
if (!_enabled) return; if (!_enabled) return;
const step = _selectedStep; const step = _selectedStep;
this.enable(viewer); this.enable();
if (_a) placeMarker('a', _a); if (_a) placeMarker('a', _a);
if (_b) placeMarker('b', _b); if (_b) placeMarker('b', _b);
if (_route) drawRoute(_route); if (_route) drawRoute(_route);
@@ -1654,52 +1516,35 @@ export function createDirectionsLayer({
const selected = _selectedStep; const selected = _selectedStep;
clearRouteGraphics(); clearRouteGraphics();
_selectedStep = selected; _selectedStep = selected;
removeEntity(_markerA);
removeEntity(_markerB);
_markerA = _markerB = null;
_removeClickHandler(); _removeClickHandler();
services.picking.unregisterPickOwner('directions'); services.picking.unregisterPickOwner('directions');
if (_native) _native.destroy(); _display?.destroy();
else if (_stepPoints && _viewer) {
services.sprites.unregisterSpriteCollection('directions', _stepPoints);
_viewer.scene.primitives.remove(_stepPoints);
}
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false);
_stepPoints = _viewer = _engine = _native = null; _display = _engine = null;
}, },
/** /** Show the row chips and listen for placement clicks. */
* Show the row chips and listen for placement clicks. enable() {
* @param {Cesium.Viewer} viewer
*/
enable(viewer) {
_enabled = true; _enabled = true;
_pointerBlocked = false; _pointerBlocked = false;
if (_stepPoints) _stepPoints.show = true; _display?.show(true);
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, true); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, true);
_installClickHandler(viewer); _installClickHandler();
services.picking.registerPickOwner( services.picking.registerPickOwner(
'directions', 'directions',
(pickedId) => (pickedId) =>
typeof pickedId === 'string' && pickedId.startsWith('directions:'), typeof pickedId === 'string' && pickedId.startsWith('directions:'),
); );
syncRenderHold();
if (viewer) services.sprites.restoreSpriteOrder(viewer);
}, },
/** /** Remove the route, markers and listeners. */
* Remove the route, markers and listeners. disable() {
* @param {Cesium.Viewer} viewer
*/
disable(viewer) {
_enabled = false; _enabled = false;
clearAll(); clearAll();
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false); _overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false);
_removeClickHandler(); _removeClickHandler();
services.picking.unregisterPickOwner('directions'); services.picking.unregisterPickOwner('directions');
if (_stepPoints) _stepPoints.show = false; _display?.show(false);
syncRenderHold();
void viewer;
}, },
/** Nothing to poll: routes are requested on placement and mode change. */ /** Nothing to poll: routes are requested on placement and mode change. */
@@ -1797,12 +1642,9 @@ export function createDirectionsLayer({
} }
}, },
/** /** Tear down entirely. */
* Tear down entirely. destroy() {
* @param {Cesium.Viewer} viewer if (_enabled) this.disable();
*/
destroy(viewer) {
if (_enabled) this.disable(viewer);
// disable() has already run these, but destroy() must also be safe on a // disable() has already run these, but destroy() must also be safe on a
// layer that was never enabled: a claim or a flight left behind would // layer that was never enabled: a claim or a flight left behind would
// outlive the whole application. // outlive the whole application.
@@ -1814,14 +1656,13 @@ export function createDirectionsLayer({
_rowControlsListener = null; _rowControlsListener = null;
this.detachRenderer(); this.detachRenderer();
_overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID); _overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID);
_viewer = null;
}, },
/** /**
* Place one endpoint at a picked ground point. The globe click handler is * Place one endpoint at a picked ground point. The globe click handler is
* the only caller in the running app; it is on the instance because a * the only caller in the running app; it is on the instance because the
* ScreenSpaceEventHandler needs a real canvas, and the ownership timing * ownership timing this decides is exactly what has to be tested without
* this decides is exactly what has to be tested. * a click.
*/ */
placeEndpoint: placeDirectionsEndpoint, placeEndpoint: placeDirectionsEndpoint,
+344
View File
@@ -0,0 +1,344 @@
// Directions on the globe contract, the same on every engine:
// - the route: one engine.ground corridor set (9 px, draped on terrain and
// 3D tiles), its dash cells lit and dim as the route flow material draws
// them at rest;
// - the maneuver dots: one point mover set over the scene, white with a
// route-colour outline, picked as `directions:step:<index>`; a dot without
// a ground anchor yet is hidden;
// - the A and B markers: a point mover set for the discs and a sprite set
// for their letters, over the scene, at the rendered surface under each
// marker (sampled again whenever the camera comes to rest);
// - clicks through engine.input with the engine's pick.
// Placement, routing, anchoring, selection and the route flight stay in the
// layer.
import * as geo from '../../../geo/index.js';
import {
MARKER_COLORS,
MARKER_LETTER_FONT,
MARKER_LETTER_OFFSET_PX,
MARKER_LETTER_OUTLINE_PX,
MARKER_LETTER_SPRITE_PX,
MARKER_OUTLINE_COLOR,
MARKER_OUTLINE_PX,
MARKER_PIXEL_SIZE,
ORDER,
ROUTE_DASH_CELLS,
ROUTE_DASH_COLOR,
ROUTE_DASH_DUTY,
ROUTE_GAP_COLOR,
ROUTE_WIDTH_PX,
STEP_COLOR,
STEP_OUTLINE_COLOR,
STEP_OUTLINE_PX,
STEP_PIXEL_SIZE,
STEP_SELECTED_PIXEL_SIZE,
} from './style.js';
const ID = 'directions';
export const DIRECTIONS_SET_IDS = Object.freeze({
route: `${ID}-route`,
steps: `${ID}-steps`,
markers: `${ID}-markers`,
letters: `${ID}-marker-letters`,
});
const LETTERS = ['a', 'b'];
const FIRST_STEP_CAPACITY = 16;
/** The pickId of maneuver dot `index`. */
export const stepPickId = (index) => `${ID}:step:${index}`;
const wrapLon = (lon) => ((lon + 540) % 360) - 180;
/**
* The route cut into its dash cells, lit and dim in turn: each of
* ROUTE_DASH_CELLS equal lengths along the route is lit for its first
* ROUTE_DASH_DUTY and dim for the rest. Every original vertex is kept.
* @param {number[][]} path [lon, lat] pairs.
* @returns {{ positions: number[][], lit: boolean }[]}
*/
export function routeDashPieces(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);
if (!(total > 0)) return [];
// The point at arc length `at` on segment i - 1 .. i.
const pointAt = (i, at) => {
const f = Math.max(
0,
Math.min(1, (at - arc[i - 1]) / (arc[i] - arc[i - 1] || 1)),
);
const [lon, lat] = path[i - 1];
const [nextLon, nextLat] = path[i];
const delta = wrapLon(nextLon - lon);
return [wrapLon(lon + f * delta), lat + f * (nextLat - lat)];
};
const cuts = [];
for (let cell = 0; cell < ROUTE_DASH_CELLS; cell++) {
cuts.push({ at: (total * (cell + ROUTE_DASH_DUTY)) / ROUTE_DASH_CELLS });
cuts.push({ at: (total * (cell + 1)) / ROUTE_DASH_CELLS });
}
const pieces = [];
let start = path[0];
let i = 1;
cuts.forEach(({ at }, index) => {
const last = index === cuts.length - 1;
const positions = [start];
while (i < path.length - 1 && arc[i] < at) positions.push(path[i++]);
start = last ? path.at(-1) : pointAt(i, at);
positions.push(start);
pieces.push({ positions, lit: index % 2 === 0 });
});
return pieces;
}
/** The letters' atlas: one cell per marker, the glyph above its centre. */
function letterAtlas() {
const document = globalThis.document;
const canvas = document?.createElement?.('canvas');
const ctx = canvas?.getContext?.('2d');
if (!ctx) return null;
const size = MARKER_LETTER_SPRITE_PX;
canvas.width = size * LETTERS.length;
canvas.height = size;
ctx.font = MARKER_LETTER_FONT;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineJoin = 'round';
ctx.lineWidth = MARKER_LETTER_OUTLINE_PX * 2;
ctx.strokeStyle = 'black';
ctx.fillStyle = 'white';
const cells = {};
LETTERS.forEach((letter, index) => {
const x = size * index + size / 2;
const y = size / 2 - MARKER_LETTER_OFFSET_PX;
ctx.strokeText(letter.toUpperCase(), x, y);
ctx.fillText(letter.toUpperCase(), x, y);
cells[letter] = [
index / LETTERS.length,
0,
(index + 1) / LETTERS.length,
1,
];
});
return { canvas, cells };
}
/**
* @param {import('../../../globe/contract.js').GlobeEngine} engine
*/
export function createDirectionsDisplay(engine) {
if (!engine?.ground || !engine?.movers || !engine?.input)
throw new TypeError('The Directions display needs a globe engine');
const markers = engine.movers.createSet({
id: DIRECTIONS_SET_IDS.markers,
kind: 'point',
order: ORDER.markers,
capacity: LETTERS.length,
depthTest: 'always',
pickable: false,
point: {
sizePx: MARKER_PIXEL_SIZE,
outlinePx: MARKER_OUTLINE_PX,
outlineColor: MARKER_OUTLINE_COLOR,
},
});
const atlas = letterAtlas();
const letters = atlas
? engine.movers.createSet({
id: DIRECTIONS_SET_IDS.letters,
kind: 'sprite',
order: ORDER.letters,
capacity: LETTERS.length,
depthTest: 'always',
pickable: false,
sprite: { sizePx: MARKER_LETTER_SPRITE_PX, atlas },
})
: null;
let steps = null;
let stepCapacity = 0;
let route = null;
let visible = true;
let destroyed = false;
/** Marker letter → { lon, lat } or null. */
const ends = { a: null, b: null };
/** Dot rows: { index, position | null }. */
let stepRows = [];
let selected = null;
const own = () => [markers, letters, steps].filter(Boolean);
// The rendered surface under a marker (tiles, else terrain), without the
// display's own markers.
function groundPosition({ lon, lat }) {
const carto = geo.cartographic.fromDegrees(lon, lat);
let height;
try {
height = engine.surface.sampleHeightDetailed(carto, {
exclude: own(),
})?.height;
} catch {
height = undefined;
}
return geo.cartesian3.fromDegrees(
lon,
lat,
Number.isFinite(height) ? height : 0,
);
}
function placeMarkers() {
if (destroyed) return;
LETTERS.forEach((letter, index) => {
const point = ends[letter];
const show = visible && Boolean(point);
const position = point ? groundPosition(point) : undefined;
markers.set(index, {
...(position ? { position } : {}),
color: MARKER_COLORS[letter],
show,
});
letters?.set(index, {
...(position ? { position } : {}),
cell: letter,
show,
});
});
markers.setCount(LETTERS.length);
markers.commit();
if (letters) {
letters.setCount(LETTERS.length);
letters.commit();
}
}
function writeSteps() {
if (stepRows.length > stepCapacity) {
steps?.destroy();
stepCapacity = Math.max(FIRST_STEP_CAPACITY, stepRows.length);
steps = engine.movers.createSet({
id: DIRECTIONS_SET_IDS.steps,
kind: 'point',
order: ORDER.steps,
capacity: stepCapacity,
// The shared ground floor is a coarse cell, and on the photoreal
// stack the rendered mesh can stand above it: the dots draw over
// the scene, as every other anchored sprite in the app does.
depthTest: 'always',
pickable: true,
point: {
sizePx: STEP_PIXEL_SIZE,
outlinePx: STEP_OUTLINE_PX,
outlineColor: STEP_OUTLINE_COLOR,
},
});
}
if (!steps) return;
stepRows.forEach((row, i) =>
steps.set(i, {
...(row.position ? { position: row.position } : {}),
color: STEP_COLOR,
scale:
row.index === selected
? STEP_SELECTED_PIXEL_SIZE / STEP_PIXEL_SIZE
: 1,
show: visible && Boolean(row.position),
pickId: stepPickId(row.index),
}),
);
steps.setCount(stepRows.length);
steps.commit();
}
const removeMoveEnd = engine.camera.moveEnd.addEventListener(() => {
if (ends.a || ends.b) placeMarkers();
});
placeMarkers();
return {
/** Places marker `letter` ('a' or 'b') at { lon, lat }, or removes it (null). */
setMarker(letter, point) {
if (destroyed || !LETTERS.includes(letter)) return;
ends[letter] = point ? { lon: point.lon, lat: point.lat } : null;
placeMarkers();
},
/** Draws the route ([lon, lat] pairs), or removes it (null). */
setRoute(geometry) {
if (destroyed) return;
route?.destroy();
route = null;
const pieces = geometry ? routeDashPieces(geometry) : [];
if (!pieces.length) return;
route = engine.ground.createSet({
id: DIRECTIONS_SET_IDS.route,
kind: 'corridor',
drape: 'both',
pickable: false,
});
route.replace(
pieces.map(({ positions, lit }) => ({
positions,
color: lit ? ROUTE_DASH_COLOR : ROUTE_GAP_COLOR,
widthPx: ROUTE_WIDTH_PX,
})),
);
route.commit();
route.show(visible);
},
/**
* The maneuver dots, in order: { index, position } with position a
* src/geo Cartesian3, or null while the dot has no anchor (hidden).
*/
setSteps(rows) {
if (destroyed) return;
stepRows = rows.map(({ index, position }) => ({
index,
position: position ? geo.cartesian3.clone(position) : null,
}));
if (!stepRows.some((row) => row.index === selected)) selected = null;
writeSteps();
},
/** Draws dot `index` at its selected size (null: none). */
selectStep(index) {
if (destroyed) return;
selected = Number.isInteger(index) ? index : null;
writeSteps();
},
/** Where dot `index` is drawn, or null while it is hidden or absent. */
stepPosition(index) {
const row = stepRows.find((entry) => entry.index === index);
return row?.position && visible
? geo.cartesian3.clone(row.position)
: null;
},
/** Shows or hides everything the display draws. */
show(next) {
if (destroyed) return;
visible = Boolean(next);
route?.show(visible);
placeMarkers();
writeSteps();
},
/** Listens for clicks with the engine's pick; returns the remover. */
onClick(listener) {
return engine.input.on('click', listener, { pick: true });
},
destroy() {
if (destroyed) return;
destroyed = true;
removeMoveEnd();
route?.destroy();
steps?.destroy();
markers.destroy();
letters?.destroy();
route = steps = null;
stepRows = [];
},
};
}
@@ -0,0 +1,389 @@
// The Directions display on the engine contract, on the fake engine and on
// the Cesium engine (real Cesium point and billboard collections and the
// engine's ground batches over fake primitives): the route's dash cells,
// width, colours and drape; the maneuver dots' style, anchoring, selection
// and picks; the A and B markers at the surface with their letters; show and
// hide through the layer; and what teardown leaves.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs';
import { createApplicationDirectionsLayer } from '../../../data/directions.js';
import {
DIRECTIONS_SET_IDS as IDS,
createDirectionsDisplay,
routeDashPieces,
stepPickId,
} from './display.js';
import {
DIRECTIONS_ROUTE_COLOR,
ROUTE_DASH_CELLS,
ROUTE_DASH_DUTY,
} from './style.js';
import { rgbaFromCss } from '../../shared/color.js';
const near = (actual, expected, label, tolerance = 1e-6) =>
assert.ok(
Math.abs(actual - expected) < tolerance,
`${label}: ${actual} vs ${expected}`,
);
const nearAll = (actual, expected, label, tolerance) =>
expected.forEach((value, index) =>
near(actual[index], value, `${label}[${index}]`, tolerance),
);
const ROUTE = rgbaFromCss(DIRECTIONS_ROUTE_COLOR);
const ROUTE_RGB = [ROUTE.red, ROUTE.green, ROUTE.blue];
const A = { lat: 60.1699, lon: 24.9384 };
const B = { lat: 60.2055, lon: 24.6559 };
const MID = { lat: (A.lat + B.lat) / 2, lon: (A.lon + B.lon) / 2 };
const payload = {
ok: true,
distanceM: 21479,
durationS: 1478,
geometry: [
[A.lon, A.lat],
[MID.lon, MID.lat],
[B.lon, B.lat],
],
steps: [
{ instruction: 'Head out', distanceM: 8, durationS: 1, ...A },
{ instruction: 'Turn right', distanceM: 236, durationS: 39, ...MID },
{ instruction: 'Arrive', distanceM: 0, durationS: 0, ...B },
],
};
const settle = async () => {
for (let i = 0; i < 5; i++)
await new Promise((resolve) => setImmediate(resolve));
};
/** A document whose canvases draw nothing (for the letters' atlas). */
function installDocument(t) {
const saved = globalThis.document;
const context = new Proxy({}, { get: () => () => {}, set: () => true });
globalThis.document = {
createElement: () => ({ getContext: () => context }),
addEventListener() {},
removeEventListener() {},
};
t.after(() => {
if (saved === undefined) delete globalThis.document;
else globalThis.document = saved;
});
}
// What the display gave each mover set: its options and every instance's
// fields, merged as set() merges them, up to setCount.
function recordMovers(engine) {
const sets = new Map();
const createSet = engine.movers.createSet.bind(engine.movers);
engine.movers.createSet = (options) => {
const set = createSet(options);
const record = { options, rows: [], count: 0, live: true };
sets.set(options.id, record);
const { set: write, setCount, destroy } = set;
set.set = (index, fields) => {
write(index, fields);
record.rows[index] = { ...record.rows[index], ...fields };
};
set.setCount = (count) => {
setCount(count);
record.count = count;
};
set.destroy = () => {
destroy();
record.live = false;
};
return set;
};
return (id) => {
const record = sets.get(id);
return record?.live ? record : undefined;
};
}
// The Cesium point drawing instance `index` of mover set `id`.
function cesiumPoint(viewer, id, index) {
const items = (collection) =>
Array.from({ length: collection?.length ?? 0 }, (_, i) =>
collection.get(i),
);
for (const root of items(viewer.scene.primitives))
for (const child of items(root))
if (child.gevMoverSet === id) return child.get(index);
return undefined;
}
function fixture(t, makeHarness, { document = true } = {}) {
if (document) installDocument(t);
const h = makeHarness();
if (h.viewer)
h.viewer.creditDisplay ??= {
addStaticCredit() {},
removeStaticCredit() {},
};
const movers = recordMovers(h.engine);
let floor = 0;
const requests = [];
const layer = createApplicationDirectionsLayer({
source: {
getRoute: () => new Promise((resolve) => requests.push(resolve)),
},
groundFloor: {
GROUND_FLOOR_LIFT_M: 2,
cachedGroundFloor: () => floor,
warmGroundFloor() {},
},
createRenderer: createDirectionsDisplay,
});
const cards = [];
layer._setOverlayHostForTest({
setEntries: (id, entries) => cards.push(...entries),
setVisible() {},
clearSource() {},
});
layer.init(null, { engine: h.engine });
layer.enable();
t.after(() => {
layer.destroy();
h.destroy();
});
return {
...h,
layer,
movers,
cards,
setFloor(value) {
floor = value;
},
async route() {
layer.placeEndpoint('a', A);
layer.placeEndpoint('b', B);
requests.at(-1)(payload);
await settle();
},
};
}
test('the display needs a globe engine', () => {
assert.throws(() => createDirectionsDisplay(null), TypeError);
});
test('the route is cut into lit and dim dash cells that keep every turn', () => {
const path = payload.geometry;
const pieces = routeDashPieces(path);
assert.equal(pieces.length, ROUTE_DASH_CELLS * 2);
pieces.forEach((piece, index) =>
assert.equal(piece.lit, index % 2 === 0, `piece ${index}`),
);
assert.deepEqual(pieces[0].positions[0], path[0]);
assert.deepEqual(pieces.at(-1).positions.at(-1), path.at(-1));
// Pieces join end to end, and the middle vertex is kept.
for (let i = 1; i < pieces.length; i++)
assert.deepEqual(pieces[i].positions[0], pieces[i - 1].positions.at(-1));
assert.ok(
pieces.some((piece) =>
piece.positions.some(([lon, lat]) => lon === MID.lon && lat === MID.lat),
),
);
// A lit cell is the duty fraction of its cell's length.
const length = (positions) =>
positions
.slice(1)
.reduce(
(sum, [lon, lat], i) =>
sum +
geo.cartesian3.distance(
geo.cartesian3.fromDegrees(...positions[i]),
geo.cartesian3.fromDegrees(lon, lat),
),
0,
);
const lit = length(pieces[2].positions);
const dim = length(pieces[3].positions);
near(lit / (lit + dim), ROUTE_DASH_DUTY, 'duty', 1e-3);
assert.deepEqual(routeDashPieces([[0, 0]]), []);
});
for (const makeHarness of GROUND_HARNESSES) {
const { name } = makeHarness();
test(`the route is one 9 px corridor set draped on terrain and tiles, its cells at 0.95 and 0.171 (${name})`, async (t) => {
const f = fixture(t, makeHarness);
await f.route();
const set = f.set(IDS.route);
assert.equal(set.options.kind, 'corridor');
assert.equal(set.options.drape, 'both');
assert.equal(set.options.pickable, false);
assert.equal(set.shown, true);
assert.equal(set.items.length, ROUTE_DASH_CELLS * 2);
set.items.forEach((item, index) => {
assert.equal(item.widthPx, 9);
nearAll(
item.color,
[...ROUTE_RGB, index % 2 === 0 ? 0.95 : 0.95 * 0.18],
`colour ${index}`,
);
});
assert.deepEqual(set.items[0].positions[0], payload.geometry[0]);
assert.deepEqual(
set.items.at(-1).positions.at(-1),
payload.geometry.at(-1),
);
if (f.cesium) {
const { fills, lines } = f.primitives(IDS.route);
assert.equal(fills.length, 0);
assert.equal(lines.length, 1, 'one ground polyline batch');
assert.equal(
lines[0].options.classificationType,
Cesium.ClassificationType.BOTH,
);
}
});
test(`maneuver dots are 8 px white over the scene with a 2 px route outline, hidden until anchored (${name})`, async (t) => {
const f = fixture(t, makeHarness);
f.setFloor(null);
await f.route();
const steps = f.movers(IDS.steps);
assert.equal(steps.options.kind, 'point');
assert.equal(steps.options.depthTest, 'always');
assert.equal(steps.options.pickable, true);
assert.equal(steps.options.order, 0);
assert.equal(steps.options.point.sizePx, 8);
assert.equal(steps.options.point.outlinePx, 2);
nearAll(steps.options.point.outlineColor, [...ROUTE_RGB, 1], 'outline');
// Departure and arrival have no dot; the turn's cell is still cold.
assert.equal(steps.count, 1);
assert.equal(steps.rows[0].show, false);
assert.equal(steps.rows[0].pickId, stepPickId(1));
// Once the ground floor answers, the dot stands on it.
f.setFloor(30);
await new Promise((resolve) => setTimeout(resolve, 450));
const anchored = f.movers(IDS.steps).rows[0];
assert.equal(anchored.show, true);
nearAll(anchored.color, [1, 1, 1, 0.95], 'dot colour');
const at = geo.cartographic.fromCartesian(anchored.position);
near(at.height, 32, 'floor + lift', 1e-3);
near(geo.math.toDegrees(at.longitude), MID.lon, 'dot lon');
if (f.cesium) {
const point = cesiumPoint(f.viewer, IDS.steps, 0);
assert.equal(point.pixelSize, 8);
assert.equal(point.outlineWidth, 2);
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY);
assert.equal(point.show, true);
}
});
test(`a click on a dot selects it at 13 px and opens its card; a click elsewhere clears it (${name})`, async (t) => {
const f = fixture(t, makeHarness);
await f.route();
f.click({ id: stepPickId(1) });
await settle();
assert.equal(f.layer.getRowControls().list.items[1].active, true);
near(f.movers(IDS.steps).rows[0].scale, 13 / 8, 'selected scale');
assert.equal(f.cards.at(-1).id, 'directions-step-1');
if (f.cesium)
assert.equal(cesiumPoint(f.viewer, IDS.steps, 0).pixelSize, 13);
f.click(undefined);
await settle();
assert.equal(f.layer.getRowControls().list.items[1].active, false);
assert.equal(f.movers(IDS.steps).rows[0].scale, 1);
});
test(`A and B are 14 px discs with a black outline and their letters, at the surface (${name})`, async (t) => {
const f = fixture(t, makeHarness);
if (!f.cesium) f.engine.fake.script('sampleHeight', () => 120);
f.layer.placeEndpoint('a', A);
const markers = f.movers(IDS.markers);
assert.equal(markers.options.kind, 'point');
assert.equal(markers.options.depthTest, 'always');
assert.equal(markers.options.order, 1);
assert.equal(markers.options.point.sizePx, 14);
assert.equal(markers.options.point.outlinePx, 2);
assert.deepEqual(markers.options.point.outlineColor, [0, 0, 0, 1]);
const a = rgbaFromCss('#5dff9f');
nearAll(markers.rows[0].color, [a.red, a.green, a.blue, 1], 'A colour');
assert.equal(markers.rows[0].show, true);
assert.equal(markers.rows[1].show, false, 'B is not placed yet');
if (!f.cesium)
near(
geo.cartographic.fromCartesian(markers.rows[0].position).height,
120,
'A on the sampled surface',
1e-3,
);
const letters = f.movers(IDS.letters);
assert.equal(letters.options.kind, 'sprite');
assert.equal(letters.options.order, 2);
assert.equal(letters.options.depthTest, 'always');
assert.equal(letters.rows[0].cell, 'a');
assert.equal(letters.rows[0].show, true);
assert.deepEqual(letters.rows[0].position, markers.rows[0].position);
f.layer.placeEndpoint('b', B);
const b = rgbaFromCss('#ff6b6b');
nearAll(
f.movers(IDS.markers).rows[1].color,
[b.red, b.green, b.blue, 1],
'B colour',
);
assert.equal(f.movers(IDS.letters).rows[1].cell, 'b');
// CLEAR takes both away.
f.layer.setParams({ clear: true });
assert.equal(f.movers(IDS.markers).rows[0].show, false);
assert.equal(f.movers(IDS.markers).rows[1].show, false);
});
test(`disabling hides and clears everything; destroying leaves no sets and no listeners (${name})`, async (t) => {
const f = fixture(t, makeHarness);
await f.route();
assert.equal(f.listeners(), 1, 'one click listener');
f.layer.disable();
assert.equal(f.set(IDS.route), undefined, 'the route is gone');
assert.equal(f.movers(IDS.steps).count, 0);
assert.equal(f.listeners(), 0);
f.layer.enable();
assert.equal(f.listeners(), 1);
f.layer.detachRenderer();
assert.equal(f.engine.movers.list().length, 0);
assert.equal(f.sets(), 0);
assert.equal(f.listeners(), 0);
});
test(`without a document the markers draw without letters (${name})`, (t) => {
const saved = globalThis.document;
delete globalThis.document;
t.after(() => {
if (saved !== undefined) globalThis.document = saved;
});
const f = fixture(t, makeHarness, { document: false });
f.layer.placeEndpoint('a', A);
assert.equal(f.movers(IDS.letters), undefined);
assert.equal(f.movers(IDS.markers).rows[0].show, true);
});
}
test('an engine switch redraws the route, dots and markers on the new engine only', async (t) => {
const [makeFake, makeCesium] = GROUND_HARNESSES;
const f = fixture(t, makeFake);
await f.route();
const next = makeCesium();
next.viewer.creditDisplay ??= {
addStaticCredit() {},
removeStaticCredit() {},
};
const movers = recordMovers(next.engine);
t.after(() => next.destroy());
f.layer.suspendCamera();
f.layer.detachRenderer();
f.layer.attachRenderer(null, { engine: next.engine });
f.layer.resumeCamera();
assert.equal(f.engine.movers.list().length, 0);
assert.equal(f.sets(), 0);
assert.equal(next.set(IDS.route).items.length, ROUTE_DASH_CELLS * 2);
assert.equal(movers(IDS.steps).count, 1);
assert.equal(movers(IDS.markers).rows[0].show, true);
assert.equal(movers(IDS.markers).rows[1].show, true);
f.layer.detachRenderer();
});
-209
View File
@@ -1,209 +0,0 @@
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 = [];
},
};
}
+49
View File
@@ -0,0 +1,49 @@
// What the Directions display draws, as Cesium drew it: colours rgba in
// 0..1, sizes in CSS pixels.
import { rgbaFromCss } from '../../shared/color.js';
/** Route colour: the annotation palette's cyan, so voice routes and Directions match. */
export const DIRECTIONS_ROUTE_COLOR = '#39d0ff';
const rgba = (css, alpha) => {
const { red, green, blue } = rgbaFromCss(css);
return [red, green, blue, alpha];
};
// The route flow material (src/annotations/worldAnnotationRenderer.js): the
// route colour at 0.95, 64 dash cells along the route, each lit for its
// first 46 % and at 18 % alpha between.
export const ROUTE_WIDTH_PX = 9;
export const ROUTE_DASH_CELLS = 64;
export const ROUTE_DASH_DUTY = 0.46;
export const ROUTE_DASH_COLOR = rgba(DIRECTIONS_ROUTE_COLOR, 0.95);
export const ROUTE_GAP_COLOR = rgba(DIRECTIONS_ROUTE_COLOR, 0.95 * 0.18);
// Maneuver dots: white at 0.95 with a 2 px route-colour outline, 8 px, 13 px
// while selected.
export const STEP_COLOR = [1, 1, 1, 0.95];
export const STEP_OUTLINE_COLOR = rgba(DIRECTIONS_ROUTE_COLOR, 1);
export const STEP_OUTLINE_PX = 2;
export const STEP_PIXEL_SIZE = 8;
export const STEP_SELECTED_PIXEL_SIZE = 13;
// The A and B markers: a 14 px disc with a 2 px black outline, and its
// letter (bold 13 px monospace, white with a 3 px black outline) 20 px above.
export const MARKER_COLORS = Object.freeze({
a: rgba('#5dff9f', 1),
b: rgba('#ff6b6b', 1),
});
export const MARKER_PIXEL_SIZE = 14;
export const MARKER_OUTLINE_PX = 2;
export const MARKER_OUTLINE_COLOR = [0, 0, 0, 1];
export const MARKER_LETTER_FONT =
'bold 13px "JetBrains Mono", "SF Mono", monospace';
export const MARKER_LETTER_OUTLINE_PX = 3;
export const MARKER_LETTER_OFFSET_PX = 20;
// The letter's sprite: a square this size centred on the marker, the glyph
// drawn MARKER_LETTER_OFFSET_PX above its centre.
export const MARKER_LETTER_SPRITE_PX = 64;
// Draw order among the display's mover sets: dots, marker discs, letters.
export const ORDER = Object.freeze({ steps: 0, markers: 1, letters: 2 });
+25 -43
View File
@@ -1,5 +1,9 @@
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js'; import {
plausibleSurfaceHeight,
sampledSurfaceHeight,
terrainSurfaceHeight,
} from './view.js';
/** Fly the globe camera once to frame a photo after it opens. */ /** Fly the globe camera once to frame a photo after it opens. */
export function createCameraFraming({ state }) { export function createCameraFraming({ state }) {
@@ -8,7 +12,7 @@ export function createCameraFraming({ state }) {
* nothing newer took the camera. Without it, framing flies directly. * nothing newer took the camera. Without it, framing flies directly.
*/ */
let navigation = null; let navigation = null;
/** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */ /** Our framing flight while it is current; the engine calls `cancel` when another flight starts. */
let flight = null; let flight = null;
function attachNavigation(next) { function attachNavigation(next) {
@@ -27,14 +31,10 @@ export function createCameraFraming({ state }) {
* ellipsoid before tiles load, so only a plausible height counts. * ellipsoid before tiles load, so only a plausible height counts.
*/ */
function groundHeightAt(lon, lat, fallback) { function groundHeightAt(lon, lat, fallback) {
const scene = state.viewer?.scene; const engine = state.map?.engine;
const carto = Cesium.Cartographic.fromDegrees(lon, lat); const carto = geo.cartographic.fromDegrees(lon, lat);
const engine = state.viewer?.engine; let height = sampledSurfaceHeight(engine, carto);
let height = engine if (height === null) height = terrainSurfaceHeight(engine, carto);
? (engine.surface.sampleHeight(carto) ??
engine.surface.terrainHeight(carto))
: sampledSurfaceHeight(scene, carto);
if (height === null) height = scene?.globe?.getHeight?.(carto);
if (!plausibleSurfaceHeight(height)) height = fallback; if (!plausibleSurfaceHeight(height)) height = fallback;
return plausibleSurfaceHeight(height) ? height : 0; return plausibleSurfaceHeight(height) ? height : 0;
} }
@@ -45,7 +45,8 @@ export function createCameraFraming({ state }) {
*/ */
function frame(ticket) { function frame(ticket) {
const { position, bearing, altitude } = state.street; const { position, bearing, altitude } = state.street;
if (!ticket || !position || !state.viewer) return; const engine = state.map?.engine;
if (!ticket || !position || !engine) return;
// Detached (shell teardown) since the ticket was issued: nothing to frame for. // Detached (shell teardown) since the ticket was issued: nothing to frame for.
if (ticket.generation !== null && !navigation?.reassert(ticket.generation)) if (ticket.generation !== null && !navigation?.reassert(ticket.generation))
return; return;
@@ -57,38 +58,19 @@ export function createCameraFraming({ state }) {
const release = () => { const release = () => {
if (flight === current) flight = null; if (flight === current) flight = null;
}; };
if (state.viewer.engine) { engine.camera.flyToSphere(
state.viewer.engine.camera.flyToSphere(
{
center: Cesium.Cartesian3.fromDegrees(
position.lon,
position.lat,
ground + 2,
),
radius: 4,
},
{
heading: Cesium.Math.toRadians(bearing || 0),
pitch: Cesium.Math.toRadians(-32),
range: 140,
duration: 1.6,
complete: release,
cancel: release,
},
);
return;
}
state.viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
4,
),
{ {
offset: new Cesium.HeadingPitchRange( center: geo.cartesian3.fromDegrees(
Cesium.Math.toRadians(bearing || 0), position.lon,
Cesium.Math.toRadians(-32), position.lat,
140, ground + 2,
), ),
radius: 4,
},
{
heading: geo.math.toRadians(bearing || 0),
pitch: geo.math.toRadians(-32),
range: 140,
duration: 1.6, duration: 1.6,
complete: release, complete: release,
cancel: release, cancel: release,
@@ -101,7 +83,7 @@ export function createCameraFraming({ state }) {
if (!flight) return; if (!flight) return;
// Give up ownership first: cancelFlight delivers `cancel` synchronously. // Give up ownership first: cancelFlight delivers `cancel` synchronously.
flight = null; flight = null;
state.viewer?.camera?.cancelFlight?.(); state.map?.engine?.camera.cancelFlight();
} }
return { attachNavigation, begin, frame, cancel }; return { attachNavigation, begin, frame, cancel };
+35 -27
View File
@@ -1,24 +1,21 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { createCameraFraming } from './cameraFraming.js'; import { createCameraFraming } from './cameraFraming.js';
import { fakeCesiumViewer } from '../../testSupport/streetLevelFakes.mjs'; import { streetLevelMap } from '../../testSupport/streetLevelFakes.mjs';
/** Height (m) of a Cartesian above the WGS84 ellipsoid. */ /** Height (m) of a Cartesian above the WGS84 ellipsoid. */
const heightOf = (cartesian) => const heightOf = (cartesian) =>
Cesium.Cartographic.fromCartesian(cartesian).height; geo.cartographic.fromCartesian(cartesian).height;
/** /**
* A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles * A display whose surface sample is `sampled` (e.g. −14,886 m before the
* under the photo load) and whose globe terrain is `globe`. * tiles under the photo load) and whose terrain is `globe`.
*/ */
function setup({ sampled, globe = null, altitude = 149 }) { function setup({ sampled, globe = null, altitude = 149 }) {
const viewer = fakeCesiumViewer(); const harness = streetLevelMap();
Object.assign(viewer.scene, { harness.engine.fake.script('sampleHeight', () => sampled);
sampleHeightSupported: true, harness.engine.fake.script('terrainHeight', () => globe ?? undefined);
sampleHeight: () => sampled,
globe: { getHeight: () => globe },
});
const state = { const state = {
services: {}, services: {},
street: { street: {
@@ -26,13 +23,15 @@ function setup({ sampled, globe = null, altitude = 149 }) {
bearing: 105, bearing: 105,
altitude, altitude,
}, },
viewer, map: harness.map,
/** What the camera follows (an aircraft), released by a reassert. */
tracked: undefined,
}; };
return { return {
framing: createCameraFraming({ state }), framing: createCameraFraming({ state }),
state, state,
viewer, harness,
flights: viewer.flights.list, flights: harness.flights.list,
}; };
} }
@@ -41,6 +40,11 @@ test('framing a photo stands the sphere on a plausible scene sample', () => {
framing.frame(framing.begin()); framing.frame(framing.begin());
assert.equal(flights.length, 1); assert.equal(flights.length, 1);
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01); assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01);
assert.equal(flights[0].sphere.radius, 4);
const { heading, pitch, range, duration } = flights[0].options;
assert.ok(Math.abs(heading - geo.math.toRadians(105)) < 1e-12);
assert.ok(Math.abs(pitch - geo.math.toRadians(-32)) < 1e-12);
assert.deepEqual([range, duration], [140, 1.6]);
}); });
test('a sample kilometres underground falls back to the globe, then the image altitude', () => { test('a sample kilometres underground falls back to the globe, then the image altitude', () => {
@@ -57,7 +61,8 @@ test('a sample kilometres underground falls back to the globe, then the image al
}); });
test('cancel stops the framing flight while it is still ours', () => { test('cancel stops the framing flight while it is still ours', () => {
const { framing, viewer } = setup({ sampled: 121 }); const { framing, harness } = setup({ sampled: 121 });
const viewer = harness;
framing.frame(framing.begin()); framing.frame(framing.begin());
framing.cancel(); framing.cancel();
assert.equal( assert.equal(
@@ -76,18 +81,21 @@ test('cancel stops the framing flight while it is still ours', () => {
}); });
test('cancel leaves a landed flight and a newer navigation flight alone', () => { test('cancel leaves a landed flight and a newer navigation flight alone', () => {
const { framing, viewer, flights } = setup({ sampled: 121 }); const { framing, harness, flights } = setup({ sampled: 121 });
framing.frame(framing.begin()); framing.frame(framing.begin());
viewer.flights.land(); harness.flights.land();
framing.cancel(); framing.cancel();
assert.equal(viewer.flights.cancelled, 0, 'landed: nothing to stop'); assert.equal(harness.flights.cancelled, 0, 'landed: nothing to stop');
framing.frame(framing.begin()); framing.frame(framing.begin());
// A search result flies the globe elsewhere; Cesium cancels ours first. // A search result flies the globe elsewhere; the engine cancels ours first.
viewer.camera.flyToBoundingSphere(null, {}); harness.engine.camera.flyToSphere(
{ center: geo.cartesian3.fromDegrees(0, 0), radius: 1 },
{},
);
assert.equal(flights.length, 3); assert.equal(flights.length, 3);
framing.cancel(); framing.cancel();
assert.equal(viewer.flights.cancelled, 0, 'the newer flight keeps going'); assert.equal(harness.flights.cancelled, 0, 'the newer flight keeps going');
}); });
/** /**
@@ -107,7 +115,7 @@ function navigationFor(state, { refuse = false } = {}) {
}, },
reassert(ticket) { reassert(ticket) {
if (ticket !== generation) return false; if (ticket !== generation) return false;
state.viewer.trackedEntity = undefined; state.tracked = undefined;
return true; return true;
}, },
/** Another feature takes the camera. */ /** Another feature takes the camera. */
@@ -118,14 +126,14 @@ function navigationFor(state, { refuse = false } = {}) {
test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => { test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => {
const { framing, state, flights } = setup({ sampled: 160 }); const { framing, state, flights } = setup({ sampled: 160 });
state.viewer.trackedEntity = { id: 'aircraft' }; state.tracked = { id: 'aircraft' };
const nav = navigationFor(state); const nav = navigationFor(state);
framing.attachNavigation(nav); framing.attachNavigation(nav);
const ticket = framing.begin(); const ticket = framing.begin();
assert.deepEqual(nav.begins, ['photo']); assert.deepEqual(nav.begins, ['photo']);
assert.ok(state.viewer.trackedEntity, 'nothing is released while it loads'); assert.ok(state.tracked, 'nothing is released while it loads');
framing.frame(ticket); framing.frame(ticket);
assert.equal(state.viewer.trackedEntity, undefined); assert.equal(state.tracked, undefined);
assert.equal(flights.length, 1); assert.equal(flights.length, 1);
}); });
@@ -136,10 +144,10 @@ test('a photo that finishes loading after newer navigation does not frame', () =
const ticket = framing.begin(); const ticket = framing.begin();
// The user flies to Sacramento, and then tracks an aircraft, while it loads. // The user flies to Sacramento, and then tracks an aircraft, while it loads.
nav.handOff(); nav.handOff();
state.viewer.trackedEntity = { id: 'aircraft' }; state.tracked = { id: 'aircraft' };
framing.frame(ticket); framing.frame(ticket);
assert.equal(flights.length, 0, 'no flight back to the photo'); assert.equal(flights.length, 0, 'no flight back to the photo');
assert.deepEqual(state.viewer.trackedEntity, { id: 'aircraft' }); assert.deepEqual(state.tracked, { id: 'aircraft' });
}); });
test('a refused claim (cockpit) does not frame the photo', () => { test('a refused claim (cockpit) does not frame the photo', () => {
+14 -24
View File
@@ -1,46 +1,36 @@
import * as Cesium from 'cesium';
/** /**
* One static Cesium credit per active provider: CC BY-SA imagery needs * One static on-screen credit per active provider (engine.credits): CC
* visible attribution that goes away with the imagery. * BY-SA imagery needs visible attribution that goes away with the imagery.
* `view` is the layer's display (render/display.js).
*/ */
export function createCredits() { export function createCredits() {
/** @type {Map<string, object>} provider id → Cesium.Credit */ /** @type {Map<string, () => void>} provider id → its credit's remover */
const shown = new Map(); const shown = new Map();
function show(viewer, def) { function show(view, def) {
if (shown.has(def.id)) return; if (shown.has(def.id)) return;
if (viewer?.engine) { const credits = view?.engine?.credits;
shown.set( if (!credits) return;
def.id,
viewer.engine.credits.addStatic(def.credit.html, { onScreen: true }),
);
return;
}
if (!viewer?.creditDisplay) return;
try { try {
const credit = new Cesium.Credit(def.credit.html, true); shown.set(def.id, credits.addStatic(def.credit.html, { onScreen: true }));
viewer.creditDisplay.addStaticCredit(credit);
shown.set(def.id, credit);
} catch { } catch {
/* credit display unavailable */ /* credit display unavailable */
} }
} }
function hide(viewer, def) { function hide(_view, def) {
const credit = shown.get(def.id); const remove = shown.get(def.id);
if (!credit) return; if (!remove) return;
try { try {
if (typeof credit === 'function') credit(); remove();
else viewer?.creditDisplay?.removeStaticCredit?.(credit);
} catch { } catch {
/* credit display already torn down */ /* credit display already torn down */
} }
shown.delete(def.id); shown.delete(def.id);
} }
function hideAll(viewer) { function hideAll(view) {
for (const id of [...shown.keys()]) hide(viewer, { id }); for (const id of [...shown.keys()]) hide(view, { id });
} }
return { show, hide, hideAll }; return { show, hide, hideAll };
+8 -11
View File
@@ -3,11 +3,10 @@
// from is ignored and the lit provider keeps the panel. // from is ignored and the lit provider keeps the panel.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium';
import { createStreetLevelLayer } from './index.js'; import { createStreetLevelLayer } from './index.js';
import { import {
fakeCesiumViewer,
fakeStreetLevelProvider as fakeProvider, fakeStreetLevelProvider as fakeProvider,
streetLevelMap,
} from '../../testSupport/streetLevelFakes.mjs'; } from '../../testSupport/streetLevelFakes.mjs';
const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -25,11 +24,6 @@ async function twoProviders(t) {
globalThis.document = Object.assign(new EventTarget(), { globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }), createElement: () => ({ getContext: () => drawing }),
}); });
t.mock.method(
Cesium.GroundPolylinePrimitive,
'initializeTerrainHeights',
async () => {},
);
const lookups = []; const lookups = [];
const previous = fakeProvider({ const previous = fakeProvider({
nearestImage: (point, { signal } = {}) => { nearestImage: (point, { signal } = {}) => {
@@ -43,10 +37,13 @@ async function twoProviders(t) {
pickPrefix: 'pmx:', pickPrefix: 'pmx:',
nearestImage: async () => 'pmx-1', nearestImage: async () => 'pmx-1',
}); });
const viewer = fakeCesiumViewer(); const harness = streetLevelMap();
const layer = createStreetLevelLayer({ providers: [previous, lit] }); const layer = createStreetLevelLayer({
layer.init(viewer); providers: [previous, lit],
layer.enable(viewer); services: { createDisplay: () => harness.map },
});
layer.init(null, { engine: harness.engine });
layer.enable();
layer.attachViewerHost({}); layer.attachViewerHost({});
t.after(() => { t.after(() => {
layer.destroy(); layer.destroy();
+9 -8
View File
@@ -3,9 +3,9 @@ import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js';
import { cameraHeightAboveGround, cameraLocation } from './view.js'; import { cameraHeightAboveGround, cameraLocation } from './view.js';
/** Whether the camera is low enough for a click on the ground to mean a street. */ /** Whether the camera is low enough for a click on the ground to mean a street. */
export function groundClickHeightOk(viewer) { export function groundClickHeightOk(map) {
if (!cameraLocation(viewer)) return false; if (!cameraLocation(map)) return false;
return cameraHeightAboveGround(viewer) <= GROUND_CLICK_MAX_HEIGHT_M; return cameraHeightAboveGround(map) <= GROUND_CLICK_MAX_HEIGHT_M;
} }
/** /**
@@ -18,9 +18,9 @@ export function groundClickHeightOk(viewer) {
export function createGroundClick({ state, openNearest, isAvailable }) { export function createGroundClick({ state, openNearest, isAvailable }) {
return function openAtGround(screenPosition, picked) { return function openAtGround(screenPosition, picked) {
const scenePick = state.services.scenePick; const scenePick = state.services.scenePick;
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition) if (!state.enabled || !state.map || !scenePick?.pickGroundPosition)
return false; return false;
if (!scenePick.isSurfacePick(picked) || !groundClickHeightOk(state.viewer)) if (!scenePick.isSurfacePick(picked) || !groundClickHeightOk(state.map))
return false; return false;
const providerIds = [...state.providers.values()] const providerIds = [...state.providers.values()]
.filter( .filter(
@@ -32,9 +32,10 @@ export function createGroundClick({ state, openNearest, isAvailable }) {
) )
.map((entry) => entry.def.id); .map((entry) => entry.def.id);
if (!providerIds.length) return false; if (!providerIds.length) return false;
const position = state.viewer.engine const position = scenePick.pickGroundPosition(
? state.viewer.engine.picker.pickGlobe(screenPosition) state.map.engine,
: scenePick.pickGroundPosition(state.viewer, screenPosition); screenPosition,
);
const carto = position && geo.cartographic.fromCartesian(position); const carto = position && geo.cartographic.fromCartesian(position);
if (!carto) return false; if (!carto) return false;
return openNearest( return openNearest(
+21 -22
View File
@@ -1,10 +1,11 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js'; import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import * as scenePick from '../../data/scenePick.js'; import * as scenePick from '../../data/scenePick.js';
import { cameraEngine } from '../../testSupport/streetLevelFakes.mjs';
const GROUND = Cesium.Cartesian3.fromDegrees(-121.4944, 38.5816, 10); const GROUND = geo.cartesian3.fromDegrees(-121.4944, 38.5816, 10);
const TILESET = { isCesium3DTileset: true }; const TILESET = { isCesium3DTileset: true };
/** /**
@@ -22,6 +23,15 @@ function harness({
ellipsoid = undefined, ellipsoid = undefined,
} = {}) { } = {}) {
const opened = []; const opened = [];
// The display's engine: the camera `height` m up, the depth buffer and the
// ellipsoid as scripted.
const engine = cameraEngine({
lon: -121.4944,
lat: 38.5816,
altitude: height,
});
engine.fake.script('pickPosition', () => picked);
engine.fake.script('pickEllipsoid', () => ellipsoid);
const state = { const state = {
enabled, enabled,
services: { scenePick }, services: { scenePick },
@@ -39,17 +49,7 @@ function harness({
], ],
), ),
), ),
viewer: { map: { engine },
scene: { pickPositionSupported: true, pickPosition: () => picked },
camera: {
pickEllipsoid: () => ellipsoid,
positionCartographic: Cesium.Cartographic.fromDegrees(
-121.4944,
38.5816,
height,
),
},
},
}; };
const openAtGround = createGroundClick({ const openAtGround = createGroundClick({
state, state,
@@ -81,7 +81,7 @@ test('a click on the map opens the click-to-open providers there, without moving
test('where the depth buffer names no place, the ellipsoid answers', () => { test('where the depth buffer names no place, the ellipsoid answers', () => {
const { opened, openAtGround } = harness({ const { opened, openAtGround } = harness({
picked: new Cesium.Cartesian3(500, 0, 0), picked: geo.cartesian3.create(500, 0, 0),
ellipsoid: GROUND, ellipsoid: GROUND,
}); });
openAtGround({ x: 1, y: 1 }); openAtGround({ x: 1, y: 1 });
@@ -120,14 +120,13 @@ test('nothing opens over another layer’s object, the sky, from high up, off it
}); });
test('street zoom is measured above the ground under the camera', () => { test('street zoom is measured above the ground under the camera', () => {
const viewer = (height) => ({ const map = (height) => {
camera: { const engine = cameraEngine({ lon: 0, lat: 0, altitude: height });
positionCartographic: Cesium.Cartographic.fromDegrees(0, 0, height), engine.fake.script('terrainHeight', () => 1500);
}, return { engine };
scene: { globe: { getHeight: () => 1500 } }, };
});
// 1,500 m up a plateau, 2,000 m above it: still a street. // 1,500 m up a plateau, 2,000 m above it: still a street.
assert.equal(groundClickHeightOk(viewer(3500)), true); assert.equal(groundClickHeightOk(map(3500)), true);
assert.equal(groundClickHeightOk(viewer(5000)), false); assert.equal(groundClickHeightOk(map(5000)), false);
assert.equal(groundClickHeightOk(null), false); assert.equal(groundClickHeightOk(null), false);
}); });
+46 -50
View File
@@ -1,5 +1,4 @@
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { whenCesiumSdk } from '../../app/loadCesium.js';
import { createState } from './state.js'; import { createState } from './state.js';
import { createMarker } from './marker.js'; import { createMarker } from './marker.js';
import { createCameraFraming } from './cameraFraming.js'; import { createCameraFraming } from './cameraFraming.js';
@@ -17,6 +16,7 @@ import {
} from './uiState.js'; } from './uiState.js';
import { groundUnderCamera, viewCentre } from './view.js'; import { groundUnderCamera, viewCentre } from './view.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js'; import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { createStreetLevelDisplay } from './render/display.js';
import { msg, t, translateKnown } from '../../i18n/index.js'; import { msg, t, translateKnown } from '../../i18n/index.js';
import { import {
AIM_AT_GROUND_MESSAGE, AIM_AT_GROUND_MESSAGE,
@@ -39,7 +39,9 @@ const GROUND_RETRY_MS = 1000;
* The core owns what they share: the filter and switches, the photo viewer * The core owns what they share: the filter and switches, the photo viewer
* host, the position marker, the click handler and nearest-image lookups. * host, the position marker, the click handler and nearest-image lookups.
* Providers own coverage, sequences and their viewer. One source is lit at * Providers own coverage, sequences and their viewer. One source is lit at
* a time. * a time. Everything on the globe goes through the display on the engine it
* is attached to: `services.createDisplay(engine)` (the app's renderer
* registry), else render/display.js; a display of null draws nothing.
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options * @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
*/ */
export function createStreetLevelLayer({ export function createStreetLevelLayer({
@@ -179,9 +181,9 @@ export function createStreetLevelLayer({
for (const entry of state.providers.values()) { for (const entry of state.providers.values()) {
if (!entry.def.requiresMapStack) continue; if (!entry.def.requiresMapStack) continue;
if (state.enabled && entry.on && providerAvailable(entry)) if (state.enabled && entry.on && providerAvailable(entry))
parts.credits.show(state.viewer, entry.def); parts.credits.show(state.map, entry.def);
else { else {
parts.credits.hide(state.viewer, entry.def); parts.credits.hide(state.map, entry.def);
parts.viewerHost.unmount(entry.def.id); parts.viewerHost.unmount(entry.def.id);
} }
} }
@@ -203,13 +205,13 @@ export function createStreetLevelLayer({
*/ */
function syncGroundClickReady() { function syncGroundClickReady() {
cancelGroundRetry(); cancelGroundRetry();
const ready = state.enabled && groundClickHeightOk(state.viewer); const ready = state.enabled && groundClickHeightOk(state.map);
if ( if (
state.enabled && state.enabled &&
state.viewer && state.map &&
!ready && !ready &&
groundRetries < GROUND_RETRY_LIMIT && groundRetries < GROUND_RETRY_LIMIT &&
groundUnderCamera(state.viewer) === null groundUnderCamera(state.map) === null
) { ) {
groundRetries++; groundRetries++;
groundRetry = setTimeout(syncGroundClickReady, GROUND_RETRY_MS); groundRetry = setTimeout(syncGroundClickReady, GROUND_RETRY_MS);
@@ -226,10 +228,10 @@ export function createStreetLevelLayer({
} }
let removeCameraListener = null; let removeCameraListener = null;
function watchCamera(viewer) { function watchCamera(view) {
removeCameraListener?.(); removeCameraListener?.();
removeCameraListener = removeCameraListener =
viewer?.camera?.moveEnd?.addEventListener(remeasureGround) || null; view?.engine?.camera.moveEnd.addEventListener(remeasureGround) || null;
} }
const activeEntries = () => const activeEntries = () =>
@@ -253,18 +255,18 @@ export function createStreetLevelLayer({
} }
function activate(entry) { function activate(entry) {
if (!state.viewer) return; if (!state.map) return;
entry.instance.activate(state.viewer); entry.instance.activate(state.map);
// Without a known key, a switch-on asks again: the server may be back, // Without a known key, a switch-on asks again: the server may be back,
// or have a key now. // or have a key now.
if (entry.status?.configured !== true) checkStatus(entry); if (entry.status?.configured !== true) checkStatus(entry);
if (providerAvailable(entry)) parts.credits.show(state.viewer, entry.def); if (providerAvailable(entry)) parts.credits.show(state.map, entry.def);
} }
function deactivate(entry) { function deactivate(entry) {
parts.viewerHost.unmount(entry.def.id); parts.viewerHost.unmount(entry.def.id);
entry.instance.deactivate(); entry.instance.deactivate();
parts.credits.hide(state.viewer, entry.def); parts.credits.hide(state.map, entry.def);
} }
/** The nearest-image lookup in flight; any newer user action aborts it. */ /** The nearest-image lookup in flight; any newer user action aborts it. */
@@ -277,15 +279,11 @@ export function createStreetLevelLayer({
/** Ground under the screen centre; null for sky, undefined when it cannot aim. */ /** Ground under the screen centre; null for sky, undefined when it cannot aim. */
function screenCentreGround() { function screenCentreGround() {
const canvas = const engine = state.map?.engine;
state.viewer?.engine?.host.canvas ?? state.viewer?.scene?.canvas; const size = engine?.host.size();
const pick = state.services.scenePick?.pickGroundPosition; const pick = state.services.scenePick?.pickGroundPosition;
if (!pick || !canvas?.clientWidth || !canvas?.clientHeight) if (!pick || !size?.width || !size?.height) return undefined;
return undefined; const position = pick(engine, { x: size.width / 2, y: size.height / 2 });
const center = { x: canvas.clientWidth / 2, y: canvas.clientHeight / 2 };
const position = state.viewer.engine
? state.viewer.engine.picker.pickGlobe(center)
: pick(state.viewer, center);
const carto = position && geo.cartographic.fromCartesian(position); const carto = position && geo.cartographic.fromCartesian(position);
return carto return carto
? { ? {
@@ -498,16 +496,18 @@ export function createStreetLevelLayer({
}; };
}, },
init(viewer, options) { init(scene, options) {
this.attachRenderer(viewer, options); this.attachRenderer(scene, options);
for (const entry of state.providers.values()) checkStatus(entry); for (const entry of state.providers.values()) checkStatus(entry);
}, },
/** Draws on `engine` through its display; the first argument is not read. */
attachRenderer( attachRenderer(
viewer, _scene,
{ engine = null, waitForCameraRestore = false } = {}, { engine = null, waitForCameraRestore = false } = {},
) { ) {
const map = viewer ?? services.nativeRenderer?.(engine); const createDisplay = services.createDisplay ?? createStreetLevelDisplay;
state.viewer = map; const map = engine ? (createDisplay(engine) ?? null) : null;
state.map = map;
waitingForView = waitForCameraRestore; waitingForView = waitForCameraRestore;
if (!map) return; if (!map) return;
parts.marker.ensure(map); parts.marker.ensure(map);
@@ -534,13 +534,13 @@ export function createStreetLevelLayer({
resumeViewport() { resumeViewport() {
if (!waitingForView) return; if (!waitingForView) return;
waitingForView = false; waitingForView = false;
if (state.enabled && state.viewer) { if (state.enabled && state.map) {
for (const entry of activeEntries()) entry.instance.refreshCoverage?.(); for (const entry of activeEntries()) entry.instance.refreshCoverage?.();
remeasureGround(); remeasureGround();
} }
}, },
detachRenderer() { detachRenderer() {
const map = state.viewer; const map = state.map;
if (!map) return; if (!map) return;
waitingForView = true; waitingForView = true;
removeCameraListener?.(); removeCameraListener?.();
@@ -552,26 +552,22 @@ export function createStreetLevelLayer({
if (entry.instance.detachMap) entry.instance.detachMap(); if (entry.instance.detachMap) entry.instance.detachMap();
else if (entry.on) entry.instance.deactivate(); else if (entry.on) entry.instance.deactivate();
} }
parts.marker.destroy(map); parts.marker.destroy();
parts.credits.hideAll(map); parts.credits.hideAll(map);
map.destroy?.();
unsubscribeMapStack?.(); unsubscribeMapStack?.();
unsubscribeMapStack = null; unsubscribeMapStack = null;
mapStack = null; mapStack = null;
state.viewer = null; state.map = null;
}, },
enable(viewer, { signal } = {}) { enable() {
// The marker, cones, coverage and ground picks use Cesium's math and state.enabled = true;
// primitives on either globe: a Navara page loads the Cesium SDK first. parts.marker.setVisible(true);
return whenCesiumSdk(() => { parts.selection.install(state.map);
state.enabled = true; for (const entry of activeEntries()) activate(entry);
if (viewer) state.viewer = viewer; watchCamera(state.map);
parts.marker.setVisible(true); remeasureGround();
parts.selection.install(state.viewer); notify();
for (const entry of activeEntries()) activate(entry);
watchCamera(state.viewer);
remeasureGround();
notify();
}, signal);
}, },
disable() { disable() {
@@ -583,7 +579,7 @@ export function createStreetLevelLayer({
parts.viewerHost.unmount(); parts.viewerHost.unmount();
for (const entry of state.providers.values()) entry.instance.deactivate(); for (const entry of state.providers.values()) entry.instance.deactivate();
syncGroundClickReady(); syncGroundClickReady();
parts.credits.hideAll(state.viewer); parts.credits.hideAll(state.map);
parts.selection.uninstall(); parts.selection.uninstall();
parts.marker.setVisible(false); parts.marker.setVisible(false);
notify(); notify();
@@ -594,17 +590,17 @@ export function createStreetLevelLayer({
}, },
destroy() { destroy() {
const viewer = state.viewer; const map = state.map;
layer.disable(); layer.disable();
for (const entry of state.providers.values()) for (const entry of state.providers.values()) entry.instance.destroy();
entry.instance.destroy(viewer); parts.marker.destroy();
parts.marker.destroy(viewer); map?.destroy?.();
parts.framing.attachNavigation(null); parts.framing.attachNavigation(null);
unsubscribeMapStack?.(); unsubscribeMapStack?.();
unsubscribeMapStack = null; unsubscribeMapStack = null;
mapStack = null; mapStack = null;
state.listeners.clear(); state.listeners.clear();
state.viewer = null; state.map = null;
}, },
getStats() { getStats() {
@@ -716,7 +712,7 @@ export function createStreetLevelLayer({
return false; return false;
} }
} }
view ||= viewCentre(state.viewer); view ||= viewCentre(state.map);
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon)) if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
return false; return false;
abortNearest(); abortNearest();
+195 -161
View File
@@ -1,21 +1,34 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { createStreetLevelLayer } from './index.js'; import { createStreetLevelLayer } from './index.js';
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js'; import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
import { createMapillaryProvider } from './providers/mapillary/index.js'; import { createMapillaryProvider } from './providers/mapillary/index.js';
import { createKartaViewProvider } from './providers/kartaview/index.js'; import { createKartaViewProvider } from './providers/kartaview/index.js';
import { import {
fakeCesiumViewer,
fakeMapillarySource, fakeMapillarySource,
fakePhotoViewer, fakePhotoViewer,
fakeStreetLevelProvider as fakeProvider, fakeStreetLevelProvider as fakeProvider,
streetLevelMap,
} from '../../testSupport/streetLevelFakes.mjs'; } from '../../testSupport/streetLevelFakes.mjs';
import { setLocale } from '../../i18n/index.js'; import { setLocale } from '../../i18n/index.js';
/** /**
* A layer over `source` and `photoViewer`, initialised on a stand-in viewer * A layer drawing on `harness` (the display on the fake engine,
* and (unless `enable` is false) enabled. * streetLevelMap), initialised on its engine.
*/
function mountLayer(harness, providers, services = {}) {
const layer = createStreetLevelLayer({
providers,
services: { createDisplay: () => harness.map, ...services },
});
layer.init(null, { engine: harness.engine });
return layer;
}
/**
* A layer over `source` and `photoViewer`, initialised on the display of a
* fake engine and (unless `enable` is false) enabled.
*/ */
async function startLayer( async function startLayer(
t, t,
@@ -30,22 +43,15 @@ async function startLayer(
const saved = globalThis.document; const saved = globalThis.document;
// The click handler listens for Esc on the document. // The click handler listens for Esc on the document.
globalThis.document = new EventTarget(); globalThis.document = new EventTarget();
// Skip Cesium's one-time terrain table download for draped lines. const harness = streetLevelMap({ view });
t.mock.method( const layer = mountLayer(harness, providers);
Cesium.GroundPolylinePrimitive, if (enable) layer.enable();
'initializeTerrainHeights',
async () => {},
);
const viewer = fakeCesiumViewer({ view });
const layer = createStreetLevelLayer({ providers });
layer.init(viewer);
if (enable) layer.enable(viewer);
t.after(() => { t.after(() => {
layer.destroy(); layer.destroy();
globalThis.document = saved; globalThis.document = saved;
}); });
await settle(); await settle();
return { layer, viewer, source, photoViewer }; return { layer, harness, source, photoViewer };
} }
const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -65,8 +71,8 @@ const posingViewer = () =>
fakePhotoViewer({ pose: { position: { lon: -121.49, lat: 38.58 } } }); fakePhotoViewer({ pose: { position: { lon: -121.49, lat: 38.58 } } });
test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => { test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => {
const { layer, viewer } = await startLayer(t); const { layer, harness } = await startLayer(t);
const shown = () => viewer.credits.map((credit) => credit.html); const shown = () => harness.credits();
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on enable'); assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on enable');
assert.match(shown()[0], /CC BY-SA 4\.0/); assert.match(shown()[0], /CC BY-SA 4\.0/);
layer.setParams({ mapillary: false }); layer.setParams({ mapillary: false });
@@ -114,19 +120,19 @@ test('a key status that failed is asked again on the next switch-on', async (t)
return { configured: true }; return { configured: true };
}, },
}); });
const { layer, viewer } = await startLayer(t, { source, view: true }); const { layer, harness } = await startLayer(t, { source, view: true });
assert.equal(asked, 1, 'one check for init and the first switch-on'); assert.equal(asked, 1, 'one check for init and the first switch-on');
assert.equal(layer.getUIState().keyRequired, true); assert.equal(layer.getUIState().keyRequired, true);
up = true; // the server is reachable again up = true; // the server is reachable again
layer.disable(); layer.disable();
layer.enable(viewer); layer.enable();
await settle(); await settle();
assert.equal(asked, 2); assert.equal(asked, 2);
assert.equal(layer.getUIState().keyRequired, false); assert.equal(layer.getUIState().keyRequired, false);
assert.ok(source.calls.tiles.length > 0, 'coverage loads'); assert.ok(source.calls.tiles.length > 0, 'coverage loads');
// A known key is not asked again. // A known key is not asked again.
layer.disable(); layer.disable();
layer.enable(viewer); layer.enable();
await settle(); await settle();
assert.equal(asked, 2); assert.equal(asked, 2);
}); });
@@ -148,7 +154,7 @@ test('a key status that never answers gives up, so the next switch-on asks again
signal?.addEventListener('abort', () => reject(signal.reason)); signal?.addEventListener('abort', () => reject(signal.reason));
}), }),
}); });
const { layer, viewer } = await startLayer(t, { source, view: true }); const { layer, harness } = await startLayer(t, { source, view: true });
assert.equal(asked, 1); assert.equal(asked, 1);
assert.equal(timeouts[0].ms, 10_000, 'the check can be cut short'); assert.equal(timeouts[0].ms, 10_000, 'the check can be cut short');
assert.equal(layer.getUIState().coverage.loading, true, 'LOADING meanwhile'); assert.equal(layer.getUIState().coverage.loading, true, 'LOADING meanwhile');
@@ -157,7 +163,7 @@ test('a key status that never answers gives up, so the next switch-on asks again
assert.equal(layer.getUIState().coverage.loading, false); assert.equal(layer.getUIState().coverage.loading, false);
assert.equal(layer.getUIState().keyRequired, true, 'no token: KEY REQUIRED'); assert.equal(layer.getUIState().keyRequired, true, 'no token: KEY REQUIRED');
layer.disable(); layer.disable();
layer.enable(viewer); layer.enable();
assert.equal(asked, 2, 'asked again, not stuck on the first'); assert.equal(asked, 2, 'asked again, not stuck on the first');
}); });
@@ -170,7 +176,7 @@ test('a rejected key gates the layer as KEY REJECTED until it goes off', async (
throw rejected; throw rejected;
}, },
}); });
const { layer, viewer } = await startLayer(t, { source, view: true }); const { layer, harness } = await startLayer(t, { source, view: true });
await settle(); await settle();
const stats = layer.getStats(); const stats = layer.getStats();
assert.equal(stats.keyRequired, true); assert.equal(stats.keyRequired, true);
@@ -179,7 +185,7 @@ test('a rejected key gates the layer as KEY REJECTED until it goes off', async (
const ui = layer.getUIState(); const ui = layer.getUIState();
assert.deepEqual([ui.keyRequired, ui.keyRejected], [true, true]); assert.deepEqual([ui.keyRequired, ui.keyRejected], [true, true]);
layer.disable(); layer.disable();
layer.enable(viewer); layer.enable();
assert.equal(layer.getUIState().keyRejected, false, 'asked again once on'); assert.equal(layer.getUIState().keyRejected, false, 'asked again once on');
}); });
@@ -218,23 +224,23 @@ test('a sequence that fails to load reports it apart from the photo, and the err
}); });
test('closing the photo, switching the layer off or destroying it stops the framing flight', async (t) => { test('closing the photo, switching the layer off or destroying it stops the framing flight', async (t) => {
const { layer, viewer } = await startLayer(t, { const { layer, harness } = await startLayer(t, {
photoViewer: posingViewer(), photoViewer: posingViewer(),
}); });
layer.attachViewerHost({}); layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true); assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.equal(viewer.flights.started, 1, 'the photo is framed'); assert.equal(harness.flights.started, 1, 'the photo is framed');
layer.closeViewer(); layer.closeViewer();
assert.equal(viewer.flights.cancelled, 1, 'closed'); assert.equal(harness.flights.cancelled, 1, 'closed');
await layer.openImage('mapillary', 'img2'); await layer.openImage('mapillary', 'img2');
layer.disable(); layer.disable();
assert.equal(viewer.flights.cancelled, 2, 'layer off'); assert.equal(harness.flights.cancelled, 2, 'layer off');
layer.enable(viewer); layer.enable();
await layer.openImage('mapillary', 'img3'); await layer.openImage('mapillary', 'img3');
layer.destroy(); layer.destroy();
assert.equal(viewer.flights.cancelled, 3, 'destroyed'); assert.equal(harness.flights.cancelled, 3, 'destroyed');
}); });
test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => { test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => {
@@ -270,7 +276,7 @@ test('setParams ignores unknown keys and malformed values', async (t) => {
}); });
test('switching Mapillary on while the layer is off draws nothing', async (t) => { test('switching Mapillary on while the layer is off draws nothing', async (t) => {
const { layer, viewer, source, photoViewer } = await startLayer(t, { const { layer, harness, source, photoViewer } = await startLayer(t, {
view: true, view: true,
enable: false, enable: false,
}); });
@@ -279,18 +285,18 @@ test('switching Mapillary on while the layer is off draws nothing', async (t) =>
layer.setParams({ mapillary: true }); layer.setParams({ mapillary: true });
await settle(); await settle();
assert.equal(source.calls.tiles.length, 0, 'no coverage drawn'); assert.equal(source.calls.tiles.length, 0, 'no coverage drawn');
assert.deepEqual(viewer.credits, [], 'no credit shown'); assert.deepEqual(harness.credits(), [], 'no credit shown');
assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up'); assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up');
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept'); assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
// Enabling the layer is what draws it. // Enabling the layer is what draws it.
layer.enable(viewer); layer.enable();
await settle(); await settle();
assert.ok(source.calls.tiles.length > 0); assert.ok(source.calls.tiles.length > 0);
assert.equal(viewer.credits.length, 1); assert.equal(harness.credits().length, 1);
}); });
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => { test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
const { layer, viewer } = await startLayer(t, { providers: [] }); const { layer, harness } = await startLayer(t, { providers: [] });
assert.deepEqual(layer.providerIds, []); assert.deepEqual(layer.providerIds, []);
assert.deepEqual(layer.getUIState().providers, []); assert.deepEqual(layer.getUIState().providers, []);
assert.equal(layer.getUIState().keyRequired, false); assert.equal(layer.getUIState().keyRequired, false);
@@ -299,69 +305,13 @@ test('an empty provider composition enables and tears down without acquiring ima
/No street-level imagery providers/, /No street-level imagery providers/,
); );
layer.disable(); layer.disable();
layer.enable(viewer); layer.enable();
assert.deepEqual(layer.getUIState().providers, []); assert.deepEqual(layer.getUIState().providers, []);
}); });
// ── Several providers: one source at a time, Google 3D, nearest lookups ── // ── Several providers: one source at a time, Google 3D, nearest lookups ──
/** /** A layer over `providers`, initialised and enabled on the fake engine. */
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
* click handler, camera events, a primitive list and a credit display.
*/
function fakeViewer() {
const credits = [];
/** Cesium's flight bookkeeping: a new flight cancels the current one. */
let flight = null;
const flights = { started: 0, cancelled: 0 };
const stopFlight = () => {
const current = flight;
flight = null;
current?.cancel?.();
return Boolean(current);
};
const canvas = Object.assign(new EventTarget(), {
style: {},
// Keep Cesium's handler on the canvas; there is no real document here.
disableRootEvents: true,
onwheel: null,
});
return {
credits,
flights,
scene: {
canvas,
primitives: { add: (p) => p, remove() {} },
// Enough of a scene for the position marker to clamp to the ground.
frameState: { mode: Cesium.SceneMode.SCENE3D },
updateHeight: () => () => {},
getHeight: () => undefined,
},
camera: {
changed: new Cesium.Event(),
moveStart: new Cesium.Event(),
moveEnd: new Cesium.Event(),
flyToBoundingSphere(sphere, options = {}) {
stopFlight();
flight = options;
flights.started++;
},
cancelFlight() {
if (stopFlight()) flights.cancelled++;
},
},
creditDisplay: {
addStaticCredit: (credit) => credits.push(credit),
removeStaticCredit: (credit) => {
// Cesium ignores a credit it does not hold.
const at = credits.indexOf(credit);
if (at !== -1) credits.splice(at, 1);
},
},
};
}
/** A layer over `providers`, initialised and enabled on a stand-in viewer. */
async function enabledLayer(t, providers = [fakeProvider()]) { async function enabledLayer(t, providers = [fakeProvider()]) {
const saved = globalThis.document; const saved = globalThis.document;
// A pose moves the position marker, whose glyph is drawn on a canvas. // A pose moves the position marker, whose glyph is drawn on a canvas.
@@ -369,16 +319,15 @@ async function enabledLayer(t, providers = [fakeProvider()]) {
globalThis.document = Object.assign(new EventTarget(), { globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }), createElement: () => ({ getContext: () => drawing }),
}); });
const viewer = fakeViewer(); const harness = streetLevelMap();
const layer = createStreetLevelLayer({ providers }); const layer = mountLayer(harness, providers);
layer.init(viewer); layer.enable();
layer.enable(viewer);
t.after(() => { t.after(() => {
layer.destroy(); layer.destroy();
globalThis.document = saved; globalThis.document = saved;
}); });
await settle(); await settle();
return { layer, viewer }; return { layer, harness };
} }
function posingProvider(options) { function posingProvider(options) {
@@ -536,7 +485,7 @@ test('an image picked during a nearest lookup wins over its late answer', async
test('closing the viewer or switching the layer off retires a nearest lookup', async (t) => { test('closing the viewer or switching the layer off retires a nearest lookup', async (t) => {
const { lookups, nearestImage } = slowLookups(); const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage }); const provider = fakeProvider({ nearestImage });
const { layer, viewer } = await enabledLayer(t, [provider]); const { layer, harness } = await enabledLayer(t, [provider]);
layer.attachViewerHost({}); layer.attachViewerHost({});
const closed = layer.openNearest({ lat: 38.58, lon: -121.49 }); const closed = layer.openNearest({ lat: 38.58, lon: -121.49 });
@@ -553,7 +502,7 @@ test('closing the viewer or switching the layer off retires a nearest lookup', a
const disabled = layer.openNearest({ lat: 38.58, lon: -121.49 }); const disabled = layer.openNearest({ lat: 38.58, lon: -121.49 });
layer.disable(); layer.disable();
layer.enable(viewer); layer.enable();
lookups[1].answer.resolve('img1'); lookups[1].answer.resolve('img1');
assert.equal(await disabled, false, 'nor does it open once back on'); assert.equal(await disabled, false, 'nor does it open once back on');
assert.deepEqual(provider.calls.open, []); assert.deepEqual(provider.calls.open, []);
@@ -571,14 +520,13 @@ function initialisedLayer(t, providers) {
globalThis.document = Object.assign(new EventTarget(), { globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }), createElement: () => ({ getContext: () => drawing }),
}); });
const viewer = fakeViewer(); const harness = streetLevelMap();
const layer = createStreetLevelLayer({ providers }); const layer = mountLayer(harness, providers);
layer.init(viewer);
t.after(() => { t.after(() => {
layer.destroy(); layer.destroy();
globalThis.document = saved; globalThis.document = saved;
}); });
return { layer, viewer }; return { layer, harness };
} }
test('switching a provider on while the layer is off activates nothing (M01)', async (t) => { test('switching a provider on while the layer is off activates nothing (M01)', async (t) => {
@@ -590,7 +538,7 @@ test('switching a provider on while the layer is off activates nothing (M01)', a
instance.viewer.prewarm = async (host) => prewarmed.push(host); instance.viewer.prewarm = async (host) => prewarmed.push(host);
return instance; return instance;
}; };
const { layer, viewer } = initialisedLayer(t, [provider]); const { layer, harness } = initialisedLayer(t, [provider]);
layer.attachViewerHost({}); layer.attachViewerHost({});
layer.setProviderEnabled('mapillary', false); layer.setProviderEnabled('mapillary', false);
layer.setProviderEnabled('mapillary', true); layer.setProviderEnabled('mapillary', true);
@@ -598,13 +546,13 @@ test('switching a provider on while the layer is off activates nothing (M01)', a
layer.setParams({ mapillary: true }); layer.setParams({ mapillary: true });
await new Promise((resolve) => setTimeout(resolve, 40)); // past whenIdle await new Promise((resolve) => setTimeout(resolve, 40)); // past whenIdle
assert.equal(provider.calls.activate, 0, 'no coverage drawn'); assert.equal(provider.calls.activate, 0, 'no coverage drawn');
assert.deepEqual(viewer.credits, [], 'no credit shown'); assert.deepEqual(harness.credits(), [], 'no credit shown');
assert.deepEqual(prewarmed, [], 'no viewer stood up'); assert.deepEqual(prewarmed, [], 'no viewer stood up');
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept'); assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
// Enabling the layer is what activates it, once. // Enabling the layer is what activates it, once.
layer.enable(viewer); layer.enable();
assert.equal(provider.calls.activate, 1); assert.equal(provider.calls.activate, 1);
assert.equal(viewer.credits.length, 1); assert.equal(harness.credits().length, 1);
}); });
/** The real Google provider over the Street View fakes, beside a Mapillary stand-in. */ /** The real Google provider over the Street View fakes, beside a Mapillary stand-in. */
@@ -642,11 +590,11 @@ async function withStreetView(
getApiKey: () => 'browser-key', getApiKey: () => 'browser-key',
loader, loader,
}); });
const { layer, viewer } = await enabledLayer(t, [mapillary, google]); const { layer, harness } = await enabledLayer(t, [mapillary, google]);
const stack = fakeMapStack(mapStack); const stack = fakeMapStack(mapStack);
layer.attachMapStackController(stack); layer.attachMapStackController(stack);
layer.attachViewerHost(fakeHost()); layer.attachViewerHost(fakeHost());
return { layer, viewer, stack, fake, loader, mapillaryLookups }; return { layer, harness, stack, fake, loader, mapillaryLookups };
} }
test('Street View starts off, and the layer row names the key of the providers that are on', async (t) => { test('Street View starts off, and the layer row names the key of the providers that are on', async (t) => {
@@ -758,7 +706,7 @@ test('one imagery source at a time: lighting one darkens the other, either way',
}); });
test('Street View appears only on the Google 3D map: chip, credit, source and images', async (t) => { test('Street View appears only on the Google 3D map: chip, credit, source and images', async (t) => {
const { layer, viewer, stack } = await withStreetView(t, { const { layer, harness, stack } = await withStreetView(t, {
mapStack: 'esri-imagery', mapStack: 'esri-imagery',
}); });
layer.setProviderEnabled('google', true); layer.setProviderEnabled('google', true);
@@ -766,7 +714,7 @@ test('Street View appears only on the Google 3D map: chip, credit, source and im
const ui = () => layer.getUIState(); const ui = () => layer.getUIState();
const listed = () => ui().providers.map((p) => p.id); const listed = () => ui().providers.map((p) => p.id);
const credited = () => const credited = () =>
viewer.credits.some((credit) => /Street View imagery/.test(credit.html)); harness.credits().some((html) => /Street View imagery/.test(html));
assert.deepEqual(listed(), ['mapillary'], 'no Street View chip'); assert.deepEqual(listed(), ['mapillary'], 'no Street View chip');
assert.equal(credited(), false, 'no Google credit on another map'); assert.equal(credited(), false, 'no Google credit on another map');
assert.equal(layer.source, 'Mapillary'); assert.equal(layer.source, 'Mapillary');
@@ -795,17 +743,16 @@ test('under Google 3D the ground is measured again once its tiles load, without
t.after(() => { t.after(() => {
globalThis.document = saved; globalThis.document = saved;
}); });
const harness = streetLevelMap();
// Google 3D (the globe hidden), 4 km up the ellipsoid, 2 km over a city
// whose tiles are still loading.
await harness.engine.maps.setPhotorealTiles({ kind: 'photoreal' });
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const viewer = fakeViewer();
// 4 km up the ellipsoid, 2 km over a city whose tiles are still loading.
let ground; let ground;
viewer.camera.positionCartographic = { height: 4000 }; harness.engine.fake.setPose({ height: 4000 });
viewer.scene.globe = { show: false }; harness.engine.fake.script('sampleHeight', () => ground);
viewer.scene.sampleHeightSupported = true; const layer = mountLayer(harness, [fakeProvider()]);
viewer.scene.sampleHeight = () => ground; layer.enable();
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
layer.init(viewer);
layer.enable(viewer);
t.after(() => layer.destroy()); t.after(() => layer.destroy());
assert.equal(layer.getUIState().groundClickReady, false); assert.equal(layer.getUIState().groundClickReady, false);
@@ -814,31 +761,29 @@ test('under Google 3D the ground is measured again once its tiles load, without
assert.equal(layer.getUIState().groundClickReady, true); assert.equal(layer.getUIState().groundClickReady, true);
}); });
test('an unknown ground is measured only a few times, and not after the layer is off', (t) => { test('an unknown ground is measured only a few times, and not after the layer is off', async (t) => {
const saved = globalThis.document; const saved = globalThis.document;
globalThis.document = new EventTarget(); globalThis.document = new EventTarget();
t.after(() => { t.after(() => {
globalThis.document = saved; globalThis.document = saved;
}); });
const harness = streetLevelMap();
await harness.engine.maps.setPhotorealTiles({ kind: 'photoreal' });
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const viewer = fakeViewer();
let samples = 0; let samples = 0;
viewer.camera.positionCartographic = { height: 4000 }; harness.engine.fake.setPose({ height: 4000 });
viewer.scene.globe = { show: false }; harness.engine.fake.script('sampleHeight', () => {
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => {
samples++; samples++;
return undefined; return undefined;
}; });
const layer = createStreetLevelLayer({ providers: [fakeProvider()] }); const layer = mountLayer(harness, [fakeProvider()]);
layer.init(viewer); layer.enable();
layer.enable(viewer);
t.after(() => layer.destroy()); t.after(() => layer.destroy());
t.mock.timers.tick(60_000); t.mock.timers.tick(60_000);
const measured = samples; const measured = samples;
assert.ok(measured > 1 && measured <= 12, `measured ${measured} times`); assert.ok(measured > 1 && measured <= 12, `measured ${measured} times`);
viewer.camera.moveEnd.raiseEvent(); harness.engine.fake.emit('camera.moveEnd');
layer.disable(); layer.disable();
const atDisable = samples; const atDisable = samples;
t.mock.timers.tick(60_000); t.mock.timers.tick(60_000);
@@ -887,7 +832,7 @@ test('the nearest-image refusal is written in the language of the page', async (
}); });
test('an image opened from a ground click leaves the camera where it is', async (t) => { test('an image opened from a ground click leaves the camera where it is', async (t) => {
const { layer, viewer } = await withStreetView(t); const { layer, harness } = await withStreetView(t);
layer.setProviderEnabled('google', true); layer.setProviderEnabled('google', true);
await settle(); await settle();
await layer.openNearest( await layer.openNearest(
@@ -895,7 +840,7 @@ test('an image opened from a ground click leaves the camera where it is', async
{ providerIds: ['google'], frame: false }, { providerIds: ['google'], frame: false },
); );
assert.equal(layer.getUIState().street.imageId, 'pano-a'); assert.equal(layer.getUIState().street.imageId, 'pano-a');
assert.equal(viewer.flights.started, 0, 'no framing flight'); assert.equal(harness.flights.started, 0, 'no framing flight');
}); });
test('off Google 3D not even Google\u2019s script is loaded for Street View', async (t) => { test('off Google 3D not even Google\u2019s script is loaded for Street View', async (t) => {
@@ -923,17 +868,18 @@ test('the button’s “centre of the view” is the ground under the middle of
globalThis.document = Object.assign(new EventTarget(), { globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }), createElement: () => ({ getContext: () => drawing }),
}); });
const viewer = fakeViewer(); const harness = streetLevelMap();
const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0); const centre = geo.cartesian3.fromDegrees(-121.4937, 38.5799, 0);
Object.assign(viewer.scene, { harness.engine.fake.script('pickPosition', (at) =>
pickPositionSupported: true, at.x === 500 && at.y === 300 ? centre : undefined,
pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined), );
harness.engine.fake.setSize(1000, 600);
const layer = create({
providers: [provider],
services: { scenePick, createDisplay: () => harness.map },
}); });
viewer.scene.canvas.clientWidth = 1000; layer.init(null, { engine: harness.engine });
viewer.scene.canvas.clientHeight = 600; layer.enable();
const layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
t.after(() => { t.after(() => {
layer.destroy(); layer.destroy();
globalThis.document = saved; globalThis.document = saved;
@@ -959,18 +905,17 @@ test('the button aimed at the sky opens nothing, and says to aim at a street', a
globalThis.document = Object.assign(new EventTarget(), { globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }), createElement: () => ({ getContext: () => drawing }),
}); });
const viewer = fakeViewer(); const harness = streetLevelMap();
// Every ground pick misses: the middle of the screen is sky. // Every ground pick misses: the middle of the screen is sky.
Object.assign(viewer.scene, { harness.engine.fake.script('pickPosition', undefined);
pickPositionSupported: true, harness.engine.fake.script('pickEllipsoid', undefined);
pickPosition: () => undefined, harness.engine.fake.setSize(1000, 600);
const layer = create({
providers: [provider],
services: { scenePick, createDisplay: () => harness.map },
}); });
viewer.camera.pickEllipsoid = () => undefined; layer.init(null, { engine: harness.engine });
viewer.scene.canvas.clientWidth = 1000; layer.enable();
viewer.scene.canvas.clientHeight = 600;
const layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
t.after(() => { t.after(() => {
layer.destroy(); layer.destroy();
globalThis.document = saved; globalThis.document = saved;
@@ -1009,7 +954,7 @@ test('a failed lookup’s error goes when another source is lit; a provider can
}, },
}); });
const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' }); const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' });
const { layer, viewer } = await enabledLayer(t, [failing, other]); const { layer, harness } = await enabledLayer(t, [failing, other]);
layer.attachViewerHost({}); layer.attachViewerHost({});
await layer.openNearest({ lat: 1, lon: 1 }); await layer.openNearest({ lat: 1, lon: 1 });
assert.match(layer.getUIState().street.error, /did not answer/); assert.match(layer.getUIState().street.error, /did not answer/);
@@ -1019,18 +964,18 @@ test('a failed lookup’s error goes when another source is lit; a provider can
// A provider's own open (KartaView's ‹ ›) can leave the camera alone. // A provider's own open (KartaView's ‹ ›) can leave the camera alone.
await other.context().actions.openImage('img-1', { frame: false }); await other.context().actions.openImage('img-1', { frame: false });
assert.deepEqual(other.calls.open, ['img-1']); assert.deepEqual(other.calls.open, ['img-1']);
assert.equal(viewer.flights.started, 0); assert.equal(harness.flights.started, 0);
}); });
test('a provider’s own open can leave the camera where it is; a default open frames', async (t) => { test('a provider’s own open can leave the camera where it is; a default open frames', async (t) => {
const provider = posingProvider(); const provider = posingProvider();
const { layer, viewer } = await enabledLayer(t, [provider]); const { layer, harness } = await enabledLayer(t, [provider]);
layer.attachViewerHost({}); layer.attachViewerHost({});
await provider.context().actions.openImage('img-1', { frame: false }); await provider.context().actions.openImage('img-1', { frame: false });
assert.deepEqual(provider.calls.open, ['img-1']); assert.deepEqual(provider.calls.open, ['img-1']);
assert.equal(viewer.flights.started, 0, 'frame: false keeps the camera'); assert.equal(harness.flights.started, 0, 'frame: false keeps the camera');
await provider.context().actions.openImage('img-2'); await provider.context().actions.openImage('img-2');
assert.equal(viewer.flights.started, 1, 'a default open frames the image'); assert.equal(harness.flights.started, 1, 'a default open frames the image');
}); });
test('a provider’s openNearest (a line click) asks only that provider and leaves the camera', async (t) => { test('a provider’s openNearest (a line click) asks only that provider and leaves the camera', async (t) => {
@@ -1040,7 +985,7 @@ test('a provider’s openNearest (a line click) asks only that provider and leav
pickPrefix: 'oth:', pickPrefix: 'oth:',
nearestImage: async () => 'other-near', nearestImage: async () => 'other-near',
}); });
const { layer, viewer } = await enabledLayer(t, [lit, other]); const { layer, harness } = await enabledLayer(t, [lit, other]);
layer.attachViewerHost({}); layer.attachViewerHost({});
const point = { lat: 38.58, lon: -121.49 }; const point = { lat: 38.58, lon: -121.49 };
// The other source is off: its click asks nobody else. // The other source is off: its click asks nobody else.
@@ -1050,7 +995,7 @@ test('a provider’s openNearest (a line click) asks only that provider and leav
assert.deepEqual(other.calls.open, []); assert.deepEqual(other.calls.open, []);
await lit.context().actions.openNearest(point); await lit.context().actions.openNearest(point);
assert.deepEqual(lit.calls.open, ['lit-near']); assert.deepEqual(lit.calls.open, ['lit-near']);
assert.equal(viewer.flights.started, 0, 'the user picked the spot'); assert.equal(harness.flights.started, 0, 'the user picked the spot');
}); });
test('KartaView through the core: its lines counted as streets', async (t) => { test('KartaView through the core: its lines counted as streets', async (t) => {
@@ -1183,3 +1128,92 @@ test('filter-only updates keep the lit source, even a chosen keyless Mapillary',
); );
} }
}); });
// ── The whole layer on the Cesium engine ────────────────────────────────
test('on the Cesium engine the layer drapes coverage, selects a line on a click and draws its cones', async (t) => {
const { encodeCoverageTile } =
await import('./providers/mapillary/coverageFixture.mjs');
const { tileBounds } = await import('./tileMath.js');
const { cesiumStreetLevelMap } =
await import('../../testSupport/streetLevelFakes.mjs');
const saved = globalThis.document;
globalThis.document = new EventTarget();
const harness = cesiumStreetLevelMap();
// One sequence across the middle of every tile asked for.
const source = fakeMapillarySource({
getTile: async (_layer, z, x, y) => {
const { west, east, south, north } = tileBounds(x, y, z);
const lat = (south + north) / 2;
return encodeCoverageTile(
{ x, y, z },
{
sequences: [
{
id: 'seq-1',
parts: [
[
[west + (east - west) * 0.25, lat],
[west + (east - west) * 0.75, lat],
],
],
},
],
},
);
},
getSequenceImages: async () => [
{
id: 'img-1',
geometry: { coordinates: [-121.4944, 38.5816] },
compass_angle: 90,
captured_at: 1_700_000_000_000,
},
],
});
const layer = mountLayer(harness, [
createMapillaryProvider({ source, photoViewer: fakePhotoViewer() }),
]);
layer.enable();
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
await settle();
await settle();
const lines = harness.groundSets('mapillary-coverage:');
assert.ok(lines.length > 0, 'coverage drawn');
assert.ok(
harness.engine.ground
.list()
.some((entry) => entry.id === lines[0].id && entry.count > 0),
'through the engine’s ground sets',
);
assert.equal(lines[0].options.drape, 'both');
assert.ok(lines[0].items.every((item) => item.pickId === 'mly:seq:seq-1'));
// Cesium answers the click's pick in a 9 px window.
const windows = [];
const pick = harness.viewer.scene.pick;
harness.viewer.scene.pick = (position, width, height) => {
windows.push([width, height]);
return pick(position, width, height);
};
harness.click({ id: 'mly:seq:seq-1', primitive: {} });
assert.deepEqual(windows, [[9, 9]]);
await settle();
assert.equal(layer.getUIState().sequence.selectedId, 'seq-1');
assert.ok(
harness.groundSets('mapillary-coverage:selected').length > 0,
'highlighted',
);
harness.frame();
assert.deepEqual(
harness.spritesShown('street-level:mapillary-cones').map((s) => s.pickId),
['mly:img:img-1'],
'its cone drawn',
);
layer.disable();
assert.equal(harness.groundSets('mapillary-coverage:').length, 0);
harness.frame();
assert.equal(harness.spritesShown('street-level:').length, 0);
});
+52 -130
View File
@@ -1,11 +1,13 @@
import * as Cesium from 'cesium';
import { passesImageryFilter } from './filter.js'; import { passesImageryFilter } from './filter.js';
import { groundUnderCamera, viewFocus, visibleBbox } from './view.js'; import { groundUnderCamera, viewFocus, visibleBbox } from './view.js';
import { tilesForBbox } from './tileMath.js'; import { tilesForBbox } from './tileMath.js';
import { msg } from '../../i18n/index.js'; import { msg } from '../../i18n/index.js';
/** Sequences per primitive: smaller batches build, and show, sooner. */ /** Line alpha of the coverage; the selected sequence is drawn opaque. */
const SEQUENCE_PRIMITIVE_BATCH = 120; const COVERAGE_ALPHA = 0.92;
/** Draw order of the ground sets: the selected sequence over the coverage. */
const COVERAGE_ORDER = 0;
const HIGHLIGHT_ORDER = 1;
/** Hint above the street-zoom ceiling, where no coverage is drawn. */ /** Hint above the street-zoom ceiling, where no coverage is drawn. */
export const ZOOM_IN_HINT = msg('Zoom in to see street-level coverage'); export const ZOOM_IN_HINT = msg('Zoom in to see street-level coverage');
@@ -45,48 +47,10 @@ export function freshCoverage({ keyless = false } = {}) {
blockTimer: null, blockTimer: null,
debounceTimer: null, debounceTimer: null,
removeCameraListener: null, removeCameraListener: null,
terrainReady: null,
hint: '', hint: '',
}; };
} }
/** Line instances for sequences' parts, every part picking its sequence. */
function lineInstances(sequences, color, width, prefix) {
const attributes = {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(color),
};
const instances = [];
for (const sequence of sequences)
for (const coordinates of sequence.parts) {
let positions;
try {
positions = Cesium.Cartesian3.fromDegreesArray(coordinates.flat());
} catch {
continue;
}
if (positions.length < 2) continue;
instances.push(
new Cesium.GeometryInstance({
geometry: new Cesium.GroundPolylineGeometry({ positions, width }),
id: `${prefix}${sequence.id}`,
attributes,
}),
);
}
return instances;
}
/** Draped lines that follow terrain and 3D tiles (Google 3D) alike. */
function linePrimitive(geometryInstances, asynchronous) {
return new Cesium.GroundPolylinePrimitive({
geometryInstances,
appearance: new Cesium.PolylineColorAppearance(),
classificationType: Cesium.ClassificationType.BOTH,
asynchronous,
allowPicking: true,
});
}
function pointsOf(sequence) { function pointsOf(sequence) {
let count = 0; let count = 0;
for (const part of sequence.parts) count += part.length; for (const part of sequence.parts) count += part.length;
@@ -126,11 +90,11 @@ export function coverageZoom(level) {
/** /**
* Camera-driven sequence lines from any XYZ line-tile source, near the * Camera-driven sequence lines from any XYZ line-tile source, near the
* ground only, draped on terrain and 3D tiles alike. Nothing is drawn from * ground only, draped on terrain and 3D tiles alike (the display's ground
* orbit. * corridors, render/display.js). Nothing is drawn from orbit.
* *
* @param {object} options * @param {object} options
* @param {object} options.state Provider state: `context`, `services`, `viewer`, `coverage` (freshCoverage), `sequence`. * @param {object} options.state Provider state: `context`, `services`, `map` (the display), `coverage` (freshCoverage), `sequence`.
* @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array<object>}>} options.fetchTile * @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array<object>}>} options.fetchTile
* A decoded tile; sequences are `{id, parts, capturedAt, isPano}`. A * A decoded tile; sequences are `{id, parts, capturedAt, isPano}`. A
* rejection may carry `keyRequired`, `keyRejected` or `retryAfterSec`. * rejection may carry `keyRequired`, `keyRejected` or `retryAfterSec`.
@@ -165,12 +129,6 @@ export function createLineCoverage({
render?.governorRequestRender?.(renderKey); render?.governorRequestRender?.(renderKey);
} }
function ensureTerrainReady() {
if (state.viewer?.engine) return;
return (state.coverage.terrainReady ||=
Cesium.GroundPolylinePrimitive.initializeTerrainHeights());
}
function tileKey({ x, y, z }) { function tileKey({ x, y, z }) {
return `${z}/${x}/${y}`; return `${z}/${x}/${y}`;
} }
@@ -209,35 +167,25 @@ export function createLineCoverage({
return drawn; return drawn;
} }
/** Batched draped primitives for a tile's sequences. */ /** The display's lines for `sequences` (`color` CSS, at `alpha`), or null. */
function buildSequencePrimitives(sequences) { function drawLines(sequences, id, style) {
const instances = lineInstances( return (
sequences, state.map?.sequenceLines?.(sequences, {
Cesium.Color.fromCssColorString(colors.coverage).withAlpha(0.92), id: `${renderKey}:${id}`,
tuning.lineWidthPx, pickPrefix,
pickPrefix, ...style,
}) ?? null
); );
const primitives = [];
for (let i = 0; i < instances.length; i += SEQUENCE_PRIMITIVE_BATCH)
primitives.push(
linePrimitive(instances.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), true),
);
return primitives;
} }
/** /**
* Draw the selected sequence again over the coverage, in the selection * Draw the selected sequence again over the coverage, in the selection
* colour, from every loaded tile it crosses; none when nothing is selected. * colour, from every loaded tile it crosses; none when nothing is selected.
* Built at once: its tiles loaded, so the terrain table has too.
*/ */
function highlight(sequenceId = state.sequence.selectedId) { function highlight(sequenceId = state.sequence.selectedId) {
const scene = state.viewer?.scene; state.coverage.highlight?.destroy();
if (state.coverage.highlight) { state.coverage.highlight = null;
if (state.viewer?.engine) state.coverage.highlight.destroy(); if (!sequenceId || !state.map) return;
else scene?.groundPrimitives?.remove(state.coverage.highlight);
state.coverage.highlight = null;
}
if (!sequenceId || (!state.viewer?.engine && !scene)) return;
// From every loaded line, drawn or not: a sequence selected after its // From every loaded line, drawn or not: a sequence selected after its
// tiles were drawn may have been left out by their caps. // tiles were drawn may have been left out by their caps.
const current = filter(); const current = filter();
@@ -246,56 +194,36 @@ export function createLineCoverage({
const sequence = entry.sequenceList.find(({ id }) => id === sequenceId); const sequence = entry.sequenceList.find(({ id }) => id === sequenceId);
if (sequence && passes(sequence, current)) parts.push(...sequence.parts); if (sequence && passes(sequence, current)) parts.push(...sequence.parts);
} }
if (state.viewer?.engine) { state.coverage.highlight = drawLines(
state.coverage.highlight = state.viewer.lines(
[{ id: sequenceId, parts }],
colors.selected,
tuning.lineWidthPx + 1,
pickPrefix,
);
requestRender();
return;
}
const instances = lineInstances(
[{ id: sequenceId, parts }], [{ id: sequenceId, parts }],
Cesium.Color.fromCssColorString(colors.selected), 'selected',
tuning.lineWidthPx + 1, {
pickPrefix, color: colors.selected,
); widthPx: tuning.lineWidthPx + 1,
if (!instances.length) return; order: HIGHLIGHT_ORDER,
state.coverage.highlight = scene.groundPrimitives.add( },
linePrimitive(instances, false),
); );
requestRender(); requestRender();
} }
function attachPrimitive(entry) { function attachPrimitive(entry, key) {
const scene = state.viewer?.scene; if (!state.map) return;
if (!scene && !state.viewer?.engine) return;
// Lookup, picking and counts follow what is drawn, not the whole tile. // Lookup, picking and counts follow what is drawn, not the whole tile.
const sequences = drawnSequences(entry); const sequences = drawnSequences(entry);
entry.sequences = new Map(sequences.map((s) => [s.id, s])); entry.sequences = new Map(sequences.map((s) => [s.id, s]));
if (state.viewer?.engine) { const lines = drawLines(sequences, key, {
const group = state.viewer.lines( color: colors.coverage,
sequences, alpha: COVERAGE_ALPHA,
colors.coverage, widthPx: tuning.lineWidthPx,
tuning.lineWidthPx, order: COVERAGE_ORDER,
pickPrefix, });
); entry.primitives = lines ? [lines] : [];
entry.primitives = group ? [group] : [];
return;
}
entry.primitives = buildSequencePrimitives(sequences);
for (const primitive of entry.primitives)
scene.groundPrimitives.add(primitive);
} }
function detachPrimitive(entry) { function detachPrimitive(entry) {
const scene = state.viewer?.scene;
for (const primitive of entry.primitives || []) { for (const primitive of entry.primitives || []) {
try { try {
if (state.viewer?.engine) primitive.destroy(); primitive.destroy();
else scene?.groundPrimitives?.remove(primitive);
} catch { } catch {
/* already gone */ /* already gone */
} }
@@ -318,12 +246,7 @@ export function createLineCoverage({
state.coverage.pending.set(key, controller); state.coverage.pending.set(key, controller);
notify(); notify();
try { try {
// Fetch and the one-time terrain-height table load run side by side, const decoded = await fetchTile(tile, { signal: controller.signal });
// both handled from the start so an early tile failure is not unhandled.
const [decoded] = await Promise.all([
fetchTile(tile, { signal: controller.signal }),
ensureTerrainReady(),
]);
// Only an abort, a zoom change, a clear or a newer request for this tile // Only an abort, a zoom change, a clear or a newer request for this tile
// discards it; a refresh that still wants the tile keeps it. // discards it; a refresh that still wants the tile keeps it.
if ( if (
@@ -341,7 +264,7 @@ export function createLineCoverage({
? sequences ? sequences
: sequences.sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0)); : sequences.sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0));
entry.total = sequences.length; entry.total = sequences.length;
attachPrimitive(entry); attachPrimitive(entry, key);
state.coverage.tiles.set(key, entry); state.coverage.tiles.set(key, entry);
state.coverage.lastError = null; state.coverage.lastError = null;
// Over the new lines, and along any of the selection's parts they add. // Over the new lines, and along any of the selection's parts they add.
@@ -417,11 +340,9 @@ export function createLineCoverage({
* The tiles for the camera: what the screen shows within range of it, at * The tiles for the camera: what the screen shows within range of it, at
* the zoom for its height above ground, nearest the camera first. * the zoom for its height above ground, nearest the camera first.
*/ */
function wantedTiles(viewer) { function wantedTiles(map) {
const ground = groundUnderCamera(viewer); const ground = groundUnderCamera(map);
const cameraHeight = const cameraHeight = map.engine?.camera.height();
viewer.engine?.camera.pose().cartographic.height ??
viewer.camera?.positionCartographic?.height;
const height = Number.isFinite(cameraHeight) const height = Number.isFinite(cameraHeight)
? cameraHeight - (ground ?? 0) ? cameraHeight - (ground ?? 0)
: null; : null;
@@ -436,28 +357,28 @@ export function createLineCoverage({
height * tuning.viewRangePerHeight, height * tuning.viewRangePerHeight,
), ),
}; };
const bbox = visibleBbox(viewer, ranged); const bbox = visibleBbox(map, ranged);
if (!bbox) return { hint: NO_GROUND_HINT }; if (!bbox) return { hint: NO_GROUND_HINT };
// Ranked from between the camera's ground point and the screen centre's: // Ranked from between the camera's ground point and the screen centre's:
// a tilted view's box centre can sit kilometres past both. // a tilted view's box centre can sit kilometres past both.
const tiles = tilesForBbox(bbox, zoom, { const tiles = tilesForBbox(bbox, zoom, {
limit: tuning.maxTiles, limit: tuning.maxTiles,
from: viewFocus(viewer, ranged), from: viewFocus(map, ranged),
}); });
return { zoom, tiles }; return { zoom, tiles };
} }
/** Recompute the tile set for the current camera and reconcile primitives. */ /** Recompute the tile set for the current camera and reconcile primitives. */
function refresh() { function refresh() {
const viewer = state.viewer; const map = state.map;
if ( if (
!viewer || !map ||
state.context.waitingForView?.() || state.context.waitingForView?.() ||
!state.context.isActive() || !state.context.isActive() ||
state.coverage.blocked state.coverage.blocked
) )
return; return;
const { zoom, tiles, hint } = wantedTiles(viewer); const { zoom, tiles, hint } = wantedTiles(map);
if (hint) { if (hint) {
state.coverage.hint = hint; state.coverage.hint = hint;
clear(); clear();
@@ -485,10 +406,11 @@ export function createLineCoverage({
state.coverage.debounceTimer = setTimeout(refresh, tuning.moveDebounceMs); state.coverage.debounceTimer = setTimeout(refresh, tuning.moveDebounceMs);
} }
function attach(viewer) { function attach(map) {
detach(); detach();
const remove = viewer.camera.changed.addEventListener(scheduleRefresh); const { camera } = map.engine;
const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh); const remove = camera.changed.addEventListener(scheduleRefresh);
const removeEnd = camera.moveEnd.addEventListener(scheduleRefresh);
state.coverage.removeCameraListener = () => { state.coverage.removeCameraListener = () => {
remove(); remove();
removeEnd(); removeEnd();
@@ -515,9 +437,9 @@ export function createLineCoverage({
/** Rebuild every loaded tile from its decoded lines (after a filter change). */ /** Rebuild every loaded tile from its decoded lines (after a filter change). */
function rebuild() { function rebuild() {
for (const entry of state.coverage.tiles.values()) { for (const [key, entry] of state.coverage.tiles) {
detachPrimitive(entry); detachPrimitive(entry);
attachPrimitive(entry); attachPrimitive(entry, key);
} }
highlight(); highlight();
requestRender(); requestRender();
+86 -108
View File
@@ -1,6 +1,5 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium';
import { import {
coverageZoom, coverageZoom,
createLineCoverage, createLineCoverage,
@@ -10,7 +9,11 @@ import {
import { resolveFilter } from './filter.js'; import { resolveFilter } from './filter.js';
import { encodeCoverageTile } from './providers/mapillary/coverageFixture.mjs'; import { encodeCoverageTile } from './providers/mapillary/coverageFixture.mjs';
import { decodeCoverageTile } from './providers/mapillary/decode.js'; import { decodeCoverageTile } from './providers/mapillary/decode.js';
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs'; import {
cameraEngine,
coverageMap,
streetLevelMap,
} from '../../testSupport/streetLevelFakes.mjs';
import { import {
COLORS, COLORS,
COVERAGE_TUNING, COVERAGE_TUNING,
@@ -50,26 +53,9 @@ function createCoverage({ state, source, zoomFor = coverageZoomForHeight }) {
} }
const RAD = Math.PI / 180; const RAD = Math.PI / 180;
// Node has no WebGL context to report line-width limits; a browser's
// context sets these on startup, and appearances validate against them.
Cesium.ContextLimits._minimumAliasedLineWidth = 1;
Cesium.ContextLimits._maximumAliasedLineWidth = 10;
const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** Ground sets of the Mapillary coverage (and its highlight). */
/** A scene collection that remembers what is on the globe. */ const PREFIX = 'mapillary-coverage:';
function collection() {
const items = new Set();
return {
items,
add(primitive) {
items.add(primitive);
return primitive;
},
remove(primitive) {
return items.delete(primitive);
},
};
}
/** The centre of the z14 tile holding a point, so a small view is one tile. */ /** The centre of the z14 tile holding a point, so a small view is one tile. */
function tileCentre(lon, lat) { function tileCentre(lon, lat) {
@@ -83,34 +69,6 @@ function tileCentre(lon, lat) {
}; };
} }
/** A viewer looking straight down on a 0.004° square around `view`. */
function fakeViewer(view) {
return {
view,
scene: {
canvas: { clientWidth: 100, clientHeight: 100 },
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
groundPrimitives: collection(),
primitives: collection(),
},
camera: {
get positionCartographic() {
return {
longitude: view.lon * RAD,
latitude: view.lat * RAD,
height: view.height,
};
},
pickEllipsoid(point) {
return Cesium.Cartesian3.fromDegrees(
view.lon - 0.002 + (point.x / 100) * 0.004,
view.lat + 0.002 - (point.y / 100) * 0.004,
);
},
},
};
}
/** A source whose tile requests the test resolves, one call at a time. */ /** A source whose tile requests the test resolves, one call at a time. */
function deferredSource() { function deferredSource() {
const calls = []; const calls = [];
@@ -124,12 +82,17 @@ function deferredSource() {
}; };
} }
function setup({ zoomFor } = {}) { /**
* Coverage over a display looking straight down on a 0.004° square at a z14
* tile centre (`harness`: another display), its tile requests deferred.
*/
function setup({ zoomFor, harness = null } = {}) {
const centre = tileCentre(-121.4944, 38.5816); const centre = tileCentre(-121.4944, 38.5816);
const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 }); const viewer =
harness ?? coverageMap({ lon: centre.lon, lat: centre.lat, height: 900 });
const source = deferredSource(); const source = deferredSource();
const state = { const state = {
viewer, map: viewer.map,
services: {}, services: {},
enabled: true, enabled: true,
providerOn: true, providerOn: true,
@@ -141,9 +104,8 @@ function setup({ zoomFor } = {}) {
}, },
coverage: { coverage: {
...freshCoverage(), ...freshCoverage(),
// The key status is in; skip Cesium's one-time terrain table download. // The key status is in.
blocked: null, blocked: null,
terrainReady: Promise.resolve(),
}, },
sequence: { selectedId: null }, sequence: { selectedId: null },
}; };
@@ -162,9 +124,7 @@ function setup({ zoomFor } = {}) {
], ],
}); });
const tileKey = `14/${centre.tile.x}/${centre.tile.y}`; const tileKey = `14/${centre.tile.x}/${centre.tile.y}`;
const onGlobe = () => const onGlobe = () => viewer.groundSets().length + viewer.moverSets().length;
viewer.scene.groundPrimitives.items.size +
viewer.scene.primitives.items.size;
return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre }; return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre };
} }
@@ -205,18 +165,47 @@ test('a superseded tile request cannot strand lines on the globe', async () => {
}); });
test('sequence lines are draped on the globe and on 3D tiles alike', async () => { test('sequence lines are draped on the globe and on 3D tiles alike', async () => {
const { viewer, source, coverage, bytes } = setup(); const { viewer, source, coverage, bytes, centre, tileKey } = setup();
coverage.refresh(); coverage.refresh();
source.calls[0].resolve(bytes); source.calls[0].resolve(bytes);
await settle(); await settle();
const { groundPrimitives, primitives } = viewer.scene; assert.equal(viewer.moverSets().length, 0);
assert.equal(primitives.items.size, 0); const sets = viewer.groundSets(PREFIX);
assert.equal(groundPrimitives.items.size, 1); assert.equal(sets.length, 1);
const [line] = groundPrimitives.items; const [line] = sets;
assert.ok(line instanceof Cesium.GroundPolylinePrimitive); assert.ok(line.id.startsWith(`${PREFIX}${tileKey}#`), line.id);
assert.equal(line.classificationType, Cesium.ClassificationType.BOTH); assert.deepEqual(line.options, {
id: line.id,
kind: 'corridor',
order: 0,
pickable: true,
drape: 'both',
});
// The provider colour at 0.92, 2.5 px, every part picking its sequence.
const [r, g, b] = [0x05 / 255, 0xcb / 255, 0x63 / 255];
assert.equal(line.items.length, 1);
const [item] = line.items;
assert.deepEqual(
{ ...item, positions: undefined },
{
positions: undefined,
color: [r, g, b, 0.92],
widthPx: COVERAGE_TUNING.lineWidthPx,
pickId: `${PICK_PREFIX.sequence}seq-1`,
},
);
// The decoded line, as the tile encodes it (to its quantisation).
const expected = [
[centre.lon - 0.001, centre.lat],
[centre.lon + 0.001, centre.lat],
];
assert.equal(item.positions.length, 2);
item.positions.forEach(([lon, lat], index) => {
assert.ok(Math.abs(lon - expected[index][0]) < 1e-5);
assert.ok(Math.abs(lat - expected[index][1]) < 1e-5);
});
coverage.clear(); coverage.clear();
assert.equal(groundPrimitives.items.size, 0); assert.equal(viewer.groundSets().length, 0);
}); });
test('panning off a tile that is still loading stops counting it as loading', async () => { test('panning off a tile that is still loading stops counting it as loading', async () => {
@@ -254,7 +243,7 @@ test('a zoomFor in the older {zoom, kind} shape still draws its lines', async ()
assert.equal(source.calls[0].z, 14); assert.equal(source.calls[0].z, 14);
source.calls[0].resolve(bytes); source.calls[0].resolve(bytes);
await settle(); await settle();
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'its line drawn'); assert.equal(viewer.groundSets(PREFIX).length, 1, 'its line drawn');
assert.equal(coverage.stats().hint, ''); assert.equal(coverage.stats().hint, '');
coverage.clear(); coverage.clear();
}); });
@@ -276,11 +265,11 @@ test('from orbit nothing is requested or drawn, and the hint says to zoom in', a
coverage.refresh(); coverage.refresh();
source.calls[0].resolve(bytes); source.calls[0].resolve(bytes);
await settle(); await settle();
assert.ok(viewer.scene.groundPrimitives.items.size > 0, 'street lines'); assert.ok(viewer.groundSets(PREFIX).length > 0, 'street lines');
viewer.view.height = 20_000_000; viewer.view.height = 20_000_000;
coverage.refresh(); coverage.refresh();
assert.equal(source.calls.length, 1, 'no tile is asked for'); assert.equal(source.calls.length, 1, 'no tile is asked for');
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'nothing drawn'); assert.equal(viewer.groundSets(PREFIX).length, 0, 'nothing drawn');
assert.equal(state.coverage.zoom, null); assert.equal(state.coverage.zoom, null);
assert.equal(coverage.stats().hint, ZOOM_IN_HINT); assert.equal(coverage.stats().hint, ZOOM_IN_HINT);
viewer.view.height = 900; viewer.view.height = 900;
@@ -447,24 +436,10 @@ test('a sequence that crosses a tile edge is counted once', async () => {
coverage.clear(); coverage.clear();
}); });
/** A camera over a 100×100 canvas with a 60°×40° view (see rayCamera). */
function tiltedCamera({ lon, lat, height, heading, pitch }) {
return rayCamera({
lon,
lat,
altitude: height,
heading,
pitch,
width: 100,
height: 100,
fovX: 60,
fovY: 40,
});
}
/** /**
* Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east * Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east
* at ground `aheadM` metres out; the globe terrain knows the ground. * at ground `aheadM` metres out, with a 60°×40° view; the globe terrain knows
* the ground.
*/ */
function denverStreetView(aheadM) { function denverStreetView(aheadM) {
const z = 14; const z = 14;
@@ -475,20 +450,21 @@ function denverStreetView(aheadM) {
const lat = (tile.south + tile.north) / 2; const lat = (tile.south + tile.north) / 2;
const ground = 1600; const ground = 1600;
const above = 300; const above = 300;
const context = setup();
context.viewer.scene.globe = {
show: true,
ellipsoid: Cesium.Ellipsoid.WGS84,
getHeight: () => ground,
};
const tileWidthM = (tile.east - tile.west) * 111_320 * Math.cos(lat * RAD); const tileWidthM = (tile.east - tile.west) * 111_320 * Math.cos(lat * RAD);
context.viewer.camera = tiltedCamera({ // 40° high; the width keeps 60° across.
const aspect = Math.tan(30 * RAD) / Math.tan(20 * RAD);
const engine = cameraEngine({
lon, lon,
lat, lat,
height: ground + above, altitude: ground + above,
heading: 90, heading: 90,
pitch: -Math.atan(above / aheadM(tileWidthM)) / RAD, pitch: -Math.atan(above / aheadM(tileWidthM)) / RAD,
width: Math.round(1000 * aspect),
height: 1000,
fovY: 40,
}); });
engine.fake.script('terrainHeight', () => ground);
const context = setup({ harness: streetLevelMap({ engine }) });
context.coverage.refresh(); context.coverage.refresh();
const keys = context.source.calls.map((call) => call.key); const keys = context.source.calls.map((call) => call.key);
/** Requested tiles as [dx, dy] from the tile under the camera. */ /** Requested tiles as [dx, dy] from the tile under the camera. */
@@ -531,13 +507,22 @@ test('the selected sequence is drawn over the coverage, and goes with the select
coverage.refresh(); coverage.refresh();
source.calls[0].resolve(bytes); source.calls[0].resolve(bytes);
await settle(); await settle();
const lines = () => [...viewer.scene.groundPrimitives.items]; const lines = () => viewer.groundSets(PREFIX);
assert.equal(lines().length, 1, 'the coverage'); assert.equal(lines().length, 1, 'the coverage');
coverage.highlight('seq-1'); coverage.highlight('seq-1');
assert.equal(lines().length, 2); assert.equal(lines().length, 2);
const highlight = lines().at(-1); const highlight = lines().at(-1);
assert.equal(state.coverage.highlight, highlight, 'drawn last, on top'); assert.deepEqual(state.coverage.highlight.ids(), [highlight.id]);
assert.equal(highlight.classificationType, Cesium.ClassificationType.BOTH); assert.ok(highlight.id.startsWith(`${PREFIX}selected#`));
// Over the coverage, in the selection colour, a pixel wider.
assert.ok(highlight.options.order > lines()[0].options.order, 'on top');
assert.equal(highlight.options.drape, 'both');
assert.deepEqual(
highlight.items.map((item) => item.color),
[[0, 0xd4 / 255, 1, 1]],
);
assert.equal(highlight.items[0].widthPx, COVERAGE_TUNING.lineWidthPx + 1);
assert.equal(highlight.items[0].pickId, `${PICK_PREFIX.sequence}seq-1`);
coverage.highlight('not-drawn'); coverage.highlight('not-drawn');
assert.equal(lines().length, 1, 'nothing to highlight off the map'); assert.equal(lines().length, 1, 'nothing to highlight off the map');
coverage.highlight(null); coverage.highlight(null);
@@ -554,10 +539,9 @@ test('a tile that lands with the selected sequence draws the highlight again, on
source.calls[0].resolve(bytes); source.calls[0].resolve(bytes);
await settle(); await settle();
assert.ok(state.coverage.highlight, 'highlighted once its tile is in'); assert.ok(state.coverage.highlight, 'highlighted once its tile is in');
assert.equal( const [coverageSet, highlightSet] = viewer.groundSets(PREFIX);
[...viewer.scene.groundPrimitives.items].at(-1), assert.deepEqual(state.coverage.highlight.ids(), [highlightSet.id]);
state.coverage.highlight, assert.ok(highlightSet.options.order > coverageSet.options.order);
);
coverage.clear(); coverage.clear();
}); });
@@ -566,12 +550,12 @@ test("a zoom change drops the old zoom's lines", async () => {
coverage.refresh(); coverage.refresh();
source.calls[0].resolve(bytes); source.calls[0].resolve(bytes);
await settle(); await settle();
const [old] = viewer.scene.groundPrimitives.items; const [old] = viewer.groundSets(PREFIX);
assert.ok(old, 'the z14 lines are drawn'); assert.ok(old, 'the z14 lines are drawn');
viewer.view.height = 3000; // zoom out to z13 viewer.view.height = 3000; // zoom out to z13
coverage.refresh(); coverage.refresh();
assert.equal(state.coverage.zoom, 13); assert.equal(state.coverage.zoom, 13);
assert.equal(viewer.scene.groundPrimitives.items.has(old), false); assert.equal(old.live, false);
assert.ok(state.coverage.pending.size > 0, 'the new zoom is loading'); assert.ok(state.coverage.pending.size > 0, 'the new zoom is loading');
coverage.clear(); coverage.clear();
}); });
@@ -600,12 +584,8 @@ test('a rate limit holds every refresh inside its wait, not only the first', asy
coverage.unblock(); coverage.unblock();
}); });
test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => { test('a tile that fails is reported at once, never unhandled', async () => {
const { source, state, coverage, tileKey } = setup(); const { source, state, coverage, tileKey } = setup();
let terrainLoaded;
state.coverage.terrainReady = new Promise((resolve) => {
terrainLoaded = resolve;
});
const unhandled = []; const unhandled = [];
const onUnhandled = (reason) => unhandled.push(reason); const onUnhandled = (reason) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled); process.on('unhandledRejection', onUnhandled);
@@ -618,8 +598,6 @@ test('a tile that fails before the terrain table loads is reported at once, neve
assert.equal(state.coverage.lastError, 'Mapillary tiles HTTP 500'); assert.equal(state.coverage.lastError, 'Mapillary tiles HTTP 500');
assert.deepEqual(unhandled, []); assert.deepEqual(unhandled, []);
} finally { } finally {
terrainLoaded();
await settle();
process.off('unhandledRejection', onUnhandled); process.off('unhandledRejection', onUnhandled);
} }
}); });
+24 -48
View File
@@ -1,7 +1,5 @@
import * as Cesium from 'cesium';
import { positionMarkerGlyph, viewConeMarkerGlyph } from './glyphs.js'; import { positionMarkerGlyph, viewConeMarkerGlyph } from './glyphs.js';
import { COLORS, POSITION_PICK_ID } from './policy.js'; import { COLORS, MARKER_SPRITE_ORDER, POSITION_PICK_ID } from './policy.js';
import { createHorizonCull } from './view.js';
const SPRITE_ID = 'street-level:marker'; const SPRITE_ID = 'street-level:marker';
@@ -21,32 +19,22 @@ function markerLook(style) {
}; };
} }
/** On-globe marker for the open image, clamped to the ground. */ /**
* On-globe marker for the open image, on the ground, turned to its bearing
* (a sprite of the display, render/display.js).
*/
export function createMarker({ state }) { export function createMarker({ state }) {
const { render, sprites } = state.services; const { render } = state.services;
function requestRender() { function requestRender() {
render?.governorRequestRender?.('street-level-marker'); render?.governorRequestRender?.('street-level-marker');
} }
// It skips the depth test: hide it while it is behind the globe. function ensure(view) {
const horizon = createHorizonCull({ if (state.marker.collection || !view?.sprites) return;
getViewer: () => state.viewer, state.marker.collection = view.sprites(SPRITE_ID, {
items: () => (state.marker.billboard ? [state.marker.billboard] : []), order: MARKER_SPRITE_ORDER,
onChange: requestRender,
});
function ensure(viewer) {
if (state.marker.collection) return;
if (viewer?.engine) {
state.marker.collection = viewer.billboards(SPRITE_ID);
return;
}
state.marker.collection = new Cesium.BillboardCollection({
scene: viewer.scene,
}); });
viewer.scene.primitives.add(state.marker.collection);
sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection);
} }
/** Move (or create) the marker; null removes it. `style`: the provider's `marker`. */ /** Move (or create) the marker; null removes it. `style`: the provider's `marker`. */
@@ -56,51 +44,39 @@ export function createMarker({ state }) {
if (!position) { if (!position) {
collection.removeAll(); collection.removeAll();
state.marker.billboard = null; state.marker.billboard = null;
horizon.stop();
requestRender(); requestRender();
return; return;
} }
const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat);
const look = markerLook(style); const look = markerLook(style);
if (state.marker.billboard && state.marker.look !== look.key) { if (state.marker.billboard && state.marker.look !== look.key) {
collection.remove?.(state.marker.billboard); collection.remove(state.marker.billboard);
state.marker.billboard = null; state.marker.billboard = null;
} }
const place = {
lon: position.lon,
lat: position.lat,
bearing: bearing || 0,
};
if (!state.marker.billboard) { if (!state.marker.billboard) {
state.marker.look = look.key; state.marker.look = look.key;
state.marker.billboard = collection.add({ state.marker.billboard = collection.add({
id: POSITION_PICK_ID, pickId: POSITION_PICK_ID,
position: cartesian,
image: look.image, image: look.image,
imageId: `sl-position:${look.key}`, imageKey: `sl-position:${look.key}`,
width: look.size, sizePx: look.size,
height: look.size, ...place,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
}); });
} else { } else collection.move(state.marker.billboard, place);
state.marker.billboard.position = cartesian;
}
state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0);
if (state.viewer?.engine) collection.changed();
else horizon.update([state.marker.billboard]);
requestRender(); requestRender();
} }
function setVisible(visible) { function setVisible(visible) {
if (state.marker.collection) state.marker.collection.show = visible; state.marker.collection?.show(visible);
requestRender(); requestRender();
} }
function destroy(viewer) { function destroy() {
horizon.stop(); state.marker.collection?.destroy();
const collection = state.marker.collection;
if (!collection) return;
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
if (viewer?.engine) collection.destroy();
else viewer?.scene?.primitives?.remove(collection);
state.marker.collection = null; state.marker.collection = null;
state.marker.billboard = null; state.marker.billboard = null;
} }
+102 -90
View File
@@ -1,112 +1,124 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { createMarker } from './marker.js'; import { createMarker } from './marker.js';
import { MARKER_SPRITE_ORDER, POSITION_PICK_ID } from './policy.js';
import { STREET_LEVEL_MAPS } from '../../testSupport/streetLevelFakes.mjs';
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */ const SET = 'street-level:marker';
function setup() {
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 }; /** The marker on the display of `harness` (an engine looking down on Sacramento). */
const preRender = new Set(); function setup(harness) {
const viewer = {
camera: {
get positionWC() {
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
},
},
scene: {
primitives: { add: (p) => p, remove() {} },
preRender: {
addEventListener(listener) {
preRender.add(listener);
return () => preRender.delete(listener);
},
},
},
};
const state = { const state = {
services: {}, services: {},
viewer, map: harness.map,
marker: { marker: { collection: null, billboard: null, look: null },
// Stands in for the billboard collection; ensure() keeps it.
collection: {
show: true,
add: (options) => ({ ...options }),
remove() {},
removeAll() {},
},
billboard: null,
},
}; };
const marker = createMarker({ state }); const marker = createMarker({ state });
const frame = () => { marker.ensure(harness.map);
for (const listener of [...preRender]) listener(); /** The marker's drawn instance after a frame, or undefined. */
const drawn = () => {
harness.frame();
return harness.spritesShown(SET)[0];
}; };
return { state, view, preRender, marker, frame }; return { state, marker, drawn };
} }
test('the photo marker is hidden behind the globe, and shows from its side', () => { const degreesOf = (position) => {
const { state, view, preRender, marker, frame } = setup(); const carto = geo.cartographic.fromCartesian(position);
// Placed on the far side of the Earth from the camera. return {
marker.set({ lon: 58.51, lat: -38.58 }, 90); lon: geo.math.toDegrees(carto.longitude),
const billboard = state.marker.billboard; lat: geo.math.toDegrees(carto.latitude),
assert.equal(billboard.show, false, 'not drawn through the globe'); height: carto.height,
// Fly round to its side: it shows. };
view.lon = 58.51; };
view.lat = -38.58;
frame();
assert.equal(billboard.show, true);
// Moved to the far side again, without the camera moving.
marker.set({ lon: -121.49, lat: 38.58 }, 0);
assert.equal(billboard.show, false, 'culled where it now stands');
marker.clear();
assert.equal(preRender.size, 0, 'the cull listener is gone');
});
test('destroying the marker stops the horizon cull', () => { for (const create of STREET_LEVEL_MAPS) {
const { marker, preRender } = setup(); const name = create().name;
marker.set({ lon: -121.49, lat: 38.58 }, 0);
assert.equal(preRender.size, 1);
marker.destroy();
assert.equal(preRender.size, 0);
});
const SPOT = { lon: -121.49, lat: 38.58 }; test(`the photo marker is hidden behind the globe, and shows from its side (${name})`, () => {
const harness = create();
const { marker, drawn } = setup(harness);
// Placed on the far side of the Earth from the camera.
marker.set({ lon: 58.51, lat: -38.58 }, 90);
assert.equal(drawn(), undefined, 'not drawn through the globe');
assert.equal(harness.moverSets(SET).length, 1, 'but placed');
// Moved to the camera's side: it shows.
marker.set({ lon: -121.4944, lat: 38.5816 }, 0);
assert.ok(drawn());
marker.clear();
assert.equal(drawn(), undefined);
marker.destroy();
assert.equal(harness.moverSets(SET).length, 0, 'nothing left drawn');
});
test('the marker is clamped to the ground and turned to the bearing', () => { test(`the marker sits on the ground, over the scene, turned to the bearing (${name})`, () => {
const { state, marker } = setup(); const harness = create();
marker.set(SPOT, 45); const { state, marker, drawn } = setup(harness);
const billboard = state.marker.billboard; marker.set({ lon: -121.4944, lat: 38.5816 }, 90);
assert.equal( let sprite = drawn();
billboard.heightReference, const [set] = harness.moverSets(SET);
Cesium.HeightReference.CLAMP_TO_GROUND, assert.equal(set.options.kind, 'sprite');
); assert.equal(set.options.depthTest, 'always');
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45)); assert.equal(set.options.order, MARKER_SPRITE_ORDER);
// Moving it keeps the same billboard. assert.equal(set.options.pickable, true);
marker.set({ lon: -121.5, lat: 38.6 }, 90); assert.equal(set.options.sprite.sizePx, 40);
assert.equal(state.marker.billboard, billboard); assert.equal(sprite.pickId, POSITION_PICK_ID);
const placed = Cesium.Cartographic.fromCartesian(billboard.position); const placed = degreesOf(sprite.position);
assert.ok(Math.abs(Cesium.Math.toDegrees(placed.longitude) + 121.5) < 1e-9); assert.ok(Math.abs(placed.lon + 121.4944) < 1e-9);
assert.equal(billboard.rotation, -Cesium.Math.toRadians(90)); assert.ok(Math.abs(placed.height) < 1e-6, 'on the terrain (0 m here)');
}); // Looking straight down, north up: east is a quarter turn clockwise.
assert.ok(Math.abs(sprite.rotation + Math.PI / 2) < 1e-6);
// Moving it keeps the same sprite.
const row = state.marker.billboard;
marker.set({ lon: -121.4945, lat: 38.5817 }, 180);
assert.equal(state.marker.billboard, row);
sprite = drawn();
assert.ok(Math.abs(degreesOf(sprite.position).lon + 121.4945) < 1e-9);
assert.ok(Math.abs(Math.abs(sprite.rotation) - Math.PI) < 1e-4);
marker.setVisible(false);
assert.equal(drawn(), undefined, 'hidden with the layer');
marker.setVisible(true);
assert.ok(drawn());
});
}
test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pin', () => { test('a Street View photo gets a blue view cone; a Mapillary photo GEV’s pin', () => {
const { state, marker } = setup(); const harness = STREET_LEVEL_MAPS[0]();
const removed = []; const { state, marker, drawn } = setup(harness);
state.marker.collection.remove = (billboard) => removed.push(billboard);
const streetView = { kind: 'view-cone', color: '#4285f4' }; const streetView = { kind: 'view-cone', color: '#4285f4' };
marker.set({ lon: -121.49, lat: 38.58 }, 45, streetView); marker.set({ lon: -121.4944, lat: 38.5816 }, 45, streetView);
const cone = state.marker.billboard; const cone = state.marker.billboard;
assert.equal(cone.imageId, 'sl-position:view-cone:#4285f4'); assert.equal(cone.imageKey, 'sl-position:view-cone:#4285f4');
assert.equal( assert.equal(cone.sizePx, 48);
cone.rotation, assert.ok(
-Cesium.Math.toRadians(45), Math.abs(drawn().rotation + Math.PI / 4) < 1e-6,
'it turns with the view', 'it turns with the view',
); );
// Dragging the panorama keeps the look and the billboard. // Dragging the panorama keeps the look and the sprite.
marker.set({ lon: -121.49, lat: 38.58 }, 90, streetView); marker.set({ lon: -121.4944, lat: 38.5816 }, 90, streetView);
assert.equal(state.marker.billboard, cone); assert.equal(state.marker.billboard, cone);
// A Mapillary photo next (its provider has no marker): the pin. // A Mapillary photo next (its provider has no marker): the pin.
marker.set({ lon: -121.49, lat: 38.58 }, 90, undefined); marker.set({ lon: -121.4944, lat: 38.5816 }, 90, undefined);
assert.equal(state.marker.billboard.imageId, 'sl-position:pin'); assert.equal(state.marker.billboard.imageKey, 'sl-position:pin');
assert.deepEqual(removed, [cone]); drawn();
assert.deepEqual(
harness.moverSets(SET).map((set) => set.id),
[`${SET}:sl-position:pin`],
'the cone is gone',
);
});
test('without a display the marker draws nothing and does not throw', () => {
const state = {
services: {},
map: null,
marker: { collection: null, billboard: null, look: null },
};
const marker = createMarker({ state });
marker.ensure(null);
marker.set({ lon: 0, lat: 0 }, 0);
marker.setVisible(true);
marker.destroy();
assert.equal(state.marker.collection, null);
}); });
+7
View File
@@ -14,6 +14,13 @@ export const CLICK_PICK_WINDOW_PX = 9;
/** Pick id of the viewer position marker; the core owns it, not a provider. */ /** Pick id of the viewer position marker; the core owns it, not a provider. */
export const POSITION_PICK_ID = 'sl:pos'; export const POSITION_PICK_ID = 'sl:pos';
/**
* Sprite order (engine.movers): under the moving contacts, as map data; a
* sequence's image cones over the position marker.
*/
export const MARKER_SPRITE_ORDER = -1;
export const CONE_SPRITE_ORDER = -0.9;
/** One colour per imagery source: chip, coverage lines and cones. */ /** One colour per imagery source: chip, coverage lines and cones. */
export const PROVIDER_COLORS = Object.freeze({ export const PROVIDER_COLORS = Object.freeze({
mapillary: '#05cb63', mapillary: '#05cb63',
@@ -94,7 +94,7 @@ export function createKartaViewProvider({
const state = { const state = {
context, context,
services: context.services, services: context.services,
viewer: null, map: null,
coverage: freshCoverage({ keyless: true }), coverage: freshCoverage({ keyless: true }),
sequence: { sequence: {
selectedId: null, selectedId: null,
@@ -445,23 +445,23 @@ export function createKartaViewProvider({
return { configured: true }; return { configured: true };
}, },
init(cesiumViewer) { init(map) {
state.viewer = cesiumViewer; state.map = map;
cones.ensureCollections(cesiumViewer); cones.ensureCollections(map);
cones.setVisible(false); cones.setVisible(false);
}, },
activate(cesiumViewer) { activate(map) {
state.viewer = cesiumViewer; state.map = map;
cones.setVisible(true); cones.setVisible(true);
coverage.attach(cesiumViewer); coverage.attach(map);
}, },
refreshCoverage: () => coverage.refresh(), refreshCoverage: () => coverage.refresh(),
detachMap() { detachMap() {
coverage.releaseGraphics(); coverage.releaseGraphics();
cones.releaseCollection(state.viewer); cones.releaseCollection();
state.viewer = null; state.map = null;
}, },
repaintMap() { repaintMap() {
coverage.rebuild(); coverage.rebuild();
@@ -479,11 +479,11 @@ export function createKartaViewProvider({
masks.clear(); masks.clear();
}, },
destroy(cesiumViewer) { destroy() {
coverage.detach(); coverage.detach();
coverage.clear(); coverage.clear();
coverage.unblock(); coverage.unblock();
cones.destroy(cesiumViewer); cones.destroy();
tiles.clear(); tiles.clear();
masks.clear(); masks.clear();
}, },
@@ -15,7 +15,8 @@ import {
import { validateProviders } from '../../registry.js'; import { validateProviders } from '../../registry.js';
import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs'; import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs';
import { import {
coverageViewer, coverageMap,
streetLevelMap,
tileCentre, tileCentre,
} from '../../../../testSupport/streetLevelFakes.mjs'; } from '../../../../testSupport/streetLevelFakes.mjs';
@@ -402,33 +403,35 @@ test('archived photos are skipped: by the nearest lookup, and by › and ‹', a
); );
}); });
/** A stand-in Cesium viewer the cones can draw on; `cones()` lists their photo indexes. */ /** The display the cones draw on; `cones()` lists their first and last photo indexes. */
async function withCones(t, env) { async function withCones(t, env) {
const Cesium = await import('cesium');
const drawing = new Proxy({}, { get: () => () => {}, set: () => true }); const drawing = new Proxy({}, { get: () => () => {}, set: () => true });
const saved = globalThis.document; const saved = globalThis.document;
globalThis.document = { globalThis.document = {
createElement: () => ({ getContext: () => drawing }), createElement: () => ({ getContext: () => drawing }),
}; };
t.after(() => (globalThis.document = saved)); t.after(() => (globalThis.document = saved));
let collection = null; const harness = streetLevelMap();
env.instance.init({ // The cones are drawn hidden until the provider is switched on; they are
scene: { // read whether shown or not.
frameState: { mode: Cesium.SceneMode.SCENE3D }, let group = null;
updateHeight: () => () => {}, const sprites = harness.map.sprites;
getHeight: () => undefined, harness.map.sprites = (...args) => (group = sprites(...args));
primitives: { add: (c) => (collection = c), remove() {} }, env.instance.init(harness.map);
preRender: { addEventListener: () => () => {} }, // The cones paint on the next frame.
}, const ids = () => {
camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) }, group?.show(true);
}); harness.frame();
return harness
.moverSets('street-level:kartaview-cones')
.flatMap((set) => set.instances)
.map((cone) => cone.pickId);
};
const cones = () => { const cones = () => {
const indexes = []; const indexes = ids().map((id) => Number(id.split('-').at(-1)));
for (let i = 0; i < collection.length; i++)
indexes.push(Number(collection.get(i).id.split('-').at(-1)));
return [Math.min(...indexes), Math.max(...indexes)]; return [Math.min(...indexes), Math.max(...indexes)];
}; };
cones.count = () => collection?.length ?? 0; cones.count = () => ids().length;
return cones; return cones;
} }
@@ -733,10 +736,7 @@ function coverageSetup(
return { query }; return { query };
}; };
const centre = tileCentre(-83.0458, 42.3314); const centre = tileCentre(-83.0458, 42.3314);
const viewer = coverageViewer( const harness = coverageMap({ lon: centre.lon, lat: centre.lat, height });
{ lon: centre.lon, lat: centre.lat, height },
t,
);
const tiles = []; const tiles = [];
let hold = null; let hold = null;
env.source.getTile = (tile) => { env.source.getTile = (tile) => {
@@ -751,11 +751,12 @@ function coverageSetup(
}; };
return hold ? hold.then(() => answer) : Promise.resolve(answer); return hold ? hold.then(() => answer) : Promise.resolve(answer);
}; };
env.instance.init(viewer); env.instance.init(harness.map);
t.after(() => env.instance.destroy(viewer)); t.after(() => env.instance.destroy());
return { return {
...env, ...env,
viewer, harness,
map: harness.map,
centre, centre,
tiles, tiles,
masks, masks,
@@ -779,8 +780,8 @@ function coverageSetup(
} }
/** The camera came to rest: coverage refreshes once its debounce is over. */ /** The camera came to rest: coverage refreshes once its debounce is over. */
async function moved(viewer) { async function moved(harness) {
viewer.camera.moveEnd.raiseEvent(); harness.moveEnd();
await new Promise((resolve) => await new Promise((resolve) =>
setTimeout(resolve, COVERAGE_TUNING.moveDebounceMs + 20), setTimeout(resolve, COVERAGE_TUNING.moveDebounceMs + 20),
); );
@@ -799,7 +800,7 @@ test('a filter thins the lines to those its PNG mask draws; without one they all
// 360° photos lie on the middle line only. // 360° photos lie on the middle line only.
drawnRows: (query) => (query === 'fieldOfView=360' ? [[254, 258]] : []), drawnRows: (query) => (query === 'fieldOfView=360' ? [[254, 258]] : []),
}); });
env.instance.activate(env.viewer); env.instance.activate(env.map);
assert.ok(await until(() => env.instance.coverageStats().count === 1)); assert.ok(await until(() => env.instance.coverageStats().count === 1));
assert.deepEqual(env.masks, ['fieldOfView=360']); assert.deepEqual(env.masks, ['fieldOfView=360']);
// Unfiltered: every line, and the tile's lines are not asked for again. // Unfiltered: every line, and the tile's lines are not asked for again.
@@ -815,7 +816,7 @@ test('a filter thins the lines to those its PNG mask draws; without one they all
env.instance.setFilter(); env.instance.setFilter();
assert.ok(await until(() => env.instance.coverageStats().loading)); assert.ok(await until(() => env.instance.coverageStats().loading));
assert.equal( assert.equal(
env.viewer.scene.groundPrimitives.items.size, env.harness.groundSets('kartaview-coverage:').length,
0, 0,
'the unfiltered lines are not shown as filtered ones', 'the unfiltered lines are not shown as filtered ones',
); );
@@ -832,7 +833,7 @@ test('a filter thins the lines to those its PNG mask draws; without one they all
`startDate=${new Date(since).toISOString().slice(0, 10)}`, `startDate=${new Date(since).toISOString().slice(0, 10)}`,
); );
assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT); assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT);
assert.equal(env.viewer.scene.groundPrimitives.items.size, 0); assert.equal(env.harness.groundSets('kartaview-coverage:').length, 0);
// The same filter again asks KartaView for nothing. // The same filter again asks KartaView for nothing.
env.instance.setFilter(); env.instance.setFilter();
for (let i = 0; i < 5; i++) await settle(); for (let i = 0; i < 5; i++) await settle();
@@ -841,12 +842,12 @@ test('a filter thins the lines to those its PNG mask draws; without one they all
test('coverage hints: zoom in from too high, “no photos” only once an empty view has loaded', async (t) => { test('coverage hints: zoom in from too high, “no photos” only once an empty view has loaded', async (t) => {
const env = coverageSetup(t, { height: 7_000 }); const env = coverageSetup(t, { height: 7_000 });
env.instance.activate(env.viewer); env.instance.activate(env.map);
assert.equal(env.instance.coverageStats().hint, TOO_HIGH_HINT); assert.equal(env.instance.coverageStats().hint, TOO_HIGH_HINT);
assert.deepEqual(env.tiles, [], 'nothing asked from up there'); assert.deepEqual(env.tiles, [], 'nothing asked from up there');
const release = env.holdTiles(); const release = env.holdTiles();
env.viewer.view.height = 900; env.harness.view.height = 900;
await moved(env.viewer); await moved(env.harness);
assert.ok(await until(() => env.instance.coverageStats().loading)); assert.ok(await until(() => env.instance.coverageStats().loading));
assert.equal(env.instance.coverageStats().hint, '', 'not while it loads'); assert.equal(env.instance.coverageStats().hint, '', 'not while it loads');
release(); release();
@@ -864,14 +865,14 @@ test('coverage tiles are kept while KartaView is on and dropped once it is switc
], ],
], ],
}); });
env.instance.activate(env.viewer); env.instance.activate(env.map);
assert.ok(await until(() => env.instance.coverageStats().count === 1)); assert.ok(await until(() => env.instance.coverageStats().count === 1));
await moved(env.viewer); await moved(env.harness);
assert.ok(await until(() => env.instance.coverageStats().count === 1)); assert.ok(await until(() => env.instance.coverageStats().count === 1));
assert.equal(env.tiles.length, 1, 'kept while on'); assert.equal(env.tiles.length, 1, 'kept while on');
// Switched off: the decoded lines are not kept, so on again asks again. // Switched off: the decoded lines are not kept, so on again asks again.
env.instance.deactivate(); env.instance.deactivate();
env.instance.activate(env.viewer); env.instance.activate(env.map);
assert.ok(await until(() => env.instance.coverageStats().count === 1)); assert.ok(await until(() => env.instance.coverageStats().count === 1));
assert.equal(env.tiles.length, 2); assert.equal(env.tiles.length, 2);
}); });
@@ -40,7 +40,7 @@ function createProviderState(context) {
return { return {
context, context,
services: context.services, services: context.services,
viewer: null, map: null,
coverage: freshCoverage(), coverage: freshCoverage(),
sequence: { sequence: {
selectedId: null, selectedId: null,
@@ -137,24 +137,24 @@ export function createMapillaryProvider({ source, photoViewer = null }) {
return { configured }; return { configured };
}, },
init(viewer) { init(map) {
state.viewer = viewer; state.map = map;
parts.sequences.ensureCollections(viewer); parts.sequences.ensureCollections(map);
parts.sequences.setVisible(false); parts.sequences.setVisible(false);
}, },
activate(viewer) { activate(map) {
state.viewer = viewer; state.map = map;
parts.sequences.setVisible(true); parts.sequences.setVisible(true);
parts.coverage.attach(viewer); parts.coverage.attach(map);
}, },
/** Clear lines and cones, forget refusals. */ /** Clear lines and cones, forget refusals. */
refreshCoverage: () => parts.coverage.refresh(), refreshCoverage: () => parts.coverage.refresh(),
detachMap() { detachMap() {
parts.coverage.releaseGraphics(); parts.coverage.releaseGraphics();
parts.sequences.releaseCollection(state.viewer); parts.sequences.releaseCollection();
state.viewer = null; state.map = null;
}, },
repaintMap() { repaintMap() {
parts.coverage.rebuild(); parts.coverage.rebuild();
@@ -168,10 +168,10 @@ export function createMapillaryProvider({ source, photoViewer = null }) {
parts.sequences.setVisible(false); parts.sequences.setVisible(false);
}, },
destroy(viewer) { destroy() {
parts.coverage.detach(); parts.coverage.detach();
parts.coverage.clear(); parts.coverage.clear();
parts.sequences.destroy(viewer); parts.sequences.destroy();
}, },
/** The filter changed: redraw. Each draw reads it from the context. */ /** The filter changed: redraw. Each draw reads it from the context. */
@@ -1,14 +1,15 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium';
import { createMapillaryProvider } from './index.js'; import { createMapillaryProvider } from './index.js';
import { mapillaryImageUrl } from './policy.js'; import { mapillaryImageUrl } from './policy.js';
import { sequenceIdFromPick } from '../../lineCoverage.js'; import { sequenceIdFromPick } from '../../lineCoverage.js';
import { thinImages } from '../../sequenceCones.js'; import { thinImages } from '../../sequenceCones.js';
import { validateProviders } from '../../registry.js'; import { validateProviders } from '../../registry.js';
import { import {
SACRAMENTO_VIEW,
cameraEngine,
fakeMapillarySource, fakeMapillarySource,
rayCamera, streetLevelMap,
} from '../../../../testSupport/streetLevelFakes.mjs'; } from '../../../../testSupport/streetLevelFakes.mjs';
function fakeContext(filter = { pano: 'all', sinceMs: null }) { function fakeContext(filter = { pano: 'all', sinceMs: null }) {
@@ -83,49 +84,11 @@ test('cone thinning across the date line drops images metres apart', () => {
); );
}); });
/** A viewer looking straight down on downtown Sacramento from 900 m. */ /** The display looking straight down on downtown Sacramento from 900 m. */
function streetViewer() { const streetMap = () =>
const collection = () => ({ streetLevelMap({ engine: cameraEngine(SACRAMENTO_VIEW) }).map;
add: (item) => item,
remove: () => true,
contains: () => false,
get length() {
return 0;
},
});
const event = () => ({ addEventListener: () => () => {} });
const camera = Object.assign(
rayCamera({
lon: -121.4944,
lat: 38.5816,
altitude: 900,
pitch: -90,
width: 100,
height: 100,
}),
{ changed: event(), moveEnd: event() },
);
return {
camera,
scene: {
canvas: { clientWidth: 100, clientHeight: 100 },
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
primitives: collection(),
groundPrimitives: collection(),
preRender: event(),
postRender: event(),
requestRender() {},
},
};
}
test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => { test('coverage asks for no tiles before the key status is known, and none without a key', async () => {
// Skip Cesium's one-time terrain table download for draped lines.
t.mock.method(
Cesium.GroundPolylinePrimitive,
'initializeTerrainHeights',
async () => {},
);
for (const configured of [false, true]) { for (const configured of [false, true]) {
let answer; let answer;
const tiles = []; const tiles = [];
@@ -138,10 +101,10 @@ test('coverage asks for no tiles before the key status is known, and none withou
}, },
}); });
const instance = createMapillaryProvider({ source }).create(fakeContext()); const instance = createMapillaryProvider({ source }).create(fakeContext());
const viewer = streetViewer(); const map = streetMap();
instance.init(viewer); instance.init(map);
const status = instance.status(); const status = instance.status();
instance.activate(viewer); instance.activate(map);
assert.equal(tiles.length, 0, 'nothing before the status answers'); assert.equal(tiles.length, 0, 'nothing before the status answers');
answer({ configured }); answer({ configured });
assert.deepEqual(await status, { configured }); assert.deepEqual(await status, { configured });
@@ -151,7 +114,7 @@ test('coverage asks for no tiles before the key status is known, and none withou
assert.equal(tiles.length, 0, 'a key-less install never asks'); assert.equal(tiles.length, 0, 'a key-less install never asks');
assert.equal(instance.coverageStats().keyRequired, true); assert.equal(instance.coverageStats().keyRequired, true);
} }
instance.destroy(viewer); instance.destroy();
} }
}); });
@@ -164,7 +164,7 @@ export function createPanoramaxProvider({
const state = { const state = {
context, context,
services: context.services, services: context.services,
viewer: null, map: null,
coverage: freshCoverage({ keyless: true }), coverage: freshCoverage({ keyless: true }),
sequence: { sequence: {
selectedId: null, selectedId: null,
@@ -256,9 +256,7 @@ export function createPanoramaxProvider({
* from high up the click may land tens of metres off the line. * from high up the click may land tens of metres off the line.
*/ */
function lineClickRadius() { function lineClickRadius() {
const height = const height = state.map?.engine?.camera.height();
state.viewer?.engine?.camera.pose().cartographic.height ??
state.viewer?.camera?.positionCartographic?.height;
if (!Number.isFinite(height)) return NEAREST_RADIUS_M; if (!Number.isFinite(height)) return NEAREST_RADIUS_M;
return Math.min( return Math.min(
LINE_CLICK_RADIUS_MAX_M, LINE_CLICK_RADIUS_MAX_M,
@@ -443,23 +441,23 @@ export function createPanoramaxProvider({
return { configured: true }; return { configured: true };
}, },
init(cesiumViewer) { init(map) {
state.viewer = cesiumViewer; state.map = map;
cones.ensureCollections(cesiumViewer); cones.ensureCollections(map);
cones.setVisible(false); cones.setVisible(false);
}, },
activate(cesiumViewer) { activate(map) {
state.viewer = cesiumViewer; state.map = map;
cones.setVisible(true); cones.setVisible(true);
coverage.attach(cesiumViewer); coverage.attach(map);
}, },
refreshCoverage: () => coverage.refresh(), refreshCoverage: () => coverage.refresh(),
detachMap() { detachMap() {
coverage.releaseGraphics(); coverage.releaseGraphics();
cones.releaseCollection(state.viewer); cones.releaseCollection();
state.viewer = null; state.map = null;
}, },
repaintMap() { repaintMap() {
coverage.rebuild(); coverage.rebuild();
@@ -478,11 +476,11 @@ export function createPanoramaxProvider({
pictureLists.clear(); pictureLists.clear();
}, },
destroy(cesiumViewer) { destroy() {
coverage.detach(); coverage.detach();
coverage.clear(); coverage.clear();
coverage.unblock(); coverage.unblock();
cones.destroy(cesiumViewer); cones.destroy();
}, },
/** The filter changed: redraw. Each draw reads it from the context. */ /** The filter changed: redraw. Each draw reads it from the context. */
@@ -16,7 +16,9 @@ import { validateProviders } from '../../registry.js';
import { tileBounds } from '../../tileMath.js'; import { tileBounds } from '../../tileMath.js';
import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs'; import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs';
import { import {
coverageViewer, cameraEngine,
coverageMap,
streetLevelMap,
tileCentre, tileCentre,
} from '../../../../testSupport/streetLevelFakes.mjs'; } from '../../../../testSupport/streetLevelFakes.mjs';
import { import {
@@ -347,9 +349,8 @@ test('a picture Panoramax does not know, or an id it refuses, fails the open wit
await assert.rejects(env.instance.viewer.open('busy'), /busy/); await assert.rejects(env.instance.viewer.open('busy'), /busy/);
}); });
/** Cones on a stand-in Cesium viewer; `count()` reads how many are drawn. */ /** Cones on the display; `count()` reads how many are drawn, shown or not. */
async function withCones(t, env) { async function withCones(t, env) {
const Cesium = await import('cesium');
// The cone glyph draws on a canvas: any drawing call is fine. // The cone glyph draws on a canvas: any drawing call is fine.
const drawing = new Proxy({}, { get: () => () => {}, set: () => true }); const drawing = new Proxy({}, { get: () => () => {}, set: () => true });
const saved = globalThis.document; const saved = globalThis.document;
@@ -357,26 +358,33 @@ async function withCones(t, env) {
createElement: () => ({ getContext: () => drawing }), createElement: () => ({ getContext: () => drawing }),
}; };
t.after(() => (globalThis.document = saved)); t.after(() => (globalThis.document = saved));
let collection = null; const harness = streetLevelMap();
env.instance.init({ // The cones are drawn hidden until the provider is switched on; they are
scene: { // read whether shown or not.
frameState: { mode: Cesium.SceneMode.SCENE3D }, let group = null;
updateHeight: () => () => {}, const sprites = harness.map.sprites;
getHeight: () => undefined, harness.map.sprites = (...args) => (group = sprites(...args));
primitives: { add: (c) => (collection = c), remove() {} }, env.instance.init(harness.map);
preRender: { addEventListener: () => () => {} }, // The cones paint on the next frame.
}, const pickIds = () => {
camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) }, group?.show(true);
}); harness.frame();
return harness
.moverSets('street-level:panoramax-cones')
.flatMap((set) => set.instances)
.map((cone) => String(cone.pickId));
};
return { return {
count: () => collection?.length ?? 0, count: () => pickIds().length,
ids: () => ids: () => pickIds().map((id) => id.replace(PICK_PREFIX.image, '')),
Array.from({ length: collection?.length ?? 0 }, (_, i) =>
String(collection.get(i).id).replace(PICK_PREFIX.image, ''),
),
}; };
} }
/** The display with the camera `height` m up. */
const mapAt = (height) =>
streetLevelMap({ engine: cameraEngine({ lon: 0, lat: 0, altitude: height }) })
.map;
/** z15 tiles holding every item of `items` as a picture. */ /** z15 tiles holding every item of `items` as a picture. */
const pictureTiles = (items) => (tile) => { const pictureTiles = (items) => (tile) => {
if (tile.z !== 15) return new Uint8Array(0); if (tile.z !== 15) return new Uint8Array(0);
@@ -454,14 +462,7 @@ test('a line clicked from high up looks for its sequence further off; the radius
...sequence('line', 1, { offset: 220 }), ...sequence('line', 1, { offset: 220 }),
]; ];
const { instance, source } = setup({ items }); const { instance, source } = setup({ items });
const height = (h) => const height = (h) => instance.init(mapAt(h));
instance.init({
camera: { positionCartographic: { height: h } },
scene: {
primitives: { add() {}, remove() {} },
preRender: { addEventListener: () => () => {} },
},
});
const click = () => const click = () =>
instance.handlePick(`${PICK_PREFIX.sequence}line`, { instance.handlePick(`${PICK_PREFIX.sequence}line`, {
groundPoint: () => east(0), groundPoint: () => east(0),
@@ -473,7 +474,8 @@ test('a line clicked from high up looks for its sequence further off; the radius
'line-0', 'line-0',
'220 m off, from 25 km', '220 m off, from 25 km',
); );
assert.equal(source.calls.nearest.at(-1).radius, 250); // The camera height round-trips through the globe: to the millimetre.
assert.equal(Math.round(source.calls.nearest.at(-1).radius), 250);
height(1_000); height(1_000);
click(); click();
assert.equal( assert.equal(
@@ -636,12 +638,13 @@ function coverageSetup(t, { sequences = [], filter, height = 900 } = {}) {
}, },
{ filter }, { filter },
); );
const viewer = coverageViewer({ ...centre, height }, t); const harness = coverageMap({ ...centre, height });
env.instance.init(viewer); env.instance.init(harness.map);
t.after(() => env.instance.destroy(viewer)); t.after(() => env.instance.destroy());
return { return {
...env, ...env,
viewer, harness,
map: harness.map,
centre, centre,
holdTiles() { holdTiles() {
let release; let release;
@@ -672,7 +675,7 @@ test('sequence lines draw near the ground, and the imagery filter thins them', a
line('old-flat', { date: '2019-06-01' }), line('old-flat', { date: '2019-06-01' }),
], ],
}); });
env.instance.activate(env.viewer); env.instance.activate(env.map);
assert.ok(await until(() => env.instance.coverageStats().count === 2)); assert.ok(await until(() => env.instance.coverageStats().count === 2));
assert.equal(env.instance.coverageStats().keyRequired, false); assert.equal(env.instance.coverageStats().keyRequired, false);
assert.equal(env.instance.coverageStats().error, null); assert.equal(env.instance.coverageStats().error, null);
@@ -685,26 +688,26 @@ test('sequence lines draw near the ground, and the imagery filter thins them', a
line('old-flat'), line('old-flat'),
], ],
}); });
filtered.instance.activate(filtered.viewer); filtered.instance.activate(filtered.map);
assert.ok(await until(() => filtered.instance.coverageStats().count === 1)); assert.ok(await until(() => filtered.instance.coverageStats().count === 1));
}); });
test('the selected sequence’s line is drawn again over the coverage, and goes with it', async (t) => { test('the selected sequence’s line is drawn again over the coverage, and goes with it', async (t) => {
const env = coverageSetup(t, { sequences: [line('a'), line('b')] }); const env = coverageSetup(t, { sequences: [line('a'), line('b')] });
env.instance.activate(env.viewer); env.instance.activate(env.map);
assert.ok(await until(() => env.instance.coverageStats().count === 2)); assert.ok(await until(() => env.instance.coverageStats().count === 2));
const ground = env.viewer.scene.groundPrimitives.items; const ground = () => env.harness.groundSets('panoramax-coverage:').length;
const drawn = ground.size; const drawn = ground();
await env.instance.selectSequence('a'); await env.instance.selectSequence('a');
assert.equal(ground.size, drawn + 1, 'one highlight over the lines'); assert.equal(ground(), drawn + 1, 'one highlight over the lines');
env.instance.clearSequence(); env.instance.clearSequence();
assert.equal(ground.size, drawn); assert.equal(ground(), drawn);
}); });
test('coverage hints: nothing while loading, “no pictures” once an empty view has loaded', async (t) => { test('coverage hints: nothing while loading, “no pictures” once an empty view has loaded', async (t) => {
const env = coverageSetup(t); const env = coverageSetup(t);
const release = env.holdTiles(); const release = env.holdTiles();
env.instance.activate(env.viewer); env.instance.activate(env.map);
assert.ok(await until(() => env.instance.coverageStats().loading)); assert.ok(await until(() => env.instance.coverageStats().loading));
assert.equal(env.instance.coverageStats().hint, ''); assert.equal(env.instance.coverageStats().hint, '');
release(); release();
@@ -714,14 +717,14 @@ test('coverage hints: nothing while loading, “no pictures” once an empty vie
test('from high up nothing is drawn or fetched, and the panel says to zoom in', async (t) => { test('from high up nothing is drawn or fetched, and the panel says to zoom in', async (t) => {
const env = coverageSetup(t, { height: 200_000, sequences: [line('a')] }); const env = coverageSetup(t, { height: 200_000, sequences: [line('a')] });
env.instance.activate(env.viewer); env.instance.activate(env.map);
assert.ok( assert.ok(
await until(() => env.instance.coverageStats().hint === ZOOM_IN_HINT), await until(() => env.instance.coverageStats().hint === ZOOM_IN_HINT),
); );
assert.equal(env.instance.coverageStats().count, 0); assert.equal(env.instance.coverageStats().count, 0);
assert.equal(env.instance.coverageStats().loading, false); assert.equal(env.instance.coverageStats().loading, false);
assert.deepEqual(env.source.calls.tiles, []); assert.deepEqual(env.source.calls.tiles, []);
assert.equal(env.viewer.scene.groundPrimitives.items.size, 0); assert.equal(env.harness.groundSets('panoramax-coverage:').length, 0);
}); });
test('switching off clears the cones and forgets a pending line click', async (t) => { test('switching off clears the cones and forgets a pending line click', async (t) => {
@@ -747,7 +750,7 @@ test('lines in view that the filter hides all of say so, not that there is nothi
filter: { pano: 'pano', sinceMs: null }, filter: { pano: 'pano', sinceMs: null },
sequences: [line('flat-only')], sequences: [line('flat-only')],
}); });
env.instance.activate(env.viewer); env.instance.activate(env.map);
assert.ok( assert.ok(
await until(() => env.instance.coverageStats().hint === FILTERED_HINT), await until(() => env.instance.coverageStats().hint === FILTERED_HINT),
); );
@@ -821,13 +824,7 @@ test('a line clicked from low down still searches its own sequence within 50 m',
...sequence('clicked', 1, { offset: 30 }), ...sequence('clicked', 1, { offset: 30 }),
]; ];
const { instance, source } = setup({ items }); const { instance, source } = setup({ items });
instance.init({ instance.init(mapAt(300));
camera: { positionCartographic: { height: 300 } },
scene: {
primitives: { add() {}, remove() {} },
preRender: { addEventListener: () => () => {} },
},
});
instance.handlePick(`${PICK_PREFIX.sequence}clicked`, { instance.handlePick(`${PICK_PREFIX.sequence}clicked`, {
groundPoint: () => east(0), groundPoint: () => east(0),
}); });
@@ -876,25 +873,25 @@ test('a coverage tile decoded a moment ago is not fetched again; switched off, t
asked.push(`${tile.z}/${tile.x}/${tile.y}`); asked.push(`${tile.z}/${tile.x}/${tile.y}`);
return getTile(tile, options); return getTile(tile, options);
}; };
env.instance.activate(env.viewer); env.instance.activate(env.map);
assert.ok(await until(() => env.instance.coverageStats().count === 1)); assert.ok(await until(() => env.instance.coverageStats().count === 1));
const z14 = asked.length; const z14 = asked.length;
// Up a zoom band and back down: the z14 tile comes from memory. // Up a zoom band and back down: the z14 tile comes from memory.
// (Activating again refreshes at once, without the camera debounce.) // (Activating again refreshes at once, without the camera debounce.)
env.viewer.view.height = 3_000; env.harness.view.height = 3_000;
env.instance.activate(env.viewer); env.instance.activate(env.map);
assert.ok(await until(() => asked.some((key) => key.startsWith('13/')))); assert.ok(await until(() => asked.some((key) => key.startsWith('13/'))));
assert.ok(await until(() => env.instance.coverageStats().count === 1)); assert.ok(await until(() => env.instance.coverageStats().count === 1));
const withZ13 = asked.length; const withZ13 = asked.length;
env.viewer.view.height = 900; env.harness.view.height = 900;
env.instance.activate(env.viewer); env.instance.activate(env.map);
assert.ok(await until(() => env.instance.coverageStats().count === 1)); assert.ok(await until(() => env.instance.coverageStats().count === 1));
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(asked.length, withZ13, 'nothing fetched again'); assert.equal(asked.length, withZ13, 'nothing fetched again');
assert.ok(z14 > 0); assert.ok(z14 > 0);
// Off and on: the memory went back, so the tile is fetched again. // Off and on: the memory went back, so the tile is fetched again.
env.instance.deactivate(); env.instance.deactivate();
env.instance.activate(env.viewer); env.instance.activate(env.map);
assert.ok(await until(() => env.instance.coverageStats().count === 1)); assert.ok(await until(() => env.instance.coverageStats().count === 1));
assert.ok(asked.length > withZ13); assert.ok(asked.length > withZ13);
}); });
+381
View File
@@ -0,0 +1,381 @@
// Street Level on the globe contract (engine.ground, engine.movers,
// engine.surface, engine.projector, engine.camera), the same on every
// engine. What the layer core and its providers draw through it:
// - coverage: sequence lines as engine.ground corridors in CSS pixels,
// draped on terrain and 3D tiles alike, every part of a sequence picking
// it; sets of at most 120 lines, so a dense tile shows in parts;
// - the position marker and the selected sequence's image cones: sprite
// movers, one set per glyph, on the ground under them, drawn over the
// scene, hidden behind the horizon of an ellipsoid 1 km under WGS84
// (ground can lie below WGS84, and the camera with it) and turned to
// their compass bearing on screen.
// Picks and the camera stay with the core (../selection.js, ../view.js),
// on the same engine (`display.engine`).
//
// The contract has no ground clamping for sprites: a sprite sits on the
// terrain, or under Google 3D on the rendered tiles (one sample each), and
// is placed again whenever the surface settles.
import * as geo from '../../../geo/index.js';
import { rgbaFromCss } from '../../shared/color.js';
import { plausibleSurfaceHeight } from '../view.js';
/** Lines per ground set: smaller sets build, and show, sooner. */
export const SEQUENCE_LINE_BATCH = 120;
/** Below this the projected bearing is noise (straight into the screen). */
const MIN_SCREEN_COMPONENT = 1e-3;
const WHITE = Object.freeze([1, 1, 1, 1]);
/**
* Horizon occluder depth below WGS84 (m): ground can lie below the ellipsoid
* (NYC −22 m), and an occluder at 0 m would hide the cones around it.
*/
const HORIZON_CULL_DEPTH_M = 1000;
const CULL_RADII = Object.freeze({
x: geo.ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M,
y: geo.ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M,
z: geo.ellipsoid.WGS84.radii.z - HORIZON_CULL_DEPTH_M,
});
const finitePoint = (point) =>
Array.isArray(point) &&
Number.isFinite(point[0]) &&
Number.isFinite(point[1]) &&
Math.abs(point[1]) <= 90;
/** `css` (#rrggbb[aa]) as rgba in 0..1, its alpha replaced by `alpha` when given. */
export function lineColor(css, alpha) {
const rgba = rgbaFromCss(css);
return [rgba.red, rgba.green, rgba.blue, alpha ?? rgba.alpha];
}
/**
* Whether `position` is in front of the horizon seen from `eye`, for an
* ellipsoid HORIZON_CULL_DEPTH_M under WGS84 (Cesium's
* EllipsoidalOccluder.isPointVisible on that ellipsoid).
*/
export function aboveHorizon(eye, position) {
const cx = eye.x / CULL_RADII.x;
const cy = eye.y / CULL_RADII.y;
const cz = eye.z / CULL_RADII.z;
const limbSquared = cx * cx + cy * cy + cz * cz - 1;
const vx = position.x / CULL_RADII.x - cx;
const vy = position.y / CULL_RADII.y - cy;
const vz = position.z / CULL_RADII.z - cz;
const vtDotVc = -(vx * cx + vy * cy + vz * cz);
const occluded =
limbSquared < 0
? vtDotVc > 0
: vtDotVc > limbSquared &&
(vtDotVc * vtDotVc) / (vx * vx + vy * vy + vz * vz) > limbSquared;
return !occluded;
}
const scratchEnu = geo.matrix4.create();
const scratchLocal = geo.cartesian3.create();
const scratchWorld = geo.cartesian3.create();
/**
* A sprite's screen rotation (radians, counter-clockwise from screen up)
* that points it along `bearingDeg` (clockwise from north) at `position`:
* the local course vector on the camera's right and up axes, or `previous`
* when it points into the screen.
*/
export function screenRotation(position, bearingDeg, pose, previous = 0) {
const course = geo.math.toRadians(bearingDeg || 0);
geo.transforms.eastNorthUpToFixedFrame(
position,
geo.ellipsoid.WGS84,
scratchEnu,
);
geo.cartesian3.fromElements(
Math.sin(course),
Math.cos(course),
0,
scratchLocal,
);
geo.matrix4.multiplyByPointAsVector(scratchEnu, scratchLocal, scratchWorld);
const x = geo.cartesian3.dot(scratchWorld, pose.right);
const y = geo.cartesian3.dot(scratchWorld, pose.up);
if (x * x + y * y < MIN_SCREEN_COMPONENT * MIN_SCREEN_COMPONENT)
return previous;
return Math.atan2(-x, y);
}
/**
* @param {import('../../../globe/contract.js').GlobeEngine} engine
*/
export function createStreetLevelDisplay(engine) {
if (!engine?.ground || !engine?.movers)
throw new TypeError('The Street Level display needs a globe engine');
/** Live sprite groups and line handles, for destroy(). */
const live = new Set();
let serial = 0;
const tilesShown = () => engine.maps?.photorealActive?.() === true;
/**
* Ground height (m) under a point and whether it came from the surface
* shown: the terrain on the globe; under Google 3D the rendered tiles
* (`exclude`: the sprites themselves), else the terrain. Null when
* neither answers.
*/
function groundHeight(lon, lat, exclude) {
const carto = geo.cartographic.fromDegrees(lon, lat);
let terrain;
try {
terrain = engine.surface.terrainHeightSupported
? engine.surface.terrainHeight(carto)
: undefined;
} catch {
terrain = undefined;
}
const known = Number.isFinite(terrain);
if (!tilesShown()) return { height: known ? terrain : null, exact: known };
let sampled;
try {
sampled = engine.surface.supported
? engine.surface.sampleHeight(carto, exclude)
: undefined;
} catch {
sampled = undefined;
}
if (plausibleSurfaceHeight(sampled))
return { height: sampled, exact: true };
return { height: known ? terrain : null, exact: false };
}
/**
* A group of sprites, each `{ pickId, lon, lat, bearing, image, imageKey,
* sizePx, scaleByDistance? }`: one sprite set per `imageKey`, at `order`
* among the engine's sets.
* @param {string} id
* @param {{ order?: number }} [options]
*/
function sprites(id, { order = 0 } = {}) {
const rows = new Set();
/** imageKey → { bank, capacity } */
const banks = new Map();
let visible = true;
let dirty = true;
let lastPose = '';
let settled = null;
let tiles = tilesShown();
let destroyed = false;
const wake = () => {
if (destroyed) return;
dirty = true;
engine.render.request(id);
};
const ownSets = () => [...banks.values()].map(({ bank }) => bank);
function place(row) {
const { height, exact } = groundHeight(row.lon, row.lat, ownSets());
row.position = geo.cartesian3.fromDegrees(row.lon, row.lat, height ?? 0);
row.exact = exact;
}
function bankFor(key, first, count) {
let record = banks.get(key);
if (record && record.capacity >= count) return record.bank;
record?.bank.destroy();
const capacity = Math.max(1, count);
const bank = engine.movers.createSet({
id: `${id}:${key}`,
kind: 'sprite',
capacity,
order,
depthTest: 'always',
pickable: true,
sprite: {
sizePx: first.sizePx,
atlas: { url: first.image, cells: { glyph: [0, 0, 1, 1] } },
...(first.scaleByDistance
? { scaleByDistance: first.scaleByDistance }
: {}),
},
});
banks.set(key, { bank, capacity });
return bank;
}
function paint() {
if (destroyed) return;
// The surface settled (or the map stack changed): place every sprite
// again on the globe, where terrain is cheap; under tiles only the
// ones not on them yet.
const loaded = engine.surface.tilesLoaded();
const nowTiles = tilesShown();
if ((loaded && settled === false) || nowTiles !== tiles) {
for (const row of rows)
if (!nowTiles || !row.exact || nowTiles !== tiles) place(row);
dirty = true;
}
settled = loaded;
tiles = nowTiles;
if (!rows.size && !dirty) return;
const pose = engine.camera.pose();
const eye = pose.position;
const signature = [
pose.position.x,
pose.position.y,
pose.position.z,
pose.heading,
pose.pitch,
pose.roll,
].join('/');
if (!dirty && signature === lastPose) return;
dirty = false;
lastPose = signature;
const groups = new Map();
if (visible)
for (const row of rows) {
if (!groups.has(row.imageKey)) groups.set(row.imageKey, []);
groups.get(row.imageKey).push(row);
}
// Hidden, a set draws nothing and keeps its atlas; a glyph no row
// uses any more goes.
for (const [key, record] of banks) {
if (groups.has(key)) continue;
if (visible) {
record.bank.destroy();
banks.delete(key);
} else if (record.bank.count()) {
record.bank.setCount(0);
record.bank.commit();
}
}
for (const [key, group] of groups) {
const bank = bankFor(key, group[0], group.length);
group.forEach((row, index) => {
row.rotation = screenRotation(
row.position,
row.bearing,
pose,
row.rotation,
);
bank.set(index, {
position: row.position,
rotation: row.rotation,
color: WHITE,
cell: 'glyph',
pickId: row.pickId,
show: aboveHorizon(eye, row.position),
});
});
bank.setCount(group.length);
bank.commit();
}
}
const removePaint = engine.frames.preRender.addEventListener(paint);
const group = {
/** Adds a sprite; the returned row is its handle. */
add(spec) {
const row = { ...spec, rotation: 0, position: null, exact: false };
place(row);
rows.add(row);
wake();
return row;
},
/** Moves or turns a sprite (`lon`, `lat`, `bearing`). */
move(row, { lon = row.lon, lat = row.lat, bearing = row.bearing } = {}) {
if (!rows.has(row)) return;
const moved = lon !== row.lon || lat !== row.lat;
Object.assign(row, { lon, lat, bearing });
if (moved) place(row);
wake();
},
remove(row) {
if (rows.delete(row)) wake();
},
removeAll() {
rows.clear();
wake();
},
show(value) {
visible = value !== false;
wake();
},
shown: () => visible,
count: () => rows.size,
destroy() {
if (destroyed) return;
destroyed = true;
removePaint();
for (const { bank } of banks.values()) bank.destroy();
banks.clear();
rows.clear();
live.delete(group);
engine.render.request(id);
},
};
live.add(group);
return group;
}
/**
* Sequence lines draped on the ground: every part of every sequence as a
* corridor `widthPx` wide in the CSS `color` (at `alpha` when given),
* picking `pickPrefix` + its id. Null when nothing is drawable; else a
* handle to destroy.
* @param {Array<{id: string, parts: Array<Array<[number, number]>>}>} sequences
* @param {{ id: string, color: string, alpha?: number, widthPx: number, pickPrefix: string, order?: number }} style
*/
function sequenceLines(
sequences,
{ id, color: css, alpha, widthPx, pickPrefix, order },
) {
const color = lineColor(css, alpha);
const items = [];
for (const sequence of sequences)
for (const part of sequence.parts || []) {
// A part with a bad point is skipped; the others still draw.
if (!Array.isArray(part) || part.length < 2) continue;
if (!part.every(finitePoint)) continue;
items.push({
positions: part,
color,
widthPx,
pickId: `${pickPrefix}${sequence.id}`,
});
}
if (!items.length) return null;
const sets = [];
const base = `${id}#${serial++}`;
for (let at = 0; at < items.length; at += SEQUENCE_LINE_BATCH) {
const set = engine.ground.createSet({
id: `${base}:${sets.length}`,
kind: 'corridor',
order,
pickable: true,
drape: 'both',
});
sets.push(set);
set.replace(items.slice(at, at + SEQUENCE_LINE_BATCH));
set.commit();
}
const handle = {
/** Ground set ids, in draw order. */
ids: () => sets.map((set) => set.id),
count: () => items.length,
destroy() {
if (!live.delete(handle)) return;
for (const set of sets) set.destroy();
engine.render.request(id);
},
};
live.add(handle);
return handle;
}
return {
engine,
sprites,
sequenceLines,
/** Everything still drawn goes. */
destroy() {
for (const item of [...live]) item.destroy();
},
};
}
@@ -0,0 +1,351 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as geo from '../../../geo/index.js';
import {
aboveHorizon,
createStreetLevelDisplay,
lineColor,
screenRotation,
SEQUENCE_LINE_BATCH,
} from './display.js';
import {
STREET_LEVEL_MAPS,
streetLevelMap,
} from '../../../testSupport/streetLevelFakes.mjs';
const line = (n) => [
[-121.49, 38.58 + n * 1e-5],
[-121.48, 38.58 + n * 1e-5],
];
const STYLE = Object.freeze({
id: 'test-coverage:14/1/2',
color: '#00ff00',
alpha: 0.92,
widthPx: 2.5,
pickPrefix: 'mly:seq:',
order: 0,
});
test('the display needs a globe engine', () => {
assert.throws(() => createStreetLevelDisplay(null), /globe engine/);
assert.throws(() => createStreetLevelDisplay({}), /globe engine/);
});
test('a line colour is the CSS colour, its alpha replaced when asked', () => {
assert.deepEqual(lineColor('#00d4ff'), [0, 0xd4 / 255, 1, 1]);
assert.deepEqual(lineColor('#05cb63', 0.92), [
0x05 / 255,
0xcb / 255,
0x63 / 255,
0.92,
]);
});
for (const create of STREET_LEVEL_MAPS) {
const name = create().name;
test(`sequence lines are pickable pixel corridors draped on terrain and tiles (${name})`, () => {
const harness = create();
const lines = harness.map.sequenceLines(
[
{ id: 'a', parts: [line(0), line(1)] },
{ id: 'b', parts: [line(2)] },
],
STYLE,
);
const [set] = harness.groundSets(STYLE.id);
assert.deepEqual(lines.ids(), [set.id]);
assert.deepEqual(set.options, {
id: set.id,
kind: 'corridor',
order: 0,
pickable: true,
drape: 'both',
});
// Every part of a sequence picks it.
assert.deepEqual(set.items, [
{
positions: line(0),
color: [0, 1, 0, 0.92],
widthPx: 2.5,
pickId: 'mly:seq:a',
},
{
positions: line(1),
color: [0, 1, 0, 0.92],
widthPx: 2.5,
pickId: 'mly:seq:a',
},
{
positions: line(2),
color: [0, 1, 0, 0.92],
widthPx: 2.5,
pickId: 'mly:seq:b',
},
]);
assert.equal(
harness.engine.ground.list().find((entry) => entry.id === set.id).count,
3,
'committed to the engine',
);
lines.destroy();
lines.destroy();
assert.equal(harness.engine.ground.list().length, 0, 'gone, once');
});
test(`a dense tile is drawn in sets of ${SEQUENCE_LINE_BATCH} lines (${name})`, () => {
const harness = create();
const sequences = Array.from({ length: 250 }, (_, i) => ({
id: `s${i}`,
parts: [line(i)],
}));
const lines = harness.map.sequenceLines(sequences, STYLE);
const sets = harness.groundSets(STYLE.id);
assert.deepEqual(
sets.map((set) => set.items.length),
[SEQUENCE_LINE_BATCH, SEQUENCE_LINE_BATCH, 10],
);
assert.deepEqual(
lines.ids(),
sets.map((set) => set.id),
);
assert.equal(lines.count(), 250);
harness.map.destroy();
assert.equal(harness.engine.ground.list().length, 0);
});
}
test('a part with a bad point is skipped; nothing drawable draws nothing', () => {
const harness = streetLevelMap();
const lines = harness.map.sequenceLines(
[
{
id: 'a',
parts: [
line(0),
[[-121.49, 38.58]],
[
[-121.49, Number.NaN],
[-121.48, 38.58],
],
[
[-121.49, 95],
[-121.48, 38.58],
],
null,
],
},
{ id: 'b', parts: [] },
{ id: 'c' },
],
STYLE,
);
assert.deepEqual(
harness.corridors(STYLE.id).map((item) => item.pickId),
['mly:seq:a'],
);
lines.destroy();
assert.equal(
harness.map.sequenceLines([{ id: 'b', parts: [] }], STYLE),
null,
);
assert.equal(harness.engine.ground.list().length, 0);
});
/** One sprite of a group on `harness`'s display, and its drawn instance. */
function oneSprite(harness, spec = {}) {
const group = harness.map.sprites('test-sprites', { order: -1 });
const row = group.add({
pickId: 'p:1',
lon: -121.4944,
lat: 38.5816,
bearing: 0,
image: 'data:image/svg+xml;utf8,<svg/>',
imageKey: 'glyph',
sizePx: 26,
...spec,
});
const drawn = () => {
harness.frame();
return harness.spritesShown('test-sprites')[0];
};
return { group, row, drawn };
}
const heightOf = (position) => geo.cartographic.fromCartesian(position).height;
test('a sprite sits on the terrain; under Google 3D on the tiles, placed again when they settle', async () => {
const harness = streetLevelMap({ view: true });
const { fake } = harness.engine;
fake.script('terrainHeight', () => 12);
const globe = oneSprite(harness);
assert.ok(Math.abs(heightOf(globe.drawn().position) - 12) < 1e-6);
globe.group.destroy();
await harness.engine.maps.setPhotorealTiles({ kind: 'photoreal' });
let tiles;
const excluded = [];
fake.script('sampleHeight', (_position, exclude) => {
excluded.push(exclude);
return tiles;
});
fake.script('tilesLoaded', false);
const google = oneSprite(harness);
// Not loaded yet: the terrain until the tiles answer.
assert.ok(Math.abs(heightOf(google.drawn().position) - 12) < 1e-6);
tiles = 31;
harness.frame();
assert.ok(
Math.abs(heightOf(google.drawn().position) - 12) < 1e-6,
'not placed again before the surface settles',
);
fake.script('tilesLoaded', true);
assert.ok(Math.abs(heightOf(google.drawn().position) - 31) < 1e-6);
assert.ok(
excluded.every((list) => Array.isArray(list)),
'its own sprites are left out of the sample',
);
// On the tiles now: a later settle samples nothing again.
const samples = excluded.length;
fake.script('tilesLoaded', false);
harness.frame();
fake.script('tilesLoaded', true);
harness.frame();
assert.equal(excluded.length, samples);
// An implausible sample (tiles still streaming) is not ground.
tiles = -14_886;
google.group.move(google.row, { lon: -121.4945 });
assert.ok(Math.abs(heightOf(google.drawn().position) - 12) < 1e-6);
});
test('a sprite without any ground sits on the ellipsoid', () => {
const harness = streetLevelMap({ view: true });
harness.engine.fake.script('terrainHeight', () => undefined);
assert.ok(Math.abs(heightOf(oneSprite(harness).drawn().position)) < 1e-6);
});
test('a group shows and hides, removes and moves its sprites, and goes for good', () => {
const harness = streetLevelMap({ view: true });
const { group, row, drawn } = oneSprite(harness);
assert.equal(drawn().pickId, 'p:1');
group.show(false);
assert.equal(group.shown(), false);
assert.equal(drawn(), undefined);
assert.equal(
harness.moverSets('test-sprites').length,
1,
'its set kept, with its atlas',
);
group.show(true);
assert.ok(drawn());
group.move(row, { bearing: 90 });
assert.ok(Math.abs(drawn().rotation + Math.PI / 2) < 1e-6);
group.remove(row);
assert.equal(drawn(), undefined);
assert.equal(group.count(), 0);
// A removed row does not come back with a move.
group.move(row, { lon: 0 });
assert.equal(group.count(), 0);
group.add({ ...row });
group.removeAll();
assert.equal(drawn(), undefined);
group.destroy();
assert.equal(harness.moverSets('test-sprites').length, 0);
// Destroyed: nothing paints again.
group.add({ ...row });
harness.frame();
assert.equal(harness.moverSets('test-sprites').length, 0);
});
test('a group grows its sprite set as rows arrive, one set per glyph', () => {
const harness = streetLevelMap({ view: true });
const group = harness.map.sprites('test-sprites');
const add = (n, imageKey) =>
group.add({
pickId: `p:${imageKey}:${n}`,
lon: -121.4944 + n * 1e-5,
lat: 38.5816,
bearing: 0,
image: 'data:,',
imageKey,
sizePx: 26,
});
add(0, 'flat');
harness.frame();
for (let n = 1; n < 5; n++) add(n, 'flat');
add(0, 'pano');
harness.frame();
const sets = harness.moverSets('test-sprites');
assert.deepEqual(
sets.map((set) => [set.id, set.options.capacity, set.instances.length]),
[
['test-sprites:flat', 5, 5],
['test-sprites:pano', 1, 1],
],
);
assert.equal(sets[0].options.order, 0, 'order 0 unless asked');
assert.equal(sets[0].options.sprite.scaleByDistance, undefined);
});
test('a sprite turns with the camera, keeping its compass bearing on screen', () => {
const harness = streetLevelMap({ view: true });
const { drawn } = oneSprite(harness, { bearing: 0 });
assert.ok(Math.abs(drawn().rotation) < 1e-6, 'north is up');
// The camera turned to face east: north is now to the left.
harness.engine.fake.setPose({
position: harness.engine.camera.pose().position,
heading: Math.PI / 2,
pitch: -Math.PI / 2,
roll: 0,
});
assert.ok(Math.abs(drawn().rotation - Math.PI / 2) < 1e-6);
});
test('a bearing straight into the screen keeps the last rotation', () => {
const position = geo.cartesian3.fromDegrees(0, 0);
// Looking north along the ground: the local north is the view direction.
const pose = {
right: geo.cartesian3.create(0, 1, 0),
up: geo.cartesian3.create(1, 0, 0),
};
assert.equal(screenRotation(position, 0, pose, 0.7), 0.7);
assert.ok(
Math.abs(screenRotation(position, 90, pose, 0.7) + Math.PI / 2) < 1e-9,
);
});
const at = (lon, lat, height) => geo.cartesian3.fromDegrees(lon, lat, height);
test('cones and the marker stay visible on ground below the WGS84 ellipsoid', () => {
// NYC at street level: eye 2.4 m above ground at -22 m, a cone 10 m away.
assert.equal(
aboveHorizon(at(-74.006, 40.7128, -19.6), at(-74.006, 40.71289, -22)),
true,
);
// Colombo framing view: camera at -21 m, cones at -95 m about 80 m away.
const colombo = at(79.8612, 6.9271, -21);
assert.equal(aboveHorizon(colombo, at(79.8612, 6.92782, -95)), true);
assert.equal(aboveHorizon(colombo, at(79.86192, 6.9271, -95)), true);
// Austin, ground well above the ellipsoid.
assert.equal(
aboveHorizon(at(-97.7431, 30.2672, 132), at(-97.7431, 30.2681, 130)),
true,
);
});
test('the horizon cull still hides the far side of the Earth from orbit', () => {
const orbit = at(0, 0, 20_000_000);
assert.equal(aboveHorizon(orbit, at(10, 10, 0)), true);
assert.equal(aboveHorizon(orbit, at(180, 0, 0)), false);
});
test('destroying the display takes everything it still draws', () => {
const harness = streetLevelMap({ view: true });
harness.map.sequenceLines([{ id: 'a', parts: [line(0)] }], STYLE);
const { drawn } = oneSprite(harness);
assert.ok(drawn());
harness.map.destroy();
assert.equal(harness.engine.ground.list().length, 0);
assert.equal(harness.engine.movers.list().length, 0);
harness.map.destroy();
});
-183
View File
@@ -1,183 +0,0 @@
import * as geo from '../../../geo/index.js';
import { screenProjectedRotation } from '../../../data/iconOrientation.js';
import { bindGroundShapePicks } from '../../shared/render/navaraGroundPicks.js';
export function createStreetMap(engine, { view }) {
const { camera } = engine;
function billboards(id) {
const rows = new Set(),
sets = new Map();
let visible = true,
dirty = true,
lastCamera = '',
settled = false;
const wake = () => {
dirty = true;
engine.render.request(id);
};
function paint() {
const pose = camera.pose();
const signature = [
pose.position.x,
pose.position.y,
pose.position.z,
pose.heading,
pose.pitch,
pose.roll,
].join('/');
const loaded = engine.surface.tilesLoaded();
if (!dirty && signature === lastCamera && loaded === settled) return;
settled = loaded;
dirty = false;
lastCamera = signature;
const scenes = { camera: { rightWC: pose.right, upWC: pose.up } };
const groups = new Map();
if (visible)
for (const row of rows) {
const key = row.imageId ?? row.image;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(row);
}
for (const [key, record] of sets)
if (!groups.has(key)) {
record.bank.destroy();
sets.delete(key);
}
for (const [key, group] of groups) {
const first = group[0],
scale = first.scaleByDistance;
let rec = sets.get(key);
if (!rec || rec.capacity < group.length) {
rec?.bank.destroy();
const capacity = Math.max(1, group.length);
const bank = engine.movers.createSet({
id: id + '-' + key,
kind: 'sprite',
capacity,
depthTest: 'always',
pickable: true,
sprite: {
sizePx: first.width,
atlas: { url: first.image, cells: { glyph: [0, 0, 1, 1] } },
scaleByDistance: scale
? [scale.near, scale.nearValue, scale.far, scale.farValue]
: null,
},
});
rec = { capacity, bank };
sets.set(key, rec);
}
let i = 0;
for (const row of group) {
const pos = row.position,
carto = geo.cartographic.fromCartesian(pos);
const h = engine.surface.terrainHeight(carto) ?? carto.height;
const ground = geo.cartesian3.fromRadians(
carto.longitude,
carto.latitude,
h,
);
rec.bank.set(i++, {
position: ground,
rotation: screenProjectedRotation(
scenes,
ground,
-geo.math.toDegrees(row.rotation || 0),
0,
),
color: [1, 1, 1, 1],
cell: 'glyph',
pickId: row.id,
show: row.show !== false && engine.projector.horizonVisible(ground),
});
}
rec.bank.setCount(i);
rec.bank.commit();
}
}
const off = engine.frames.preRender.addEventListener(paint);
return {
add(spec) {
const row = { ...spec };
rows.add(row);
wake();
return row;
},
remove(row) {
rows.delete(row);
wake();
},
removeAll() {
rows.clear();
wake();
},
set show(value) {
visible = value;
wake();
},
get show() {
return visible;
},
changed: wake,
destroy() {
off();
for (const rec of sets.values()) rec.bank.destroy();
sets.clear();
rows.clear();
},
};
}
function lines(sequences, color, width, prefix) {
const features = [],
shapes = [];
for (const seq of sequences)
for (const path of seq.parts) {
if (path.length < 2) continue;
const id = prefix + seq.id;
features.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: path },
properties: { id },
});
shapes.push({
id,
geometry: { type: 'line', positions: path },
style: { lineWidth: width },
});
}
if (!features.length) return null;
const source = view.addSource({
type: 'geojson',
data: { type: 'FeatureCollection', features },
});
let layer, unbind;
try {
layer = view.addLayer({
type: 'vector',
source,
polyline: {
color: parseInt(color.slice(1), 16),
width,
opacity: 0.92,
clampToGround: true,
lit: false,
transparent: true,
},
});
unbind = bindGroundShapePicks(engine, 'street-level', shapes);
} catch (e) {
layer?.delete();
source.delete();
throw e;
}
return {
destroy() {
unbind();
layer.delete();
source.delete();
engine.render.request('street-level-line');
},
};
}
return { engine, camera, billboards, lines };
}
+26 -34
View File
@@ -1,22 +1,24 @@
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { CLICK_PICK_WINDOW_PX, STREET_LEVEL_LAYER_ID } from './policy.js'; import { CLICK_PICK_WINDOW_PX, STREET_LEVEL_LAYER_ID } from './policy.js';
/** One click handler for every provider's lines and cones, and the marker. */ /**
* One click handler for every provider's lines and cones, and the marker,
* on the display's engine (engine.input for the click, engine.picker for
* the pick: the input's own pick has no pick window).
*/
export function createSelection({ state, parts }) { export function createSelection({ state, parts }) {
const { picking, input } = state.services; const { picking, input } = state.services;
/** The id carries a provider's prefix: that provider handles the pick. */ /** The id carries a provider's prefix: that provider handles the pick. */
function onClick(click) { function onClick(click) {
const viewer = state.viewer; const engine = state.map?.engine;
if (!viewer || !state.enabled) return; if (!engine || !state.enabled || !click?.position) return;
if (input?.isPointerFree && !input.isPointerFree()) return; if (input?.isPointerFree && !input.isPointerFree()) return;
const picked = viewer.engine const picked = engine.picker.pick(
? click.picked click.position,
: viewer.scene.pick( CLICK_PICK_WINDOW_PX,
click.position, CLICK_PICK_WINDOW_PX,
CLICK_PICK_WINDOW_PX, );
CLICK_PICK_WINDOW_PX,
);
const id = picking?.resolvePickId const id = picking?.resolvePickId
? picking.resolvePickId(picked) ? picking.resolvePickId(picked)
: picked?.id; : picked?.id;
@@ -29,20 +31,21 @@ export function createSelection({ state, parts }) {
// The position marker is the core's own: nothing to select. // The position marker is the core's own: nothing to select.
if (!route.instance) return; if (!route.instance) return;
route.instance.handlePick(route.id, { route.instance.handlePick(route.id, {
groundPoint: () => groundPointAt(viewer, click.position), groundPoint: () => groundPointAt(engine, click.position),
}); });
} }
/** Lon/lat of the ground under a screen point, or null (sky, no picker). */ /** Lon/lat of the ground under a screen point, or null (sky, no picker). */
function groundPointAt(viewer, screenPosition) { function groundPointAt(engine, screenPosition) {
const position = viewer.engine const position = state.services.scenePick?.pickGroundPosition?.(
? viewer.engine.picker.pickGlobe(screenPosition) engine,
: state.services.scenePick?.pickGroundPosition?.(viewer, screenPosition); screenPosition,
const carto = position && Cesium.Cartographic.fromCartesian(position); );
const carto = position && geo.cartographic.fromCartesian(position);
return carto return carto
? { ? {
lon: Cesium.Math.toDegrees(carto.longitude), lon: geo.math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude), lat: geo.math.toDegrees(carto.latitude),
} }
: null; : null;
} }
@@ -67,20 +70,9 @@ export function createSelection({ state, parts }) {
parts.clearSequences(); parts.clearSequences();
} }
function install(viewer) { function install(map) {
if (state.clickHandler) return; if (state.clickHandler || !map?.engine) return;
if (viewer.engine) { state.clickHandler = map.engine.input.on('click', onClick);
const remove = viewer.engine.input.on('click', onClick, { pick: true });
state.clickHandler = { isDestroyed: () => false, destroy: remove };
} else {
state.clickHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
);
state.clickHandler.setInputAction(
onClick,
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
}
document.addEventListener('keydown', onKeyDown); document.addEventListener('keydown', onKeyDown);
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, parts.router.ownsPick); picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, parts.router.ownsPick);
} }
@@ -88,7 +80,7 @@ export function createSelection({ state, parts }) {
function uninstall() { function uninstall() {
// Nothing to undo for a layer that was never enabled. // Nothing to undo for a layer that was never enabled.
if (!state.clickHandler) return; if (!state.clickHandler) return;
if (!state.clickHandler.isDestroyed()) state.clickHandler.destroy(); state.clickHandler();
state.clickHandler = null; state.clickHandler = null;
document.removeEventListener('keydown', onKeyDown); document.removeEventListener('keydown', onKeyDown);
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID); picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID);
+91 -34
View File
@@ -1,11 +1,21 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { createSelection } from './selection.js'; import { createSelection } from './selection.js';
import { createPickRouter } from './pickRouter.js'; import { createPickRouter } from './pickRouter.js';
import { CLICK_PICK_WINDOW_PX } from './policy.js';
import { STREET_LEVEL_MAPS } from '../../testSupport/streetLevelFakes.mjs';
/** The selection installed on a stand-in viewer, with what its clicks reach. */ /**
function harness({ selected = false, services = {}, providers = [] } = {}) { * The selection installed on the display of `map` (a STREET_LEVEL_MAPS
* harness, the fake engine's by default), with what its clicks reach.
*/
function harness({
selected = false,
services = {},
providers = [],
map = STREET_LEVEL_MAPS[0](),
} = {}) {
const saved = globalThis.document; const saved = globalThis.document;
globalThis.document = new EventTarget(); globalThis.document = new EventTarget();
// The document's keydown listeners, so a test can hand them any target. // The document's keydown listeners, so a test can hand them any target.
@@ -29,16 +39,8 @@ function harness({ selected = false, services = {}, providers = [] } = {}) {
const groundClicks = []; const groundClicks = [];
/** What the pointer is over: a pick id, or nothing. */ /** What the pointer is over: a pick id, or nothing. */
const scene = { under: null }; const scene = { under: null };
const canvas = Object.assign(new EventTarget(), {
// Keep Cesium's handler on the canvas; there is no real document here.
disableRootEvents: true,
onwheel: null,
});
const viewer = {
scene: { canvas, pick: () => scene.under && { id: scene.under } },
};
const state = { const state = {
viewer, map: map.map,
enabled: true, enabled: true,
clickHandler: null, clickHandler: null,
services: { picking: null, input: null, ...services }, services: { picking: null, input: null, ...services },
@@ -72,16 +74,19 @@ function harness({ selected = false, services = {}, providers = [] } = {}) {
}, },
}; };
const selection = createSelection({ state, parts }); const selection = createSelection({ state, parts });
selection.install(viewer); selection.install(map.map);
const action = (type) =>
state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]);
return { return {
state, state,
map,
sequences, sequences,
opened, opened,
groundClicks, groundClicks,
scene, scene,
click: (x = 10) => action('LEFT_CLICK')({ position: { x, y: 10 } }), click: (x = 10) =>
map.click(scene.under ? { id: scene.under, primitive: {} } : undefined, {
x,
y: 10,
}),
/** A keydown reaching the document from `target`; the event as handled. */ /** A keydown reaching the document from `target`; the event as handled. */
key(key, target, { defaultPrevented = false } = {}) { key(key, target, { defaultPrevented = false } = {}) {
const event = { const event = {
@@ -102,25 +107,73 @@ function harness({ selected = false, services = {}, providers = [] } = {}) {
}; };
} }
test('a click on a line selects its sequence, on a cone opens its image, elsewhere does nothing', () => { for (const create of STREET_LEVEL_MAPS) {
const name = create().name;
test(`a click on a line selects its sequence, on a cone opens its image, elsewhere does nothing (${name})`, () => {
const h = harness({ map: create() });
try {
h.scene.under = 'mly:seq:abc';
h.click();
assert.deepEqual(h.sequences.selects, ['abc']);
h.scene.under = 'mly:img:42';
h.click();
assert.deepEqual(h.opened, ['42']);
for (const under of [null, 'cctv:7', 'sl:pos']) {
h.scene.under = under;
h.click();
}
assert.deepEqual([h.sequences.selects, h.opened], [['abc'], ['42']]);
// The marker is ours; nothing and another layer's pick go to the ground.
assert.deepEqual(
h.groundClicks.map(({ picked }) => picked?.id),
[undefined, 'cctv:7'],
);
} finally {
h.restore();
}
});
}
test('a click picks in a window a few pixels wide, through engine.input and engine.picker', () => {
const h = harness(); const h = harness();
try {
const { fake } = h.map.engine;
assert.deepEqual(
fake.callsOf('input.on').map(({ args }) => args),
[['click', {}]],
'one click listener, without its own pick',
);
h.scene.under = 'mly:seq:abc';
h.click(30);
assert.deepEqual(fake.callsOf('picker.pick').at(-1).args, [
{ x: 30, y: 10 },
CLICK_PICK_WINDOW_PX,
CLICK_PICK_WINDOW_PX,
]);
assert.deepEqual(h.sequences.selects, ['abc']);
h.restore();
h.scene.under = 'mly:seq:def';
h.click();
assert.deepEqual(h.sequences.selects, ['abc'], 'uninstalled: no clicks');
} finally {
h.restore();
}
});
test('a tool that owns the pointer, or a layer that is off, takes no click', () => {
let free = false;
const h = harness({ services: { input: { isPointerFree: () => free } } });
try { try {
h.scene.under = 'mly:seq:abc'; h.scene.under = 'mly:seq:abc';
h.click(); h.click();
assert.deepEqual(h.sequences.selects, ['abc']); assert.deepEqual(h.sequences.selects, [], 'the tool keeps it');
h.scene.under = 'mly:img:42'; free = true;
h.state.enabled = false;
h.click(); h.click();
assert.deepEqual(h.opened, ['42']); assert.deepEqual(h.sequences.selects, [], 'the layer is off');
for (const under of [null, 'cctv:7', 'sl:pos']) { h.state.enabled = true;
h.scene.under = under; h.click();
h.click(); assert.deepEqual(h.sequences.selects, ['abc']);
}
assert.deepEqual([h.sequences.selects, h.opened], [['abc'], ['42']]);
// The marker is ours; nothing and another layer's pick go to the ground.
assert.deepEqual(
h.groundClicks.map(({ picked }) => picked?.id),
[undefined, 'cctv:7'],
);
} finally { } finally {
h.restore(); h.restore();
} }
@@ -228,9 +281,9 @@ test('a click on a provider line hands it the ground under the click, measured o
], ],
services: { services: {
scenePick: { scenePick: {
pickGroundPosition: (viewer, position) => { pickGroundPosition: (engine, position) => {
groundPicks.push(position); groundPicks.push({ engine, position });
return Cesium.Cartesian3.fromDegrees(-83.0458, 42.3314, 190); return geo.cartesian3.fromDegrees(-83.0458, 42.3314, 190);
}, },
}, },
}, },
@@ -248,7 +301,11 @@ test('a click on a provider line hands it the ground under the click, measured o
const point = handled[0].click.groundPoint(); const point = handled[0].click.groundPoint();
assert.ok(Math.abs(point.lon + 83.0458) < 1e-9); assert.ok(Math.abs(point.lon + 83.0458) < 1e-9);
assert.ok(Math.abs(point.lat - 42.3314) < 1e-9); assert.ok(Math.abs(point.lat - 42.3314) < 1e-9);
assert.deepEqual(groundPicks, [{ x: 40, y: 10 }]); assert.deepEqual(
groundPicks.map(({ position }) => position),
[{ x: 40, y: 10 }],
);
assert.equal(groundPicks[0].engine, h.map.engine, 'on the display engine');
} finally { } finally {
h.restore(); h.restore();
} }
+29 -58
View File
@@ -1,12 +1,16 @@
import * as Cesium from 'cesium';
import { imageConeGlyph } from './glyphs.js'; import { imageConeGlyph } from './glyphs.js';
import { passesImageryFilter } from './filter.js'; import { passesImageryFilter } from './filter.js';
import { createHorizonCull, metresBetween } from './view.js'; import { metresBetween } from './view.js';
import { CONE_SPRITE_ORDER } from './policy.js';
import { msg } from '../../i18n/index.js'; import { msg } from '../../i18n/index.js';
/** Cones closer than this to the previous kept one are dropped. */ /** Cones closer than this to the previous kept one are dropped. */
const IMAGE_CONE_MIN_SPACING_M = 3; const IMAGE_CONE_MIN_SPACING_M = 3;
const IMAGE_CONE_SIZE_PX = 26; const IMAGE_CONE_SIZE_PX = 26;
/** NearFarScalar [nearM, nearScale, farM, farScale]: cones shrink with distance. */
export const IMAGE_CONE_SCALE_BY_DISTANCE = Object.freeze([
200, 1.1, 6000, 0.35,
]);
/** /**
* @typedef {object} ConeImage One image of a sequence, as the cones draw it. * @typedef {object} ConeImage One image of a sequence, as the cones draw it.
@@ -32,8 +36,8 @@ export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
} }
/** /**
* Image cones for one selected sequence, any provider's, clamped to the * Image cones for one selected sequence, any provider's, on the ground
* ground. A cone's pick id is `pickPrefix` + the image id; 360° images draw * (sprites of the display, render/display.js). A cone's pick id is `pickPrefix` + the image id; 360° images draw
* as rings. Why a sequence could not load stays in `state.sequence.error` * as rings. Why a sequence could not load stays in `state.sequence.error`
* until it goes. * until it goes.
* *
@@ -43,7 +47,7 @@ export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
* The sequence's images in capture order, thinned (see `thinImages`). * The sequence's images in capture order, thinned (see `thinImages`).
* @param {{coverage: string}} options.colors * @param {{coverage: string}} options.colors
* @param {string} options.pickPrefix Pick-id prefix of the cones. * @param {string} options.pickPrefix Pick-id prefix of the cones.
* @param {string} options.spriteId Sprite-order id of the cone collection. * @param {string} options.spriteId Id of the cone sprites.
* @param {string} options.renderKey Render-governor reason. * @param {string} options.renderKey Render-governor reason.
* @param {(sequenceId: string|null) => void} [options.onHighlight] * @param {(sequenceId: string|null) => void} [options.onHighlight]
* Highlight the sequence's coverage line (null: none). * Highlight the sequence's coverage line (null: none).
@@ -60,7 +64,7 @@ export function createSequenceCones({
onHighlight = () => {}, onHighlight = () => {},
passes = passesImageryFilter, passes = passesImageryFilter,
}) { }) {
const { render, sprites } = state.services; const { render } = state.services;
function requestRender() { function requestRender() {
render?.governorRequestRender?.(renderKey); render?.governorRequestRender?.(renderKey);
@@ -70,41 +74,22 @@ export function createSequenceCones({
state.context.notify(); state.context.notify();
} }
/** Drawn cone billboards. */ function ensureCollections(view) {
let cones = []; if (state.sequence.collection || !view?.sprites) return;
state.sequence.collection = view.sprites(spriteId, {
// The cones skip the depth test: hide the ones behind the globe. order: CONE_SPRITE_ORDER,
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: () => cones,
onChange: requestRender,
});
function ensureCollections(viewer) {
if (state.sequence.collection) return;
if (viewer?.engine) {
state.sequence.collection = viewer.billboards(spriteId);
return;
}
state.sequence.collection = new Cesium.BillboardCollection({
scene: viewer.scene,
}); });
viewer.scene.primitives.add(state.sequence.collection);
sprites?.registerSpriteCollection?.(spriteId, state.sequence.collection);
} }
function clearCones() { function clearCones() {
state.sequence.collection?.removeAll(); state.sequence.collection?.removeAll();
state.sequence.images = []; state.sequence.images = [];
cones = [];
horizon.stop();
} }
function renderCones(images) { function renderCones(images) {
const collection = state.sequence.collection; const collection = state.sequence.collection;
if (!collection) return; if (!collection) return;
collection.removeAll(); collection.removeAll();
cones = [];
// One colour per source; 360° images keep the ring shape. // One colour per source; 360° images keep the ring shape.
const cone = imageConeGlyph({ size: 32, color: colors.coverage }); const cone = imageConeGlyph({ size: 32, color: colors.coverage });
const ring = imageConeGlyph({ const ring = imageConeGlyph({
@@ -116,24 +101,17 @@ export function createSequenceCones({
const filter = state.context.getFilter(); const filter = state.context.getFilter();
for (const image of images) { for (const image of images) {
if (!passes(image, filter)) continue; if (!passes(image, filter)) continue;
const billboard = collection.add({ collection.add({
id: `${pickPrefix}${image.id}`, pickId: `${pickPrefix}${image.id}`,
position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat), lon: image.lon,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, lat: image.lat,
bearing: image.compassAngle,
image: image.isPano ? ring : cone, image: image.isPano ? ring : cone,
imageId: image.isPano ? `${spriteId}:pano` : `${spriteId}:flat`, imageKey: image.isPano ? 'pano' : 'flat',
width: IMAGE_CONE_SIZE_PX, sizePx: IMAGE_CONE_SIZE_PX,
height: IMAGE_CONE_SIZE_PX, scaleByDistance: IMAGE_CONE_SCALE_BY_DISTANCE,
rotation: -Cesium.Math.toRadians(image.compassAngle),
alignedAxis: Cesium.Cartesian3.UNIT_Z,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
}); });
cones.push(billboard);
} }
if (state.viewer?.engine) collection.changed();
else horizon.update();
requestRender(); requestRender();
} }
@@ -142,7 +120,7 @@ export function createSequenceCones({
* reloads a selected one (a provider that loads part of a sequence moved on). * reloads a selected one (a provider that loads part of a sequence moved on).
*/ */
async function select(sequenceId, { force = false } = {}) { async function select(sequenceId, { force = false } = {}) {
if (!sequenceId || (!state.viewer && !state.context.waitingForView?.())) if (!sequenceId || (!state.map && !state.context.waitingForView?.()))
return; return;
// Already on screen, or already on its way. // Already on screen, or already on its way.
if ( if (
@@ -208,24 +186,17 @@ export function createSequenceCones({
} }
function setVisible(visible) { function setVisible(visible) {
if (state.sequence.collection) state.sequence.collection.show = visible; state.sequence.collection?.show(visible);
requestRender(); requestRender();
} }
function releaseCollection(viewer) { function releaseCollection() {
horizon.stop(); state.sequence.collection?.destroy();
cones = []; state.sequence.collection = null;
const collection = state.sequence.collection;
if (collection) {
sprites?.unregisterSpriteCollection?.(spriteId, collection);
if (viewer?.engine) collection.destroy();
else viewer?.scene?.primitives?.remove(collection);
state.sequence.collection = null;
}
} }
function destroy(viewer) { function destroy() {
state.sequence.abort?.abort(); state.sequence.abort?.abort();
releaseCollection(viewer); releaseCollection();
state.sequence.images = []; state.sequence.images = [];
state.sequence.selectedId = null; state.sequence.selectedId = null;
} }
+135 -64
View File
@@ -1,26 +1,20 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { resolveFilter } from './filter.js'; import { resolveFilter } from './filter.js';
import { createSequenceCones, thinImages } from './sequenceCones.js'; import { CONE_SPRITE_ORDER } from './policy.js';
import {
createSequenceCones,
IMAGE_CONE_SCALE_BY_DISTANCE,
thinImages,
} from './sequenceCones.js';
import {
STREET_LEVEL_MAPS,
streetLevelMap,
} from '../../testSupport/streetLevelFakes.mjs';
const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
const SPRITES = 'street-level:test-cones';
/** A billboard collection that remembers the cones on the globe. */
function cones() {
const items = [];
return {
items,
show: true,
add(options) {
items.push(options);
return options;
},
removeAll() {
items.length = 0;
},
};
}
/** A source whose sequence lookups the test answers, one call at a time. */ /** A source whose sequence lookups the test answers, one call at a time. */
function deferredSource() { function deferredSource() {
@@ -49,13 +43,14 @@ function records(sequenceId, count = 2) {
})); }));
} }
function setup() { /** Cones on the display of `harness` (the fake engine over Sacramento). */
function setup(harness = streetLevelMap({ view: true })) {
const source = deferredSource(); const source = deferredSource();
/** Every highlight asked of the coverage: a sequence id, or null for none. */ /** Every highlight asked of the coverage: a sequence id, or null for none. */
const highlights = []; const highlights = [];
const state = { const state = {
services: {}, services: {},
viewer: {}, map: harness.map,
filter: { pano: 'all', sinceDays: 0 }, filter: { pano: 'all', sinceDays: 0 },
context: { context: {
getFilter: () => resolveFilter(state.filter), getFilter: () => resolveFilter(state.filter),
@@ -64,7 +59,7 @@ function setup() {
sequence: { sequence: {
selectedId: null, selectedId: null,
images: [], images: [],
collection: cones(), collection: null,
loading: false, loading: false,
error: null, error: null,
abort: null, abort: null,
@@ -87,13 +82,18 @@ function setup() {
}, },
colors: { coverage: '#05cb63' }, colors: { coverage: '#05cb63' },
pickPrefix: 'mly:img:', pickPrefix: 'mly:img:',
spriteId: 'street-level:test-cones', spriteId: SPRITES,
renderKey: 'test-sequence', renderKey: 'test-sequence',
onHighlight: (id) => highlights.push(id), onHighlight: (id) => highlights.push(id),
}); });
const drawn = () => sequences.ensureCollections(harness.map);
state.sequence.collection.items.map((cone) => cone.id.split(':').at(-1)); /** The cones drawn after a frame, in drawing order. */
return { state, source, sequences, highlights, drawn }; const shown = () => {
harness.frame();
return harness.spritesShown(SPRITES);
};
const drawn = () => shown().map((cone) => cone.pickId.split(':').at(-1));
return { state, source, sequences, highlights, drawn, shown, harness };
} }
test('a failed sequence load leaves no stale cones and can be retried', async () => { test('a failed sequence load leaves no stale cones and can be retried', async () => {
@@ -160,38 +160,61 @@ test('clearSelection cancels the lookup and drops the highlight', async () => {
assert.deepEqual(drawn(), [], 'the late answer draws nothing'); assert.deepEqual(drawn(), [], 'the late answer draws nothing');
}); });
test('cones are clamped to the ground and turned to their compass angle', async () => { for (const create of STREET_LEVEL_MAPS) {
const { state, source, sequences } = setup(); const name = create().name;
sequences.select('A');
source.last('A').resolve(records('A', 2)); test(`cones sit on the ground, over the scene, turned to their compass angle (${name})`, async () => {
await settle(); const harness = create();
const items = state.sequence.collection.items; const { source, sequences, shown } = setup(harness);
assert.equal(items.length, 2); sequences.select('A');
for (const cone of items) { source.last('A').resolve([
assert.equal(cone.heightReference, Cesium.HeightReference.CLAMP_TO_GROUND); ...records('A', 2),
assert.equal(cone.rotation, -Cesium.Math.toRadians(90)); {
} id: 'A-pano',
}); geometry: { coordinates: [-121.488, 38.58] },
compass_angle: 0,
captured_at: 1_800_000_000_000,
is_pano: true,
},
]);
await settle();
const cones = shown();
assert.equal(cones.length, 3);
// Flat images and 360° rings: one sprite set each.
const sets = harness.moverSets(SPRITES);
assert.deepEqual(sets.map((set) => set.id).sort(), [
`${SPRITES}:flat`,
`${SPRITES}:pano`,
]);
for (const set of sets) {
assert.equal(set.options.depthTest, 'always');
assert.equal(set.options.order, CONE_SPRITE_ORDER);
assert.equal(set.options.pickable, true);
assert.equal(set.options.sprite.sizePx, 26);
assert.deepEqual(
set.options.sprite.scaleByDistance,
IMAGE_CONE_SCALE_BY_DISTANCE,
);
}
const flat = cones.filter((cone) => !cone.pickId.endsWith('pano'));
for (const cone of flat) {
const height = geo.cartographic.fromCartesian(cone.position).height;
assert.ok(Math.abs(height) < 1e-6, 'on the terrain (0 m here)');
// Compass 90° from straight down, north up: a quarter turn clockwise.
assert.ok(Math.abs(cone.rotation + Math.PI / 2) < 1e-3);
}
sequences.setVisible(false);
assert.equal(shown().length, 0, 'hidden with the provider');
sequences.setVisible(true);
assert.equal(shown().length, 3);
sequences.releaseCollection();
assert.equal(harness.moverSets(SPRITES).length, 0, 'nothing left drawn');
});
}
test('cones behind the globe are hidden, and show again from the other side', async () => { test('cones behind the globe are hidden, and show again from the other side', async () => {
const { state, source, sequences } = setup(); const harness = streetLevelMap({ view: true });
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 }; const { source, sequences, shown } = setup(harness);
const preRender = new Set();
state.viewer = {
camera: {
get positionWC() {
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
},
},
scene: {
preRender: {
addEventListener(listener) {
preRender.add(listener);
return () => preRender.delete(listener);
},
},
},
};
sequences.select('A'); sequences.select('A');
// One image under the camera, one near its antipode. // One image under the camera, one near its antipode.
source.last('A').resolve([ source.last('A').resolve([
@@ -203,15 +226,63 @@ test('cones behind the globe are hidden, and show again from the other side', as
}, },
]); ]);
await settle(); await settle();
const [near, far] = state.sequence.collection.items; const ids = () => shown().map((cone) => cone.pickId);
assert.equal(near.show, true, 'the near side stays visible'); assert.deepEqual(
assert.equal(far.show, false, 'the far side does not show through'); ids(),
['mly:img:A-0'],
'the far side does not show through',
);
// Fly round to the other hemisphere: the two swap. // Fly round to the other hemisphere: the two swap.
view.lon = 58.51; harness.engine.fake.setPose({
view.lat = -38.58; longitude: geo.math.toRadians(58.51),
for (const listener of [...preRender]) listener(); latitude: geo.math.toRadians(-38.58),
assert.equal(near.show, false); height: 20_000_000,
assert.equal(far.show, true); });
assert.deepEqual(ids(), ['mly:img:A-far']);
sequences.clearSelection(); sequences.clearSelection();
assert.equal(preRender.size, 0, 'the cull listener is gone'); assert.deepEqual(ids(), []);
});
test('the cones paint once per camera move, not every frame', async () => {
const harness = streetLevelMap({ view: true });
const { source, sequences, shown } = setup(harness);
sequences.select('A');
source.last('A').resolve(records('A', 2));
await settle();
assert.equal(shown().length, 2);
const commits = () =>
harness.moverSets(SPRITES).reduce((sum, set) => sum + set.commits, 0);
const before = commits();
harness.engine.fake.frames(5);
assert.equal(commits(), before, 'a still camera repaints nothing');
// Turned where it stands.
harness.engine.fake.setPose({
position: harness.engine.camera.pose().position,
heading: 1,
pitch: -Math.PI / 2,
roll: 0,
});
harness.frame();
assert.equal(commits(), before + 1, 'a moved camera repaints once');
harness.engine.fake.frames(3);
assert.equal(commits(), before + 1, 'and then rests again');
});
test('without a display the cones draw nothing and do not throw', async () => {
const { state, source, sequences } = setup({
map: null,
frame() {},
spritesShown: () => [],
});
state.map = null;
sequences.ensureCollections(null);
state.context.waitingForView = () => true;
sequences.select('A');
source.last('A').resolve(records('A'));
await settle();
assert.equal(state.sequence.selectedId, 'A');
sequences.rerender();
sequences.setVisible(true);
sequences.destroy();
assert.equal(state.sequence.collection, null);
}); });
+1 -1
View File
@@ -36,7 +36,7 @@ export function freshStreet() {
export function createState({ services }) { export function createState({ services }) {
return { return {
services, services,
viewer: null, map: null,
enabled: false, enabled: false,
listeners: new Set(), listeners: new Set(),
notify: null, notify: null,
+88 -166
View File
@@ -1,10 +1,28 @@
import * as Cesium from 'cesium'; // What Street Level reads of the camera and the ground, on the globe
// contract: `view` is the layer's display (render/display.js), whose
// `engine` it asks. Screen rays meet an ellipsoid raised to the ground
// under the camera, as Cesium's camera.pickEllipsoid does with that
// ellipsoid, so a hidden globe (Google 3D) still has a ground to aim at.
import * as geo from '../../geo/index.js';
import { intersectEllipsoid } from '../../globe/cameraMath.js';
import { wrapLon } from './tileMath.js'; import { wrapLon } from './tileMath.js';
const engineOf = (view) => view?.engine ?? null;
/** The camera's Cartographic (radians, metres), or null without an engine. */
export const cameraLocation = (view) => export const cameraLocation = (view) =>
view?.engine?.camera.pose().cartographic ?? engineOf(view)?.camera.pose().cartographic ?? null;
view?.camera?.positionCartographic;
const canvasOf = (view) => view?.engine?.host.canvas ?? view?.scene?.canvas; /** The canvas size in CSS px ({0, 0} without an engine). */
function canvasSize(view) {
const size = engineOf(view)?.host?.size?.();
return { width: size?.width || 0, height: size?.height || 0 };
}
/** Whether Google 3D tiles are the surface shown (the globe is hidden). */
const tilesShown = (engine) => engine?.maps?.photorealActive?.() === true;
/** Equirectangular metres between {lon, lat} points, the short way round ±180°; street scale. */ /** Equirectangular metres between {lon, lat} points, the short way round ±180°; street scale. */
export function metresBetween(a, b) { export function metresBetween(a, b) {
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180; const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
@@ -24,82 +42,83 @@ export function plausibleSurfaceHeight(height) {
* when unsampleable or implausible (`sampleHeight` reads kilometres * when unsampleable or implausible (`sampleHeight` reads kilometres
* underground before tiles load). * underground before tiles load).
*/ */
export function sampledSurfaceHeight(scene, carto) { export function sampledSurfaceHeight(engine, carto) {
let height = null; let height = null;
try { try {
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto); if (engine?.surface?.supported) height = engine.surface.sampleHeight(carto);
} catch { } catch {
/* not sampleable yet */ /* not sampleable yet */
} }
return plausibleSurfaceHeight(height) ? height : null; return plausibleSurfaceHeight(height) ? height : null;
} }
/** Height (m) of the terrain at `carto`, or null. */
export function terrainSurfaceHeight(engine, carto) {
let height;
try {
if (engine?.surface?.terrainHeightSupported)
height = engine.surface.terrainHeight(carto);
} catch {
height = undefined;
}
return Number.isFinite(height) ? height : null;
}
/** /**
* Ellipsoidal height (m) of the ground under the camera, or null. Under * Ellipsoidal height (m) of the ground under the camera, or null. Under
* Google 3D (globe hidden) the rendered surface counts, minus any roof above * Google 3D (globe hidden) the rendered surface counts, minus any roof above
* the camera. * the camera.
*/ */
export function groundUnderCamera(viewer) { export function groundUnderCamera(view) {
const carto = cameraLocation(viewer); const engine = engineOf(view);
const scene = viewer?.scene; const carto = cameraLocation(view);
if (carto && viewer?.engine) { if (!carto) return null;
const surface = viewer.engine.surface; if (!tilesShown(engine)) {
const h = surface.sampleHeight(carto) ?? surface.terrainHeight(carto); const ground = terrainSurfaceHeight(engine, carto);
return plausibleSurfaceHeight(h) && h < carto.height ? h : null; if (ground !== null) return ground;
} }
if (!carto || !scene) return null; const sampled = sampledSurfaceHeight(engine, carto);
const globe = scene.globe;
if (globe && globe.show !== false) {
const ground = globe.getHeight?.(carto);
if (Number.isFinite(ground)) return ground;
}
const sampled = sampledSurfaceHeight(scene, carto);
return sampled !== null && sampled < carto.height ? sampled : null; return sampled !== null && sampled < carto.height ? sampled : null;
} }
/** The ellipsoid `height` metres above `ellipsoid` (the ground at that height). */ /** WGS84 raised by `height` metres (the ground at that height), else WGS84. */
function raisedEllipsoid(ellipsoid, height) { function raisedEllipsoid(height) {
if (!Number.isFinite(height) || Math.abs(height) < 1) return ellipsoid; const wgs84 = geo.ellipsoid.WGS84;
const { x, y, z } = ellipsoid.radii; if (!Number.isFinite(height) || Math.abs(height) < 1)
return new Cesium.Ellipsoid(x + height, y + height, z + height); return { ellipsoid: wgs84, height: 0 };
const { x, y, z } = wgs84.radii;
return {
ellipsoid: geo.ellipsoid.create(x + height, y + height, z + height),
height,
};
} }
/** /**
* Where a screen point's ray meets the ground, as a Cartographic, or null. * Where a screen point's ray meets the ground, as a Cartographic, or null.
* `maxRange` (metres from the camera) drops hits near the horizon. * `maxRange` (metres from the camera) drops hits near the horizon.
*/ */
function groundHit(camera, point, ellipsoid, maxRange, engine) { function groundHit(engine, point, { ellipsoid, height }, maxRange) {
let cartesian = null; let cartesian = null;
try { try {
cartesian = engine const { origin, direction } = engine.camera.pickRay(point);
? engine.picker.pickGlobe(point) cartesian = intersectEllipsoid(origin, direction, height);
: camera.pickEllipsoid(point, ellipsoid);
} catch { } catch {
cartesian = null; cartesian = null;
} }
if (!cartesian) return null; if (!cartesian) return null;
if ( if (
Number.isFinite(maxRange) && Number.isFinite(maxRange) &&
(engine?.camera.livePosition || camera.positionWC) && geo.cartesian3.distance(cartesian, engine.camera.livePosition) > maxRange
Cesium.Cartesian3.distance(
cartesian,
engine?.camera.livePosition ?? camera.positionWC,
) > maxRange
) )
return null; return null;
return ( return geo.cartographic.fromCartesian(cartesian, ellipsoid) || null;
Cesium.Cartographic.fromCartesian(
cartesian,
engine ? Cesium.Ellipsoid.WGS84 : ellipsoid,
) || null
);
} }
/** Camera height (m) above the ground under it. */ /** Camera height (m) above the ground under it. */
export function cameraHeightAboveGround(viewer) { export function cameraHeightAboveGround(view) {
const carto = cameraLocation(viewer); const carto = cameraLocation(view);
if (!carto) return null; if (!carto) return null;
return carto.height - (groundUnderCamera(viewer) ?? 0); return carto.height - (groundUnderCamera(view) ?? 0);
} }
/** Screen rays per side, less one, that `visibleBbox` casts. */ /** Screen rays per side, less one, that `visibleBbox` casts. */
@@ -111,32 +130,22 @@ const BBOX_GRID = 5;
* to the ground (Denver is 1,600 m up); `maxRange` (m) drops far hits. * to the ground (Denver is 1,600 m up); `maxRange` (m) drops far hits.
*/ */
export function visibleBbox( export function visibleBbox(
viewer, view,
{ groundHeight = null, maxRange = null } = {}, { groundHeight = null, maxRange = null } = {},
) { ) {
const scene = viewer?.scene; const engine = engineOf(view);
const camera = viewer?.camera; if (!engine) return null;
if ((!scene && !viewer?.engine) || !camera) return null; const { width, height } = canvasSize(view);
const canvas = canvasOf(viewer);
const width = canvas?.clientWidth || canvas?.width || 0;
const height = canvas?.clientHeight || canvas?.height || 0;
const hits = []; const hits = [];
if (width > 0 && height > 0) { if (width > 0 && height > 0) {
const ellipsoid = raisedEllipsoid( const ellipsoid = raisedEllipsoid(groundHeight);
scene?.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
groundHeight,
);
const point = new Cesium.Cartesian2();
for (let i = 0; i <= BBOX_GRID; i++) { for (let i = 0; i <= BBOX_GRID; i++) {
for (let j = 0; j <= BBOX_GRID; j++) { for (let j = 0; j <= BBOX_GRID; j++) {
point.x = (width * i) / BBOX_GRID;
point.y = (height * j) / BBOX_GRID;
const carto = groundHit( const carto = groundHit(
camera, engine,
point, { x: (width * i) / BBOX_GRID, y: (height * j) / BBOX_GRID },
ellipsoid, ellipsoid,
maxRange, maxRange,
viewer.engine,
); );
if (carto) hits.push(carto); if (carto) hits.push(carto);
} }
@@ -148,8 +157,8 @@ export function visibleBbox(
let east = -Infinity; let east = -Infinity;
let north = -Infinity; let north = -Infinity;
for (const carto of hits) { for (const carto of hits) {
const lon = Cesium.Math.toDegrees(carto.longitude); const lon = geo.math.toDegrees(carto.longitude);
const lat = Cesium.Math.toDegrees(carto.latitude); const lat = geo.math.toDegrees(carto.latitude);
west = Math.min(west, lon); west = Math.min(west, lon);
east = Math.max(east, lon); east = Math.max(east, lon);
south = Math.min(south, lat); south = Math.min(south, lat);
@@ -162,7 +171,7 @@ export function visibleBbox(
let west360 = Infinity; let west360 = Infinity;
let east360 = -Infinity; let east360 = -Infinity;
for (const carto of hits) { for (const carto of hits) {
const lon = Cesium.Math.toDegrees(carto.longitude); const lon = geo.math.toDegrees(carto.longitude);
const shifted = lon < 0 ? lon + 360 : lon; const shifted = lon < 0 ? lon + 360 : lon;
west360 = Math.min(west360, shifted); west360 = Math.min(west360, shifted);
east360 = Math.max(east360, shifted); east360 = Math.max(east360, shifted);
@@ -178,12 +187,12 @@ export function visibleBbox(
} }
/** The point on the ground under the camera, as {lon, lat} degrees. */ /** The point on the ground under the camera, as {lon, lat} degrees. */
export function cameraNadir(viewer) { export function cameraNadir(view) {
const carto = cameraLocation(viewer); const carto = cameraLocation(view);
if (!carto) return null; if (!carto) return null;
return { return {
lon: Cesium.Math.toDegrees(carto.longitude), lon: geo.math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude), lat: geo.math.toDegrees(carto.latitude),
}; };
} }
@@ -191,30 +200,20 @@ export function cameraNadir(viewer) {
* Where coverage tiles are ranked from: halfway between the nadir and the * Where coverage tiles are ranked from: halfway between the nadir and the
* screen centre's ground, or the nadir when that is out of `maxRange`. * screen centre's ground, or the nadir when that is out of `maxRange`.
*/ */
export function viewFocus( export function viewFocus(view, { groundHeight = null, maxRange = null } = {}) {
viewer, const nadir = cameraNadir(view);
{ groundHeight = null, maxRange = null } = {}, const { width, height } = canvasSize(view);
) {
const nadir = cameraNadir(viewer);
const scene = viewer?.scene;
const canvas = canvasOf(viewer);
const width = canvas?.clientWidth || canvas?.width || 0;
const height = canvas?.clientHeight || canvas?.height || 0;
if (!nadir || !width || !height) return nadir; if (!nadir || !width || !height) return nadir;
const hit = groundHit( const hit = groundHit(
viewer.camera, engineOf(view),
new Cesium.Cartesian2(width / 2, height / 2), { x: width / 2, y: height / 2 },
raisedEllipsoid( raisedEllipsoid(groundHeight),
scene?.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
groundHeight,
),
maxRange, maxRange,
viewer.engine,
); );
if (!hit) return nadir; if (!hit) return nadir;
const ahead = { const ahead = {
lon: Cesium.Math.toDegrees(hit.longitude), lon: geo.math.toDegrees(hit.longitude),
lat: Cesium.Math.toDegrees(hit.latitude), lat: geo.math.toDegrees(hit.latitude),
}; };
return { return {
lon: wrapLon(nadir.lon + wrapLon(ahead.lon - nadir.lon) / 2), lon: wrapLon(nadir.lon + wrapLon(ahead.lon - nadir.lon) / 2),
@@ -222,82 +221,10 @@ export function viewFocus(
}; };
} }
/**
* Horizon occluder depth below WGS84 (m): ground can lie below the ellipsoid
* (NYC −22 m), and an occluder at 0 m would hide the cones around it.
*/
const HORIZON_CULL_DEPTH_M = 1000;
let HORIZON_CULL_ELLIPSOID;
/**
* Hide billboards and points behind the horizon: they skip the depth test, so
* nothing else stops the far side drawing through the globe. Re-culls on
* pre-render after the camera moves; stops when nothing is left to cull.
* @param {{getViewer: () => object|null, items: () => Iterable<{position: object, show: boolean}>, onChange?: () => void}} options
*/
export function createHorizonCull({ getViewer, items, onChange }) {
let occluder;
let from = null;
let stopListening = null;
/** Show what is in front of the horizon, hide the rest; the count seen. */
function cull(list, position) {
HORIZON_CULL_ELLIPSOID ??= new Cesium.Ellipsoid(
Cesium.Ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M,
Cesium.Ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M,
Cesium.Ellipsoid.WGS84.radii.z - HORIZON_CULL_DEPTH_M,
);
occluder ??= new Cesium.EllipsoidalOccluder(HORIZON_CULL_ELLIPSOID);
occluder.cameraPosition = position;
let seen = 0;
let changed = false;
for (const item of list) {
seen++;
const show = occluder.isPointVisible(item.position);
if (item.show !== show) {
item.show = show;
changed = true;
}
}
if (changed) onChange?.();
return seen;
}
/** Cull everything from the camera at `position`; the count seen. */
function cullAll(position) {
from = Cesium.Cartesian3.clone(position, from);
return cull(items(), position);
}
function onPreRender() {
const position = getViewer()?.camera?.positionWC;
if (!position || (from && Cesium.Cartesian3.equals(from, position))) return;
if (!cullAll(position)) stop();
}
/** Cull `added` items (or all) now, then all again whenever the camera moves. */
function update(added) {
const viewer = getViewer();
const position = viewer?.camera?.positionWC;
if (position && !(added ? cull(added, position) : cullAll(position)))
return;
stopListening ||=
viewer?.scene?.preRender?.addEventListener(onPreRender) || null;
}
function stop() {
stopListening?.();
stopListening = null;
from = null;
}
return { update, stop };
}
/** The middle of the visible ground, else the point under the camera; null without a camera. */ /** The middle of the visible ground, else the point under the camera; null without a camera. */
export function viewCentre(viewer) { export function viewCentre(view) {
if (!viewer) return null; if (!engineOf(view)) return null;
const bbox = visibleBbox(viewer); const bbox = visibleBbox(view);
if (bbox) { if (bbox) {
// Across the date line (west > east) the middle is on the far side of 0°. // Across the date line (west > east) the middle is on the far side of 0°.
const span = bbox[2] - bbox[0] + (bbox[0] > bbox[2] ? 360 : 0); const span = bbox[2] - bbox[0] + (bbox[0] > bbox[2] ? 360 : 0);
@@ -305,10 +232,5 @@ export function viewCentre(viewer) {
if (lon > 180) lon -= 360; if (lon > 180) lon -= 360;
return { lat: (bbox[1] + bbox[3]) / 2, lon }; return { lat: (bbox[1] + bbox[3]) / 2, lon };
} }
const carto = cameraLocation(viewer); return cameraNadir(view);
if (!carto) return null;
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
};
} }
+56 -219
View File
@@ -1,108 +1,97 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium';
import { import {
cameraNadir, cameraNadir,
createHorizonCull,
groundUnderCamera, groundUnderCamera,
metresBetween, metresBetween,
viewCentre,
viewFocus, viewFocus,
visibleBbox, visibleBbox,
} from './view.js'; } from './view.js';
import { latToTileY, lonToTileX, tilesForBbox } from './tileMath.js'; import { latToTileY, lonToTileX, tilesForBbox } from './tileMath.js';
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs'; import { cameraEngine } from '../../testSupport/streetLevelFakes.mjs';
const RAD = Math.PI / 180; /**
/** A viewer 300 m above a street 1,600 m up (Denver-like). */ * The display's view of a fake engine (view.js reads `engine` only): 300 m
function viewer({ globeShown, globeHeight }) { * above a street 1,600 m up (Denver-like), the terrain at `terrain` and the
return { * rendered surface at `sample`; `tiles` shows Google 3D (the globe hidden).
camera: { */
positionCartographic: { async function denver({ terrain, sample, tiles = false }) {
longitude: -104.99 * RAD, const engine = cameraEngine({ lon: -104.99, lat: 39.74, altitude: 1900 });
latitude: 39.74 * RAD, engine.fake.script('terrainHeight', () => terrain);
height: 1900, engine.fake.script('sampleHeight', () => sample);
}, if (tiles) await engine.maps.setPhotorealTiles({ kind: 'photoreal' });
}, return { engine };
scene: { globe: { show: globeShown, getHeight: () => globeHeight } },
};
} }
test('a shown globe answers the ground under the camera', () => { test('a shown globe answers the ground under the camera', async () => {
assert.equal( assert.equal(groundUnderCamera(await denver({ terrain: 1600 })), 1600);
groundUnderCamera(viewer({ globeShown: true, globeHeight: 1600 })),
1600,
);
}); });
test('a hidden globe (Google 3D) or a missing sample gives no ground', () => { test('a hidden globe (Google 3D) or a missing sample gives no ground', async () => {
// A hidden globe's getHeight is ignored even when it returns a number. // A hidden globe's terrain is ignored even when it returns a number.
assert.equal( assert.equal(
groundUnderCamera(viewer({ globeShown: false, globeHeight: 0 })), groundUnderCamera(await denver({ terrain: 0, tiles: true })),
null,
);
assert.equal(
groundUnderCamera(viewer({ globeShown: true, globeHeight: undefined })),
null, null,
); );
assert.equal(groundUnderCamera(await denver({ terrain: undefined })), null);
assert.equal(groundUnderCamera({}), null); assert.equal(groundUnderCamera({}), null);
assert.equal(groundUnderCamera(null), null);
}); });
/** The Denver viewer with Google 3D: no globe, a sampleable rendered surface. */ /** The Denver view with Google 3D: no globe, a sampleable rendered surface. */
function google3d(sample) { const google3d = (sample) => denver({ terrain: 0, sample, tiles: true });
const view = viewer({ globeShown: false, globeHeight: 0 });
view.scene.sampleHeightSupported = true;
view.scene.sampleHeight = () => sample;
return view;
}
test('on Google 3D the rendered surface answers the ground under the camera', () => { test('on Google 3D the rendered surface answers the ground under the camera', async () => {
assert.equal(groundUnderCamera(google3d(1612)), 1612); assert.equal(groundUnderCamera(await google3d(1612)), 1612);
}); });
test('an implausible or above-camera surface sample gives no ground', () => { test('an implausible or above-camera surface sample gives no ground', async () => {
// Before tiles stream in, sampleHeight can read kilometres underground. // Before tiles stream in, sampleHeight can read kilometres underground.
assert.equal(groundUnderCamera(google3d(-14_886)), null); assert.equal(groundUnderCamera(await google3d(-14_886)), null);
// A roof above the camera (1,900 m) would put the camera underground. // A roof above the camera (1,900 m) would put the camera underground.
assert.equal(groundUnderCamera(google3d(1950)), null); assert.equal(groundUnderCamera(await google3d(1950)), null);
assert.equal(groundUnderCamera(google3d(undefined)), null); assert.equal(groundUnderCamera(await google3d(undefined)), null);
}); });
/** A camera whose screen rays land on a grid of lon/lat points. */ /** Straight down from `altitude` over `lon`/`lat` on a 100 px canvas. */
function gridViewer(lons, lats) { const downAt = (lon, lat, altitude) => ({
const points = []; engine: cameraEngine({ lon, lat, altitude, width: 100, height: 100 }),
for (const lat of lats) });
for (const lon of lons)
points.push(Cesium.Cartesian3.fromDegrees(lon, lat));
let next = 0;
return {
scene: { canvas: { clientWidth: 100, clientHeight: 100 }, globe: {} },
camera: {
pickEllipsoid: () => points[next++ % points.length],
positionCartographic: { longitude: 0, latitude: 0, height: 1000 },
},
};
}
test('a view across the date line is a narrow box with west > east', () => { test('a view across the date line is a narrow box with west > east', () => {
const viewer = gridViewer([178, 179, -179, -178], [-1, 0, 1]); // About ±1° around 180° from 200 km up.
const bbox = visibleBbox(viewer); const bbox = visibleBbox(downAt(180, 0, 200_000));
assert.ok(bbox[0] > 177 && bbox[0] < 179, `west ${bbox[0]}`); assert.ok(bbox[0] > 178 && bbox[0] < 180, `west ${bbox[0]}`);
assert.ok(bbox[2] < -177 && bbox[2] > -179, `east ${bbox[2]}`); assert.ok(bbox[2] < -178 && bbox[2] > -180, `east ${bbox[2]}`);
// Its middle is on the date line, not at 0°.
assert.ok(
Math.abs(Math.abs(viewCentre(downAt(180, 0, 200_000)).lon) - 180) < 0.01,
);
}); });
test('an ordinary view keeps west < east', () => { test('an ordinary view keeps west < east', () => {
const bbox = visibleBbox(gridViewer([10, 11, 12], [50, 51])); const bbox = visibleBbox(downAt(11, 50.5, 50_000));
assert.ok(bbox[0] < bbox[2]); assert.ok(bbox[0] < bbox[2]);
assert.ok(bbox[0] < 11 && bbox[2] > 11 && bbox[1] < 50.5 && bbox[3] > 50.5);
}); });
/** A pinhole viewer `agl` m above ground `ground` m up, looking north. */ test('without an engine or a canvas there is no box, and the centre is the nadir', () => {
assert.equal(visibleBbox(null), null);
assert.equal(viewCentre(null), null);
const view = downAt(11, 50.5, 50_000);
view.engine.fake.setSize(0, 0);
assert.equal(visibleBbox(view), null);
const centre = viewCentre(view);
assert.ok(
Math.abs(centre.lon - 11) < 1e-9 && Math.abs(centre.lat - 50.5) < 1e-9,
);
});
/** A pinhole view `agl` m above ground `ground` m up, looking north. */
function pinhole({ lon, lat, ground, agl, pitch, w = 1600, h = 900 }) { function pinhole({ lon, lat, ground, agl, pitch, w = 1600, h = 900 }) {
return { return {
scene: { engine: cameraEngine({
canvas: { clientWidth: w, clientHeight: h },
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
},
camera: rayCamera({
lon, lon,
lat, lat,
altitude: ground + agl, altitude: ground + agl,
@@ -233,155 +222,3 @@ test('metresBetween measures the short way round the date line', () => {
) < 1e-9, ) < 1e-9,
); );
}); });
/**
* A horizon cull over `items` for a camera the test moves, with the frame's
* pre-render listeners and a count of the points tested against the horizon.
*/
function cullHarness(t, items) {
const visible = t.mock.method(
Cesium.EllipsoidalOccluder.prototype,
'isPointVisible',
);
const preRender = new Set();
const camera = { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1e7) };
const viewer = {
camera,
scene: {
preRender: {
addEventListener(listener) {
preRender.add(listener);
return () => preRender.delete(listener);
},
},
},
};
let changes = 0;
const cull = createHorizonCull({
getViewer: () => viewer,
items: () => items,
onChange: () => changes++,
});
return {
cull,
preRender,
tested: () => visible.mock.callCount(),
changes: () => changes,
frame: () => {
for (const listener of [...preRender]) listener();
},
// Moved in place, as Cesium updates positionWC.
moveTo: (lon, lat) =>
Cesium.Cartesian3.fromDegrees(
lon,
lat,
1e7,
undefined,
camera.positionWC,
),
};
}
const point = (lon, lat) => ({
position: Cesium.Cartesian3.fromDegrees(lon, lat),
show: true,
});
test('the horizon cull does no work while the camera stands still', (t) => {
const near = point(0, 0);
const far = point(180, 0);
const h = cullHarness(t, [near, far]);
h.cull.update();
assert.equal(h.tested(), 2);
assert.deepEqual([near.show, far.show], [true, false]);
assert.equal(h.preRender.size, 1, 'listening for camera moves');
for (let i = 0; i < 5; i++) h.frame();
assert.equal(h.tested(), 2, 'a still camera re-culls nothing');
h.moveTo(180, 0);
h.frame();
assert.equal(h.tested(), 4, 'a moved camera re-culls everything once');
assert.deepEqual([near.show, far.show], [false, true]);
h.frame();
h.frame();
assert.equal(h.tested(), 4, 'and then rests again');
h.cull.stop();
assert.equal(h.preRender.size, 0);
});
test('the horizon cull stops listening once nothing is left, and starts again for new items', (t) => {
const items = [point(0, 0)];
const h = cullHarness(t, items);
h.cull.update();
assert.equal(h.preRender.size, 1);
// Every item goes (the tiles are dropped); the next camera move finds none.
items.length = 0;
h.moveTo(10, 0);
h.frame();
assert.equal(h.preRender.size, 0, 'the listener is removed');
// New items arrive: they are culled now, and camera moves are watched again.
const far = point(-170, 0);
items.push(far);
h.cull.update([far]);
assert.equal(far.show, false, 'culled on arrival');
assert.equal(h.preRender.size, 1, 'the listener is back');
h.moveTo(-170, 0);
h.frame();
assert.equal(far.show, true, 're-culled when the camera moves');
assert.equal(h.changes(), 2, 'a redraw for each change');
h.cull.stop();
});
/** A cull over `points` ([lon, lat, height]) seen from a camera at `camera`. */
function cullFrom(camera, points) {
const items = points.map(([lon, lat, height]) => ({
position: Cesium.Cartesian3.fromDegrees(lon, lat, height),
show: true,
}));
const horizon = createHorizonCull({
getViewer: () => ({
camera: { positionWC: Cesium.Cartesian3.fromDegrees(...camera) },
scene: { preRender: { addEventListener: () => () => {} } },
}),
items: () => items,
});
horizon.update();
horizon.stop();
return items.map((item) => item.show);
}
test('cones and the marker stay visible on ground below the WGS84 ellipsoid', () => {
// NYC at street level: eye 2.4 m above ground at -22 m, a cone 10 m away.
assert.deepEqual(
cullFrom([-74.006, 40.7128, -19.6], [[-74.006, 40.71289, -22]]),
[true],
);
// Colombo framing view: camera at -21 m, cones at -95 m about 80 m away.
assert.deepEqual(
cullFrom(
[79.8612, 6.9271, -21],
[
[79.8612, 6.92782, -95],
[79.86192, 6.9271, -95],
],
),
[true, true],
);
// Austin, ground well above the ellipsoid.
assert.deepEqual(
cullFrom([-97.7431, 30.2672, 132], [[-97.7431, 30.2681, 130]]),
[true],
);
});
test('the horizon cull still hides the far side of the Earth from orbit', () => {
assert.deepEqual(
cullFrom(
[0, 0, 20_000_000],
[
[10, 10, 0],
[180, 0, 0],
],
),
[true, false],
);
});
+34 -51
View File
@@ -1,7 +1,8 @@
import { DOT_FADE_MS } from './retention.js'; import { DOT_FADE_MS } from './retention.js';
import { flowBucket, flowSpeedScale } from '../../data/trafficFlowStyle.js'; import { flowBucket, flowSpeedScale } from '../../data/trafficFlowStyle.js';
import { presetDotOutline } from '../../data/trafficPresetStyle.js'; import { presetDotOutline } from '../../data/trafficPresetStyle.js';
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { Rgba, nearFarScale } from '../shared/color.js';
import { queuePlatoons, locateAlongRoad } from '../../data/trafficQueue.js'; import { queuePlatoons, locateAlongRoad } from '../../data/trafficQueue.js';
import { import {
SPEED_MPS, SPEED_MPS,
@@ -10,8 +11,7 @@ import {
CREEP_MOVE_MS, CREEP_MOVE_MS,
CREEP_STOP_MS, CREEP_STOP_MS,
CREEP_BURST, CREEP_BURST,
HEAT_JAM_BASE_ALPHA, SIM_DOT_COLOR,
HEAT_JAM_PULSE_ALPHA,
} from './policy.js'; } from './policy.js';
export function createAnimation({ export function createAnimation({
@@ -21,10 +21,7 @@ export function createAnimation({
source, source,
}) { }) {
function syncRenderDemand() { function syncRenderDemand() {
const needed = Boolean( const needed = Boolean(layerState._enabled && layerState._dots.length);
layerState._enabled &&
(layerState._dots.length || layerState._heatJamPrim),
);
// Height easing only runs in a held frame. Settle it before releasing, so // Height easing only runs in a held frame. Settle it before releasing, so
// a population emptied mid-correction cannot report loading forever. // a population emptied mid-correction cannot report loading forever.
if (!needed && layerState._heightPending) parts.retention.finishHeights(); if (!needed && layerState._heightPending) parts.retention.finishHeights();
@@ -33,11 +30,15 @@ export function createAnimation({
if (needed) { if (needed) {
layerState._lastAnimTime = 0; layerState._lastAnimTime = 0;
services.render.holdContinuousRender('traffic'); services.render.holdContinuousRender('traffic');
if (layerState._engine) layerState._engine.render.request('traffic'); layerState._engine?.render.request('traffic');
else layerState._viewer?.scene?.requestRender?.();
} else services.render.releaseContinuousRender('traffic'); } else services.render.releaseContinuousRender('traffic');
} }
/** Writes the dots to the engine's display (once a frame, and on a clear). */
function drawDots() {
layerState._display?.drawDots(layerState._dots);
}
/** /**
* Spawn animated dot primitives along a single road. * Spawn animated dot primitives along a single road.
* *
@@ -45,7 +46,7 @@ export function createAnimation({
* randomized speed (base +/-30%), and given a direction (alternating * randomized speed (base +/-30%), and given a direction (alternating
* forward/backward to simulate two-way traffic). * forward/backward to simulate two-way traffic).
* *
* @param {{waypoints:Cesium.Cartesian3[], segmentDist:number[], type:string, coords:number[][]}} road * @param {{waypoints:object[], segmentDist:number[], type:string, coords:number[][]}} road
* Parsed road object with pre-computed waypoints. * Parsed road object with pre-computed waypoints.
* @param {number} altitude - Camera altitude (used if budgetCount is null). * @param {number} altitude - Camera altitude (used if budgetCount is null).
* @param {number|null} [budgetCount=null] - Pre-allocated dot count. Falls back * @param {number|null} [budgetCount=null] - Pre-allocated dot count. Falls back
@@ -82,7 +83,7 @@ export function createAnimation({
? presetDotOutline(layerState._stylePreset, bucket) ? presetDotOutline(layerState._stylePreset, bucket)
: null; : null;
const outlineColor = outlineSpec const outlineColor = outlineSpec
? new Cesium.Color( ? new Rgba(
outlineSpec.rgba[0] / 255, outlineSpec.rgba[0] / 255,
outlineSpec.rgba[1] / 255, outlineSpec.rgba[1] / 255,
outlineSpec.rgba[2] / 255, outlineSpec.rgba[2] / 255,
@@ -140,7 +141,7 @@ export function createAnimation({
: -1; : -1;
// Compute initial Cartesian3 position via linear interpolation // Compute initial Cartesian3 position via linear interpolation
Cesium.Cartesian3.lerp( geo.cartesian3.lerp(
road.waypoints[segIdx], road.waypoints[segIdx],
road.waypoints[segIdx + 1], road.waypoints[segIdx + 1],
t, t,
@@ -151,31 +152,30 @@ export function createAnimation({
// depth override made distant roads look like vehicles crossing roofs. // depth override made distant roads look like vehicles crossing roofs.
const jamProminent = bucket === 'jam' && parts.style.jamDensityOn(); const jamProminent = bucket === 'jam' && parts.style.jamDensityOn();
const id = layerState._nextDotId++; const id = layerState._nextDotId++;
const point = layerState._pointCollection.add({ // The dot's look, which the display draws (depth-tested: it respects
position: Cesium.Cartesian3.clone(layerState._scratchLerp), // the sampled mesh at every viewing distance).
const point = {
position: geo.cartesian3.clone(layerState._scratchLerp),
pixelSize, pixelSize,
// No flow data → today's exact simulated white. // No flow data → today's exact simulated white.
color: flowColor || Cesium.Color.WHITE.withAlpha(0.85), color: flowColor || SIM_DOT_COLOR,
scaleByDistance: new Cesium.NearFarScalar( show: true,
scaleByDistance: nearFarScale(
100, 100,
1.5, 1.5,
layerState._fadeScaleFar, layerState._fadeScaleFar,
jamProminent ? JAM_DOT_FAR_SCALE : 0.3, jamProminent ? JAM_DOT_FAR_SCALE : 0.3,
), ),
translucencyByDistance: new Cesium.NearFarScalar( translucencyByDistance: nearFarScale(
100, 100,
1.0, 1.0,
layerState._fadeTransFar, layerState._fadeTransFar,
0.0, 0.0,
), ),
// Respect the sampled mesh at every viewing distance. // Preset dark halo (none on the keyless/normal path).
disableDepthTestDistance: 0, outlineColor: outlineSpec ? outlineColor : null,
// Preset dark halo (spread only when present — the keyless/normal outlineWidth: outlineSpec ? outlineSpec.width : 0,
// path passes the exact shipped option set). };
...(outlineSpec
? { outlineColor, outlineWidth: outlineSpec.width }
: {}),
});
layerState._bucketCounts[bucket || 'sim'] += 1; layerState._bucketCounts[bucket || 'sim'] += 1;
layerState._motion.added++; layerState._motion.added++;
@@ -210,7 +210,7 @@ export function createAnimation({
} }
/** /**
* Per-frame animation callback registered on `scene.preRender`. * Per-frame animation callback registered on `engine.frames.preRender`.
* *
* For every active dot: * For every active dot:
* 1. Ease heights and fade arrivals/departures, including paused dots. * 1. Ease heights and fade arrivals/departures, including paused dots.
@@ -219,7 +219,7 @@ export function createAnimation({
* 3. Advance t in the dot's travel direction, handling segment boundary * 3. Advance t in the dot's travel direction, handling segment boundary
* crossings and faded end-of-road departures. * crossings and faded end-of-road departures.
* 4. Linearly interpolate between the two bounding waypoints and update the * 4. Linearly interpolate between the two bounding waypoints and update the
* point primitive's position. * dot's position; the display draws the dots once the frame is done.
* *
* Delta time is capped at 100 ms to prevent large jumps after background tabs. * Delta time is capped at 100 ms to prevent large jumps after background tabs.
*/ */
@@ -306,27 +306,12 @@ export function createAnimation({
if (now < dot.stoppedUntil) break; if (now < dot.stoppedUntil) break;
} }
// Lerp between pre-computed Cartesian3 waypoints (no trig needed). // Lerp between pre-computed waypoints (no trig needed), in place: a
// Pass the scratch directly: PointPrimitive's position setter clones the // fresh position per dot per frame is 360–720k objects a second across
// value into its own storage (and skips the VBO dirty flag when equal), so // 6000 dots.
// the extra defensive clone here allocated 360–720k Cartesian3/s of pure
// garbage across 6000 dots. (perf item 5)
const a = dot.waypoints[dot.segIdx]; const a = dot.waypoints[dot.segIdx];
const b = dot.waypoints[dot.segIdx + 1]; const b = dot.waypoints[dot.segIdx + 1];
Cesium.Cartesian3.lerp(a, b, dot.t, layerState._scratchLerp); geo.cartesian3.lerp(a, b, dot.t, dot.point.position);
dot.point.position = layerState._scratchLerp;
}
// 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);
} }
layerState._animFrame++; layerState._animFrame++;
@@ -355,24 +340,22 @@ export function createAnimation({
/** Remove all point primitives and reset dot/road arrays and counters. */ /** Remove all point primitives and reset dot/road arrays and counters. */
function clearDots() { function clearDots() {
services.credits?.hideOsmCredit?.( services.credits?.hideOsmCredit?.(layerState._engine, 'traffic');
layerState._engine ?? layerState._viewer,
'traffic',
);
parts.retention.clear(); parts.retention.clear();
if (layerState._dots.length) layerState._motion.rebuilds++; if (layerState._dots.length) layerState._motion.rebuilds++;
if (layerState._pointCollection) layerState._pointCollection.removeAll();
parts.rendering.removeHeatLines(); parts.rendering.removeHeatLines();
layerState._dots = []; layerState._dots = [];
layerState._roads = []; layerState._roads = [];
layerState._count = 0; layerState._count = 0;
layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 };
layerState._closedRoads = 0; layerState._closedRoads = 0;
drawDots();
syncRenderDemand(); syncRenderDemand();
} }
return { return {
spawnDotsForRoad, spawnDotsForRoad,
animate, animate,
drawDots,
maybeStopLight, maybeStopLight,
clearDots, clearDots,
syncRenderDemand, syncRenderDemand,
+1 -1
View File
@@ -208,7 +208,7 @@ export function createControls({ state: layerState, services, parts, source }) {
* @param {Object} [options] * @param {Object} [options]
* @param {number} [options.maxCount] - Maximum objects to return (defaults to all). * @param {number} [options.maxCount] - Maximum objects to return (defaults to all).
* @param {number} [options.seed] - Integer seed to offset the sampling start. * @param {number} [options.seed] - Integer seed to offset the sampling start.
* @returns {Array<{position:Cesium.Cartesian3, id:string, type:string}>} * @returns {Array<{position:{x:number,y:number,z:number}, id:string, type:string}>}
*/ */
getDetectableObjects(options = {}) { getDetectableObjects(options = {}) {
if (!layerState._enabled || layerState._dots.length === 0) return []; if (!layerState._enabled || layerState._dots.length === 0) return [];
+45 -14
View File
@@ -6,6 +6,29 @@ import { createFlow } from './flow.js';
import { createRetention } from './retention.js'; import { createRetention } from './retention.js';
import { createControls } from './controls.js'; import { createControls } from './controls.js';
import { createState } from './state.js'; import { createState } from './state.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
// An engine-shaped view of a Cesium camera: the canvas size, and worldToWindow
// through Cesium's own projection (undefined behind the camera or in front of
// the near plane, as the engines answer).
function cameraEngine(scene) {
const eye = new C.Cartesian3();
return {
host: {
size: () => ({
width: scene.canvas.clientWidth,
height: scene.canvas.clientHeight,
}),
},
projector: {
worldToWindow(point, result) {
C.Matrix4.multiplyByPoint(scene.camera.viewMatrix, point, eye);
if (eye.z >= -scene.camera.frustum.near) return undefined;
return C.SceneTransforms.worldToWindowCoordinates(scene, point, result);
},
},
};
}
const bounds = { west: 0, east: 0.01, south: 0, north: 0.01 }; const bounds = { west: 0, east: 0.01, south: 0, north: 0.01 };
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
@@ -87,7 +110,7 @@ for (const y of [-175, -200, -300]) test(`off-screen camera-plane height correct
}; };
const point = new C.Cartesian3(0, y, 100), target = new C.Cartesian3(0, y, 197); const point = new C.Cartesian3(0, y, 100), target = new C.Cartesian3(0, y, 197);
const state = createState({ services: {} }); const state = createState({ services: {} });
Object.assign(state, { _viewer: { scene }, _roads: [{ heightMoving: true, waypoints: [point], targetWaypoints: [target], segmentDist: [] }] }); Object.assign(state, { _engine: cameraEngine(scene), _roads: [{ heightMoving: true, waypoints: [point], targetWaypoints: [target], segmentDist: [] }] });
const retention = createRetention({ state, parts: {} }); const retention = createRetention({ state, parts: {} });
retention.easeHeights(1 / 60); retention.easeHeights(1 / 60);
const controls = createControls({ state, services: {}, source: { getFlowSessionStats: () => ({}) }, parts: { model: { trafficFeedPresentation: () => ({}) } } }); const controls = createControls({ state, services: {}, source: { getFlowSessionStats: () => ({}) }, parts: { model: { trafficFeedPresentation: () => ({}) } } });
@@ -111,7 +134,7 @@ test('visible corrections use the pixel limit without the off-screen world-speed
frustum: new C.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1.6, near: 1, far: 1e7 }) }, frustum: new C.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1.6, near: 1, far: 1e7 }) },
}; };
const point = new C.Cartesian3(0, 1000, 0), target = new C.Cartesian3(0, 1000, 300); const point = new C.Cartesian3(0, 1000, 0), target = new C.Cartesian3(0, 1000, 300);
const state = { _viewer: { scene }, _roads: [{ heightMoving: true, waypoints: [point], targetWaypoints: [target], segmentDist: [] }] }; const state = { _engine: cameraEngine(scene), _roads: [{ heightMoving: true, waypoints: [point], targetWaypoints: [target], segmentDist: [] }] };
const retention = createRetention({ state, parts: {} }); const retention = createRetention({ state, parts: {} });
let loadingFrames = 0, maxPixels = 0; let loadingFrames = 0, maxPixels = 0;
for (let frame = 0; frame < 180; frame++) { for (let frame = 0; frame < 180; frame++) {
@@ -137,7 +160,7 @@ for (const targetHeight of [200, 2000]) test(`visible height steps stay pixel-li
frustum: new C.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1.6, near: 1, far: 1e7 }) }, frustum: new C.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1.6, near: 1, far: 1e7 }) },
}; };
const point = new C.Cartesian3(0, 50, 400), target = new C.Cartesian3(0, 50, targetHeight); const point = new C.Cartesian3(0, 50, 400), target = new C.Cartesian3(0, 50, targetHeight);
const state = { _viewer: { scene }, _roads: [{ heightMoving: true, waypoints: [point], targetWaypoints: [target], segmentDist: [] }] }; const state = { _engine: cameraEngine(scene), _roads: [{ heightMoving: true, waypoints: [point], targetWaypoints: [target], segmentDist: [] }] };
const before = C.SceneTransforms.worldToWindowCoordinates(scene, point); const before = C.SceneTransforms.worldToWindowCoordinates(scene, point);
assert.ok(before.y > 0 && before.y < 800); assert.ok(before.y > 0 && before.y < 800);
const trial = C.Cartesian3.lerp(point, target, 0.2, new C.Cartesian3()); const trial = C.Cartesian3.lerp(point, target, 0.2, new C.Cartesian3());
@@ -173,6 +196,24 @@ function holdFlowTasks(t) {
}; };
} }
// A globe without photoreal tiles 450 m over the road, whose terrain
// answers 100 m (counted), with no rendered depth.
function terrainEngine(calls) {
const engine = createFakeEngine();
engine.fake.maps.imagery = { kind: 'osm' };
engine.fake.setPose({
longitude: (0.0005 * Math.PI) / 180,
latitude: 0,
height: 450,
});
engine.fake.script('pickPosition', undefined);
engine.fake.script('terrainHeight', () => {
calls.samples++;
return 100;
});
return engine;
}
function flowFixture(source) { function flowFixture(source) {
const state = createState({ services: {} }); const state = createState({ services: {} });
const calls = { samples: 0, selections: 0 }; const calls = { samples: 0, selections: 0 };
@@ -194,17 +235,7 @@ function flowFixture(source) {
_enabled: true, _enabled: true,
_roadMode: 'osm', _roadMode: 'osm',
_parseRoads: (data) => data.roads, _parseRoads: (data) => data.roads,
_viewer: { _engine: terrainEngine(calls),
scene: {
globe: {
show: true,
getHeight: () => {
calls.samples++;
return 100;
},
},
},
},
}); });
const parts = { const parts = {
viewport: { viewport: {
+34 -45
View File
@@ -1,5 +1,5 @@
import { roadIdentity } from './retention.js'; import { roadIdentity } from './retention.js';
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { import {
prepareRoadSurfaces, prepareRoadSurfaces,
roadHeightStations, roadHeightStations,
@@ -20,17 +20,18 @@ import { nextTask } from '../../sources/vectorTiles.js';
* the traffic layer's kilometre range). Null without a camera. * the traffic layer's kilometre range). Null without a camera.
*/ */
function groundDistanceFrom(camera, groundHeight = 0) { function groundDistanceFrom(camera, groundHeight = 0) {
const view = camera && Cesium.Cartographic.fromCartesian(camera); const view = camera && geo.cartographic.fromCartesian(camera);
if (!view) return null; if (!view) return null;
const clearance = view.height - (groundHeight || 0) - DOT_HEIGHT_OFFSET; const clearance = view.height - (groundHeight || 0) - DOT_HEIGHT_OFFSET;
const lat = Cesium.Math.toDegrees(view.latitude); const lat = geo.math.toDegrees(view.latitude);
const lon = Cesium.Math.toDegrees(view.longitude); const lon = geo.math.toDegrees(view.longitude);
const east = 111320 * Math.cos(view.latitude); const east = 111320 * Math.cos(view.latitude);
return ([roadLon, roadLat]) => return ([roadLon, roadLat]) =>
Math.hypot((roadLon - lon) * east, (roadLat - lat) * 111320, clearance); Math.hypot((roadLon - lon) * east, (roadLat - lat) * 111320, clearance);
} }
export function createFlow({ state: layerState, services, parts, source }) { export function createFlow({ state: layerState, services, parts, source }) {
const scratchScreen = geo.cartesian2.create();
const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits; const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits;
const { fetchFlowForBounds } = source; const { fetchFlowForBounds } = source;
@@ -95,10 +96,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
layerState._flowStatusKnown = true; layerState._flowStatusKnown = true;
if (layerState._liveMode) { if (layerState._liveMode) {
console.log('[Data:Traffic] TomTom key present — live flow mode'); console.log('[Data:Traffic] TomTom key present — live flow mode');
registerDynamicCredit( registerDynamicCredit(layerState._engine, TOMTOM_CREDIT);
layerState._engine ?? layerState._viewer,
TOMTOM_CREDIT,
);
} }
}) })
.catch((e) => { .catch((e) => {
@@ -291,15 +289,16 @@ export function createFlow({ state: layerState, services, parts, source }) {
// Separate whole-graph preparation from tile completion and publication. // Separate whole-graph preparation from tile completion and publication.
await nextTask(); await nextTask();
if (!current()) return false; if (!current()) return false;
const scene = layerState._trafficScene?.() ?? layerState._viewer?.scene; const engine = layerState._engine;
if (!scene) return false; if (!engine) return false;
const size = engine.host.size();
// The enable-time estimate can predate the first local mesh (especially // The enable-time estimate can predate the first local mesh (especially
// after an intercontinental jump). Once previews have measured roads, use // after an intercontinental jump). Once previews have measured roads, use
// that local evidence to select the detail view at its real elevation. // that local evidence to select the detail view at its real elevation.
const localHeights = []; const localHeights = [];
for (const prepared of preparedRoads.values()) { for (const prepared of preparedRoads.values()) {
const height = const height =
Cesium.Cartographic.fromCartesian(prepared.waypoints[0]).height - geo.cartographic.fromCartesian(prepared.waypoints[0]).height -
DOT_HEIGHT_OFFSET; DOT_HEIGHT_OFFSET;
if (Number.isFinite(height) && Math.abs(height) <= 9000) if (Number.isFinite(height) && Math.abs(height) <= 9000)
localHeights.push(height); localHeights.push(height);
@@ -313,14 +312,11 @@ export function createFlow({ state: layerState, services, parts, source }) {
// A rendered center-depth pick follows the current city's elevation even // A rendered center-depth pick follows the current city's elevation even
// while older coarse samples are being replaced. It is selection-only. // while older coarse samples are being replaced. It is selection-only.
let depthHeight; let depthHeight;
if (scene.pickPositionSupported && scene.canvas?.clientWidth) { if (engine.picker.pickPositionSupported && size.width) {
const probe = new Cesium.Cartesian2( const probe = geo.cartesian2.create(size.width / 2, size.height * 0.6);
scene.canvas.clientWidth / 2,
scene.canvas.clientHeight * 0.6,
);
try { try {
const hit = scene.pickPosition(probe); const hit = engine.picker.pickPosition(probe);
const height = hit && Cesium.Cartographic.fromCartesian(hit).height; const height = hit && geo.cartographic.fromCartesian(hit).height;
if (Number.isFinite(height) && Math.abs(height) <= 9000) if (Number.isFinite(height) && Math.abs(height) <= 9000)
depthHeight = height; depthHeight = height;
} catch { } catch {
@@ -333,7 +329,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
const prepared = !refining && preparedRoads.get(road.key); const prepared = !refining && preparedRoads.get(road.key);
if (prepared) { if (prepared) {
for (let i = 0; i < road.waypoints.length; i++) for (let i = 0; i < road.waypoints.length; i++)
Cesium.Cartesian3.clone(prepared.waypoints[i], road.waypoints[i]); geo.cartesian3.clone(prepared.waypoints[i], road.waypoints[i]);
} else if ( } else if (
(!road.surfaceReady || refining) && (!road.surfaceReady || refining) &&
(localHeights.length || depthHeight !== undefined) (localHeights.length || depthHeight !== undefined)
@@ -342,7 +338,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
// Opposite source directions may share waypoint arrays. Provisional // Opposite source directions may share waypoint arrays. Provisional
// selection must not overwrite an already measured sibling. // selection must not overwrite an already measured sibling.
road.waypoints = road.waypoints.map((point) => road.waypoints = road.waypoints.map((point) =>
Cesium.Cartesian3.clone(point), geo.cartesian3.clone(point),
); );
for (let i = 0; i < road.coords.length; i++) { for (let i = 0; i < road.coords.length; i++) {
const [lon, lat] = road.coords[i]; const [lon, lat] = road.coords[i];
@@ -351,7 +347,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
Number.isFinite(floor) && Math.abs(floor) <= 9000 Number.isFinite(floor) && Math.abs(floor) <= 9000
? Math.max(floor, layerState._viewHeightEstimate) ? Math.max(floor, layerState._viewHeightEstimate)
: layerState._viewHeightEstimate; : layerState._viewHeightEstimate;
Cesium.Cartesian3.fromDegrees( geo.cartesian3.fromDegrees(
lon, lon,
lat, lat,
height + DOT_HEIGHT_OFFSET, height + DOT_HEIGHT_OFFSET,
@@ -367,9 +363,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
// last: after a globe switch the outgoing globe's, often taken from its // last: after a globe switch the outgoing globe's, often taken from its
// coarse first tiles (about -2 200 m at Austin on keyless Cesium). Weighted // coarse first tiles (about -2 200 m at Austin on keyless Cesium). Weighted
// by those, a returning globe's population fell from 620 to 260. // by those, a returning globe's population fell from 620 to 260.
const viewCamera = const viewCamera = engine.camera.livePosition;
layerState._engine?.camera.livePosition ??
layerState._viewer?.camera?.positionWC;
const groundDistance = groundDistanceFrom( const groundDistance = groundDistanceFrom(
viewCamera, viewCamera,
layerState._viewHeightEstimate, layerState._viewHeightEstimate,
@@ -390,22 +384,20 @@ export function createFlow({ state: layerState, services, parts, source }) {
) )
return false; return false;
// Spend surface work on roads crossing the canvas, with a small pan margin. // Spend surface work on roads crossing the canvas, with a small pan margin.
if (!scene.canvas?.clientWidth) return true; if (!size.width) return true;
let minX = Infinity, let minX = Infinity,
minY = Infinity, minY = Infinity,
maxX = -Infinity, maxX = -Infinity,
maxY = -Infinity; maxY = -Infinity;
for (const point of road.waypoints) { for (const point of road.waypoints) {
const screen = scene.project const screen = engine.projector.worldToWindow(point, scratchScreen);
? scene.project(point, {})
: Cesium.SceneTransforms.worldToWindowCoordinates(scene, point);
if (!screen) continue; if (!screen) continue;
minX = Math.min(minX, screen.x); minX = Math.min(minX, screen.x);
maxX = Math.max(maxX, screen.x); maxX = Math.max(maxX, screen.x);
minY = Math.min(minY, screen.y); minY = Math.min(minY, screen.y);
maxY = Math.max(maxY, screen.y); maxY = Math.max(maxY, screen.y);
} }
road.coverageBand = maxY < scene.canvas.clientHeight / 2 ? 1 : 0; road.coverageBand = maxY < size.height / 2 ? 1 : 0;
if (clamped.coverage && groundDistance) { if (clamped.coverage && groundDistance) {
road.baseDensityWeight ??= road.densityWeight || 1; road.baseDensityWeight ??= road.densityWeight || 1;
road.densityWeight = road.densityWeight =
@@ -414,9 +406,9 @@ export function createFlow({ state: layerState, services, parts, source }) {
} }
return ( return (
maxX >= -100 && maxX >= -100 &&
minX <= scene.canvas.clientWidth + 100 && minX <= size.width + 100 &&
maxY >= -100 && maxY >= -100 &&
minY <= scene.canvas.clientHeight + 100 minY <= size.height + 100
); );
}); });
const budgets = parts.model.allocateRoadDotBudgets( const budgets = parts.model.allocateRoadDotBudgets(
@@ -425,14 +417,12 @@ export function createFlow({ state: layerState, services, parts, source }) {
roadDotBudget(altitude), roadDotBudget(altitude),
); );
const admitted = selected.filter((r, i) => budgets[i] > 0); const admitted = selected.filter((r, i) => budgets[i] > 0);
const camera = const camera = engine.camera.livePosition;
layerState._engine?.camera.livePosition ??
layerState._viewer?.camera?.positionWC;
if (camera) if (camera)
admitted.sort( admitted.sort(
(a, b) => (a, b) =>
Cesium.Cartesian3.distanceSquared(camera, a.waypoints[0]) - geo.cartesian3.distanceSquared(camera, a.waypoints[0]) -
Cesium.Cartesian3.distanceSquared(camera, b.waypoints[0]), geo.cartesian3.distanceSquared(camera, b.waypoints[0]),
); );
// The wide pass is a fast preview when detailed tiles follow. Preparing // The wide pass is a fast preview when detailed tiles follow. Preparing
// the entire coarse graph only to replace it doubles surface work. // the entire coarse graph only to replace it doubles surface work.
@@ -479,7 +469,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
const prepared = !refining && preparedRoads.get(road.key); const prepared = !refining && preparedRoads.get(road.key);
if (prepared) { if (prepared) {
for (let i = 0; i < road.waypoints.length; i++) for (let i = 0; i < road.waypoints.length; i++)
Cesium.Cartesian3.clone(prepared.waypoints[i], road.waypoints[i]); geo.cartesian3.clone(prepared.waypoints[i], road.waypoints[i]);
for (let i = 0; i < road.segmentDist.length; i++) for (let i = 0; i < road.segmentDist.length; i++)
road.segmentDist[i] = prepared.segmentDist[i]; road.segmentDist[i] = prepared.segmentDist[i];
road.surfaceReady = true; road.surfaceReady = true;
@@ -530,9 +520,9 @@ export function createFlow({ state: layerState, services, parts, source }) {
if (!current()) return false; if (!current()) return false;
let prepared = await prepareRoadSurfaces( let prepared = await prepareRoadSurfaces(
unresolved, unresolved,
scene, engine,
services.ground, services.ground,
[layerState._pointCollection], layerState._display?.excluded() ?? [],
signal, signal,
options, options,
); );
@@ -552,9 +542,9 @@ export function createFlow({ state: layerState, services, parts, source }) {
if (!current()) return false; if (!current()) return false;
prepared = await prepareRoadSurfaces( prepared = await prepareRoadSurfaces(
prepared.pending, prepared.pending,
scene, engine,
services.ground, services.ground,
[layerState._pointCollection], layerState._display?.excluded() ?? [],
signal, signal,
options, options,
); );
@@ -566,8 +556,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
current() && current() &&
!preview && !preview &&
!label.includes('refined') && !label.includes('refined') &&
scene.postRender && (!trafficSurfaceReady(engine) ||
(!trafficSurfaceReady(scene) ||
prepared.pending.length || prepared.pending.length ||
ready.some((road) => road.surfaceSettled === false)) ready.some((road) => road.surfaceSettled === false))
) { ) {
@@ -578,12 +567,12 @@ export function createFlow({ state: layerState, services, parts, source }) {
if (layerState._surfaceRefineRemove === stop) if (layerState._surfaceRefineRemove === stop)
layerState._surfaceRefineRemove = null; layerState._surfaceRefineRemove = null;
}; };
const remove = scene.postRender.addEventListener(() => { const remove = engine.frames.postRender.addEventListener(() => {
if (!current()) { if (!current()) {
stop(); stop();
return; return;
} }
if (!trafficSurfaceReady(scene)) return; if (!trafficSurfaceReady(engine)) return;
stop(); stop();
// Revalidate only local samples taken at a coarser rendered LOD. This // Revalidate only local samples taken at a coarser rendered LOD. This
// background upgrade never holds road acquisition or first paint. // background upgrade never holds road acquisition or first paint.
+10 -17
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js'; import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
@@ -66,15 +65,13 @@ export function footprintTiles(polygon, zoom, near) {
/** Reusable camera scratch; sampled only on camera events, never in the animation loop. */ /** Reusable camera scratch; sampled only on camera events, never in the animation loop. */
export function createReticleFootprint() { export function createReticleFootprint() {
const pixels = Array.from({ length: 25 }, () => geo.cartesian2.create()); const pixels = Array.from({ length: 25 }, () => geo.cartesian2.create());
const hits = Array.from({ length: 25 }, () => geo.cartesian3.create());
const carto = geo.cartographic.create(); const carto = geo.cartographic.create();
let ray; return (engine, altitude) => {
return (viewer, altitude, engine) => { const camera = engine?.camera;
const camera = engine?.camera ?? viewer?.camera; if (!camera?.pickRay) return null;
if (!camera?.getPickRay && !camera?.pickRay) return null; const pose = camera.pose();
const pose = engine?.camera.pose(); const nadir = pose.cartographic;
const nadir = pose?.cartographic ?? camera.positionCartographic; const canvas = engine.host.canvas;
const canvas = engine?.host.canvas ?? viewer.scene.canvas;
const near = { const near = {
lat: geo.math.toDegrees(nadir.latitude), lat: geo.math.toDegrees(nadir.latitude),
lon: geo.math.toDegrees(nadir.longitude), lon: geo.math.toDegrees(nadir.longitude),
@@ -91,9 +88,7 @@ export function createReticleFootprint() {
p = pixels[i]; p = pixels[i];
p.x = left + (side * col) / 4; p.x = left + (side * col) / 4;
p.y = top + (side * row) / 4; p.y = top + (side * row) / 4;
const hit = engine const hit = camera.pickEllipsoid(p);
? camera.pickEllipsoid(p)
: camera.pickEllipsoid(p, Cesium.Ellipsoid.WGS84, hits[i]);
let dx, dy; let dx, dy;
if (hit) { if (hit) {
geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84, carto); geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84, carto);
@@ -102,9 +97,7 @@ export function createReticleFootprint() {
dlon -= 360 * Math.round(dlon / 360); dlon -= 360 * Math.round(dlon / 360);
dx = dlon * 111.195 * Math.cos(nadir.latitude); dx = dlon * 111.195 * Math.cos(nadir.latitude);
} else { } else {
const picked = engine const picked = camera.pickRay(p);
? camera.pickRay(p)
: camera.getPickRay(p, (ray ??= new Cesium.Ray())) || ray;
if (!picked) continue; if (!picked) continue;
const d = picked.direction; const d = picked.direction;
dx = dx =
@@ -162,8 +155,8 @@ export function createReticleFootprint() {
nadir.latitude, nadir.latitude,
nadir.longitude, nadir.longitude,
nadir.height / 10000, nadir.height / 10000,
pose?.heading ?? camera.heading, pose.heading,
pose?.pitch ?? camera.pitch, pose.pitch,
] ]
.map((v) => Number(v || 0).toFixed(7)) .map((v) => Number(v || 0).toFixed(7))
.join(','), .join(','),
+28 -26
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { import {
convexFootprint, convexFootprint,
footprintTiles, footprintTiles,
@@ -33,33 +33,35 @@ test('oblique horizon bearings are bounded and tile caps hold at city latitudes'
[51.507, -0.128], [51.507, -0.128],
[70, 10], [70, 10],
]) { ]) {
const camera = { // An engine-shaped stub: the camera's pose and its ellipsoid and ray
positionCartographic: Cesium.Cartographic.fromDegrees(lon, lat, 300), // picks (the sky above the 300 px line), and the canvas.
heading: 0, const engine = {
pitch: -Math.PI / 12, camera: {
pickEllipsoid(p, e, result) { pose: () => ({
if (p.y < 300) return undefined; cartographic: geo.cartographic.fromDegrees(lon, lat, 300),
return Cesium.Cartesian3.fromDegrees( heading: 0,
lon + (p.x - 640) / 100000, pitch: -Math.PI / 12,
lat + (800 - p.y) / 50000, }),
0, pickEllipsoid(p) {
e, if (p.y < 300) return undefined;
result, return geo.cartesian3.fromDegrees(
); lon + (p.x - 640) / 100000,
}, lat + (800 - p.y) / 50000,
getPickRay(p, ray) { 0,
ray.direction.x = );
-Math.sin((lat * Math.PI) / 180) * Math.cos((lon * Math.PI) / 180); },
ray.direction.y = pickRay: () => ({
-Math.sin((lat * Math.PI) / 180) * Math.sin((lon * Math.PI) / 180); origin: geo.cartesian3.create(),
ray.direction.z = Math.cos((lat * Math.PI) / 180); direction: geo.cartesian3.create(
return ray; -Math.sin((lat * Math.PI) / 180) * Math.cos((lon * Math.PI) / 180),
-Math.sin((lat * Math.PI) / 180) * Math.sin((lon * Math.PI) / 180),
Math.cos((lat * Math.PI) / 180),
),
}),
}, },
host: { canvas: { width: 1280, height: 800 } },
}; };
const result = createReticleFootprint()( const result = createReticleFootprint()(engine, 300);
{ camera, scene: { canvas: { width: 1280, height: 800 } } },
300,
);
assert.ok(result.coverage.coarse.length <= 16); assert.ok(result.coverage.coarse.length <= 16);
assert.ok(result.coverage.detail.length <= 16); assert.ok(result.coverage.detail.length <= 16);
assert.ok( assert.ok(
+34 -39
View File
@@ -1,10 +1,12 @@
// Traffic republished on the incoming globe after a switch. // Traffic republished on the incoming globe after a switch.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as C from 'cesium'; import * as geo from '../../geo/index.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { createState } from './state.js'; import { createState } from './state.js';
import { createFlow } from './flow.js'; import { createFlow } from './flow.js';
import { createLifecycle } from './lifecycle.js'; import { createLifecycle } from './lifecycle.js';
import { observeTrafficSurface } from './surface.js';
import { DOT_HEIGHT_OFFSET } from './policy.js'; import { DOT_HEIGHT_OFFSET } from './policy.js';
import { nextTask } from '../../sources/vectorTiles.js'; import { nextTask } from '../../sources/vectorTiles.js';
@@ -29,43 +31,35 @@ function road(offset, height) {
key: `road:${offset}`, key: `road:${offset}`,
surfaceReady: true, surfaceReady: true,
waypoints: coords.map(([lon, lat]) => waypoints: coords.map(([lon, lat]) =>
C.Cartesian3.fromDegrees(lon, lat, height + DOT_HEIGHT_OFFSET), geo.cartesian3.fromDegrees(lon, lat, height + DOT_HEIGHT_OFFSET),
), ),
segmentDist: [190], segmentDist: [190],
}; };
} }
/** An incoming Navara-like surface whose finer tiles arrive later. */ /**
function navaraSurface() { * An incoming globe (terrain, no photoreal tiles) whose finer tiles arrive
const listeners = new Set(); * later: the camera 1 800 m over Austin, every road on screen, no rendered
const surface = { * depth, and the surface observed as the layer's bound scene observes it.
ready: false, */
height: COARSE, function incomingGlobe(t) {
samples: 0, const engine = createFakeEngine({ width: 1000, height: 800 });
surfaceKey: 'terrain', engine.fake.maps.imagery = { kind: 'osm' };
canvas: { clientWidth: 1000, clientHeight: 800 }, engine.fake.setPose({
project: () => ({ x: 500, y: 500 }), longitude: geo.math.toRadians(LON),
sampleHeightSupported: true, latitude: geo.math.toRadians(LAT),
sampleHeight: () => { height: 1800,
surface.samples++; });
return surface.height; const surface = { engine, ready: false, height: COARSE, samples: 0 };
}, engine.fake.script('tilesLoaded', () => surface.ready);
postRender: { engine.fake.script('terrainHeight', () => {
addEventListener(listener) { surface.samples++;
listeners.add(listener); return surface.height;
return () => listeners.delete(listener); });
}, engine.fake.script('worldToWindow', () => ({ x: 500, y: 500 }));
}, engine.fake.script('pickPosition', undefined);
frame: () => [...listeners].forEach((listener) => listener()), t.after(observeTrafficSurface(engine));
engine: { surface.frame = () => engine.fake.frame();
camera: {
pose: () => ({
cartographic: C.Cartographic.fromDegrees(LON, LAT, 1800),
}),
livePosition: C.Cartesian3.fromDegrees(LON, LAT, 1800),
},
},
};
return surface; return surface;
} }
@@ -80,7 +74,6 @@ function fixture(surface) {
_roadSource: 'OpenStreetMap', _roadSource: 'OpenStreetMap',
_viewHeightEstimate: GROUND, _viewHeightEstimate: GROUND,
_engine: surface.engine, _engine: surface.engine,
_trafficScene: () => surface,
_activeFetchAbort: new AbortController(), _activeFetchAbort: new AbortController(),
}); });
const flow = createFlow({ const flow = createFlow({
@@ -105,7 +98,7 @@ function fixture(surface) {
return { state, flow, weights, paints }; return { state, flow, weights, paints };
} }
const height = (point) => C.Cartographic.fromCartesian(point).height; const height = (point) => geo.cartographic.fromCartesian(point).height;
// The refinement runs in tasks the flow yields between (nextTask). Wait for // The refinement runs in tasks the flow yields between (nextTask). Wait for
// its outcome task by task instead of for a fixed time, which a loaded test // its outcome task by task instead of for a fixed time, which a loaded test
// runner can outlast. // runner can outlast.
@@ -113,12 +106,13 @@ const settle = async (done) => {
for (let task = 0; task < 500 && !done(); task++) await nextTask(); for (let task = 0; task < 500 && !done(); task++) await nextTask();
}; };
test('a republish weights roads by distance to the local ground, not by heights another globe sampled', async () => { test('a republish weights roads by distance to the local ground, not by heights another globe sampled', async (t) => {
const offsets = [0.004, 0.012, 0.02]; const offsets = [0.004, 0.012, 0.02];
const weightsFor = async (sampledHeight) => { const weightsFor = async (sampledHeight) => {
const surface = navaraSurface(); const surface = incomingGlobe(t);
surface.ready = true; surface.ready = true;
surface.height = GROUND; surface.height = GROUND;
surface.frame();
const { flow, weights } = fixture(surface); const { flow, weights } = fixture(surface);
const roads = offsets.map((offset) => road(offset, sampledHeight)); const roads = offsets.map((offset) => road(offset, sampledHeight));
await flow.applyFlowThenRender( await flow.applyFlowThenRender(
@@ -142,8 +136,9 @@ test('a republish weights roads by distance to the local ground, not by heights
assert.ok(fromGround[0] > fromGround[2]); assert.ok(fromGround[0] > fromGround[2]);
}); });
test('the handoff pass on a loading globe refines once its surface is ready', async () => { test('the handoff pass on a loading globe refines once its surface is ready', async (t) => {
const surface = navaraSurface(); const surface = incomingGlobe(t);
surface.frame();
const { flow, paints } = fixture(surface); const { flow, paints } = fixture(surface);
const roads = [road(0.004, GROUND), road(0.008, GROUND)]; const roads = [road(0.004, GROUND), road(0.008, GROUND)];
await flow.applyFlowThenRender( await flow.applyFlowThenRender(
+11 -2
View File
@@ -11,9 +11,14 @@ import { createControls } from './controls.js';
import { createLifecycle } from './lifecycle.js'; import { createLifecycle } from './lifecycle.js';
import { createRetention } from './retention.js'; import { createRetention } from './retention.js';
import { createState } from './state.js'; import { createState } from './state.js';
import { createTrafficDisplay } from './render/display.js';
/** Construct one layer with its own scene state and supplied application services. */ /**
export function createTrafficLayer({ services, source }) { * Construct one layer with its own scene state and supplied application
* services. `services.createRenderer(engine)` gives the engine's traffic
* display (render/display.js by default, the same on every engine).
*/
export function createTrafficLayer({ services: given, source }) {
if ( if (
![ ![
'requestRoads', 'requestRoads',
@@ -24,6 +29,10 @@ export function createTrafficLayer({ services, source }) {
].every((key) => typeof source?.[key] === 'function') ].every((key) => typeof source?.[key] === 'function')
) )
throw new TypeError('A traffic source is required'); throw new TypeError('A traffic source is required');
const services = {
...given,
createRenderer: given?.createRenderer ?? createTrafficDisplay,
};
const state = createState({ services }); const state = createState({ services });
const parts = {}; const parts = {};
const context = { state, services, parts, source }; const context = { state, services, parts, source };
+10 -9
View File
@@ -270,18 +270,19 @@ export function createIngestion({
layerState._viewHeightEstimate = 0; layerState._viewHeightEstimate = 0;
const centerLat = (clamped.south + clamped.north) / 2; const centerLat = (clamped.south + clamped.north) / 2;
const centerLon = (clamped.west + clamped.east) / 2; const centerLon = (clamped.west + clamped.east) / 2;
const scene = layerState._trafficScene?.() ?? layerState._viewer?.scene; const engine = layerState._engine;
const location = geo.cartographic.fromDegrees(centerLon, centerLat); const location = geo.cartographic.fromDegrees(centerLon, centerLat);
let estimated = services.ground?.cachedGroundFloor?.(centerLat, centerLon); let estimated = services.ground?.cachedGroundFloor?.(centerLat, centerLon);
if (!Number.isFinite(estimated)) { if (!Number.isFinite(estimated) && engine) {
try { try {
estimated = scene?.engine estimated = !engine.maps.photorealActive()
? scene.sampleHeight(location) ? engine.surface.terrainHeight(location)
: scene?.globe?.show : engine.surface.supported
? scene.globe.getHeight?.(location) ? engine.surface.sampleHeight(
: scene?.sampleHeightSupported location,
? scene.sampleHeight(location, [layerState._pointCollection]) layerState._display?.excluded() ?? [],
: undefined; )
: undefined;
} catch { } catch {
/* the local mesh can still be loading */ /* the local mesh can still be loading */
} }
+71 -123
View File
@@ -1,11 +1,9 @@
import { observeTrafficSurface } from './surface.js'; import { observeTrafficSurface } from './surface.js';
import * as Cesium from 'cesium';
import { TRAFFIC_TIMING_ENABLED } from './policy.js'; import { TRAFFIC_TIMING_ENABLED } from './policy.js';
import { import {
claimCameraSensitivity, claimCameraSensitivity,
releaseCameraSensitivity, releaseCameraSensitivity,
} from '../../data/cameraSensitivity.js'; } from '../../data/cameraSensitivity.js';
import { whenCesiumSdk } from '../../app/loadCesium.js';
export function createLifecycle({ export function createLifecycle({
state: layerState, state: layerState,
@@ -22,19 +20,19 @@ export function createLifecycle({
} }
function bindScene() { function bindScene() {
const viewer = layerState._viewer, const engine = layerState._engine;
engine = layerState._engine; if (!engine) return;
const camera = engine?.camera ?? viewer.camera; const camera = engine.camera;
const frames = engine?.frames ?? viewer.scene; layerState._surfaceFrameRemover = observeTrafficSurface(engine);
if (viewer) layerState._display?.show(true);
layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene);
layerState._pointCollection.show = true;
layerState._lastAnimTime = 0; layerState._lastAnimTime = 0;
parts.animation.syncRenderDemand(); parts.animation.syncRenderDemand();
layerState._preRenderRemover = frames.preRender.addEventListener(() => { layerState._preRenderRemover = engine.frames.preRender.addEventListener(
parts.animation.animate(); () => {
layerState._native?.points.flush(); parts.animation.animate();
}); parts.animation.drawDots();
},
);
if (TRAFFIC_TIMING_ENABLED) { if (TRAFFIC_TIMING_ENABLED) {
parts.timing.clearTrafficTimingEntries(); parts.timing.clearTrafficTimingEntries();
layerState._trafficTimingCurrentAnchor = null; layerState._trafficTimingCurrentAnchor = null;
@@ -49,11 +47,11 @@ export function createLifecycle({
layerState._arrivalRemover = camera.moveEnd.addEventListener(() => layerState._arrivalRemover = camera.moveEnd.addEventListener(() =>
parts.viewport.onCameraChanged({ immediate: true }), parts.viewport.onCameraChanged({ immediate: true }),
); );
claimCameraSensitivity(engine ?? camera, 'traffic', 0.05); claimCameraSensitivity(engine, 'traffic', 0.05);
} }
function unbindScene() { function unbindScene() {
const camera = layerState._engine?.camera ?? layerState._viewer?.camera; const camera = layerState._engine?.camera;
layerState._surfaceFrameRemover?.(); layerState._surfaceFrameRemover?.();
layerState._preRenderRemover?.(); layerState._preRenderRemover?.();
layerState._trafficTimingMoveEndRemover?.(); layerState._trafficTimingMoveEndRemover?.();
@@ -72,19 +70,20 @@ export function createLifecycle({
layerState._arrivalRemover = layerState._arrivalRemover =
layerState._trafficTimingMoveEndRemover = layerState._trafficTimingMoveEndRemover =
null; null;
releaseCameraSensitivity(layerState._engine ?? camera, 'traffic'); if (layerState._engine)
releaseCameraSensitivity(layerState._engine, 'traffic');
} }
const methods = { const methods = {
/** /**
* One-time initialisation. Creates the PointPrimitiveCollection and adds it * One-time initialisation. Attaches the engine's traffic display (hidden
* to the scene (hidden). The collection is never removed and re-added — only * until the layer is enabled).
* toggled via `.show` to avoid destroy-on-remove errors.
* *
* @param {Cesium.Viewer} viewer - The Cesium viewer instance. * @param {null} _viewer - Unused: the layer draws through `options.engine`.
* @param {{ engine?: object, waitForCameraRestore?: boolean }} [options]
*/ */
init(viewer, options) { init(_viewer, options) {
this.attachRenderer(viewer, options); this.attachRenderer(_viewer, options);
layerState._dots = []; layerState._dots = [];
layerState._roads = []; layerState._roads = [];
layerState._count = 0; layerState._count = 0;
@@ -129,38 +128,28 @@ export function createLifecycle({
}, },
attachRenderer( attachRenderer(
viewer, _viewer,
{ engine = null, waitForCameraRestore = false } = {}, { engine = null, waitForCameraRestore = false } = {},
) { ) {
layerState._restoringViewport = waitForCameraRestore; layerState._restoringViewport = waitForCameraRestore;
layerState._viewer = viewer;
layerState._engine = engine; layerState._engine = engine;
layerState._native = layerState._display = engine ? services.createRenderer(engine) : null;
!viewer && engine ? services.nativeRenderer(engine) : null; layerState._display?.show(layerState._enabled);
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 (!waitForCameraRestore) readyForSurfaceWork();
if (layerState._enabled) { if (layerState._enabled) {
bindScene(); bindScene();
parts.animation.drawDots();
parts.rendering.rebuildHeatLines(layerState._roads); parts.rendering.rebuildHeatLines(layerState._roads);
if ( if (
layerState._dots.length && layerState._dots.length &&
layerState._roads.some((r) => !r.directFlow) layerState._roads.some((r) => !r.directFlow)
) )
services.credits?.showOsmCredit?.(engine ?? viewer, 'traffic', { services.credits?.showOsmCredit?.(engine, 'traffic', {
openMapTiles: true, openMapTiles: true,
}); });
if (layerState._liveMode) if (layerState._liveMode)
services.credits?.registerDynamicCredit?.( services.credits?.registerDynamicCredit?.(
engine ?? viewer, engine,
services.credits.TOMTOM_CREDIT, services.credits.TOMTOM_CREDIT,
); );
parts.viewport.onCameraChanged({ immediate: true }); parts.viewport.onCameraChanged({ immediate: true });
@@ -198,7 +187,7 @@ export function createLifecycle({
parts.viewport.onCameraChanged({ immediate: true }); parts.viewport.onCameraChanged({ immediate: true });
}, },
detachRenderer() { detachRenderer() {
if (!layerState._pointCollection) return; if (!layerState._engine) return;
unbindScene(); unbindScene();
clearTimeout(layerState._fetchTimeout); clearTimeout(layerState._fetchTimeout);
layerState._surfaceRefineRemove?.(); layerState._surfaceRefineRemove?.();
@@ -206,95 +195,62 @@ export function createLifecycle({
layerState._surfaceRevision = (layerState._surfaceRevision || 0) + 1; layerState._surfaceRevision = (layerState._surfaceRevision || 0) + 1;
layerState._preparedRoads?.clear(); layerState._preparedRoads?.clear();
parts.rendering.removeHeatLines(); parts.rendering.removeHeatLines();
services.credits?.hideOsmCredit?.( services.credits?.hideOsmCredit?.(layerState._engine, 'traffic');
layerState._engine ?? layerState._viewer, // The dots keep their look (`dot.point`) for the next engine.
'traffic', layerState._display?.destroy();
);
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) if (layerState._renderHeld)
services.render.releaseContinuousRender('traffic'); services.render.releaseContinuousRender('traffic');
layerState._renderHeld = false; layerState._renderHeld = false;
layerState._pointCollection = layerState._engine = layerState._display = null;
layerState._viewer =
layerState._engine =
layerState._native =
null;
layerState._waitForScene ??= new Promise((resolve) => { layerState._waitForScene ??= new Promise((resolve) => {
layerState._sceneReady = resolve; layerState._sceneReady = resolve;
}); });
}, },
/** /**
* Enable the traffic layer. Shows the point collection, subscribes to the * Enable the traffic layer. Shows the vehicles, subscribes to the
* preRender animation loop and camera-change events, and kicks off an * preRender animation loop and camera-change events, and kicks off an
* initial viewport check. * initial viewport check.
*
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
*/ */
enable(viewer, { signal } = {}) { enable() {
// The simulation's math is Cesium's on either globe: a Navara page if (layerState._enabled) return;
// loads the Cesium SDK first. layerState._enabled = true;
return whenCesiumSdk(() => { if (layerState._roadMode !== 'tomtom') source.prefetch?.();
if (layerState._enabled) return; bindScene();
if (viewer) layerState._viewer = viewer; // Enabling is explicit intent, not a camera gesture that needs settling.
layerState._enabled = true; parts.viewport.onCameraChanged({ immediate: true });
if (layerState._roadMode !== 'tomtom') source.prefetch?.();
bindScene();
// Enabling is explicit intent, not a camera gesture that needs settling.
parts.viewport.onCameraChanged({ immediate: true });
// Boot-order guard (otherwise the layer sits empty until the user // Boot-order guard (otherwise the layer sits empty until the user
// moves): when the persisted layer state re-enables traffic during the // moves): when the persisted layer state re-enables traffic during the
// intro flyTo, the initial check bails at high altitude — and a camera // intro flyTo, the initial check bails at high altitude — and a camera
// that then parks never re-fires camera.changed. Retry cheaply until the // that then parks never re-fires camera.changed. Retry cheaply until the
// first load commits, then self-clear. Also acts as a safety kick if a // first load commits, then self-clear. Also acts as a safety kick if a
// failed first fetch left the viewport unloaded while parked. // failed first fetch left the viewport unloaded while parked.
clearInterval(layerState._enableKickTimer); clearInterval(layerState._enableKickTimer);
layerState._enableKickTimer = setInterval(() => { layerState._enableKickTimer = setInterval(() => {
if ( if (
!layerState._enabled || !layerState._enabled ||
layerState._lastUpdate || layerState._lastUpdate ||
layerState._roadRetryStopped || layerState._roadRetryStopped ||
layerState._retryAttempts >= 3 layerState._retryAttempts >= 3
) { ) {
clearInterval(layerState._enableKickTimer); clearInterval(layerState._enableKickTimer);
layerState._enableKickTimer = null; layerState._enableKickTimer = null;
return; return;
} }
if (!layerState._fetching && !layerState._retryTimer) if (!layerState._fetching && !layerState._retryTimer)
parts.viewport.onCameraChanged(); parts.viewport.onCameraChanged();
}, 1500); }, 1500);
}, signal);
}, },
/** /**
* Disable the traffic layer. Cancels pending fetches, clears all dots, * Disable the traffic layer. Cancels pending fetches, clears all dots,
* unsubscribes from events, and hides the point collection. * unsubscribes from events, and hides the vehicles.
*
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
*/ */
disable(viewer) { disable() {
layerState._enabled = false; layerState._enabled = false;
layerState._surfaceInput = null; layerState._surfaceInput = null;
services.credits?.hideOsmCredit?.( services.credits?.hideOsmCredit?.(layerState._engine, 'traffic');
layerState._engine ?? layerState._viewer,
'traffic',
);
parts.animation.syncRenderDemand(); parts.animation.syncRenderDemand();
clearTimeout(layerState._fetchTimeout); clearTimeout(layerState._fetchTimeout);
clearInterval(layerState._enableKickTimer); clearInterval(layerState._enableKickTimer);
@@ -317,26 +273,18 @@ export function createLifecycle({
layerState._flowOldestTileAt = null; layerState._flowOldestTileAt = null;
unbindScene(); unbindScene();
if (layerState._pointCollection) layerState._pointCollection.show = false; layerState._display?.show(false);
}, },
/** /**
* Permanently tear down the layer. Disables it, removes the point collection * Permanently tear down the layer. Disables it, destroys the display and
* from the scene, and clears the tile cache. * clears the tile cache.
*
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
*/ */
destroy(viewer) { destroy() {
this.disable(viewer); this.disable();
if (layerState._pointCollection) {
layerState._viewer?.scene.primitives.remove(
layerState._pointCollection,
);
layerState._native?.destroy();
layerState._native = null;
layerState._pointCollection = null;
}
parts.rendering.removeHeatLines(); parts.rendering.removeHeatLines();
layerState._display?.destroy();
layerState._display = null;
layerState._tileCache.clear(); layerState._tileCache.clear();
if (layerState._styleListenerBound && typeof window !== 'undefined') { if (layerState._styleListenerBound && typeof window !== 'undefined') {
window.removeEventListener( window.removeEventListener(
+7 -5
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { nearFarScale } from '../shared/color.js';
import { roadSurfaceChunks } from './surface.js'; import { roadSurfaceChunks } from './surface.js';
import { t, translateKnown } from '../../i18n/index.js'; import { t, translateKnown } from '../../i18n/index.js';
import { import {
@@ -10,6 +11,7 @@ import {
DOT_HEIGHT_OFFSET, DOT_HEIGHT_OFFSET,
DENSITY_MULT, DENSITY_MULT,
JAM_DOT_FAR_SCALE, JAM_DOT_FAR_SCALE,
SIM_DOT_COLOR,
} from './policy.js'; } from './policy.js';
export function createModel({ state: layerState, services, parts, source }) { export function createModel({ state: layerState, services, parts, source }) {
@@ -33,14 +35,14 @@ export function createModel({ state: layerState, services, parts, source }) {
(Number.isFinite(floor) && Math.abs(floor) <= 9000 (Number.isFinite(floor) && Math.abs(floor) <= 9000
? floor ? floor
: layerState._viewHeightEstimate || 0) + DOT_HEIGHT_OFFSET; : layerState._viewHeightEstimate || 0) + DOT_HEIGHT_OFFSET;
return Cesium.Cartesian3.fromDegrees(lng, lat, h); return geo.cartesian3.fromDegrees(lng, lat, h);
}); });
// Pre-compute segment distances in meters for speed-to-t conversion // Pre-compute segment distances in meters for speed-to-t conversion
const segmentDist = []; const segmentDist = [];
for (let i = 0; i < waypoints.length - 1; i++) { for (let i = 0; i < waypoints.length - 1; i++) {
segmentDist.push( segmentDist.push(
Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1]), geo.cartesian3.distance(waypoints[i], waypoints[i + 1]),
); );
} }
@@ -367,7 +369,7 @@ export function createModel({ state: layerState, services, parts, source }) {
dot.bucket = bucket; dot.bucket = bucket;
dot.point.color = bucket dot.point.color = bucket
? layerState._activeBucketColors[bucket] ? layerState._activeBucketColors[bucket]
: Cesium.Color.WHITE.withAlpha(0.85); : SIM_DOT_COLOR;
dot.nominalSize = dot.nominalSize =
parts.style.baseDotSize(dot.road?.type, bucket) + parts.style.baseDotSize(dot.road?.type, bucket) +
(bucket === 'jam' ? 1 : 0) + (bucket === 'jam' ? 1 : 0) +
@@ -386,7 +388,7 @@ export function createModel({ state: layerState, services, parts, source }) {
moving: Math.random() < 0.4, moving: Math.random() < 0.4,
until: now + Math.random() * 2000, until: now + Math.random() * 2000,
}; };
dot.point.scaleByDistance = new Cesium.NearFarScalar( dot.point.scaleByDistance = nearFarScale(
100, 100,
1.5, 1.5,
layerState._fadeScaleFar, layerState._fadeScaleFar,
+83 -46
View File
@@ -10,6 +10,33 @@ import {
DOT_CHANGE_BUDGET, DOT_CHANGE_BUDGET,
roadIdentity, roadIdentity,
} from './retention.js'; } from './retention.js';
import { createTrafficDisplay, TRAFFIC_SET_IDS } from './render/display.js';
import { observeTrafficSurface } from './surface.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
/**
* A fake engine with the camera `altitude` metres over (0, 0). A zero-size
* canvas projects nothing, so screen-space limits stay out of a test unless
* it sizes the canvas. One rendered frame of the still camera certifies its
* surface, as a completed render does.
*/
function engineAt(altitude, { width = 0, height = 0 } = {}) {
const engine = createFakeEngine({ width, height, altitude });
observeTrafficSurface(engine);
engine.fake.frame();
return engine;
}
/**
* A surface pass's engine: a sized canvas looking straight down, whose
* rendered depth answers nothing (heights come from the scripted terrain).
*/
function flowEngineAt(altitude) {
const engine = engineAt(altitude, { width: 400, height: 300 });
engine.fake.script('pickPosition', undefined);
return engine;
}
const fillCount = (engine) =>
engine.fake.moverSet(TRAFFIC_SET_IDS.fill).store.count;
function setup(t) { function setup(t) {
const frames = []; const frames = [];
@@ -28,8 +55,9 @@ function setup(t) {
const state = createState({ services: {} }); const state = createState({ services: {} });
state._enabled = true; state._enabled = true;
state._liveMode = true; state._liveMode = true;
state._pointCollection = new C.PointPrimitiveCollection(); const engine = engineAt(450);
state._viewer = { scene: {} }; state._engine = engine;
state._display = createTrafficDisplay(engine);
const parts = { const parts = {
rendering: { rendering: {
removeHeatLines() {}, removeHeatLines() {},
@@ -78,15 +106,17 @@ function setup(t) {
const fn = frames.shift(); const fn = frames.shift();
fn?.(); fn?.();
parts.animation.animate(); parts.animation.animate();
parts.animation.drawDots();
}; };
const settle = () => { const settle = () => {
for (let i = 0; i < 100 && frames.length; i++) frame(); for (let i = 0; i < 100 && frames.length; i++) frame();
}; };
t.after(() => { t.after(() => {
parts.animation.clearDots(); parts.animation.clearDots();
state._pointCollection.destroy(); state._display.destroy();
engine.destroy();
}); });
return { state, parts, roads, frame, settle, frames }; return { state, parts, roads, frame, settle, frames, engine };
} }
test('incremental tiles, reordered snapshots and flow changes retain existing dot identities and progress', (t) => { test('incremental tiles, reordered snapshots and flow changes retain existing dot identities and progress', (t) => {
@@ -126,6 +156,13 @@ test('incremental tiles, reordered snapshots and flow changes retain existing do
assert.equal(target.point.show, true); assert.equal(target.point.show, true);
assert.equal(target.id, before[0].id); assert.equal(target.id, before[0].id);
assert.equal(h.state._motion.rebuilds, 0); assert.equal(h.state._motion.rebuilds, 0);
h.frame();
assert.ok(fillCount(h.engine) > 0);
assert.equal(
fillCount(h.engine),
h.state._dots.filter((d) => d.point.show && d.point.pixelSize > 0).length,
'the display draws every shown dot',
);
}); });
test('height refinement eases retained waypoints vertically without changing road or dot progress', (t) => { test('height refinement eases retained waypoints vertically without changing road or dot progress', (t) => {
@@ -171,8 +208,9 @@ test('density and replacement changes are bounded; removed roads fade out and te
assert.ok(h.state._dots.every((d) => !oldIds.has(d.id))); assert.ok(h.state._dots.every((d) => !oldIds.has(d.id)));
assert.equal(new Set(h.state._dots.map((d) => d.id)).size, 3000); assert.equal(new Set(h.state._dots.map((d) => d.id)).size, 3000);
h.parts.animation.clearDots(); h.parts.animation.clearDots();
assert.equal(fillCount(h.engine), 0, 'a clear is drawn without a frame');
h.frame(); h.frame();
assert.equal(h.state._pointCollection.length, 0); assert.equal(fillCount(h.engine), 0);
assert.equal(h.state._dots.length, 0); assert.equal(h.state._dots.length, 0);
assert.equal(h.frames.length, 0); assert.equal(h.frames.length, 0);
}); });
@@ -276,16 +314,11 @@ test('major previews merge and never retry missing mesh ahead of the detail pass
for (const measured of [true, false]) { for (const measured of [true, false]) {
let samples = 0; let samples = 0;
const paints = []; const paints = [];
const scene = { const engine = flowEngineAt(450);
globe: { engine.fake.script('terrainHeight', () => {
show: true, samples++;
tilesLoaded: true, return measured ? 100 : undefined;
getHeight: () => { });
samples++;
return measured ? 100 : undefined;
},
},
};
const coords = [ const coords = [
[0, 0], [0, 0],
[0.001, 0], [0.001, 0],
@@ -301,7 +334,7 @@ test('major previews merge and never retry missing mesh ahead of the detail pass
_dots: [], _dots: [],
_detailRoadsReady: true, _detailRoadsReady: true,
_motion: { added: 0 }, _motion: { added: 0 },
_viewer: { scene }, _engine: engine,
_enabled: true, _enabled: true,
_loadGeneration: 1, _loadGeneration: 1,
_roadSource: 'OpenStreetMap', _roadSource: 'OpenStreetMap',
@@ -431,12 +464,11 @@ test('measured preview heights select elevated detail roads before surface admis
], ],
segmentDist: [111], segmentDist: [111],
}); });
const scene = { const engine = flowEngineAt(1250);
globe: { show: true, tilesLoaded: true, getHeight: () => 750 }, engine.fake.script('terrainHeight', 750);
};
const state = { const state = {
_dots: [], _dots: [],
_viewer: { scene }, _engine: engine,
_enabled: true, _enabled: true,
_loadGeneration: 1, _loadGeneration: 1,
_roadSource: 'OpenStreetMap', _roadSource: 'OpenStreetMap',
@@ -510,16 +542,14 @@ test('provisional opposite-direction heights cannot overwrite a measured sibling
segmentDist: [111], segmentDist: [111],
}); });
const selections = []; const selections = [];
const scene = { const engine = flowEngineAt(1250);
globe: { engine.fake.script(
show: true, 'terrainHeight',
tilesLoaded: true, (c) => 750 + C.Math.toDegrees(c.longitude) * 10000,
getHeight: (c) => 750 + C.Math.toDegrees(c.longitude) * 10000, );
},
};
const state = { const state = {
_dots: [], _dots: [],
_viewer: { scene }, _engine: engine,
_enabled: true, _enabled: true,
_loadGeneration: 1, _loadGeneration: 1,
_roadSource: 'OpenStreetMap', _roadSource: 'OpenStreetMap',
@@ -581,7 +611,8 @@ test('a closure that empties the population mid-correction settles heights and c
corrected[0].flow = { level: 0, closure: true }; corrected[0].flow = { level: 0, closure: true };
for (let i = 0; i < 20 && h.state._dots.length; i++) h.frame(); for (let i = 0; i < 20 && h.state._dots.length; i++) h.frame();
assert.equal(h.state._dots.length, 0); assert.equal(h.state._dots.length, 0);
assert.equal(h.state._heatJamPrim, null); assert.equal(fillCount(h.engine), 0, 'the last removal is drawn');
assert.equal(h.state._heatLineCount, 0);
assert.equal(h.state._renderHeld, false); assert.equal(h.state._renderHeld, false);
assert.equal(h.state._heightPending, false); assert.equal(h.state._heightPending, false);
assert.equal(h.state._populationPending, false); assert.equal(h.state._populationPending, false);
@@ -598,22 +629,14 @@ test('a closure that empties the population mid-correction settles heights and c
test('large height corrections obey a screen-pixel bound and reuse projection scratch objects', (t) => { test('large height corrections obey a screen-pixel bound and reuse projection scratch objects', (t) => {
const h = setup(t); const h = setup(t);
h.state._viewer.scene = { h.engine.fake.setSize(1280, 2000);
canvas: { clientWidth: 1280, clientHeight: 2000 },
camera: {},
frameState: {},
};
const scratch = new Set(); const scratch = new Set();
t.mock.method( h.engine.fake.script('worldToWindow', (point, result) => {
C.SceneTransforms, scratch.add(result);
'worldToWindowCoordinates', result.x = 0;
(_scene, point, result) => { result.y = C.Cartographic.fromCartesian(point).height * 10;
scratch.add(result); return result;
result.x = 0; });
result.y = C.Cartographic.fromCartesian(point).height * 10;
return result;
},
);
h.parts.retention.reconcile(h.roads(), 450, true); h.parts.retention.reconcile(h.roads(), 450, true);
h.settle(); h.settle();
const dot = h.state._dots[0], const dot = h.state._dots[0],
@@ -640,10 +663,24 @@ test('grounded vehicles respect mesh occlusion even after live congestion change
h.parts.retention.reconcile(roads, 450, true); h.parts.retention.reconcile(roads, 450, true);
h.settle(); h.settle();
assert.ok(h.state._dots.length > 0); assert.ok(h.state._dots.length > 0);
assert.ok(h.state._dots.every((d) => d.point.disableDepthTestDistance === 0)); // The vehicle sets are depth-tested against the scene (Cesium's
// disableDepthTestDistance 0); no dot carries an override of its own.
const depthTests = () =>
Object.values({
outline: TRAFFIC_SET_IDS.outline,
fill: TRAFFIC_SET_IDS.fill,
}).map((id) => h.engine.fake.moverSet(id).options.depthTest);
assert.deepEqual(depthTests(), ['normal', 'normal']);
assert.ok(
h.state._dots.every((d) => !('disableDepthTestDistance' in d.point)),
);
for (const road of roads) road.flow = { level: 0.01, closure: false }; for (const road of roads) road.flow = { level: 0.01, closure: false };
h.parts.model.recolorDotsInPlace('late jam'); h.parts.model.recolorDotsInPlace('late jam');
assert.ok(h.state._dots.every((d) => d.point.disableDepthTestDistance === 0)); h.frame();
assert.deepEqual(depthTests(), ['normal', 'normal']);
assert.ok(
h.state._dots.every((d) => !('disableDepthTestDistance' in d.point)),
);
}); });
test('retained road density follows the settled view without requiring a toggle', (t) => { test('retained road density follows the settled view without requiring a toggle', (t) => {
+100 -119
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { createTrafficLayer } from './index.js'; import { createTrafficLayer } from './index.js';
function deferred() { function deferred() {
@@ -13,42 +14,35 @@ function deferred() {
function setup(t, requestRoads, getStatus = async () => ({ hasKey: false })) { function setup(t, requestRoads, getStatus = async () => ({ hasKey: false })) {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
const camera = { const engine = createFakeEngine({
positionCartographic: Cesium.Cartographic.fromDegrees( width: 100,
-97.744, height: 100,
30.267, canvas: { width: 100, height: 100 },
3200, });
), const camera = engine.camera;
get positionWC() { // Without a pick ray the layer bounds its fetch by the view rectangle
return Cesium.Cartesian3.fromRadians( // (no reticle footprint); a test that wants the footprint puts it back.
this.positionCartographic.longitude, const pickRay = camera.pickRay;
this.positionCartographic.latitude, camera.pickRay = undefined;
this.positionCartographic.height, const place = (lon, lat, height) =>
); engine.fake.setPose({
}, longitude: geo.math.toRadians(lon),
changed: new Cesium.Event(), latitude: geo.math.toRadians(lat),
moveEnd: new Cesium.Event(), height,
percentageChanged: 0.5, });
computeViewRectangle() { place(-97.744, 30.267, 3200);
const { longitude, latitude } = this.positionCartographic; // A view rectangle of ±0.0002 rad around the camera.
return new Cesium.Rectangle( engine.fake.script('viewRectangle', () => {
longitude - 0.0002, const { longitude, latitude } = camera.pose().cartographic;
latitude - 0.0002, return {
longitude + 0.0002, west: longitude - 0.0002,
latitude + 0.0002, south: latitude - 0.0002,
); east: longitude + 0.0002,
}, north: latitude + 0.0002,
pickEllipsoid: () => null, };
}; });
const viewer = { engine.fake.script('pickEllipsoid', undefined);
camera, engine.fake.script('terrainHeight', 0);
scene: {
canvas: { width: 100, height: 100 },
globe: { show: true, tilesLoaded: true, getHeight: () => 0 },
preRender: new Cesium.Event(),
primitives: { add: (value) => value, remove: () => true },
},
};
const layer = createTrafficLayer({ const layer = createTrafficLayer({
services: { services: {
credits: {}, credits: {},
@@ -62,16 +56,12 @@ function setup(t, requestRoads, getStatus = async () => ({ hasKey: false })) {
resetFlowTileCache() {}, resetFlowTileCache() {},
}, },
}); });
layer.init(viewer); layer.init(null, { engine });
t.after(() => layer.destroy(viewer)); t.after(() => layer.destroy());
const move = (lon, lat, height = 3200) => { const move = (lon, lat, height = 3200) => {
camera.positionCartographic = Cesium.Cartographic.fromDegrees( place(lon, lat, height);
lon, engine.fake.emit('camera.changed');
lat, engine.fake.emit('camera.moveEnd');
height,
);
camera.changed.raiseEvent();
camera.moveEnd.raiseEvent();
}; };
const tick = async (ms) => { const tick = async (ms) => {
t.mock.timers.tick(ms); t.mock.timers.tick(ms);
@@ -81,7 +71,7 @@ function setup(t, requestRoads, getStatus = async () => ({ hasKey: false })) {
t.mock.timers.tick(0); t.mock.timers.tick(0);
for (let i = 0; i < 30; i++) await Promise.resolve(); for (let i = 0; i < 30; i++) await Promise.resolve();
}; };
return { layer, viewer, move, tick }; return { layer, engine, camera, pickRay, place, move, tick };
} }
function roads(bounds) { function roads(bounds) {
return { return {
@@ -103,12 +93,12 @@ function roads(bounds) {
test('traffic recovers a failed destination request after another city has loaded', async (t) => { test('traffic recovers a failed destination request after another city has loaded', async (t) => {
let londonCalls = 0; let londonCalls = 0;
const { layer, viewer, move, tick } = setup(t, async (bounds) => { const { layer, move, tick } = setup(t, async (bounds) => {
if (bounds.south > 50 && ++londonCalls === 1) if (bounds.south > 50 && ++londonCalls === 1)
throw new Error('temporary Overpass outage'); throw new Error('temporary Overpass outage');
return roads(bounds); return roads(bounds);
}); });
layer.enable(viewer); layer.enable();
await tick(400); await tick(400);
await tick(2000); await tick(2000);
assert.ok(layer.getStats().count > 0); assert.ok(layer.getStats().count > 0);
@@ -127,12 +117,12 @@ test('traffic recovers a failed destination request after another city has loade
test('a superseded road response cannot release the current request controller', async (t) => { test('a superseded road response cannot release the current request controller', async (t) => {
const pending = []; const pending = [];
const { layer, viewer, move, tick } = setup(t, (bounds, { signal }) => { const { layer, move, tick } = setup(t, (bounds, { signal }) => {
const result = deferred(); const result = deferred();
pending.push({ ...result, bounds, signal }); pending.push({ ...result, bounds, signal });
return result.promise; return result.promise;
}); });
layer.enable(viewer); layer.enable();
await tick(400); await tick(400);
move(-0.1276, 51.5072); move(-0.1276, 51.5072);
await tick(400); await tick(400);
@@ -140,7 +130,7 @@ test('a superseded road response cannot release the current request controller',
assert.equal(pending[0].signal.aborted, true); assert.equal(pending[0].signal.aborted, true);
pending[0].resolve(roads(pending[0].bounds)); pending[0].resolve(roads(pending[0].bounds));
await tick(0); await tick(0);
layer.disable(viewer); layer.disable();
assert.equal( assert.equal(
pending[1].signal.aborted, pending[1].signal.aborted,
true, true,
@@ -154,12 +144,12 @@ test('a superseded road response cannot release the current request controller',
test('leaving traffic altitude cancels queued and in-flight work', async (t) => { test('leaving traffic altitude cancels queued and in-flight work', async (t) => {
const pending = []; const pending = [];
const { layer, viewer, move, tick } = setup(t, (bounds, { signal }) => { const { layer, move, tick } = setup(t, (bounds, { signal }) => {
const result = deferred(); const result = deferred();
pending.push({ ...result, bounds, signal }); pending.push({ ...result, bounds, signal });
return result.promise; return result.promise;
}); });
layer.enable(viewer); layer.enable();
move(-40, 40, 1000000); move(-40, 40, 1000000);
await tick(400); await tick(400);
assert.equal( assert.equal(
@@ -180,32 +170,28 @@ test('leaving traffic altitude cancels queued and in-flight work', async (t) =>
test('arrival below the camera change threshold loads the final city and unsubscribes on disable', async (t) => { test('arrival below the camera change threshold loads the final city and unsubscribes on disable', async (t) => {
const seen = []; const seen = [];
const { layer, viewer, tick } = setup(t, async (bounds) => { const { layer, engine, place, tick } = setup(t, async (bounds) => {
seen.push(bounds); seen.push(bounds);
return roads(bounds); return roads(bounds);
}); });
layer.enable(viewer); layer.enable();
await tick(400); await tick(400);
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees( place(-0.1276, 51.5072, 3200);
-0.1276, engine.fake.emit('camera.moveEnd');
51.5072,
3200,
);
viewer.camera.moveEnd.raiseEvent();
await tick(400); await tick(400);
assert.ok(seen.some((bounds) => bounds.south > 50)); assert.ok(seen.some((bounds) => bounds.south > 50));
layer.disable(viewer); layer.disable();
assert.equal(viewer.camera.moveEnd.numberOfListeners, 0); assert.equal(engine.camera.moveEnd.numberOfListeners, 0);
assert.equal(viewer.camera.changed.numberOfListeners, 0); assert.equal(engine.camera.changed.numberOfListeners, 0);
}); });
test('parked failures back off and disabling cancels the scheduled retry', async (t) => { test('parked failures back off and disabling cancels the scheduled retry', async (t) => {
let calls = 0; let calls = 0;
const { layer, viewer, tick } = setup(t, async () => { const { layer, tick } = setup(t, async () => {
calls++; calls++;
throw new Error('temporary Overpass outage'); throw new Error('temporary Overpass outage');
}); });
layer.enable(viewer); layer.enable();
await tick(400); await tick(400);
assert.equal(calls, 1); assert.equal(calls, 1);
assert.equal(layer.getStats().loading, false); assert.equal(layer.getStats().loading, false);
@@ -216,7 +202,7 @@ test('parked failures back off and disabling cancels the scheduled retry', async
await tick(1500); await tick(1500);
await tick(400); await tick(400);
assert.equal(calls, 2, 'second failure waits longer than the first'); assert.equal(calls, 2, 'second failure waits longer than the first');
layer.disable(viewer); layer.disable();
await tick(60000); await tick(60000);
assert.equal(calls, 2); assert.equal(calls, 2);
assert.equal(layer.getStats().error, null); assert.equal(layer.getStats().error, null);
@@ -233,7 +219,7 @@ test('not-configured roads stop both parked retries and the enable-time kick', a
json: async () => ({ code: 'OVERPASS_NOT_CONFIGURED', retryable: false }), json: async () => ({ code: 'OVERPASS_NOT_CONFIGURED', retryable: false }),
}; };
}); });
h.layer.enable(h.viewer); h.layer.enable();
await h.tick(400); await h.tick(400);
await h.tick(60_000); await h.tick(60_000);
assert.equal(requests, 1); assert.equal(requests, 1);
@@ -243,8 +229,8 @@ test('not-configured roads stop both parked retries and the enable-time kick', a
test('a declined road request reaches the row as the reason, not as a shrug', async (t) => { test('a declined road request reaches the row as the reason, not as a shrug', async (t) => {
let status = 406; let status = 406;
const { layer, viewer, tick } = setup(t, async () => ({ ok: false, status })); const { layer, tick } = setup(t, async () => ({ ok: false, status }));
layer.enable(viewer); layer.enable();
await tick(400); await tick(400);
assert.equal( assert.equal(
layer.getStats().error, layer.getStats().error,
@@ -261,17 +247,17 @@ test('a declined road request reaches the row as the reason, not as a shrug', as
}); });
test('an unclassified road failure keeps the general line', async (t) => { test('an unclassified road failure keeps the general line', async (t) => {
const { layer, viewer, tick } = setup(t, async () => { const { layer, tick } = setup(t, async () => {
throw new Error('socket hang up'); throw new Error('socket hang up');
}); });
layer.enable(viewer); layer.enable();
await tick(400); await tick(400);
assert.equal(layer.getStats().error, 'OpenFreeMap tiles unavailable'); assert.equal(layer.getStats().error, 'OpenFreeMap tiles unavailable');
}); });
test('a stalled TomTom status never blocks road acquisition or first simulated dots', async (t) => { test('a stalled TomTom status never blocks road acquisition or first simulated dots', async (t) => {
let calls = 0; let calls = 0;
const { layer, viewer, tick } = setup( const { layer, tick } = setup(
t, t,
async (box) => { async (box) => {
calls++; calls++;
@@ -281,7 +267,7 @@ test('a stalled TomTom status never blocks road acquisition or first simulated d
); );
// The source contract is exercised independently of status by the production // The source contract is exercised independently of status by the production
// load path; the real source status cancellation case is in source.test. // load path; the real source status cancellation case is in source.test.
layer.enable(viewer); layer.enable();
await tick(400); await tick(400);
await tick(2000); await tick(2000);
assert.ok(calls > 0); assert.ok(calls > 0);
@@ -290,22 +276,22 @@ test('a stalled TomTom status never blocks road acquisition or first simulated d
test('explicit enable starts roads without the camera debounce', async (t) => { test('explicit enable starts roads without the camera debounce', async (t) => {
let calls = 0; let calls = 0;
const { layer, viewer, tick } = setup(t, async (box) => { const { layer, tick } = setup(t, async (box) => {
calls++; calls++;
return roads(box); return roads(box);
}); });
layer.enable(viewer); layer.enable();
await tick(0); await tick(0);
assert.ok(calls > 0, 'the first load starts before 320 ms'); assert.ok(calls > 0, 'the first load starts before 320 ms');
}); });
test('moveEnd starts the destination request without another gesture debounce', async (t) => { test('moveEnd starts the destination request without another gesture debounce', async (t) => {
let calls = 0; let calls = 0;
const { layer, viewer, move, tick } = setup(t, async (bounds) => { const { layer, move, tick } = setup(t, async (bounds) => {
calls++; calls++;
return roads(bounds); return roads(bounds);
}); });
layer.enable(viewer); layer.enable();
await tick(400); await tick(400);
const before = calls; const before = calls;
move(-0.1276, 51.5072); move(-0.1276, 51.5072);
@@ -314,49 +300,44 @@ test('moveEnd starts the destination request without another gesture debounce',
}); });
test('traffic surface observers belong to enable and leave no listener after disable or destroy', async (t) => { test('traffic surface observers belong to enable and leave no listener after disable or destroy', async (t) => {
const { layer, viewer, tick } = setup(t, async (bounds) => roads(bounds)); const { layer, engine, tick } = setup(t, async (bounds) => roads(bounds));
viewer.camera.viewMatrix = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY); const { postRender, preRender } = engine.frames;
viewer.camera.frustum = { assert.equal(postRender.numberOfListeners, 0);
projectionMatrix: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
};
viewer.scene.camera = viewer.camera;
viewer.scene.postRender = new Cesium.Event();
assert.equal(viewer.scene.postRender.numberOfListeners, 0);
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
layer.enable(viewer); layer.enable();
layer.enable(viewer); layer.enable();
assert.equal(viewer.scene.postRender.numberOfListeners, 1); assert.equal(postRender.numberOfListeners, 1);
viewer.scene.postRender.raiseEvent(); engine.fake.emit('frames.postRender');
await tick(1); await tick(1);
layer.disable(viewer); layer.disable();
assert.equal(viewer.scene.postRender.numberOfListeners, 0); assert.equal(postRender.numberOfListeners, 0);
await tick(5000); await tick(5000);
assert.equal( assert.equal(
viewer.scene.postRender.numberOfListeners, postRender.numberOfListeners,
0, 0,
'late work cannot resubscribe', 'late work cannot resubscribe',
); );
} }
layer.enable(viewer); layer.enable();
assert.equal(viewer.scene.postRender.numberOfListeners, 1); assert.equal(postRender.numberOfListeners, 1);
layer.destroy(viewer); layer.destroy();
assert.equal(viewer.scene.postRender.numberOfListeners, 0); assert.equal(postRender.numberOfListeners, 0);
assert.equal(viewer.scene.preRender.numberOfListeners, 0); assert.equal(preRender.numberOfListeners, 0);
}); });
test('coverage changes at the same center reload and disabling invalidates coverage without dropping source caches', async (t) => { test('coverage changes at the same center reload and disabling invalidates coverage without dropping source caches', async (t) => {
const calls = []; const calls = [];
const { layer, viewer, tick } = setup(t, async (bounds) => { const { layer, tick } = setup(t, async (bounds) => {
calls.push(bounds); calls.push(bounds);
return roads(bounds); return roads(bounds);
}); });
layer.enable(viewer); layer.enable();
await tick(1000); await tick(1000);
await tick(2000); await tick(2000);
assert.ok(layer.getStats().count > 0); assert.ok(layer.getStats().count > 0);
const before = calls.length; const before = calls.length;
layer.disable(viewer); layer.disable();
layer.enable(viewer); layer.enable();
await tick(1000); await tick(1000);
await tick(2000); await tick(2000);
assert.ok(layer.getStats().count > 0, 'enable must repaint the same view'); assert.ok(layer.getStats().count > 0, 'enable must repaint the same view');
@@ -365,7 +346,7 @@ test('coverage changes at the same center reload and disabling invalidates cover
test('a settled footprint loads new tiles even when the camera centre stays put', async (t) => { test('a settled footprint loads new tiles even when the camera centre stays put', async (t) => {
const seen = []; const seen = [];
const { layer, viewer, tick } = setup( const { layer, engine, camera, pickRay, tick } = setup(
t, t,
async (bounds, { majorOnly, coverage }) => { async (bounds, { majorOnly, coverage }) => {
if (!majorOnly) seen.push({ ...bounds, coverage }); if (!majorOnly) seen.push({ ...bounds, coverage });
@@ -374,26 +355,26 @@ test('a settled footprint loads new tiles even when the camera centre stays put'
); );
layer.setParams({ roadMode: 'osm' }); layer.setParams({ roadMode: 'osm' });
let reach = 0.005; let reach = 0.005;
const camera = viewer.camera; camera.pickRay = pickRay;
camera.getPickRay = () => null; // Angles only: the camera stays where it is.
camera.heading = 0; const turn = (angles) =>
camera.pitch = -Math.PI / 2; engine.fake.setPose({ position: camera.pose().position, ...angles });
camera.pickEllipsoid = (pixel, ellipsoid, result) => turn({ heading: 0, pitch: -Math.PI / 2 });
Cesium.Cartesian3.fromDegrees( engine.fake.script('pickEllipsoid', (pixel) =>
geo.cartesian3.fromDegrees(
-97.744 + (pixel.x - 50) / 10000, -97.744 + (pixel.x - 50) / 10000,
30.267 + (pixel.y / 100) * reach, 30.267 + (pixel.y / 100) * reach,
0, 0,
ellipsoid, ),
result, );
); layer.enable();
layer.enable(viewer);
await tick(400); await tick(400);
assert.equal(seen.length, 1); assert.equal(seen.length, 1);
const firstKey = seen[0].coverage.key; const firstKey = seen[0].coverage.key;
camera.changed.raiseEvent(); engine.fake.emit('camera.changed');
// The final flight step occurs below the changed-event threshold. // The final flight step occurs below the changed-event threshold.
reach = 0.08; reach = 0.08;
camera.pitch = -Math.PI / 12; turn({ pitch: -Math.PI / 12 });
await tick(400); await tick(400);
assert.equal(seen.length, 2); assert.equal(seen.length, 2);
assert.notEqual(seen[1].coverage.key, firstKey); assert.notEqual(seen[1].coverage.key, firstKey);
@@ -401,7 +382,7 @@ test('a settled footprint loads new tiles even when the camera centre stays put'
seen[1].north > 30.33, seen[1].north > 30.33,
'sample the final view when the debounce fires', 'sample the final view when the debounce fires',
); );
camera.moveEnd.raiseEvent(); engine.fake.emit('camera.moveEnd');
await tick(400); await tick(400);
assert.equal( assert.equal(
seen.length, seen.length,
@@ -412,13 +393,13 @@ test('a settled footprint loads new tiles even when the camera centre stays put'
test('partial coverage retries while coarse roads render, then stops after three retries', async (t) => { test('partial coverage retries while coarse roads render, then stops after three retries', async (t) => {
let calls = 0; let calls = 0;
const { layer, viewer, tick } = setup(t, async (bounds) => { const { layer, tick } = setup(t, async (bounds) => {
calls++; calls++;
const response = roads(bounds); const response = roads(bounds);
const data = await response.json(); const data = await response.json();
return { ok: true, json: async () => ({ ...data, partial: true }) }; return { ok: true, json: async () => ({ ...data, partial: true }) };
}); });
layer.enable(viewer); layer.enable();
await tick(400); await tick(400);
await tick(0); await tick(0);
assert.ok(layer.getStats().count > 0); assert.ok(layer.getStats().count > 0);
+7 -4
View File
@@ -1,4 +1,4 @@
import { rgbaFromCss } from '../shared/color.js'; import { WHITE, rgbaFromCss } from '../shared/color.js';
/** /**
* @file Street Traffic selects TomTom, OpenMapTiles or Hybrid motor roads, * @file Street Traffic selects TomTom, OpenMapTiles or Hybrid motor roads,
@@ -108,7 +108,7 @@ export const SIZE_BY_TYPE = {
* Live-flow bucket colors (thresholds live in `trafficFlowStyle.js`): * Live-flow bucket colors (thresholds live in `trafficFlowStyle.js`):
* green free flow / amber slow / red jam, all at 0.9 alpha. * green free flow / amber slow / red jam, all at 0.9 alpha.
* Roads without flow data (`road.flow == null`) keep the sim's white. * Roads without flow data (`road.flow == null`) keep the sim's white.
* @const {Object<string, Cesium.Color>} * @const {Object<string, import('../shared/color.js').Rgba>}
*/ */
export const FLOW_BUCKET_COLORS = { export const FLOW_BUCKET_COLORS = {
@@ -117,6 +117,9 @@ export const FLOW_BUCKET_COLORS = {
jam: rgbaFromCss('#e05252').withAlpha(0.9), jam: rgbaFromCss('#e05252').withAlpha(0.9),
}; };
/** Simulated vehicles (and roads without flow data): white at 0.85. */
export const SIM_DOT_COLOR = WHITE.withAlpha(0.85);
// ─── Jam-viz prototype (live mode only — see 2026-07-21 design doc) ──────── // ─── Jam-viz prototype (live mode only — see 2026-07-21 design doc) ────────
/** @const {number} Max congestion heat-line polylines per render (jam first). */ /** @const {number} Max congestion heat-line polylines per render (jam first). */
@@ -138,11 +141,11 @@ export const HEAT_JAM_BASE_ALPHA = 0.55;
export const HEAT_JAM_PULSE_ALPHA = 0.2; export const HEAT_JAM_PULSE_ALPHA = 0.2;
/** @const {Cesium.Color} Jam corridor color (bucket red, alpha pulsed live). */ /** @const {import('../shared/color.js').Rgba} Jam corridor color (bucket red, alpha pulsed live). */
export const HEAT_JAM_COLOR = rgbaFromCss('#e05252'); export const HEAT_JAM_COLOR = rgbaFromCss('#e05252');
/** @const {Cesium.Color} Slow corridor color (bucket amber, faint + static). */ /** @const {import('../shared/color.js').Rgba} Slow corridor color (bucket amber, faint + static). */
export const HEAT_SLOW_COLOR = rgbaFromCss('#f0b23e').withAlpha(0.2); export const HEAT_SLOW_COLOR = rgbaFromCss('#f0b23e').withAlpha(0.2);
+181
View File
@@ -0,0 +1,181 @@
// Street Traffic on the globe contract, the same on every engine:
// - vehicles: two engine.movers point sets written from the layer's dots once
// a frame, an outline disc under each fill disc (the outline of Cesium's
// PointPrimitive, pixelSize plus outlineWidth on each side); each dot is
// sized and faded by its camera distance as a PointPrimitive's
// scaleByDistance and translucencyByDistance are (over the squared
// distance, eased by ^0.2), and depth-tested against the scene;
// - congestion heat lines: two engine.ground corridor sets (jam, then slow),
// draped on 3D tiles, as the GroundPolylinePrimitives Cesium drew with
// ClassificationType.CESIUM_3D_TILE.
// The layer keeps each dot's look as plain data on `dot.point` (position,
// pixelSize, color, outlineColor, outlineWidth, show, scaleByDistance,
// translucencyByDistance); the display only reads it.
import * as geo from '../../../geo/index.js';
import { MAX_DOTS } from '../policy.js';
export const TRAFFIC_SET_IDS = Object.freeze({
outline: 'traffic-outline',
fill: 'traffic-fill',
jam: 'traffic-heat-jam',
slow: 'traffic-heat-slow',
});
// Cesium culls a point whose translucency falls under this.
const MIN_TRANSLUCENCY = 0.004;
// Draw order of the heat-line sets: the jam batch was added first.
const HEAT_ORDER = Object.freeze({ jam: 0, slow: 1 });
/**
* A NearFarScalar `{ near, nearValue, far, farValue }` at `distance` metres:
* Cesium's interpolation over the squared distance, eased by ^0.2; 1 without
* one.
*/
export function nearFarValue(scalar, distance) {
if (!scalar) return 1;
const nearSq = scalar.near * scalar.near;
const span = scalar.far * scalar.far - nearSq;
const t =
span > 0
? Math.min(1, Math.max(0, (distance * distance - nearSq) / span))
: distance > scalar.near
? 1
: 0;
return (
scalar.nearValue + (scalar.farValue - scalar.nearValue) * Math.pow(t, 0.2)
);
}
/**
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @returns {{ drawDots: (dots: object[]) => void, show: (visible: boolean) => void,
* setHeat: (bucket: 'jam'|'slow', roads: object[], style: { color: number[], widthPx: number }) => void,
* clearHeat: () => void, excluded: () => object[], destroy: () => void }}
*/
export function createTrafficDisplay(engine) {
if (!engine?.movers || !engine?.ground)
throw new TypeError('The traffic display needs a globe engine');
const roles = ['outline', 'fill'];
const sets = roles.map((role, order) =>
engine.movers.createSet({
id: TRAFFIC_SET_IDS[role],
kind: 'point',
order,
capacity: MAX_DOTS,
depthTest: 'normal',
pickable: false,
point: { sizePx: 1, outlinePx: 0 },
}),
);
const heat = new Map();
const color = [0, 0, 0, 0];
const fields = { position: null, scale: 1, color, show: true };
const counts = [0, 0];
let visible = true;
let drawn = false;
let destroyed = false;
function commit() {
for (let i = 0; i < sets.length; i++) {
sets[i].setCount(counts[i]);
sets[i].commit();
}
}
function write(index, point, size, alpha, tint) {
color[0] = tint.red;
color[1] = tint.green;
color[2] = tint.blue;
color[3] = tint.alpha * alpha;
fields.position = point.position;
fields.scale = size;
sets[index].set(counts[index]++, fields);
}
function drawDots(dots) {
if (destroyed) return;
counts[0] = counts[1] = 0;
const camera = engine.camera.livePosition;
if (visible && camera)
for (const dot of dots) {
const point = dot.point;
// A dot at size 0 (fading in, recycled at a road end) draws nothing,
// its outline included.
if (!point?.show || !(point.pixelSize > 0) || !point.position) continue;
if (!engine.projector.horizonVisible(point.position)) continue;
const distance = geo.cartesian3.distance(point.position, camera);
const alpha = nearFarValue(point.translucencyByDistance, distance);
if (alpha < MIN_TRANSLUCENCY) continue;
const scale = nearFarValue(point.scaleByDistance, distance);
const outline = point.outlineWidth > 0 && point.outlineColor;
if (outline && counts[0] < MAX_DOTS)
write(
0,
point,
Math.max(0, (point.pixelSize + 2 * point.outlineWidth) * scale),
alpha,
point.outlineColor,
);
if (counts[1] < MAX_DOTS)
write(
1,
point,
Math.max(0, point.pixelSize * scale),
alpha,
point.color,
);
}
fields.position = null;
// An empty population is written once, not every frame.
if (!counts[0] && !counts[1] && !drawn) return;
drawn = counts[0] > 0 || counts[1] > 0;
commit();
}
function dropHeat(bucket) {
heat.get(bucket)?.destroy();
heat.delete(bucket);
}
return {
drawDots,
/** Shows or hides the vehicles (the heat lines go with the layer's roads). */
show(next) {
visible = next !== false;
if (!visible && drawn && !destroyed) {
counts[0] = counts[1] = 0;
drawn = false;
commit();
}
},
/** One bucket's heat lines: a corridor per road, along its coordinates. */
setHeat(bucket, roads, { color: rgba, widthPx }) {
if (destroyed) return;
dropHeat(bucket);
if (!roads.length) return;
const set = engine.ground.createSet({
id: TRAFFIC_SET_IDS[bucket],
kind: 'corridor',
order: HEAT_ORDER[bucket],
pickable: false,
drape: 'tiles',
});
heat.set(bucket, set);
set.replace(
roads.map((road) => ({ positions: road.coords, color: rgba, widthPx })),
);
set.commit();
},
clearHeat() {
for (const bucket of [...heat.keys()]) dropHeat(bucket);
},
/** The vehicle sets, which surface height samples look through. */
excluded: () => (destroyed ? [] : sets.slice()),
destroy() {
if (destroyed) return;
destroyed = true;
for (const bucket of [...heat.keys()]) dropHeat(bucket);
for (const set of sets) set.destroy();
},
};
}
+411
View File
@@ -0,0 +1,411 @@
// The traffic display on the engine contract, on the fake engine and on the
// Cesium engine: the vehicle sets (outline under fill, depth-tested, not
// picked), each dot's size and fade by camera distance as Cesium's
// NearFarScalar, what is skipped, show and hide; the heat-line corridors
// (colours, widths, order, drape on 3D tiles) and the layer's jam and slow
// styles; the sets heights are sampled through; teardown.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { GROUND_HARNESSES } from '../../../testSupport/groundDisplayEngines.mjs';
import { Rgba, nearFarScale } from '../../shared/color.js';
import { createState } from '../state.js';
import { createStyle } from '../style.js';
import { createRendering } from '../rendering.js';
import {
HEAT_JAM_BASE_ALPHA,
HEAT_JAM_COLOR,
HEAT_LINE_JAM_WIDTH,
HEAT_LINE_SLOW_WIDTH,
HEAT_SLOW_COLOR,
MAX_DOTS,
SIM_DOT_COLOR,
} from '../policy.js';
import {
TRAFFIC_SET_IDS as IDS,
createTrafficDisplay,
nearFarValue,
} from './display.js';
// GroundPolylinePrimitive checks its render state's line width against the
// context's limits, which a stub scene has none of.
Cesium.ContextLimits._minimumAliasedLineWidth = 1;
Cesium.ContextLimits._maximumAliasedLineWidth = 10;
const CAMERA_M = 1000;
const near = (actual, expected, label, epsilon = 1e-6) =>
assert.ok(
Math.abs(actual - expected) < epsilon,
`${label}: ${actual} vs ${expected}`,
);
const nearAll = (actual, expected, label, epsilon = 1e-6) =>
expected.forEach((value, index) =>
near(actual[index], value, `${label}[${index}]`, epsilon),
);
// A mover set's drawn instances on either engine: size in px, rgba, shown.
function drawn(h, id) {
const listed = h.engine.movers.list().find((set) => set.id === id);
if (!listed) return undefined;
const rows = [];
if (h.cesium) {
const items = (collection) =>
Array.from({ length: collection?.length ?? 0 }, (_, i) =>
collection.get(i),
);
let collection;
for (const root of items(h.viewer.scene.primitives))
for (const child of items(root))
if (child.gevMoverSet === id) collection = child;
for (let i = 0; i < listed.count; i++) {
const point = collection.get(i);
rows.push({
sizePx: point.pixelSize,
color: [
point.color.red,
point.color.green,
point.color.blue,
point.color.alpha,
],
shown: point.show,
outlineWidth: point.outlineWidth,
depthTestDistance: point.disableDepthTestDistance,
scaleByDistance: point.scaleByDistance,
});
}
} else {
const { store, options } = h.engine.fake.moverSet(id);
for (let i = 0; i < store.count; i++)
rows.push({
sizePx: options.point.sizePx * store.scales[i],
color: [...store.colors.slice(i * 4, i * 4 + 4)],
shown: store.shows[i] === 1,
});
}
return rows;
}
function fixture(t, makeHarness) {
const h = makeHarness();
t.after(() => h.destroy());
// The camera straight above the dots, CAMERA_M up.
h.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(0, 0, CAMERA_M),
});
const display = createTrafficDisplay(h.engine);
t.after(() => display.destroy());
return { h, display };
}
// A dot's look as the layer writes it (animation.js spawnDotsForRoad).
function dot(fields = {}) {
return {
point: {
position: geo.cartesian3.fromDegrees(0, 0, 0),
pixelSize: 5,
color: SIM_DOT_COLOR,
show: true,
scaleByDistance: nearFarScale(100, 1.5, 8000, 0.3),
translucencyByDistance: nearFarScale(100, 1, 10000, 0),
outlineColor: null,
outlineWidth: 0,
...fields,
},
};
}
test('distance scalars interpolate over the squared distance, eased by ^0.2, and clamp', () => {
const scalar = nearFarScale(100, 1.5, 8000, 0.3);
assert.equal(nearFarValue(null, 5000), 1);
assert.equal(nearFarValue(scalar, 50), 1.5);
assert.equal(nearFarValue(scalar, 100), 1.5);
near(nearFarValue(scalar, 8000), 0.3, 'far');
near(nearFarValue(scalar, 20000), 0.3, 'beyond far');
const t = Math.pow((1000 ** 2 - 100 ** 2) / (8000 ** 2 - 100 ** 2), 0.2);
near(nearFarValue(scalar, 1000), 1.5 + (0.3 - 1.5) * t, 'between');
});
for (const makeHarness of GROUND_HARNESSES) {
const name = makeHarness().name;
test(`vehicles are two depth-tested point sets, outline under fill, never picked (${name})`, (t) => {
const { h } = fixture(t, makeHarness);
const sets = h.engine.movers.list();
assert.deepEqual(
sets.map(({ id }) => id),
[IDS.outline, IDS.fill],
);
for (const [index, id] of [IDS.outline, IDS.fill].entries()) {
const { options } = h.cesium
? { options: null }
: h.engine.fake.moverSet(id);
assert.equal(
sets.find((set) => set.id === id).capacity,
MAX_DOTS,
`${id} capacity`,
);
if (options) {
assert.equal(options.kind, 'point');
assert.equal(options.order, index);
assert.equal(options.depthTest, 'normal');
assert.equal(options.pickable, false);
assert.equal(options.point.sizePx, 1);
assert.equal(options.point.outlinePx, 0);
}
}
});
test(`each dot is sized and faded by its camera distance, its outline a wider disc under it (${name})`, (t) => {
const { h, display } = fixture(t, makeHarness);
const outline = new Rgba(0, 0, 0, 0.9);
const jam = new Rgba(1, 0.2, 0.2, 0.9);
display.drawDots([
dot(),
dot({
pixelSize: 7,
color: jam,
outlineColor: outline,
outlineWidth: 2,
scaleByDistance: nearFarScale(100, 1.5, 8000, 0.55),
}),
]);
const distance = geo.cartesian3.distance(
h.engine.camera.livePosition,
geo.cartesian3.fromDegrees(0, 0, 0),
);
near(distance, CAMERA_M, 'camera distance', 1e-3);
const fade = nearFarValue(nearFarScale(100, 1, 10000, 0), distance);
const simScale = nearFarValue(nearFarScale(100, 1.5, 8000, 0.3), distance);
const jamScale = nearFarValue(nearFarScale(100, 1.5, 8000, 0.55), distance);
const fills = drawn(h, IDS.fill);
assert.equal(fills.length, 2);
near(fills[0].sizePx, 5 * simScale, 'sim size', 1e-4);
nearAll(
fills[0].color,
[1, 1, 1, 0.85 * fade],
'sim colour (white 0.85, faded)',
1e-6,
);
near(fills[1].sizePx, 7 * jamScale, 'jam size', 1e-4);
nearAll(fills[1].color, [1, 0.2, 0.2, 0.9 * fade], 'jam colour', 1e-6);
// Cesium: pixelSize plus outlineWidth on each side, scaled as a whole.
const outlines = drawn(h, IDS.outline);
assert.equal(outlines.length, 1, 'only the outlined dot has an outline');
near(outlines[0].sizePx, (7 + 2 * 2) * jamScale, 'outline size', 1e-4);
nearAll(outlines[0].color, [0, 0, 0, 0.9 * fade], 'outline colour', 1e-6);
if (h.cesium) {
for (const row of [...fills, ...outlines]) {
assert.equal(row.shown, true);
assert.equal(row.outlineWidth, 0);
assert.equal(row.depthTestDistance, 0, 'depth-tested');
}
}
});
test(`hidden, zero-size, faded-out and over-the-horizon dots draw nothing (${name})`, (t) => {
const { h, display } = fixture(t, makeHarness);
const outline = { outlineColor: new Rgba(0, 0, 0, 0.9), outlineWidth: 2 };
display.drawDots([
dot({ show: false, ...outline }),
dot({ pixelSize: 0, ...outline }),
dot({ translucencyByDistance: nearFarScale(100, 1, 200, 0) }),
dot({ position: geo.cartesian3.fromDegrees(180, 0, 0), ...outline }),
dot(),
]);
assert.equal(drawn(h, IDS.fill).length, 1);
assert.equal(drawn(h, IDS.outline).length, 0);
// The population empties: one write of nothing.
display.drawDots([]);
assert.equal(drawn(h, IDS.fill).length, 0);
});
test(`hiding the vehicles clears them until shown and drawn again (${name})`, (t) => {
const { h, display } = fixture(t, makeHarness);
const dots = [dot(), dot()];
display.drawDots(dots);
assert.equal(drawn(h, IDS.fill).length, 2);
display.show(false);
assert.equal(drawn(h, IDS.fill).length, 0);
display.drawDots(dots);
assert.equal(drawn(h, IDS.fill).length, 0, 'hidden: nothing draws');
display.show(true);
display.drawDots(dots);
assert.equal(drawn(h, IDS.fill).length, 2);
});
test(`heat lines are corridors draped on 3D tiles, jam under slow, one per road (${name})`, (t) => {
const { h, display } = fixture(t, makeHarness);
const roads = [
{
coords: [
[0, 0],
[0.001, 0],
],
},
{
coords: [
[0, 0.001],
[0.001, 0.001],
[0.002, 0.002],
],
},
];
display.setHeat('jam', roads, { color: [1, 0, 0, 0.55], widthPx: 9 });
display.setHeat('slow', roads.slice(1), {
color: [1, 0.7, 0.2, 0.2],
widthPx: 4,
});
display.setHeat('slow', [], { color: [1, 0.7, 0.2, 0.2], widthPx: 4 });
assert.equal(h.set(IDS.slow), undefined, 'no roads: no set');
display.setHeat('slow', roads.slice(1), {
color: [1, 0.7, 0.2, 0.2],
widthPx: 4,
});
const jam = h.set(IDS.jam);
const slow = h.set(IDS.slow);
for (const [set, order] of [
[jam, 0],
[slow, 1],
]) {
assert.equal(set.options.kind, 'corridor');
assert.equal(set.options.drape, 'tiles');
assert.equal(set.options.pickable, false);
assert.equal(set.options.order, order);
assert.equal(set.shown, true);
}
assert.equal(jam.items.length, 2);
assert.deepEqual(jam.items[1].positions, roads[1].coords);
assert.equal(jam.items[0].widthPx, 9);
assert.deepEqual(jam.items[0].color, [1, 0, 0, 0.55]);
assert.equal(slow.items.length, 1);
assert.equal(slow.items[0].widthPx, 4);
if (h.cesium) {
const { fills, lines } = h.primitives(IDS.jam);
assert.equal(fills.length, 0);
assert.equal(lines.length, 1, 'one ground polyline batch');
assert.equal(
lines[0].options.classificationType,
Cesium.ClassificationType.CESIUM_3D_TILE,
);
assert.equal(
lines[0].options.geometryInstances[0].geometry.width,
HEAT_LINE_JAM_WIDTH,
);
}
display.clearHeat();
assert.equal(h.set(IDS.jam), undefined);
assert.equal(h.set(IDS.slow), undefined);
});
test(`the layer's heat lines: jam red at its base alpha, slow amber; mono presets in grey (${name})`, (t) => {
const { h, display } = fixture(t, makeHarness);
const state = createState({ services: {} });
Object.assign(state, {
_display: display,
_liveMode: true,
_jamViz: 'heatline',
});
const parts = {};
const context = { state, services: {}, parts, source: {} };
parts.style = createStyle(context);
parts.rendering = createRendering(context);
const road = (level, offset, metres) => ({
flow: { level, closure: false },
coords: [
[offset, 0],
[offset + 0.001, 0],
],
segmentDist: [metres],
});
const roads = [
road(0.9, 0, 100),
road(0.7, 0.01, 100),
road(0.1, 0.02, 50),
road(0.1, 0.03, 300),
{ ...road(0.1, 0.04, 100), flow: { level: 0.1, closure: true } },
];
parts.rendering.rebuildHeatLines(roads);
const jam = h.set(IDS.jam);
const slow = h.set(IDS.slow);
assert.equal(jam.items.length, 2, 'jam roads, the closure left out');
assert.deepEqual(
jam.items.map((item) => item.positions[0][0]),
[0.03, 0.02],
'longest first',
);
nearAll(
jam.items[0].color,
[...HEAT_JAM_COLOR.rgba().slice(0, 3), HEAT_JAM_BASE_ALPHA],
'jam colour',
);
assert.equal(jam.items[0].widthPx, HEAT_LINE_JAM_WIDTH);
assert.equal(slow.items.length, 1);
nearAll(slow.items[0].color, HEAT_SLOW_COLOR.rgba(), 'slow colour');
assert.equal(slow.items[0].widthPx, HEAT_LINE_SLOW_WIDTH);
assert.equal(state._heatLineCount, 3);
// NVG and the other mono presets re-encode in luminance.
state._stylePreset = 'surveillance';
parts.rendering.rebuildHeatLines(roads);
nearAll(
h.set(IDS.jam).items[0].color,
[1, 1, 1, HEAT_JAM_BASE_ALPHA],
'mono jam',
);
nearAll(
h.set(IDS.slow).items[0].color,
[0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha],
'mono slow',
);
// Heat lines off: none drawn.
state._jamViz = 'density';
parts.rendering.rebuildHeatLines(roads);
assert.equal(h.set(IDS.jam), undefined);
assert.equal(state._heatLineCount, 0);
});
test(`heights are sampled through the vehicle sets, and destroy removes every set (${name})`, (t) => {
const { h, display } = fixture(t, makeHarness);
const excluded = display.excluded();
assert.equal(excluded.length, 2);
display.setHeat(
'jam',
[
{
coords: [
[0, 0],
[0.001, 0],
],
},
],
{
color: [1, 0, 0, 0.55],
widthPx: 9,
},
);
display.drawDots([dot()]);
display.destroy();
display.destroy();
assert.equal(h.engine.movers.list().length, 0);
assert.equal(h.sets(), 0);
assert.deepEqual(display.excluded(), []);
display.drawDots([dot()]);
display.setHeat(
'slow',
[
{
coords: [
[0, 0],
[0.001, 0],
],
},
],
{
color: [1, 1, 1, 1],
widthPx: 4,
},
);
assert.equal(h.sets(), 0, 'nothing draws after destroy');
});
}
-187
View File
@@ -1,187 +0,0 @@
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() };
}
+9 -3
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createAnimation } from './animation.js'; import { createAnimation } from './animation.js';
test('traffic render demand follows animated population and jam lines across empty, disable and re-enable', () => { test('traffic render demand follows the animated population across empty, disable and re-enable', () => {
const calls = []; const calls = [];
const state = { _enabled: true, _dots: [], _motion: { rebuilds: 0 } }; const state = { _enabled: true, _dots: [], _motion: { rebuilds: 0 } };
const animation = createAnimation({ const animation = createAnimation({
@@ -17,7 +17,7 @@ test('traffic render demand follows animated population and jam lines across emp
retention: { clear() {} }, retention: { clear() {} },
rendering: { rendering: {
removeHeatLines() { removeHeatLines() {
state._heatJamPrim = null; state._heatLineCount = 0;
}, },
}, },
}, },
@@ -30,7 +30,12 @@ test('traffic render demand follows animated population and jam lines across emp
assert.deepEqual(calls, [['hold', 'traffic']]); assert.deepEqual(calls, [['hold', 'traffic']]);
animation.clearDots(); animation.clearDots();
assert.deepEqual(calls.at(-1), ['release', 'traffic']); assert.deepEqual(calls.at(-1), ['release', 'traffic']);
state._heatJamPrim = {}; // Heat lines are static ground fills: they alone hold no continuous render.
state._heatLineCount = 3;
animation.syncRenderDemand();
assert.deepEqual(calls.at(-1), ['release', 'traffic']);
assert.equal(state._renderHeld, false);
state._dots.push({});
animation.syncRenderDemand(); animation.syncRenderDemand();
assert.deepEqual(calls.at(-1), ['hold', 'traffic']); assert.deepEqual(calls.at(-1), ['hold', 'traffic']);
state._enabled = false; state._enabled = false;
@@ -39,6 +44,7 @@ test('traffic render demand follows animated population and jam lines across emp
state._enabled = true; state._enabled = true;
animation.clearDots(); animation.clearDots();
assert.equal(state._renderHeld, false); assert.equal(state._renderHeld, false);
assert.equal(state._heatLineCount, 0, 'a clear removes the heat lines');
state._dots.push({}); state._dots.push({});
animation.syncRenderDemand(); animation.syncRenderDemand();
assert.equal(state._renderHeld, true); assert.equal(state._renderHeld, true);
+29 -122
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { Rgba, WHITE } from '../shared/color.js';
import { flowBucket } from '../../data/trafficFlowStyle.js'; import { flowBucket } from '../../data/trafficFlowStyle.js';
import { trafficStyleProfile } from '../../data/trafficPresetStyle.js'; import { trafficStyleProfile } from '../../data/trafficPresetStyle.js';
import { import {
@@ -34,38 +35,20 @@ export function createRendering({
: roads; : roads;
} }
/** Remove both heat-line ground primitives from the scene. */ /** Remove both heat-line sets from the display. */
function removeHeatLines() { function removeHeatLines() {
if (layerState._native) { layerState._display?.clearHeat();
layerState._heatJamPrim?.destroy();
layerState._heatSlowPrim?.destroy();
layerState._heatJamPrim = layerState._heatSlowPrim = null;
}
if (layerState._heatJamPrim) {
layerState._viewer?.scene.groundPrimitives.remove(
layerState._heatJamPrim,
);
layerState._heatJamPrim = null;
}
if (layerState._heatSlowPrim) {
layerState._viewer?.scene.groundPrimitives.remove(
layerState._heatSlowPrim,
);
layerState._heatSlowPrim = null;
}
layerState._heatLineCount = 0; layerState._heatLineCount = 0;
parts.animation.syncRenderDemand?.();
} }
/** /**
* Rebuild the congestion heat-line underlay (jam-viz heatline prototype): * Rebuild the congestion heat-line underlay (jam-viz heatline prototype):
* slow/jam roads drape a corridor line onto the rendered 3D tiles — glowing * slow/jam roads drape a corridor line onto the rendered 3D tiles — red for
* pulsing red for jam, faint flat amber for slow — with the dots animating * jam, faint flat amber for slow — with the dots animating on top. One
* on top. Two batched GroundPolylinePrimitives (one per bucket) so the jam * display set per bucket. Capped at HEAT_LINE_CAP (jam first, longest
* batch pulses through one shared material. Capped at HEAT_LINE_CAP (jam * first); overflow is logged. No-op in sim mode, when the heatline mode is
* first, longest first); overflow is logged. No-op in sim mode, when the * off, or without a display.
* heatline mode is off, or without ground-primitive support.
* *
* @param {Array} roads - Road objects visible in the current render. * @param {Array} roads - Road objects visible in the current render.
*/ */
@@ -73,22 +56,11 @@ export function createRendering({
function rebuildHeatLines(roads) { function rebuildHeatLines(roads) {
removeHeatLines(); removeHeatLines();
if ( if (
(!layerState._viewer && !layerState._native) || !layerState._display ||
!layerState._liveMode || !layerState._liveMode ||
!parts.style.heatlineOn() !parts.style.heatlineOn()
) )
return; return;
if (layerState._native) layerState._heatSupported = true;
if (layerState._heatSupported === null) {
layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported(
layerState._viewer.scene,
);
if (!layerState._heatSupported)
console.warn(
'[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled',
);
}
if (!layerState._heatSupported) return;
const candidates = []; const candidates = [];
for (const road of roads) { for (const road of roads) {
@@ -105,80 +77,27 @@ export function createRendering({
); );
const kept = candidates.slice(0, HEAT_LINE_CAP); const kept = candidates.slice(0, HEAT_LINE_CAP);
const instancesFor = (bucket, width) =>
kept
.filter((c) => c.bucket === bucket)
.map(
(c) =>
new Cesium.GeometryInstance({
geometry: new Cesium.GroundPolylineGeometry({
positions: c.road.waypoints,
width,
}),
}),
);
// Mono presets (NVG/FLIR/noir) discard hue — heat-lines re-encode in // Mono presets (NVG/FLIR/noir) discard hue — heat-lines re-encode in
// luminance like the dots: jam = white glow, slow = faint gray. // luminance like the dots: jam = white, slow = faint gray.
const monoHeat = const monoHeat =
layerState._presetDots === 'on' && layerState._presetDots === 'on' &&
trafficStyleProfile(layerState._stylePreset) === 'mono'; trafficStyleProfile(layerState._stylePreset) === 'mono';
const jamLineColor = monoHeat ? Cesium.Color.WHITE : HEAT_JAM_COLOR; const jamLineColor = monoHeat ? WHITE : HEAT_JAM_COLOR;
const slowLineColor = monoHeat const slowLineColor = monoHeat
? new Cesium.Color(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha) ? new Rgba(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha)
: HEAT_SLOW_COLOR; : HEAT_SLOW_COLOR;
const roadsFor = (bucket) =>
if (layerState._native) { kept.filter((item) => item.bucket === bucket).map((item) => item.road);
const roadsFor = (bucket) => layerState._display.setHeat('jam', roadsFor('jam'), {
kept.filter((item) => item.bucket === bucket).map((item) => item.road); color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA).rgba(),
layerState._heatJamPrim = layerState._native.makeHeat( widthPx: HEAT_LINE_JAM_WIDTH,
roadsFor('jam'), });
jamLineColor, layerState._display.setHeat('slow', roadsFor('slow'), {
HEAT_LINE_JAM_WIDTH, color: slowLineColor.rgba(),
HEAT_JAM_BASE_ALPHA, widthPx: HEAT_LINE_SLOW_WIDTH,
); });
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(
new Cesium.GroundPolylinePrimitive({
geometryInstances: jamInstances,
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
appearance: new Cesium.PolylineMaterialAppearance({
material: Cesium.Material.fromType('PolylineGlow', {
color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA),
glowPower: 0.25,
}),
}),
}),
);
}
const slowInstances = instancesFor('slow', HEAT_LINE_SLOW_WIDTH);
if (slowInstances.length) {
layerState._heatSlowPrim = layerState._viewer.scene.groundPrimitives.add(
new Cesium.GroundPolylinePrimitive({
geometryInstances: slowInstances,
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
appearance: new Cesium.PolylineMaterialAppearance({
material: Cesium.Material.fromType('Color', {
color: slowLineColor,
}),
}),
}),
);
}
layerState._heatLineCount = kept.length; layerState._heatLineCount = kept.length;
parts.animation.syncRenderDemand?.();
if (candidates.length > kept.length) { if (candidates.length > kept.length) {
console.log( console.log(
`[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`, `[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`,
@@ -227,7 +146,7 @@ export function createRendering({
// nadir view: oblique pitches put the loaded roads many km away even at // nadir view: oblique pitches put the loaded roads many km away even at
// low altitude. Probe three roads and stretch the curves accordingly. // low altitude. Probe three roads and stretch the curves accordingly.
let areaDist = altitude; let areaDist = altitude;
if ((layerState._viewer || layerState._engine) && filteredRoads.length) { if (layerState._engine && filteredRoads.length) {
const probes = [ const probes = [
filteredRoads[0], filteredRoads[0],
filteredRoads[Math.floor(filteredRoads.length / 2)], filteredRoads[Math.floor(filteredRoads.length / 2)],
@@ -238,11 +157,7 @@ export function createRendering({
if (wp) if (wp)
areaDist = Math.max( areaDist = Math.max(
areaDist, areaDist,
Cesium.Cartesian3.distance( geo.cartesian3.distance(layerState._engine.camera.livePosition, wp),
layerState._engine?.camera.livePosition ??
layerState._viewer.camera.positionWC,
wp,
),
); );
} }
} }
@@ -259,18 +174,10 @@ export function createRendering({
: null; : null;
parts.retention.reconcile(filteredRoads, altitude, replace); parts.retention.reconcile(filteredRoads, altitude, replace);
if (layerState._dots.length && filteredRoads.some((r) => !r.directFlow)) if (layerState._dots.length && filteredRoads.some((r) => !r.directFlow))
services.credits?.showOsmCredit?.( services.credits?.showOsmCredit?.(layerState._engine, 'traffic', {
layerState._engine ?? layerState._viewer, openMapTiles: true,
'traffic', });
{ else services.credits?.hideOsmCredit?.(layerState._engine, 'traffic');
openMapTiles: true,
},
);
else
services.credits?.hideOsmCredit?.(
layerState._engine ?? layerState._viewer,
'traffic',
);
parts.model.recolorDotsInPlace(label); parts.model.recolorDotsInPlace(label);
const renderMetrics = state const renderMetrics = state
+34 -36
View File
@@ -1,4 +1,4 @@
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { MAX_DOTS, roadDotBudget } from './policy.js'; import { MAX_DOTS, roadDotBudget } from './policy.js';
export const DOT_CHANGE_BUDGET = 768; export const DOT_CHANGE_BUDGET = 768;
@@ -15,28 +15,22 @@ export function createRetention({ state, parts }) {
const heightStep = { x: 0, y: 0, z: 0 }; const heightStep = { x: 0, y: 0, z: 0 };
const screenFrom = { x: 0, y: 0 }; const screenFrom = { x: 0, y: 0 };
const screenTo = { x: 0, y: 0 }; const screenTo = { x: 0, y: 0 };
const eye = { x: 0, y: 0, z: 0 }; // The window position of a point in front of the camera (undefined behind
function projectSafely(scene, point, screen) { // it or in front of the near plane, as Cesium's worldToWindowCoordinates).
if (scene.project) return Boolean(scene.project(point, screen)); function projectSafely(engine, point, screen) {
if (scene.camera.viewMatrix) {
Cesium.Matrix4.multiplyByPoint(scene.camera.viewMatrix, point, eye);
if (eye.z >= -(scene.camera.frustum?.near || 0)) return false;
}
return ( return (
Boolean( Boolean(engine.projector.worldToWindow(point, screen)) &&
Cesium.SceneTransforms.worldToWindowCoordinates(scene, point, screen),
) &&
Number.isFinite(screen.x) && Number.isFinite(screen.x) &&
Number.isFinite(screen.y) Number.isFinite(screen.y)
); );
} }
function nearCanvas(scene, point, screen) { function nearCanvas(engine, size, point, screen) {
return ( return (
projectSafely(scene, point, screen) && projectSafely(engine, point, screen) &&
screen.x >= -100 && screen.x >= -100 &&
screen.x <= scene.canvas.clientWidth + 100 && screen.x <= size.width + 100 &&
screen.y >= -100 && screen.y >= -100 &&
screen.y <= scene.canvas.clientHeight + 100 screen.y <= size.height + 100
); );
} }
let timer = null; let timer = null;
@@ -51,9 +45,10 @@ export function createRetention({ state, parts }) {
}; };
function pump() { function pump() {
timer = null; timer = null;
if (!state._enabled || !state._pointCollection) return; if (!state._enabled || !state._display) return;
let budget = DOT_CHANGE_BUDGET; let budget = DOT_CHANGE_BUDGET;
let pending = false; let pending = false;
let removed = false;
const now = Date.now(); const now = Date.now();
// Removing a road or reducing density fades only surplus dots. A retained // Removing a road or reducing density fades only surplus dots. A retained
// dot never changes road, direction, segment or progress. // dot never changes road, direction, segment or progress.
@@ -69,7 +64,6 @@ export function createRetention({ state, parts }) {
for (let i = record.dots.length - 1; i >= 0 && budget > 0; i--) { for (let i = record.dots.length - 1; i >= 0 && budget > 0; i--) {
const dot = record.dots[i]; const dot = record.dots[i];
if (!dot.retiring || now - dot.retiring < DOT_FADE_MS) continue; if (!dot.retiring || now - dot.retiring < DOT_FADE_MS) continue;
state._pointCollection.remove(dot.point);
const last = state._dots.pop(); const last = state._dots.pop();
if (last !== dot) { if (last !== dot) {
state._dots[dot.index] = last; state._dots[dot.index] = last;
@@ -77,6 +71,7 @@ export function createRetention({ state, parts }) {
} }
record.dots.splice(i, 1); record.dots.splice(i, 1);
budget--; budget--;
removed = true;
state._motion.removed++; state._motion.removed++;
} }
let active = 0; let active = 0;
@@ -112,6 +107,9 @@ export function createRetention({ state, parts }) {
if (!record.target && !record.dots.length) if (!record.target && !record.dots.length)
records.delete(record.road.key); records.delete(record.road.key);
} }
// A removal outside a frame is drawn now: the population may have
// emptied, and then no frame follows.
if (removed) parts.animation.drawDots?.();
refreshCounts(); refreshCounts();
state._populationPending = pending; state._populationPending = pending;
if (pending) schedule(); if (pending) schedule();
@@ -140,11 +138,11 @@ export function createRetention({ state, parts }) {
// so an asynchronous height pass cannot move half a road in one frame. // so an asynchronous height pass cannot move half a road in one frame.
const road = { const road = {
...candidate, ...candidate,
waypoints: candidate.waypoints.map((p) => Cesium.Cartesian3.clone(p)), waypoints: candidate.waypoints.map((p) => geo.cartesian3.clone(p)),
segmentDist: candidate.segmentDist.slice(), segmentDist: candidate.segmentDist.slice(),
source: candidate, source: candidate,
targetWaypoints: candidate.waypoints.map((p) => targetWaypoints: candidate.waypoints.map((p) =>
Cesium.Cartesian3.clone(p), geo.cartesian3.clone(p),
), ),
heightMoving: false, heightMoving: false,
}; };
@@ -156,7 +154,7 @@ export function createRetention({ state, parts }) {
record.road.densityWeight = candidate.densityWeight; record.road.densityWeight = candidate.densityWeight;
for (let i = 0; i < candidate.waypoints.length; i++) { for (let i = 0; i < candidate.waypoints.length; i++) {
if ( if (
!Cesium.Cartesian3.equalsEpsilon( !geo.cartesian3.equalsEpsilon(
record.road.targetWaypoints[i], record.road.targetWaypoints[i],
candidate.waypoints[i], candidate.waypoints[i],
0, 0,
@@ -164,7 +162,7 @@ export function createRetention({ state, parts }) {
) )
) )
record.road.heightMoving = state._heightPending = true; record.road.heightMoving = state._heightPending = true;
Cesium.Cartesian3.clone( geo.cartesian3.clone(
candidate.waypoints[i], candidate.waypoints[i],
record.road.targetWaypoints[i], record.road.targetWaypoints[i],
); );
@@ -215,10 +213,9 @@ export function createRetention({ state, parts }) {
} }
function easeHeights(dt) { function easeHeights(dt) {
const blend = Math.min(1, dt * 12); const blend = Math.min(1, dt * 12);
const scene = state._trafficScene?.() ?? state._viewer?.scene; const engine = state._engine;
const canProject = const size = engine?.host.size();
scene?.canvas?.clientHeight && const canProject = Boolean(size?.height);
(scene.project || (scene.camera && scene.frameState));
state._heightPending = false; state._heightPending = false;
for (let r = 0; r < state._roads.length; r++) { for (let r = 0; r < state._roads.length; r++) {
const road = state._roads[r]; const road = state._roads[r];
@@ -227,25 +224,26 @@ export function createRetention({ state, parts }) {
for (let i = 0; i < road.waypoints.length; i++) { for (let i = 0; i < road.waypoints.length; i++) {
const point = road.waypoints[i], const point = road.waypoints[i],
target = road.targetWaypoints[i]; target = road.targetWaypoints[i];
if (Cesium.Cartesian3.distanceSquared(point, target) < 0.0001) if (geo.cartesian3.distanceSquared(point, target) < 0.0001)
Cesium.Cartesian3.clone(target, point); geo.cartesian3.clone(target, point);
else { else {
const visible = canProject && nearCanvas(scene, point, screenFrom); const visible =
canProject && nearCanvas(engine, size, point, screenFrom);
let step = visible let step = visible
? blend ? blend
: Math.min( : Math.min(
blend, blend,
(120 * dt) / Cesium.Cartesian3.distance(point, target), (120 * dt) / geo.cartesian3.distance(point, target),
); );
if (visible) { if (visible) {
let safe = false; let safe = false;
for (let attempt = 0; attempt < 16; attempt++) { for (let attempt = 0; attempt < 16; attempt++) {
Cesium.Cartesian3.lerp(point, target, step, heightStep); geo.cartesian3.lerp(point, target, step, heightStep);
if (!projectSafely(scene, heightStep, screenTo)) { if (!projectSafely(engine, heightStep, screenTo)) {
step *= 0.5; step *= 0.5;
continue; continue;
} }
const pixels = Cesium.Cartesian2.distance(screenFrom, screenTo); const pixels = geo.cartesian2.distance(screenFrom, screenTo);
if (pixels <= 12) { if (pixels <= 12) {
safe = true; safe = true;
break; break;
@@ -255,12 +253,12 @@ export function createRetention({ state, parts }) {
if (!safe) step = 0; if (!safe) step = 0;
} }
state._heightPending ||= !canProject || visible; state._heightPending ||= !canProject || visible;
Cesium.Cartesian3.lerp(point, target, step, point); geo.cartesian3.lerp(point, target, step, point);
moving = true; moving = true;
} }
} }
for (let i = 0; i < road.segmentDist.length; i++) for (let i = 0; i < road.segmentDist.length; i++)
road.segmentDist[i] = Cesium.Cartesian3.distance( road.segmentDist[i] = geo.cartesian3.distance(
road.waypoints[i], road.waypoints[i],
road.waypoints[i + 1], road.waypoints[i + 1],
); );
@@ -280,9 +278,9 @@ export function createRetention({ state, parts }) {
for (const road of roads) { for (const road of roads) {
if (!road.heightMoving) continue; if (!road.heightMoving) continue;
for (let i = 0; i < road.waypoints.length; i++) for (let i = 0; i < road.waypoints.length; i++)
Cesium.Cartesian3.clone(road.targetWaypoints[i], road.waypoints[i]); geo.cartesian3.clone(road.targetWaypoints[i], road.waypoints[i]);
for (let i = 0; i < road.segmentDist.length; i++) for (let i = 0; i < road.segmentDist.length; i++)
road.segmentDist[i] = Cesium.Cartesian3.distance( road.segmentDist[i] = geo.cartesian3.distance(
road.waypoints[i], road.waypoints[i],
road.waypoints[i + 1], road.waypoints[i + 1],
); );
+14 -27
View File
@@ -13,19 +13,20 @@ export function createState({ services }) {
layerState._roadModeUrlOverride = layerState._roadMode; layerState._roadModeUrlOverride = layerState._roadMode;
// ─── Module State ────────────────────────────────────────── // ─── Module State ──────────────────────────────────────────
/** @type {Cesium.Viewer|null} */ /** @type {import('../../globe/contract.js').GlobeEngine|null} */
layerState._viewer = null;
layerState._engine = null; layerState._engine = null;
layerState._native = null;
layerState._trafficScene = () =>
layerState._native?.surface ?? layerState._viewer?.scene;
/** @type {Cesium.PointPrimitiveCollection|null} */ /** @type {ReturnType<typeof import('./render/display.js').createTrafficDisplay>|null} The engine's traffic display. */
layerState._pointCollection = null; layerState._display = null;
/** @type {Array<{point:Cesium.PointPrimitive, waypoints:Cesium.Cartesian3[], segmentDist:number[], numSegments:number, segIdx:number, t:number, mps:number, direction:number, stoppedUntil:number}>} Active animated dots */ /**
* Active animated dots. `point` is the dot's look as plain data (position,
* pixelSize, color, outlineColor, outlineWidth, show, scaleByDistance,
* translucencyByDistance), which the display draws every frame.
* @type {Array<{point:object, waypoints:object[], segmentDist:number[], numSegments:number, segIdx:number, t:number, mps:number, direction:number, stoppedUntil:number}>}
*/
layerState._dots = []; layerState._dots = [];
layerState._nextDotId = 1; layerState._nextDotId = 1;
@@ -37,7 +38,7 @@ export function createState({ services }) {
recycled: 0, recycled: 0,
}; };
/** @type {Array<{coords:number[][], type:string, waypoints:Cesium.Cartesian3[], segmentDist:number[]}>} Parsed roads with pre-computed Cartesian3 waypoints */ /** @type {Array<{coords:number[][], type:string, waypoints:object[], segmentDist:number[]}>} Parsed roads with pre-computed Earth-fixed waypoints */
layerState._roads = []; layerState._roads = [];
@@ -171,27 +172,13 @@ export function createState({ services }) {
layerState._jamViz = 'density'; layerState._jamViz = 'density';
/** /**
* Congestion heat-lines are GroundPolylinePrimitive batches draped onto the * Congestion heat-lines are ground corridors draped onto the rendered 3D
* rendered 3D tiles (ClassificationType.CESIUM_3D_TILE). One * tiles, one display set per bucket (jam, slow).
* primitive per bucket so the jam batch can pulse via a single shared * @type {number} Heat-lines in the current render (stats).
* material uniform.
* @type {Cesium.GroundPolylinePrimitive|null}
*/ */
layerState._heatJamPrim = null;
/** @type {Cesium.GroundPolylinePrimitive|null} Slow-bucket heat-line batch. */
layerState._heatSlowPrim = null;
/** @type {number} Heat-lines in the current render (stats). */
layerState._heatLineCount = 0; layerState._heatLineCount = 0;
/** @type {boolean|null} GroundPolylinePrimitive.isSupported, checked once. */
layerState._heatSupported = null;
/** @type {number} Altitude of the last render, for late-flow heat rebuilds. */ /** @type {number} Altitude of the last render, for late-flow heat rebuilds. */
layerState._lastRenderAltitude = 0; layerState._lastRenderAltitude = 0;
@@ -220,7 +207,7 @@ export function createState({ services }) {
* Effective per-bucket dot colors: preset override when one applies, else * Effective per-bucket dot colors: preset override when one applies, else
* the shipped FLOW_BUCKET_COLORS. Rebuilt on style/param change only — * the shipped FLOW_BUCKET_COLORS. Rebuilt on style/param change only —
* spawn/recolor/restyle all read from here, no per-dot allocation. * spawn/recolor/restyle all read from here, no per-dot allocation.
* @type {{free:Cesium.Color, slow:Cesium.Color, jam:Cesium.Color}} * @type {{free:import('../shared/color.js').Rgba, slow:import('../shared/color.js').Rgba, jam:import('../shared/color.js').Rgba}}
*/ */
layerState._activeBucketColors = { ...FLOW_BUCKET_COLORS }; layerState._activeBucketColors = { ...FLOW_BUCKET_COLORS };
+4 -4
View File
@@ -4,7 +4,7 @@ import {
presetDotRgba, presetDotRgba,
presetDotOutline, presetDotOutline,
} from '../../data/trafficPresetStyle.js'; } from '../../data/trafficPresetStyle.js';
import * as Cesium from 'cesium'; import { Rgba } from '../shared/color.js';
import { import {
SIZE_BY_TYPE, SIZE_BY_TYPE,
STYLED_MIN_BASE_PX, STYLED_MIN_BASE_PX,
@@ -58,7 +58,7 @@ export function createStyle({ state: layerState, services, parts, source }) {
? presetDotRgba(layerState._stylePreset, bucket) ? presetDotRgba(layerState._stylePreset, bucket)
: null; : null;
layerState._activeBucketColors[bucket] = rgba layerState._activeBucketColors[bucket] = rgba
? new Cesium.Color(rgba[0] / 255, rgba[1] / 255, rgba[2] / 255, rgba[3]) ? new Rgba(rgba[0] / 255, rgba[1] / 255, rgba[2] / 255, rgba[3])
: FLOW_BUCKET_COLORS[bucket]; : FLOW_BUCKET_COLORS[bucket];
} }
} }
@@ -68,7 +68,7 @@ export function createStyle({ state: layerState, services, parts, source }) {
* it back to the shipped no-outline state). NVG's auto-gain saturates the * it back to the shipped no-outline state). NVG's auto-gain saturates the
* scene, so brightness alone cannot separate a dot from a bright road — * scene, so brightness alone cannot separate a dot from a bright road —
* the dark ring restores local contrast through every luma-mapping shader. * the dark ring restores local contrast through every luma-mapping shader.
* @param {Cesium.PointPrimitive} point - The dot primitive. * @param {object} point - The dot's look (`dot.point`).
* @param {'free'|'slow'|'jam'|null} bucket - Flow bucket (null = sim). * @param {'free'|'slow'|'jam'|null} bucket - Flow bucket (null = sim).
*/ */
@@ -78,7 +78,7 @@ export function createStyle({ state: layerState, services, parts, source }) {
? presetDotOutline(layerState._stylePreset, bucket) ? presetDotOutline(layerState._stylePreset, bucket)
: null; : null;
if (spec) { if (spec) {
point.outlineColor = new Cesium.Color( point.outlineColor = new Rgba(
spec.rgba[0] / 255, spec.rgba[0] / 255,
spec.rgba[1] / 255, spec.rgba[1] / 255,
spec.rgba[2] / 255, spec.rgba[2] / 255,
+79 -130
View File
@@ -1,40 +1,25 @@
import { phaseTiming } from '../../sources/phaseTiming.js'; import { phaseTiming } from '../../sources/phaseTiming.js';
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { DOT_HEIGHT_OFFSET, MAX_WAYPOINTS_PER_ROAD } from './policy.js'; import { DOT_HEIGHT_OFFSET, MAX_WAYPOINTS_PER_ROAD } from './policy.js';
function surfaceTilesReady(scene) { // Every surface read here goes through the globe contract (engine.surface,
if (scene.globe?.show) return scene.globe.tilesLoaded !== false; // engine.picker, engine.projector, engine.maps), the same on every engine.
let found = false;
for (let i = 0; i < (scene.primitives?.length || 0); i++) {
const primitive = scene.primitives.get(i);
if (!primitive.show || typeof primitive.tilesLoaded !== 'boolean') continue;
found = true;
if (!primitive.tilesLoaded) return false;
}
return found;
}
const renderedFrames = new WeakMap(); const renderedFrames = new WeakMap();
/** Observe completed frames only while an enabled traffic consumer owns them. */ /** Observe completed frames only while an enabled traffic consumer owns them. */
export function observeTrafficSurface(scene) { export function observeTrafficSurface(engine) {
if (!scene.postRender?.addEventListener || !scene.camera?.viewMatrix) if (!engine?.frames?.postRender?.addEventListener) return () => {};
return () => {}; let frame = renderedFrames.get(engine);
let frame = renderedFrames.get(scene);
if (!frame) { if (!frame) {
frame = { frame = {
view: new Cesium.Matrix4(), viewProjection: geo.matrix4.create(),
projection: new Cesium.Matrix4(),
owners: 0, owners: 0,
}; };
renderedFrames.set(scene, frame); renderedFrames.set(engine, frame);
frame.remove = scene.postRender.addEventListener(() => { frame.remove = engine.frames.postRender.addEventListener(() => {
Cesium.Matrix4.clone(scene.camera.viewMatrix, frame.view); engine.projector.viewProjection(frame.viewProjection);
Cesium.Matrix4.clone( frame.surface = trafficSurfaceKey(engine);
scene.camera.frustum.projectionMatrix, frame.ready = engine.surface.tilesLoaded();
frame.projection,
);
frame.surface = trafficSurfaceKey(scene);
frame.ready = surfaceTilesReady(scene);
}); });
} }
frame.owners++; frame.owners++;
@@ -44,30 +29,27 @@ export function observeTrafficSurface(scene) {
released = true; released = true;
if (--frame.owners === 0) { if (--frame.owners === 0) {
frame.remove(); frame.remove();
renderedFrames.delete(scene); renderedFrames.delete(engine);
} }
}; };
} }
function currentRenderedFrame(scene) { const scratchViewProjection = geo.matrix4.create();
if (scene.engine) return { ready: scene.ready }; function currentRenderedFrame(engine) {
if (!scene.postRender?.addEventListener || !scene.camera?.viewMatrix) const frame = renderedFrames.get(engine);
return { ready: surfaceTilesReady(scene) };
const frame = renderedFrames.get(scene);
return frame && return frame &&
frame.surface === trafficSurfaceKey(scene) && frame.surface === trafficSurfaceKey(engine) &&
Cesium.Matrix4.equals(frame.view, scene.camera.viewMatrix) && geo.matrix4.equals(
Cesium.Matrix4.equals( frame.viewProjection,
frame.projection, engine.projector.viewProjection(scratchViewProjection),
scene.camera.frustum.projectionMatrix,
) )
? frame ? frame
: null; : null;
} }
/** Only a completed render of this camera can certify cached surface heights. */ /** Only a completed render of this camera can certify cached surface heights. */
export function trafficSurfaceReady(scene) { export function trafficSurfaceReady(engine) {
return currentRenderedFrame(scene)?.ready === true; return currentRenderedFrame(engine)?.ready === true;
} }
/** Preserve bends and insert height samples at most 150 m apart, splitting long roads. */ /** Preserve bends and insert height samples at most 150 m apart, splitting long roads. */
@@ -141,55 +123,54 @@ const validHeight = (height) =>
* Reuse the rendered depth buffer instead of drawing a new pick frustum per * Reuse the rendered depth buffer instead of drawing a new pick frustum per
* station. Iteration corrects the projection's height estimate. A depth hit * station. Iteration corrects the projection's height estimate. A depth hit
* is accepted only at this road coordinate (2 m), never on a foreground roof * is accepted only at this road coordinate (2 m), never on a foreground roof
* or hillside projected onto a different geographic point. Translucent traffic * or hillside projected onto a different geographic point. Traffic itself is
* is absent from Cesium's default pickPosition depth pass. * translucent and absent from the depth that pickPosition reads.
*/ */
export function renderedRoadHeight(scene, lon, lat, estimate) { export function renderedRoadHeight(engine, lon, lat, estimate) {
const size = engine.host.size();
if ( if (
!scene.pickPositionSupported || !engine.picker.pickPositionSupported ||
scene.pickTranslucentDepth || !currentRenderedFrame(engine) ||
!currentRenderedFrame(scene) || !size.width
!scene.canvas?.clientWidth ||
!scene.frameState
) )
return undefined; return undefined;
let height = validHeight(estimate) ? estimate : 0; let height = validHeight(estimate) ? estimate : 0;
const point = new Cesium.Cartesian3(); const point = geo.cartesian3.create();
const screen = new Cesium.Cartesian2(); const screen = geo.cartesian2.create();
for (let attempt = 0; attempt < 4; attempt++) { for (let attempt = 0; attempt < 4; attempt++) {
Cesium.Cartesian3.fromDegrees(lon, lat, height, undefined, point); geo.cartesian3.fromDegrees(lon, lat, height, undefined, point);
if ( if (
!Cesium.SceneTransforms.worldToWindowCoordinates(scene, point, screen) || !engine.projector.worldToWindow(point, screen) ||
screen.x < 0 || screen.x < 0 ||
screen.y < 0 || screen.y < 0 ||
screen.x >= scene.canvas.clientWidth || screen.x >= size.width ||
screen.y >= scene.canvas.clientHeight screen.y >= size.height
) )
return undefined; return undefined;
let hit; let hit;
try { try {
hit = scene.pickPosition(screen); hit = engine.picker.pickPosition(screen);
} catch { } catch {
return undefined; return undefined;
} }
if (!hit) return undefined; if (!hit) return undefined;
const location = Cesium.Cartographic.fromCartesian(hit); const location = geo.cartographic.fromCartesian(hit);
if (!location || !validHeight(location.height)) return undefined; if (!location || !validHeight(location.height)) return undefined;
const dx = const dx =
(Cesium.Math.toDegrees(location.longitude) - lon) * (geo.math.toDegrees(location.longitude) - lon) *
Math.cos((lat * Math.PI) / 180) * Math.cos((lat * Math.PI) / 180) *
111320; 111320;
const dy = (Cesium.Math.toDegrees(location.latitude) - lat) * 111320; const dy = (geo.math.toDegrees(location.latitude) - lat) * 111320;
if (Math.hypot(dx, dy) <= 2) return location.height; if (Math.hypot(dx, dy) <= 2) return location.height;
height = location.height; height = location.height;
} }
return undefined; return undefined;
} }
// Per-scene, coordinate-keyed LRU. Never writes raw samples into shared ground floors. // Per-engine, coordinate-keyed LRU. Never writes raw samples into shared ground floors.
const sceneCaches = new WeakMap(); const heightCaches = new WeakMap();
// Stable ids for the objects heights are sampled from (tilesets, terrain). // Stable ids for the map sources heights are sampled from.
const surfaceIds = new WeakMap(); const surfaceIds = new WeakMap();
let nextSurfaceId = 1; let nextSurfaceId = 1;
const surfaceId = (object) => { const surfaceId = (object) => {
@@ -199,45 +180,26 @@ const surfaceId = (object) => {
}; };
/** /**
* Name what heights are sampled against: the terrain provider when the globe * Name what heights are sampled against: the photoreal tiles shown, or the
* is shown, otherwise the set of visible 3D tilesets. A map-provider switch * globe under its base imagery (the contract does not name the terrain; a map
* stack switch replaces the base imagery with it). A map-provider switch
* changes it and invalidates every cached height. * changes it and invalidates every cached height.
*/ */
const surfaceKeys = new WeakMap(); const surfaceKeys = new WeakMap();
export function trafficSurfaceKey(scene) { export function trafficSurfaceKey(engine) {
if (scene.engine) return scene.surfaceKey; let state = surfaceKeys.get(engine);
let state = surfaceKeys.get(scene);
if (!state) { if (!state) {
state = { globe: null, terrain: null, tiles: [], key: '' }; state = { tiles: false, source: null, key: '' };
surfaceKeys.set(scene, state); surfaceKeys.set(engine, state);
} }
const globe = Boolean(scene.globe?.show); const tiles = engine.maps.photorealActive();
const terrain = globe ? scene.globe.terrainProvider : null; const current = engine.maps.current();
let changed = state.globe !== globe || state.terrain !== terrain; const source = tiles ? current.photoreal : current.baseImagery;
let count = 0; if (state.tiles !== tiles || state.source !== source || !state.key) {
if (!globe) { state.tiles = tiles;
for (let i = 0; i < (scene.primitives?.length || 0); i++) { state.source = source;
const primitive = scene.primitives.get(i);
if (!primitive?.show || typeof primitive.tilesLoaded !== 'boolean')
continue;
if (state.tiles[count] !== primitive) {
state.tiles[count] = primitive;
changed = true;
}
count++;
}
}
if (count !== state.tiles.length) {
state.tiles.length = count;
changed = true;
}
if (changed) {
state.globe = globe;
state.terrain = terrain;
// Allocate only when the actual map surface changes, never per frame. // Allocate only when the actual map surface changes, never per frame.
state.key = globe state.key = `${tiles ? 'tiles' : 'globe'}:${surfaceId(source)}`;
? `globe:${surfaceId(terrain)}`
: `tiles:${state.tiles.map(surfaceId).join(',')}`;
} }
return state.key; return state.key;
} }
@@ -265,7 +227,7 @@ const nextSlice = (hasSurface) =>
/** Prepare only admitted roads in bounded slices; locally missing surfaces defer that road. */ /** Prepare only admitted roads in bounded slices; locally missing surfaces defer that road. */
export async function prepareRoadSurfaces( export async function prepareRoadSurfaces(
roads, roads,
scene, engine,
ground, ground,
excluded, excluded,
signal, signal,
@@ -274,21 +236,15 @@ export async function prepareRoadSurfaces(
// Cached heights are only as good as the surface and detail they came // Cached heights are only as good as the surface and detail they came
// from: a provider switch drops them all, and a point now seen from a // from: a provider switch drops them all, and a point now seen from a
// closer band than its sample is re-sampled against the finer mesh. // closer band than its sample is re-sampled against the finer mesh.
const surface = trafficSurfaceKey(scene); const surface = trafficSurfaceKey(engine);
let cache = sceneCaches.get(scene); let cache = heightCaches.get(engine);
if (!cache || cache.surface !== surface) { if (!cache || cache.surface !== surface) {
cache = { surface, heights: new Map() }; cache = { surface, heights: new Map() };
sceneCaches.set(scene, cache); heightCaches.set(engine, cache);
} }
const cameraCarto = const cameraCarto = engine.camera.pose().cartographic;
scene.engine?.camera.pose().cartographic ?? const cameraLon = cameraCarto ? geo.math.toDegrees(cameraCarto.longitude) : 0;
scene.camera?.positionCartographic; const cameraLat = cameraCarto ? geo.math.toDegrees(cameraCarto.latitude) : 0;
const cameraLon = cameraCarto
? Cesium.Math.toDegrees(cameraCarto.longitude)
: 0;
const cameraLat = cameraCarto
? Cesium.Math.toDegrees(cameraCarto.latitude)
: 0;
const cameraHeight = cameraCarto ? cameraCarto.height : NaN; const cameraHeight = cameraCarto ? cameraCarto.height : NaN;
const start = performance.now(); const start = performance.now();
const metrics = { const metrics = {
@@ -301,7 +257,7 @@ export async function prepareRoadSurfaces(
maxSliceMs: 0, maxSliceMs: 0,
}; };
let sliceStart = performance.now(); let sliceStart = performance.now();
const carto = new Cesium.Cartographic(); const carto = geo.cartographic.create();
const ready = [], const ready = [],
pending = []; pending = [];
for (const road of roads) { for (const road of roads) {
@@ -314,7 +270,7 @@ export async function prepareRoadSurfaces(
const [lon, lat] = road.coords[i]; const [lon, lat] = road.coords[i];
const key = `${lon.toFixed(6)},${lat.toFixed(6)}`; const key = `${lon.toFixed(6)},${lat.toFixed(6)}`;
const cached = cache.heights.get(key); const cached = cache.heights.get(key);
const settled = trafficSurfaceReady(scene); const settled = trafficSurfaceReady(engine);
const band = detailBand(cameraLon, cameraLat, cameraHeight, lon, lat); const band = detailBand(cameraLon, cameraLat, cameraHeight, lon, lat);
let height = let height =
cached && cached &&
@@ -330,38 +286,31 @@ export async function prepareRoadSurfaces(
cache.heights.set(key, cached); cache.heights.set(key, cached);
} else { } else {
if (!settled) settledRoad = false; if (!settled) settledRoad = false;
carto.longitude = Cesium.Math.toRadians(lon); carto.longitude = geo.math.toRadians(lon);
carto.latitude = Cesium.Math.toRadians(lat); carto.latitude = geo.math.toRadians(lat);
carto.height = 0; carto.height = 0;
const floor = ground?.cachedGroundFloor?.(lat, lon); const floor = ground?.cachedGroundFloor?.(lat, lon);
let sampled; let sampled;
// sampleHeight reads the locally rendered mesh, not unresolved/offscreen // The globe answers from its terrain. Photoreal tiles answer from the
// tiles. Global tilesLoaded can stay false while this street is usable. // rendered depth first, then from sampleHeight, which reads the
if (scene.engine) sampled = scene.sampleHeight(carto); // locally rendered mesh, not unresolved/offscreen tiles: global
else if (scene.globe?.show) sampled = scene.globe.getHeight?.(carto); // tilesLoaded can stay false while this street is usable.
const photoreal = engine.maps.photorealActive();
if (!photoreal) sampled = engine.surface.terrainHeight(carto);
else { else {
const estimate = Cesium.Cartographic.fromCartesian( const estimate = geo.cartographic.fromCartesian(
road.waypoints[i], road.waypoints[i],
)?.height; )?.height;
sampled = renderedRoadHeight(scene, lon, lat, estimate); sampled = renderedRoadHeight(engine, lon, lat, estimate);
if (validHeight(sampled)) metrics.depthHits++; if (validHeight(sampled)) metrics.depthHits++;
} }
if ( if (photoreal && !validHeight(sampled) && engine.surface.supported) {
!scene.engine &&
!scene.globe?.show &&
!validHeight(sampled) &&
scene.sampleHeightSupported
) {
const sampleStart = performance.now(); const sampleStart = performance.now();
metrics.sampleCount++; metrics.sampleCount++;
const shown = (excluded || []).filter((p) => p?.show);
for (const primitive of shown) primitive.show = false;
try { try {
sampled = scene.sampleHeight(carto, excluded); sampled = engine.surface.sampleHeight(carto, excluded);
} catch { } catch {
/* local mesh missing */ /* local mesh missing */
} finally {
for (const primitive of shown) primitive.show = true;
} }
metrics.sampleMs += performance.now() - sampleStart; metrics.sampleMs += performance.now() - sampleStart;
} }
@@ -408,7 +357,7 @@ export async function prepareRoadSurfaces(
const height = heights[a] + (heights[b] - heights[a]) * t; const height = heights[a] + (heights[b] - heights[a]) * t;
const [lon, lat] = road.coords[i]; const [lon, lat] = road.coords[i];
const floor = ground?.cachedGroundFloor?.(lat, lon); const floor = ground?.cachedGroundFloor?.(lat, lon);
Cesium.Cartesian3.fromDegrees( geo.cartesian3.fromDegrees(
lon, lon,
lat, lat,
(validHeight(floor) ? Math.max(height, floor) : height) + (validHeight(floor) ? Math.max(height, floor) : height) +
@@ -418,7 +367,7 @@ export async function prepareRoadSurfaces(
); );
} }
for (let i = 0; i < road.segmentDist.length; i++) for (let i = 0; i < road.segmentDist.length; i++)
road.segmentDist[i] = Cesium.Cartesian3.distance( road.segmentDist[i] = geo.cartesian3.distance(
road.waypoints[i], road.waypoints[i],
road.waypoints[i + 1], road.waypoints[i + 1],
); );
+167 -175
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as C from 'cesium'; import * as geo from '../../geo/index.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { import {
detailBand, detailBand,
renderedRoadHeight, renderedRoadHeight,
@@ -13,9 +14,26 @@ import {
} from './surface.js'; } from './surface.js';
const road = (coords) => ({ const road = (coords) => ({
coords, coords,
waypoints: coords.map(() => new C.Cartesian3()), waypoints: coords.map(() => geo.cartesian3.create()),
segmentDist: coords.slice(1).map(() => 0), segmentDist: coords.slice(1).map(() => 0),
}); });
const heightOf = (point) => geo.cartographic.fromCartesian(point).height;
const toDegrees = geo.math.toDegrees;
/**
* A fake engine over photoreal tiles (or the globe), with no rendered depth
* unless a test scripts one.
*/
function surfaceEngine({ photoreal = true } = {}) {
const engine = createFakeEngine();
if (photoreal) engine.fake.maps.photoreal = { kind: 'photoreal' };
else engine.fake.maps.imagery = { kind: 'osm' };
engine.fake.script('pickPosition', undefined);
return engine;
}
/** The camera straight down over (0, 0) at `height` metres. */
const lookFrom = (engine, height) =>
engine.fake.setPose({ longitude: 0, latitude: 0, height });
test('surface points preserve bends, bound spacing, and split rather than decimate long roads', () => { test('surface points preserve bends, bound spacing, and split rather than decimate long roads', () => {
const chunks = roadSurfaceChunks([ const chunks = roadSurfaceChunks([
[0, 0], [0, 0],
@@ -46,7 +64,7 @@ test('surface points preserve bends, bound spacing, and split rather than decima
150.01, 150.01,
); );
}); });
test('per-vertex mesh heights follow terrain, reject invalid samples, and leave shared cache unmodified', async () => { test('per-vertex mesh heights follow terrain, reject invalid samples, and leave shared cache unmodified', async (t) => {
const heights = [100, 200, NaN, 9001, -9001]; const heights = [100, 200, NaN, 9001, -9001];
let samples = 0; let samples = 0;
const r = road([ const r = road([
@@ -56,125 +74,134 @@ test('per-vertex mesh heights follow terrain, reject invalid samples, and leave
[0.003, 0], [0.003, 0],
[0.004, 0], [0.004, 0],
]); ]);
const tileset = { show: true, tilesLoaded: true }; const engine = surfaceEngine();
const scene = { let loaded = true;
globe: { show: false }, engine.fake.script('tilesLoaded', () => loaded);
primitives: { length: 1, get: () => tileset }, engine.fake.script('sampleHeight', () => heights[samples++]);
sampleHeightSupported: true, t.after(observeTrafficSurface(engine));
sampleHeight: () => heights[samples++], engine.fake.frame();
};
const ground = { const ground = {
cachedGroundFloor: () => 50, cachedGroundFloor: () => 50,
reportMeshFloorCell: () => assert.fail('raw sample poisoned shared floor'), reportMeshFloorCell: () => assert.fail('raw sample poisoned shared floor'),
}; };
let metrics; let metrics;
const prepared = await prepareRoadSurfaces([r], scene, ground, [], null, { const prepared = await prepareRoadSurfaces([r], engine, ground, [], null, {
onMetrics: (value) => (metrics = value), onMetrics: (value) => (metrics = value),
}); });
assert.equal(metrics.sampleCount, 5); assert.equal(metrics.sampleCount, 5);
assert.equal(prepared.pending.length, 1); assert.equal(prepared.pending.length, 1);
assert.equal(samples, 5); assert.equal(samples, 5);
const actual = r.waypoints.map((p) => const actual = r.waypoints.map((p) => Math.round(heightOf(p)));
Math.round(C.Cartographic.fromCartesian(p).height),
);
assert.deepEqual(actual, [103, 203, 53, 53, 53]); assert.deepEqual(actual, [103, 203, 53, 53, 53]);
tileset.tilesLoaded = false; loaded = false;
assert.equal(trafficSurfaceReady(scene), false); engine.fake.frame();
scene.sampleHeight = () => 75; assert.equal(trafficSurfaceReady(engine), false);
const resumed = await prepareRoadSurfaces([r], scene, ground, []); engine.fake.script('sampleHeight', () => 75);
const resumed = await prepareRoadSurfaces([r], engine, ground, []);
assert.equal( assert.equal(
resumed.ready.length, resumed.ready.length,
1, 1,
'other loading tiles do not block a local mesh', 'other loading tiles do not block a local mesh',
); );
let picks = 0; let picks = 0;
scene.sampleHeight = () => { engine.fake.script('sampleHeight', () => {
picks++; picks++;
return 75; return 75;
}; });
await prepareRoadSurfaces([r], scene, ground, []); await prepareRoadSurfaces([r], engine, ground, []);
assert.equal(picks, 0, 'validated coordinate heights survive cache hits'); assert.equal(picks, 0, 'validated coordinate heights survive cache hits');
const abort = new AbortController(); const abort = new AbortController();
abort.abort(); abort.abort();
await assert.rejects( await assert.rejects(
prepareRoadSurfaces([r], scene, ground, [], abort.signal), prepareRoadSurfaces([r], engine, ground, [], abort.signal),
{ name: 'AbortError' }, { name: 'AbortError' },
); );
}); });
test('the vehicle sets are excluded from mesh samples', async () => {
const engine = surfaceEngine();
const excluded = [{ id: 'traffic-fill' }];
const seen = [];
engine.fake.script('sampleHeight', (_position, exclude) => {
seen.push(exclude);
return 10;
});
await prepareRoadSurfaces(
[
road([
[0, 0],
[0.001, 0],
]),
],
engine,
null,
excluded,
);
assert.deepEqual(seen, [excluded, excluded]);
});
test('visible-globe roads floor each vertex without mesh picks', async () => { test('visible-globe roads floor each vertex without mesh picks', async () => {
const r = road([ const r = road([
[0, 0], [0, 0],
[0.001, 0], [0.001, 0],
]); ]);
let calls = 0; let calls = 0;
await prepareRoadSurfaces( const engine = surfaceEngine({ photoreal: false });
[r], engine.fake.script('terrainHeight', () => ++calls * 100);
{ engine.fake.script('sampleHeight', () => assert.fail('mesh sampled'));
globe: { show: true, tilesLoaded: true, getHeight: () => ++calls * 100 }, await prepareRoadSurfaces([r], engine, { cachedGroundFloor: () => 20 }, []);
sampleHeight: () => assert.fail('mesh sampled'),
},
{ cachedGroundFloor: () => 20 },
[],
);
assert.equal(calls, 2); assert.equal(calls, 2);
assert.deepEqual( assert.deepEqual(
r.waypoints.map((p) => Math.round(C.Cartographic.fromCartesian(p).height)), r.waypoints.map((p) => Math.round(heightOf(p))),
[103, 203], [103, 203],
); );
}); });
test('cached heights follow detail and provider; same-detail revisits sample nothing', async () => { test('cached heights follow detail and provider; same-detail revisits sample nothing', async (t) => {
const r = road([ const r = road([
[0, 0], [0, 0],
[0.001, 0], [0.001, 0],
]); ]);
const tileset = { show: true, tilesLoaded: true };
let mesh = 40, let mesh = 40,
samples = 0; samples = 0;
const camera = (height) => ({ const engine = surfaceEngine();
positionCartographic: { longitude: 0, latitude: 0, height }, engine.fake.script('sampleHeight', () => (samples++, mesh));
}); t.after(observeTrafficSurface(engine));
const scene = { const camera = (height) => {
globe: { show: false }, lookFrom(engine, height);
primitives: { length: 1, get: () => tileset }, engine.fake.frame();
sampleHeightSupported: true,
sampleHeight: () => (samples++, mesh),
camera: camera(3000),
}; };
const heightAt = () => const heightAt = () => Math.round(heightOf(r.waypoints[0]));
Math.round(C.Cartographic.fromCartesian(r.waypoints[0]).height);
// Settled but coarse: sampled from 3 km. // Settled but coarse: sampled from 3 km.
await prepareRoadSurfaces([r], scene, null, []); camera(3000);
await prepareRoadSurfaces([r], engine, null, []);
assert.equal(samples, 2); assert.equal(samples, 2);
assert.equal(heightAt(), 43); assert.equal(heightAt(), 43);
// Same detail band on a revisit: zero samples. // Same detail band on a revisit: zero samples.
await prepareRoadSurfaces([r], scene, null, []); await prepareRoadSurfaces([r], engine, null, []);
assert.equal(samples, 2); assert.equal(samples, 2);
// Zooming out never re-samples a finer height either. // Zooming out never re-samples a finer height either.
scene.camera = camera(6000); camera(6000);
await prepareRoadSurfaces([r], scene, null, []); await prepareRoadSurfaces([r], engine, null, []);
assert.equal(samples, 2); assert.equal(samples, 2);
// Closer view, finer mesh: re-sampled and the waypoint follows it. // Closer view, finer mesh: re-sampled and the waypoint follows it.
mesh = 47; mesh = 47;
scene.camera = camera(250); camera(250);
await prepareRoadSurfaces([r], scene, null, []); await prepareRoadSurfaces([r], engine, null, []);
assert.equal(samples, 4); assert.equal(samples, 4);
assert.equal(heightAt(), 50); assert.equal(heightAt(), 50);
await prepareRoadSurfaces([r], scene, null, []); await prepareRoadSurfaces([r], engine, null, []);
assert.equal(samples, 4, 'the finer sample is now the cached one'); assert.equal(samples, 4, 'the finer sample is now the cached one');
// A different photoreal tileset (map provider switch) invalidates all, // Different photoreal tiles (map provider switch) invalidate all, even
// even from a coarser view that would otherwise reuse the finer sample. // from a coarser view that would otherwise reuse the finer sample.
const other = { show: true, tilesLoaded: true }; engine.fake.maps.photoreal = { kind: 'photoreal' };
scene.primitives.get = () => other; camera(3000);
scene.camera = camera(3000);
mesh = 30; mesh = 30;
await prepareRoadSurfaces([r], scene, null, []); await prepareRoadSurfaces([r], engine, null, []);
assert.equal(samples, 6); assert.equal(samples, 6);
assert.equal(heightAt(), 33); assert.equal(heightAt(), 33);
// A finer sample that fails keeps the coarser measured height. // A finer sample that fails keeps the coarser measured height.
scene.camera = camera(250); camera(250);
mesh = NaN; mesh = NaN;
const kept = await prepareRoadSurfaces([r], scene, null, []); const kept = await prepareRoadSurfaces([r], engine, null, []);
assert.equal(samples, 8); assert.equal(samples, 8);
assert.equal(kept.ready.length, 1); assert.equal(kept.ready.length, 1);
assert.equal(heightAt(), 33); assert.equal(heightAt(), 33);
@@ -189,27 +216,18 @@ test('detail bands double with distance and ignore a missing camera', () => {
}); });
test('a camera jump cannot certify old rendered depth or cache coarse heights as settled', async () => { test('a camera jump cannot certify old rendered depth or cache coarse heights as settled', async () => {
const tileset = { show: true, tilesLoaded: true };
let height = 20, let height = 20,
samples = 0; samples = 0;
const scene = { const engine = surfaceEngine();
postRender: new C.Event(), engine.fake.script('sampleHeight', () => (samples++, height));
camera: { lookFrom(engine, 3000);
viewMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY), assert.equal(trafficSurfaceReady(engine), false);
frustum: { projectionMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY) }, const removeSurfaceObserver = observeTrafficSurface(engine);
}, engine.fake.frame();
globe: { show: false }, assert.equal(trafficSurfaceReady(engine), true);
primitives: { length: 1, get: () => tileset }, lookFrom(engine, 3100);
sampleHeightSupported: true,
sampleHeight: () => (samples++, height),
};
assert.equal(trafficSurfaceReady(scene), false);
const removeSurfaceObserver = observeTrafficSurface(scene);
scene.postRender.raiseEvent();
assert.equal(trafficSurfaceReady(scene), true);
scene.camera.viewMatrix[12] = 100;
assert.equal( assert.equal(
trafficSurfaceReady(scene), trafficSurfaceReady(engine),
false, false,
'old camera readiness is stale', 'old camera readiness is stale',
); );
@@ -217,18 +235,16 @@ test('a camera jump cannot certify old rendered depth or cache coarse heights as
[0, 0], [0, 0],
[0.001, 0], [0.001, 0],
]); ]);
await prepareRoadSurfaces([r], scene, null, []); await prepareRoadSurfaces([r], engine, null, []);
assert.equal(r.surfaceSettled, false); assert.equal(r.surfaceSettled, false);
height = 80; height = 80;
scene.postRender.raiseEvent(); engine.fake.frame();
await prepareRoadSurfaces([r], scene, null, []); await prepareRoadSurfaces([r], engine, null, []);
assert.equal(samples, 4, 'the first fresh render revalidates coarse heights'); assert.equal(samples, 4, 'the first fresh render revalidates coarse heights');
assert.equal(r.surfaceSettled, true); assert.equal(r.surfaceSettled, true);
assert.ok( assert.ok(Math.abs(heightOf(r.waypoints[0]) - 83) < 0.01);
Math.abs(C.Cartographic.fromCartesian(r.waypoints[0]).height - 83) < 0.01,
);
height = 100; height = 100;
await prepareRoadSurfaces([r, r], scene, null, [], null, { await prepareRoadSurfaces([r, r], engine, null, [], null, {
revalidate: true, revalidate: true,
}); });
assert.equal( assert.equal(
@@ -237,12 +253,10 @@ test('a camera jump cannot certify old rendered depth or cache coarse heights as
'the bounded refinement replaces initially settled coarse samples too', 'the bounded refinement replaces initially settled coarse samples too',
); );
removeSurfaceObserver(); removeSurfaceObserver();
assert.ok( assert.ok(Math.abs(heightOf(r.waypoints[0]) - 103) < 0.01);
Math.abs(C.Cartographic.fromCartesian(r.waypoints[0]).height - 103) < 0.01, await prepareRoadSurfaces([r], engine, null, []);
);
await prepareRoadSurfaces([r], scene, null, []);
assert.equal(samples, 6, 'ordinary same-band revisits still reuse the cache'); assert.equal(samples, 6, 'ordinary same-band revisits still reuse the cache');
await prepareRoadSurfaces([r], scene, null, [], null, { revalidate: true }); await prepareRoadSurfaces([r], engine, null, [], null, { revalidate: true });
assert.equal( assert.equal(
samples, samples,
6, 6,
@@ -261,103 +275,78 @@ test('height stations preserve bends while bounding sample spacing along the roa
assert.ok(distances[indices[i]] - distances[indices[i - 1]] <= 150.001); assert.ok(distances[indices[i]] - distances[indices[i - 1]] <= 150.001);
const r = road(coords); const r = road(coords);
let picks = 0; let picks = 0;
await prepareRoadSurfaces( const engine = surfaceEngine({ photoreal: false });
[r], engine.fake.script('terrainHeight', (c) => {
{ picks++;
globe: { return toDegrees(c.longitude) * 1000;
show: true, });
tilesLoaded: true, await prepareRoadSurfaces([r], engine, null, []);
getHeight: (c) => {
picks++;
return C.Math.toDegrees(c.longitude) * 1000;
},
},
},
null,
[],
);
assert.equal(picks, indices.length); assert.equal(picks, indices.length);
for (let i = 0; i < coords.length; i++) { for (let i = 0; i < coords.length; i++) {
const c = C.Cartographic.fromCartesian(r.waypoints[i]); const c = geo.cartographic.fromCartesian(r.waypoints[i]);
assert.ok(Math.abs(C.Math.toDegrees(c.longitude) - coords[i][0]) < 1e-9); assert.ok(Math.abs(toDegrees(c.longitude) - coords[i][0]) < 1e-9);
assert.ok(Math.abs(C.Math.toDegrees(c.latitude) - coords[i][1]) < 1e-9); assert.ok(Math.abs(toDegrees(c.latitude) - coords[i][1]) < 1e-9);
assert.ok(Math.abs(c.height - (coords[i][0] * 1000 + 3)) < 0.01); assert.ok(Math.abs(c.height - (coords[i][0] * 1000 + 3)) < 0.01);
} }
}); });
test('rendered road depth is geographically checked, bounded, and avoids mesh redraws', async (t) => { test('rendered road depth is geographically checked, bounded, and avoids mesh redraws', async (t) => {
t.mock.method( const engine = surfaceEngine();
C.SceneTransforms, engine.fake.script('worldToWindow', () => ({ x: 100, y: 100 }));
'worldToWindowCoordinates', engine.fake.script('pickPosition', () =>
(scene, point, out) => { geo.cartesian3.fromDegrees(-97, 30, 100),
out.x = 100;
out.y = 100;
return out;
},
); );
const scene = { engine.fake.script('sampleHeight', () =>
globe: { show: false }, assert.fail('valid depth must not draw another pick frustum'),
frameState: {}, );
canvas: { clientWidth: 800, clientHeight: 600 }, assert.equal(
pickPositionSupported: true, renderedRoadHeight(engine, -97, 30, 0),
sampleHeightSupported: true, undefined,
pickPosition: () => C.Cartesian3.fromDegrees(-97, 30, 100), 'no rendered frame of this camera yet',
sampleHeight: () => );
assert.fail('valid depth must not draw another pick frustum'), t.after(observeTrafficSurface(engine));
}; engine.fake.frame();
assert.ok(Math.abs(renderedRoadHeight(scene, -97, 30, 0) - 100) < 0.001); assert.ok(Math.abs(renderedRoadHeight(engine, -97, 30, 0) - 100) < 0.001);
const r = road([ const r = road([
[-97, 30], [-97, 30],
[-97, 30.000005], [-97, 30.000005],
]); ]);
r.waypoints = r.coords.map(([lon, lat]) => r.waypoints = r.coords.map(([lon, lat]) =>
C.Cartesian3.fromDegrees(lon, lat, 50), geo.cartesian3.fromDegrees(lon, lat, 50),
); );
const result = await prepareRoadSurfaces([r], scene, null, []); const result = await prepareRoadSurfaces([r], engine, null, []);
assert.equal(result.metrics.depthHits, 2); assert.equal(result.metrics.depthHits, 2);
assert.equal(result.metrics.sampleCount, 0); assert.equal(result.metrics.sampleCount, 0);
let calls = 0; let calls = 0;
scene.pickPosition = () => { engine.fake.script('pickPosition', () => {
calls++; calls++;
return C.Cartesian3.fromDegrees(-97.001, 30, 150); return geo.cartesian3.fromDegrees(-97.001, 30, 150);
}; });
assert.equal( assert.equal(
renderedRoadHeight(scene, -97, 30, 0), renderedRoadHeight(engine, -97, 30, 0),
undefined, undefined,
'foreground roof is not the road coordinate', 'foreground roof is not the road coordinate',
); );
assert.equal(calls, 4); assert.equal(calls, 4);
scene.pickPosition = () => C.Cartesian3.fromDegrees(-97, 30, 10000); engine.fake.script('pickPosition', () =>
assert.equal(renderedRoadHeight(scene, -97, 30, 0), undefined); geo.cartesian3.fromDegrees(-97, 30, 10000),
scene.pickPosition = () => { );
assert.equal(renderedRoadHeight(engine, -97, 30, 0), undefined);
engine.fake.script('pickPosition', () => {
throw new Error('depth unavailable'); throw new Error('depth unavailable');
}; });
assert.equal(renderedRoadHeight(scene, -97, 30, 0), undefined); assert.equal(renderedRoadHeight(engine, -97, 30, 0), undefined);
scene.pickTranslucentDepth = true; engine.fake.script('pickPosition', () =>
assert.equal(renderedRoadHeight(scene, -97, 30, 0), undefined); geo.cartesian3.fromDegrees(-97, 30, 100),
);
engine.picker.pickPositionSupported = false;
assert.equal(renderedRoadHeight(engine, -97, 30, 0), undefined);
}); });
test('surface keys reuse identity without building arrays or strings on unchanged frames', (t) => { test('surface keys reuse identity without building arrays or strings on unchanged frames', () => {
const tiles = [ const engine = surfaceEngine();
{ show: true, tilesLoaded: true }, const first = trafficSurfaceKey(engine);
{ show: true, tilesLoaded: false }, const remove = observeTrafficSurface(engine);
];
const scene = {
globe: { show: false, terrainProvider: {} },
primitives: {
get: (i) => tiles[i],
get length() {
return tiles.length;
},
},
camera: {
viewMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY),
frustum: { projectionMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY) },
},
postRender: new C.Event(),
};
const first = trafficSurfaceKey(scene);
const remove = observeTrafficSurface(scene);
const originalMap = Array.prototype.map; const originalMap = Array.prototype.map;
const originalJoin = Array.prototype.join; const originalJoin = Array.prototype.join;
let maps = 0, let maps = 0,
@@ -372,8 +361,8 @@ test('surface keys reuse identity without building arrays or strings on unchange
}; };
try { try {
for (let i = 0; i < 1000; i++) { for (let i = 0; i < 1000; i++) {
scene.postRender.raiseEvent(); engine.fake.emit('frames.postRender', i);
assert.equal(trafficSurfaceKey(scene), first); assert.equal(trafficSurfaceKey(engine), first);
} }
} finally { } finally {
Array.prototype.map = originalMap; Array.prototype.map = originalMap;
@@ -381,24 +370,27 @@ test('surface keys reuse identity without building arrays or strings on unchange
} }
assert.equal(maps, 0); assert.equal(maps, 0);
assert.equal(joins, 0); assert.equal(joins, 0);
tiles[1].show = false; engine.fake.maps.photoreal = { kind: 'photoreal' };
const hidden = trafficSurfaceKey(scene);
assert.notEqual(hidden, first);
tiles[1] = { show: true, tilesLoaded: true };
assert.notEqual( assert.notEqual(
trafficSurfaceKey(scene), trafficSurfaceKey(engine),
first, first,
'same-count tileset replacement is a new surface', 'replacement photoreal tiles are a new surface',
);
engine.fake.maps.photoreal = null;
engine.fake.maps.imagery = { kind: 'osm' };
const globe = trafficSurfaceKey(engine);
assert.notEqual(globe, first, 'the globe is another surface');
engine.fake.maps.imagery = { kind: 'esri-imagery' };
assert.notEqual(
trafficSurfaceKey(engine),
globe,
'a map stack switch on the globe is a new surface',
); );
scene.globe.show = true;
const terrain = trafficSurfaceKey(scene);
scene.globe.terrainProvider = {};
assert.notEqual(trafficSurfaceKey(scene), terrain);
remove(); remove();
remove(); remove();
assert.equal(scene.postRender.numberOfListeners, 0); assert.equal(engine.frames.postRender.numberOfListeners, 0);
assert.equal( assert.equal(
trafficSurfaceReady(scene), trafficSurfaceReady(engine),
false, false,
'readiness cannot recreate a subscription', 'readiness cannot recreate a subscription',
); );
+8 -8
View File
@@ -1,4 +1,4 @@
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { roadSurfaceChunks } from './surface.js'; import { roadSurfaceChunks } from './surface.js';
import { TRAFFIC_TIMING_ENABLED, DOT_HEIGHT_OFFSET } from './policy.js'; import { TRAFFIC_TIMING_ENABLED, DOT_HEIGHT_OFFSET } from './policy.js';
@@ -162,8 +162,8 @@ export function createTiming({ state: layerState, services, parts, source }) {
} }
/** /**
* Mark Cesium's diagnostic moveEnd notification. Cesium normally emits this * Mark the camera's diagnostic moveEnd notification. The engine emits this
* about 500 ms after stillness, so fetch has typically already started and * once the camera has been still a while (Cesium: 500 ms), so fetch has typically already started and
* never waits for this boundary. * never waits for this boundary.
*/ */
@@ -250,12 +250,12 @@ export function createTiming({ state: layerState, services, parts, source }) {
(Number.isFinite(floor) && Math.abs(floor) <= 9000 (Number.isFinite(floor) && Math.abs(floor) <= 9000
? floor ? floor
: layerState._viewHeightEstimate || 0) + DOT_HEIGHT_OFFSET; : layerState._viewHeightEstimate || 0) + DOT_HEIGHT_OFFSET;
return Cesium.Cartesian3.fromDegrees(lng, lat, h); return geo.cartesian3.fromDegrees(lng, lat, h);
}); });
const segmentDist = []; const segmentDist = [];
for (let i = 0; i < waypoints.length - 1; i++) { for (let i = 0; i < waypoints.length - 1; i++) {
segmentDist.push( segmentDist.push(
Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1]), geo.cartesian3.distance(waypoints[i], waypoints[i + 1]),
); );
} }
/* TRACE_ONLY_BEGIN */ /* TRACE_ONLY_BEGIN */
@@ -316,7 +316,7 @@ export function createTiming({ state: layerState, services, parts, source }) {
return trafficTimingPass(trace, pass, source); return trafficTimingPass(trace, pass, source);
} }
/** Record the first Cesium postRender following a completed dot render. */ /** Record the first engine postRender following a completed dot render. */
function scheduleTrafficTimingPostRender( function scheduleTrafficTimingPostRender(
state, state,
@@ -324,9 +324,9 @@ export function createTiming({ state: layerState, services, parts, source }) {
renderId, renderId,
renderMetrics, renderMetrics,
) { ) {
if (!layerState._viewer?.scene) return; if (!layerState._engine) return;
let remove = null; let remove = null;
remove = layerState._viewer.scene.postRender.addEventListener(() => { remove = layerState._engine.frames.postRender.addEventListener(() => {
remove?.(); remove?.();
layerState._trafficTimingPostRenderRemovers?.delete(remove); layerState._trafficTimingPostRenderRemovers?.delete(remove);
const visibleTime = performance.now(); const visibleTime = performance.now();
+6 -21
View File
@@ -1,5 +1,4 @@
import { createReticleFootprint } from './footprint.js'; import { createReticleFootprint } from './footprint.js';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { import {
deriveFetchCenter, deriveFetchCenter,
@@ -24,9 +23,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
*/ */
function getCameraAltitude() { function getCameraAltitude() {
const carto = const carto = layerState._engine?.camera.pose().cartographic;
layerState._engine?.camera.pose().cartographic ??
layerState._viewer?.camera.positionCartographic;
return carto ? carto.height : Infinity; return carto ? carto.height : Infinity;
} }
@@ -37,9 +34,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
*/ */
function getViewBounds() { function getViewBounds() {
const rect = const rect = layerState._engine?.camera.viewRectangle();
layerState._engine?.camera.viewRectangle() ??
layerState._viewer?.camera.computeViewRectangle();
if (!rect) return null; if (!rect) return null;
return { return {
south: geo.math.toDegrees(rect.south), south: geo.math.toDegrees(rect.south),
@@ -64,25 +59,19 @@ export function createViewport({ state: layerState, services, parts, source }) {
*/ */
function getFetchCenter() { function getFetchCenter() {
const carto = const carto = layerState._engine?.camera.pose().cartographic;
layerState._engine?.camera.pose().cartographic ??
layerState._viewer?.camera.positionCartographic;
if (!carto) return null; if (!carto) return null;
const nadirLat = geo.math.toDegrees(carto.latitude); const nadirLat = geo.math.toDegrees(carto.latitude);
const nadirLon = geo.math.toDegrees(carto.longitude); const nadirLon = geo.math.toDegrees(carto.longitude);
let hitLat; let hitLat;
let hitLon; let hitLon;
const canvas = const canvas = layerState._engine.host.canvas;
layerState._engine?.host.canvas ?? layerState._viewer.scene.canvas;
const width = canvas.clientWidth || canvas.width; const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height; const height = canvas.clientHeight || canvas.height;
if (width > 0 && height > 0) { if (width > 0 && height > 0) {
const hit = ( const hit = layerState._engine.camera.pickEllipsoid(
layerState._engine?.camera ?? layerState._viewer.camera
).pickEllipsoid(
geo.cartesian2.create(width / 2, height / 2), geo.cartesian2.create(width / 2, height / 2),
Cesium.Ellipsoid.WGS84,
); );
if (hit) { if (hit) {
const hitCarto = geo.cartographic.fromCartesian(hit); const hitCarto = geo.cartographic.fromCartesian(hit);
@@ -219,11 +208,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
return; return;
const alt = getCameraAltitude(); const alt = getCameraAltitude();
if (alt > ACTIVATION_ALTITUDE) return onCameraChanged(); if (alt > ACTIVATION_ALTITUDE) return onCameraChanged();
const footprint = reticleFootprint( const footprint = reticleFootprint(layerState._engine, alt);
layerState._viewer,
alt,
layerState._engine,
);
const bounds = footprint || getViewBounds(); const bounds = footprint || getViewBounds();
if (!bounds) return; if (!bounds) return;
const center = footprint ? null : getFetchCenter(); const center = footprint ? null : getFetchCenter();
+10 -1
View File
@@ -3592,8 +3592,17 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve
await import('../bikeshare/render/display.js'); await import('../bikeshare/render/display.js');
const app = harness(t); const app = harness(t);
const noop = () => {}; const noop = () => {};
// Traffic claims through its engine's camera, as bikeshare does: the
// viewer's. The stub has what binding its scene subscribes to.
const listeners = { addEventListener: () => noop };
const trafficEngine = {
camera: app.viewer.camera,
frames: { preRender: listeners, postRender: listeners },
projector: { viewProjection: noop },
surface: { tilesLoaded: () => false },
};
const traffic = trafficLifecycle({ const traffic = trafficLifecycle({
state: { _pointCollection: {}, _loadGeneration: 0 }, state: { _engine: trafficEngine, _loadGeneration: 0 },
services: { services: {
render: { holdContinuousRender: noop, releaseContinuousRender: noop }, render: { holdContinuousRender: noop, releaseContinuousRender: noop },
}, },
+1 -4
View File
@@ -5,10 +5,7 @@ import { fileURLToPath } from 'node:url';
import { parsers } from 'prettier/plugins/babel.mjs'; import { parsers } from 'prettier/plugins/babel.mjs';
// Existing entity graphics only. Remove entries as these callers migrate. // Existing entity graphics only. Remove entries as these callers migrate.
const legacyFiles = new Set([ const legacyFiles = new Set(['src/scenes/dataPacks/presentation.js']);
'src/scenes/dataPacks/presentation.js',
'src/layers/directions/index.js',
]);
const root = fileURLToPath(new URL('../', import.meta.url)); const root = fileURLToPath(new URL('../', import.meta.url));
const propertyName = (node) => node?.name ?? node?.value; const propertyName = (node) => node?.name ?? node?.value;
const memberName = (node) => const memberName = (node) =>
+297 -180
View File
@@ -1,5 +1,15 @@
/** Shared Street Level test stand-ins: provider snapshot and provider, Mapillary source, photo viewer, Cesium viewer, ray-casting camera. */ /**
* Shared Street Level test stand-ins: provider snapshot and provider,
* Mapillary source, photo viewer, and the layer's display on the fake
* engine or on the Cesium engine over a stub scene, with what each drew.
*/
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
import { normalizeGroundSetOptions } from '../globe/ground.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { createFakeEventHandlers } from '../globe/cesium/testing/fakeViewer.mjs';
import { createCesiumMoversEngine } from './cesiumMoversEngine.mjs';
import { createStreetLevelDisplay } from '../layers/streetLevel/render/display.js';
import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js'; import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js';
import { import {
latToTileY, latToTileY,
@@ -131,19 +141,144 @@ export function fakePhotoViewer({ pose = null } = {}) {
} }
/** /**
* A stand-in Cesium viewer a layer can be enabled on: a canvas for the click * What an engine's ground sets and mover sets were given, by id: the
* handler, camera events with Cesium's flight bookkeeping (`flights` counts * options (with the contract's defaults), a ground set's committed items, a
* them, lists them and can `land` the current one), primitive lists and a * mover set's committed instances (the fields of every set() before its
* credit display. With `view`, the camera looks straight down on Sacramento * last commit), and whether each is still live.
* from 900 m, so coverage asks for tiles.
*/ */
export function fakeCesiumViewer({ view = false } = {}) { export function watchDrawing(engine) {
const credits = []; const ground = new Map();
const createGround = engine.ground.createSet.bind(engine.ground);
engine.ground.createSet = (options) => {
const set = createGround(options);
const record = {
id: options.id,
options: normalizeGroundSetOptions(options),
pending: [],
items: [],
commits: 0,
live: true,
};
const { replace, commit, destroy } = set;
set.replace = (items) => {
replace(items);
record.pending = items;
};
set.commit = () => {
commit();
record.items = record.pending;
record.commits++;
};
set.destroy = () => {
destroy();
record.live = false;
};
ground.set(options.id, record);
return set;
};
const movers = new Map();
const createMovers = engine.movers.createSet.bind(engine.movers);
engine.movers.createSet = (options) => {
const set = createMovers(options);
const record = {
id: options.id,
options,
pending: [],
count: 0,
instances: [],
commits: 0,
live: true,
};
const { commit, destroy } = set;
const write = set.set;
const setCount = set.setCount;
set.set = (index, fields) => {
write(index, fields);
record.pending[index] = { ...record.pending[index], ...fields };
};
set.setCount = (count) => {
setCount(count);
record.count = count;
};
set.commit = () => {
commit();
record.instances = record.pending.slice(0, record.count);
record.commits++;
};
set.destroy = () => {
destroy();
record.live = false;
};
movers.set(options.id, record);
return set;
};
const live = (records, prefix) =>
[...records.values()].filter(
(record) => record.live && record.id.startsWith(prefix),
);
return {
/** Live ground sets whose id starts with `prefix`, in creation order. */
groundSets: (prefix = '') => live(ground, prefix),
/** Live mover sets whose id starts with `prefix`, in creation order. */
moverSets: (prefix = '') => live(movers, prefix),
/** Every corridor item drawn by live sets under `prefix`. */
corridors: (prefix = '') =>
live(ground, prefix).flatMap((record) => record.items),
/** Every shown sprite instance drawn by live sets under `prefix`. */
spritesShown: (prefix = '') =>
live(movers, prefix).flatMap((record) =>
record.instances.filter((instance) => instance.show !== false),
),
};
}
/**
* The fake engine as a pinhole camera `altitude` m above WGS84 at
* `lon`/`lat`, turned to `heading` and `pitch` (degrees, negative down),
* with a vertical field of view of `fovY` degrees.
*/
export function cameraEngine({
lon,
lat,
altitude,
heading = 0,
pitch = -90,
width = 1600,
height = 900,
fovY = 60,
}) {
const engine = createFakeEngine({ width, height, fovy: fovY * RAD });
engine.fake.setPose({
longitude: lon * RAD,
latitude: lat * RAD,
height: altitude,
heading: heading * RAD,
pitch: pitch * RAD,
roll: 0,
});
return engine;
}
/** Sacramento from 900 m, looking straight down, on a 100 px canvas. */
export const SACRAMENTO_VIEW = Object.freeze({
lon: -121.4944,
lat: 38.5816,
altitude: 900,
width: 100,
height: 100,
});
/**
* Cesium's flight bookkeeping on an engine: `started` counts framing
* flights, `cancelled` the cancelFlight calls that stopped one, `list`
* holds every flight asked for (`{ sphere, options }`), `land()` completes
* the current one.
*/
function watchFlights(engine) {
let flight = null; let flight = null;
const flights = { const flights = {
started: 0, started: 0,
cancelled: 0, cancelled: 0,
/** Every flight asked for, oldest first: `{ sphere, options }`. */
list: [], list: [],
land() { land() {
const current = flight; const current = flight;
@@ -151,127 +286,139 @@ export function fakeCesiumViewer({ view = false } = {}) {
current?.options.complete?.(); current?.options.complete?.();
}, },
}; };
const stopFlight = () => { const stop = () => {
const current = flight; const current = flight;
flight = null; flight = null;
current?.options.cancel?.(); current?.options.cancel?.();
return Boolean(current); return Boolean(current);
}; };
const canvas = Object.assign(new EventTarget(), { engine.camera.flyToSphere = (sphere, options = {}) => {
style: {}, stop();
// Keep Cesium's handler on the canvas; there is no real document here. flight = { sphere, options };
disableRootEvents: true, flights.list.push(flight);
onwheel: null, flights.started++;
...(view ? { clientWidth: 100, clientHeight: 100 } : {}), };
}); engine.camera.cancelFlight = () => {
const camera = Object.assign( if (stop()) flights.cancelled++;
view };
? rayCamera({ return flights;
lon: -121.4944, }
lat: 38.5816,
altitude: 900, /**
pitch: -90, * The Street Level display on the fake engine (`view`: over Sacramento, so
width: 100, * coverage asks for tiles), with what it drew (watchDrawing), its credits'
height: 100, * HTML, flight bookkeeping and `frame()` (one engine frame: sprites paint
}) * on preRender). `engine` replaces the fake one.
: {}, */
{ export function streetLevelMap({ view = false, engine = null } = {}) {
changed: new Cesium.Event(), const globe =
moveStart: new Cesium.Event(), engine ?? (view ? cameraEngine(SACRAMENTO_VIEW) : createFakeEngine());
moveEnd: new Cesium.Event(), const drawing = watchDrawing(globe);
flyToBoundingSphere(sphere, options = {}) { const flights = watchFlights(globe);
stopFlight(); const map = createStreetLevelDisplay(globe);
flight = { sphere, options };
flights.list.push(flight);
flights.started++;
},
cancelFlight() {
if (stopFlight()) flights.cancelled++;
},
},
);
return { return {
credits, engine: globe,
map,
flights, flights,
scene: { ...drawing,
canvas, credits: () => (globe.fake?.credits ?? []).map((credit) => credit.html),
primitives: { add: (p) => p, remove() {} }, frame: () => globe.fake.frame(),
groundPrimitives: { add: (p) => p, remove() {} }, /** A click at `position`, with `picked` under it (undefined: the map). */
// Enough of a scene for the position marker to clamp to the ground. click(picked, position = { x: 50, y: 50 }) {
frameState: { mode: Cesium.SceneMode.SCENE3D }, globe.fake.script('pick', picked);
updateHeight: () => () => {}, globe.fake.input('click', { position });
getHeight: () => undefined, globe.fake.unscript('pick');
},
camera,
creditDisplay: {
addStaticCredit: (credit) => credits.push(credit),
removeStaticCredit: (credit) => {
// Cesium ignores a credit it does not hold.
const at = credits.indexOf(credit);
if (at !== -1) credits.splice(at, 1);
},
}, },
}; };
} }
/** /**
* A pinhole camera `altitude` m above WGS84, at `heading` (deg from north) and * The Cesium engine over a real Cesium camera on a stub scene, with fake
* `pitch` (deg, negative down); `pickEllipsoid` hits whatever ellipsoid it gets. * ground primitives (they draw nothing) and fake billboards' atlas crops:
* `frame()` runs a turn of Cesium's loop, `click(picked)` a left click with
* `scene.pick` answering `picked` (a Cesium pick result or undefined).
*/ */
export function rayCamera({ export function cesiumStreetLevelEngine({ width = 100, height = 100 } = {}) {
lon, const handlers = createFakeEventHandlers();
lat, const primitive = (type) => (options) => {
altitude, const value = {
heading = 0, type,
pitch, options,
width = 1600, ready: false,
height = 900, show: true,
fovY = 60, destroyed: false,
fovX = null, destroy() {
}) { value.destroyed = true;
const position = Cesium.Cartesian3.fromDegrees(lon, lat, altitude); },
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(position); isDestroyed: () => value.destroyed,
const h = heading * RAD; };
const p = pitch * RAD; return value;
// East-north-up axes of the view: forward, right and up. };
const forward = [ const { engine, viewer, frame } = createCesiumMoversEngine({
Math.sin(h) * Math.cos(p), engineOptions: {
Math.cos(h) * Math.cos(p), createEventHandler: handlers.create,
Math.sin(p), scheduleFrame: (run) => run(),
]; groundFactories: { fills: primitive('fills'), lines: primitive('lines') },
const right = [Math.cos(h), -Math.sin(h), 0]; },
const up = [ });
-Math.sin(h) * Math.sin(p), viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
-Math.cos(h) * Math.sin(p), viewer.scene.canvas.clientWidth = width;
Math.cos(p), viewer.scene.canvas.clientHeight = height;
]; let native;
const halfY = Math.tan((fovY / 2) * RAD); viewer.scene.pick = () => native;
const halfX = viewer.scene.drillPick = () => (native ? [native] : []);
fovX == null ? halfY * (width / height) : Math.tan((fovX / 2) * RAD);
return { return {
positionWC: position, engine,
positionCartographic: Cesium.Cartographic.fromDegrees(lon, lat, altitude), viewer,
pickEllipsoid(point, ellipsoid = Cesium.Ellipsoid.WGS84) { frame,
const sx = ((2 * point.x) / width - 1) * halfX; /** Poses the camera over `lon`/`lat` (degrees), looking straight down. */
const sy = (1 - (2 * point.y) / height) * halfY; lookDown({ lon, lat, altitude }) {
const local = [0, 1, 2].map( engine.camera.setView({
(i) => forward[i] + sx * right[i] + sy * up[i], destination: geo.cartesian3.fromDegrees(lon, lat, altitude),
); });
const direction = Cesium.Cartesian3.normalize( },
Cesium.Matrix4.multiplyByPointAsVector( click(picked, position = { x: 50, y: 50 }) {
frame, native = picked;
new Cesium.Cartesian3(...local), for (const handler of handlers.handlers)
new Cesium.Cartesian3(), handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
), position: new Cesium.Cartesian2(position.x, position.y),
new Cesium.Cartesian3(), });
); native = undefined;
const ray = new Cesium.Ray(position, direction);
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined;
}, },
}; };
} }
/**
* The Street Level display on the Cesium engine over a stub scene
* (cesiumStreetLevelEngine), shaped as streetLevelMap's: over Sacramento
* when `view`, the terrain at `terrain` m.
*/
export function cesiumStreetLevelMap({ view = true, terrain = 0 } = {}) {
const cesium = cesiumStreetLevelEngine();
cesium.viewer.scene.globe.getHeight = () => terrain;
if (view) cesium.lookDown(SACRAMENTO_VIEW);
const drawing = watchDrawing(cesium.engine);
const flights = watchFlights(cesium.engine);
const map = createStreetLevelDisplay(cesium.engine);
return {
name: 'the Cesium engine',
engine: cesium.engine,
viewer: cesium.viewer,
map,
flights,
...drawing,
credits: () => [],
frame: cesium.frame,
click: cesium.click,
};
}
/** The display on each engine: the fake one and Cesium's, over Sacramento. */
export const STREET_LEVEL_MAPS = Object.freeze([
() => ({ name: 'the fake engine', ...streetLevelMap({ view: true }) }),
() => cesiumStreetLevelMap(),
]);
/** The centre of the tile holding a point at zoom `z`, so a small view is one tile. */ /** The centre of the tile holding a point at zoom `z`, so a small view is one tile. */
export function tileCentre(lon, lat, z = 14) { export function tileCentre(lon, lat, z = 14) {
const x = lonToTileX(lon, z); const x = lonToTileX(lon, z);
@@ -284,79 +431,49 @@ export function tileCentre(lon, lat, z = 14) {
}; };
} }
/** A scene collection that remembers what is on the globe. */
function sceneCollection() {
const items = new Set();
return {
items,
add(primitive) {
items.add(primitive);
return primitive;
},
remove(primitive) {
return items.delete(primitive);
},
};
}
/** /**
* A viewer looking straight down on a 0.004° square around `view` * The display on a fake engine looking straight down on a 0.004° square
* (`{lon, lat, height}`, mutable), enough for line coverage to attach, * around `view` (`{lon, lat, height}`, mutable: a change moves the camera),
* refresh and draw. Cesium's one-time terrain-height download is skipped. * enough for line coverage to attach, refresh and draw: its screen rays
* land on that square at any height, as the old stand-in camera's did.
* `moveEnd()` ends a camera move.
*/ */
export function coverageViewer(view, t) { export function coverageMap(start) {
const initialize = Cesium.GroundPolylinePrimitive.initializeTerrainHeights; const engine = createFakeEngine({ width: 100, height: 100 });
Cesium.GroundPolylinePrimitive.initializeTerrainHeights = () => const at = { ...start };
Promise.resolve(); const pose = () =>
t?.after(() => { engine.fake.setPose({
Cesium.GroundPolylinePrimitive.initializeTerrainHeights = initialize; longitude: at.lon * RAD,
latitude: at.lat * RAD,
height: at.height,
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
});
const view = new Proxy(at, {
set(target, key, value) {
target[key] = value;
pose();
return true;
},
}); });
// Node has no WebGL context to report line-width limits. pose();
Cesium.ContextLimits._minimumAliasedLineWidth = 1; engine.camera.pickRay = (point) => {
Cesium.ContextLimits._maximumAliasedLineWidth = 10; const origin = geo.cartesian3.fromDegrees(at.lon, at.lat, at.height);
const listeners = () => { const target = geo.cartesian3.fromDegrees(
const set = new Set(); at.lon - 0.002 + (point.x / 100) * 0.004,
return { at.lat + 0.002 - (point.y / 100) * 0.004,
set, );
addEventListener(listener) { const direction = geo.cartesian3.normalize(
set.add(listener); geo.cartesian3.subtract(target, origin, geo.cartesian3.create()),
return () => set.delete(listener); geo.cartesian3.create(),
}, );
}; return { origin, direction };
}; };
const harness = streetLevelMap({ engine });
return { return {
...harness,
view, view,
scene: { moveEnd: () => engine.fake.emit('camera.moveEnd'),
canvas: { clientWidth: 100, clientHeight: 100 },
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
frameState: { mode: Cesium.SceneMode.SCENE3D },
updateHeight: () => () => {},
getHeight: () => undefined,
groundPrimitives: sceneCollection(),
primitives: sceneCollection(),
postRender: listeners(),
preRender: listeners(),
},
camera: {
changed: new Cesium.Event(),
moveEnd: new Cesium.Event(),
get positionWC() {
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
},
get positionCartographic() {
return {
longitude: view.lon * RAD,
latitude: view.lat * RAD,
height: view.height,
};
},
pickEllipsoid(point) {
return Cesium.Cartesian3.fromDegrees(
view.lon - 0.002 + (point.x / 100) * 0.004,
view.lat + 0.002 - (point.y / 100) * 0.004,
);
},
computeViewRectangle: () => undefined,
},
}; };
} }