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
// render/navara*.js. The list grows with each layer moved onto renderers.
const RENDERED_LAYER_DIRS = [
'src/layers/streetLevel/',
'src/layers/traffic/',
'src/layers/directions/',
'src/layers/infrastructure/',
'src/layers/installations/',
'src/layers/awareness/',
+9 -9
View File
@@ -1,16 +1,16 @@
{
"metrics": {
"cesiumLayerRendererLines": 351,
"navaraLayerRendererLines": 3349,
"cesiumImportFiles": 51,
"viewerLines": 1591,
"navaraKindChecks": 8,
"navaraLayerRendererLines": 2767,
"cesiumImportFiles": 32,
"viewerLines": 1373,
"navaraKindChecks": 7,
"engineSniffCalls": 14,
"engineExtensionReads": 18,
"engineExtensionReads": 15,
"groundEffectPasses": 4,
"cesiumSdkLoads": 6,
"screenSpaceHandlerFiles": 6,
"engineNameLines": 101
"cesiumSdkLoads": 4,
"screenSpaceHandlerFiles": 4,
"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/infrastructure/index.js",
"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/geo/",
"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": {
"exports": ["./layers/bikeshare"],
@@ -2085,9 +2086,12 @@
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"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": {
"exports": ["./layers/alpr"],
@@ -3463,9 +3467,6 @@
"src/layers/awareness/render/navaraPulses.js",
"src/layers/shared/render/navaraStems.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/navaraGeometry.js",
"src/layers/shared/render/navaraStory.js",
@@ -3578,7 +3579,11 @@
"src/layers/awareness/render/display.js",
"src/layers/installations/render/namedMarkers.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": [
"hls.js",
@@ -4315,7 +4320,11 @@
"src/layers/awareness/render/pulseDisc.js",
"src/layers/awareness/render/display.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": [
"hls.js",
@@ -4674,9 +4683,11 @@
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"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": {
"exports": ["./sources/radio-browser"],
-1
View File
@@ -311,7 +311,6 @@ export function createApplicationCatalog({
createApplicationTraffic({
source: sources.traffic,
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';
/** 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 }) {
return createApplicationDirectionsLayer({
source,
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 { createInstallationsDisplay } from '../../layers/installations/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']);
@@ -133,6 +136,9 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({
cctv: createCctvDisplay,
'military-installations': createInstallationsDisplay,
'military-awareness': createAwarenessDisplay,
'street-level': createStreetLevelDisplay,
traffic: createTrafficDisplay,
directions: createDirectionsDisplay,
});
// The other application layers still draw themselves on Cesium.
+6
View File
@@ -85,6 +85,9 @@ test('the layers with extracted renderers', () => {
'cctv',
'military-installations',
'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-dams': ['infrastructure', 'createInfrastructureDisplay'],
'military-installations': ['installations', 'createInstallationsDisplay'],
'street-level': ['streetLevel', 'createStreetLevelDisplay'],
traffic: ['traffic', 'createTrafficDisplay'],
directions: ['directions', 'createDirectionsDisplay'],
'telegeography-submarine-cables': [
'submarineCables',
'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 { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/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 input from '../../data/inputOwnership.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
* 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({
sources = {},
nativeRenderer,
nativeRenderer = (engine) => rendererFor('street-level', engine),
providers = defaultProviders(sources),
}) {
return createStreetLevelLayer({
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 ground from '../../data/groundFloor.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({
source,
services: {
credits,
render,
nativeRenderer,
createRenderer: (engine) => rendererFor('traffic', engine),
ground: surface?.groundFloor || ground,
},
});
+9 -17
View File
@@ -153,23 +153,15 @@ export async function registerNavaraLayerRenderers() {
return (engine, { data }) =>
drawStorySource(engine, engine.extensions.navara, data);
},
'street-level': async () => {
const { createStreetMap } =
await import('../layers/streetLevel/render/navara.js');
return (engine) => createStreetMap(engine, engine.extensions.navara);
},
traffic: async () => {
const { createTrafficNavaraRenderer } =
await import('../layers/traffic/render/navara.js');
return (engine) =>
createTrafficNavaraRenderer(engine, engine.extensions.navara);
},
directions: async () => {
const { createDirectionsNavaraRenderer } =
await import('../layers/directions/render/navara.js');
return (engine) =>
createDirectionsNavaraRenderer(engine, engine.extensions.navara);
},
'street-level': async () =>
(await import('../layers/streetLevel/render/display.js'))
.createStreetLevelDisplay,
traffic: async () =>
(await import('../layers/traffic/render/display.js'))
.createTrafficDisplay,
directions: async () =>
(await import('../layers/directions/render/display.js'))
.createDirectionsDisplay,
'telegeography-submarine-cables': async () =>
(await import('../layers/submarineCables/render/display.js'))
.createCablesDisplay,
+8 -12
View File
@@ -3,21 +3,19 @@ import { createDirectionsSource } from '../layers/directions/source.js';
* @module directions
* @description Service bundle for the Directions layer.
*
* The layer itself lives in `src/layers/directions/`, which imports nothing
* but Cesium and the pure step formatter. This module is the one place that
* names the application's shared services — the render governor, sprite order,
* the pick registry, the world overlay, the annotation material, the camera
* verbs, the shared ground floor and the pointer arbiter — and it re-exports
* the layer's pure helpers for the tests and the share-link code.
* The layer itself lives in `src/layers/directions/` and draws through the
* globe contract (its render/display.js). This module is the one place that
* names the application's shared services — the render governor, the pick
* registry, the ground pick, the world overlay, the camera verbs, the shared
* ground floor and the pointer arbiter — and it re-exports the layer's pure
* helpers for the tests and the share-link code.
*/
import * as credits from './dataCredits.js';
import * as render from '../renderGovernor.js';
import * as sprites from './spriteOrder.js';
import * as picking from './pickRegistry.js';
import * as scenePick from './scenePick.js';
import * as overlays from '../overlays/worldOverlay.js';
import * as annotations from '../annotations/worldAnnotationRenderer.js';
import * as camera from '../cameraVerbs.js';
import * as ground from './groundFloor.js';
import * as input from './inputOwnership.js';
@@ -27,11 +25,9 @@ import { createDirectionsLayer } from '../layers/directions/index.js';
const directionsServices = Object.freeze({
credits,
render,
sprites,
picking,
scenePick,
overlays,
annotations,
camera,
ground,
input,
@@ -40,13 +36,13 @@ const directionsServices = Object.freeze({
/** Construct one Directions layer over the application scene owners. */
export function createApplicationDirectionsLayer({
source = createDirectionsSource(),
createNativeRenderer,
createRenderer,
groundFloor = ground,
} = {}) {
return createDirectionsLayer({
services: { ...directionsServices, ground: groundFloor },
source,
createNativeRenderer,
createRenderer,
});
}
+8 -8
View File
@@ -5,7 +5,6 @@ import { showOsmCredit, hideOsmCredit } from './dataCredits.js';
import { createInfrastructureLayers } from './infrastructure.js';
import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js';
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
import * as annotations from '../annotations/worldAnnotationRenderer.js';
import { createDirectionsLayer } from '../layers/directions/index.js';
import { createDirectionsSource } from '../layers/directions/source.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) => {
const v = viewer(t);
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: {
credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {},
credits: { showOsmCredit, hideOsmCredit }, input, scenePick: {}, camera: {},
overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },
sprites: { registerSpriteCollection: noop, unregisterSpriteCollection: noop, restoreSpriteOrder: noop },
render: { governorRequestRender: noop },
picking: { registerPickOwner: noop, unregisterPickOwner: 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 });
for (let i = 0; i < 30; i++) await Promise.resolve();
assert.equal(layer.getStats().error, null);
assertCredit(v); await layer.destroy(v);
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
assert.ok(engine.fake.credits.some((credit) => credit.onScreen && credit.html.includes('>© OpenStreetMap<')));
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) => {
+47 -13
View File
@@ -141,7 +141,6 @@ test('traffic timing pairs real ordering to the scheduling change and guards re-
let server;
const cacheDir = mkdtempSync(path.join(tmpdir(), 'gev-traffic-timing-vite-'));
let trafficLayer;
let viewer;
const pendingDebounce = () => {
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 postRender = eventChannel();
const preUpdate = eventChannel();
const moveEnd = eventChannel();
const changed = eventChannel();
let longitude = -97.74;
const cartographic = { latitude: Math.PI / 6, longitude: 0, height: 5000 };
const camera = {
moveEnd,
changed,
percentageChanged: 0.5,
positionCartographic: { latitude: Math.PI / 6, longitude: 0, height: 5000 },
computeViewRectangle() {
livePosition: { x: 0, y: 0, z: 0 },
pose: () => ({ cartographic: { ...cartographic }, heading: 0, pitch: -Math.PI / 2 }),
viewRectangle() {
const west = longitude * Math.PI / 180;
return {
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,
};
},
pickEllipsoid: () => undefined,
};
const setLongitude = (next) => {
longitude = next;
camera.positionCartographic.longitude = next * Math.PI / 180;
cartographic.longitude = next * Math.PI / 180;
};
setLongitude(longitude);
viewer = {
camera,
scene: {
const noSet = () => ({
set() {},
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 },
preRender,
postRender,
primitives: { add: (primitive) => primitive, remove: () => true },
size: () => ({ width: 0, height: 0 }),
},
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.clearMeasures();
trafficLayer.init(viewer);
trafficLayer.init(null, { engine });
performance.mark('traffic:stale:mark');
performance.measure('traffic:stale:measure', {
start: 'traffic:stale:mark',
end: 'traffic:stale:mark',
});
trafficLayer.enable(viewer);
trafficLayer.enable();
assert.equal(traffic.getTrafficTimingDiagnostics().marksInstalled, 1);
assert.equal(moveEnd.listenerCount(), 2, 'one timing listener plus the production arrival check');
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
)), 'the canceled/stale B load must never emit a correlated trace');
} finally {
trafficLayer?.disable(viewer);
trafficLayer?.disable();
await server?.close();
globalThis.window = originalWindow;
globalThis.document = originalDocument;
+69 -98
View File
@@ -1,9 +1,15 @@
import test from 'node:test';
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 { 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 B = { lat: 60.2055, lon: 24.6559 };
@@ -33,44 +39,7 @@ const payload = (from, to) => ({
],
});
function installDocument() {
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();
function fixture(makeHarness = createFakeGroundHarness) {
let clock = 0;
const realNow = performance.now;
performance.now = () => clock;
@@ -79,7 +48,7 @@ function fixture() {
getRoute: (request, { signal }) =>
new Promise((resolve) => requests.push({ request, signal, resolve })),
};
const natives = new Map();
const harnesses = new Map();
const layer = createApplicationDirectionsLayer({
source,
groundFloor: {
@@ -87,38 +56,39 @@ function fixture() {
cachedGroundFloor: () => 0,
warmGroundFloor() {},
},
createNativeRenderer(engine) {
const native = nativeView();
natives.set(engine, native);
return createDirectionsNavaraRenderer(engine, native);
},
createRenderer: createDirectionsDisplay,
});
layer._setOverlayHostForTest({
setEntries() {},
setVisible() {},
clearSource() {},
});
const makeEngine = () =>
createFakeEngine({ kind: 'navara', canvas: new EventTarget() });
const makeEngine = (make = makeHarness) => {
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();
layer.attachShellServices({});
layer.init(null, { engine });
layer.enable(null);
layer.enable();
return {
layer,
engine,
makeEngine,
requests,
natives,
// The route each engine draws: its live sources' first and last point.
// The route each engine draws: the first and last point of its route
// set, once per live set.
drawn(target) {
return [...natives.get(target).live].map(({ data }) => {
const features = data.features;
return [
features[0].geometry.coordinates[0],
features.at(-1).geometry.coordinates.at(-1),
];
});
const set = harnesses.get(target).set(DIRECTIONS_SET_IDS.route);
if (!set?.items.length) return [];
return [[set.items[0].positions[0], set.items.at(-1).positions.at(-1)]];
},
frames(target, count) {
for (let i = 0; i < count; i++) {
@@ -127,56 +97,57 @@ function fixture() {
}
},
cleanup() {
layer.destroy(null);
layer.destroy();
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 lateFirst of [false, true]) {
const f = fixture();
try {
f.layer.placeEndpoint('a', A);
f.layer.placeEndpoint('b', B);
f.layer.setParams({ swap: true });
assert.equal(f.requests.length, 2);
const [before, after] = f.requests;
assert.equal(
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();
for (const makeHarness of GROUND_HARNESSES)
test(`edge:directions: a late answer for the route before A/B swapped never replaces the swapped route (${makeHarness().name})`, async () => {
for (const lateFirst of [false, true]) {
const f = fixture(makeHarness);
try {
f.layer.placeEndpoint('a', A);
f.layer.placeEndpoint('b', B);
f.layer.setParams({ swap: true });
assert.equal(f.requests.length, 2);
const [before, after] = f.requests;
assert.equal(
f.drawn(f.engine).length,
0,
'the stale route is not drawn',
before.signal.aborted,
true,
'the pre-swap request is aborted',
);
}
after.resolve(payload(B, A));
await settle();
if (!lateFirst) {
before.resolve(payload(A, B));
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(
f.drawn(f.engine).length,
0,
'the stale route is not drawn',
);
}
after.resolve(payload(B, A));
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 () => {
const f = fixture();
+116 -275
View File
@@ -6,8 +6,9 @@
* again). With both ends placed the layer asks the existing `/api/route`
* proxy (OSRM on the FOSSGIS servers, © OpenStreetMap contributors) for the
* 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"
* annotation uses, and drops one dot per maneuver. Click a dot for the
* and 3D tiles in the dashes of the voice "route from A to B" annotation,
* 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
* route-flight cinematic (`flyRoute`), the way voice's `fly_route` does.
*
@@ -16,9 +17,9 @@
* route.
*/
import * as Cesium from 'cesium';
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 {
t,
@@ -68,16 +69,8 @@ export function presentRoutingFailure(error) {
return error?.message || msg('Routing unavailable');
}
/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */
export const DIRECTIONS_ROUTE_COLOR = '#39d0ff';
// 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 colour: the annotation palette's cyan, so voice routes and Directions match. */
export { DIRECTIONS_ROUTE_COLOR };
/** Route request timeout (ms) — the proxy itself gives OSRM 12 s. */
const ROUTE_TIMEOUT_MS = 15_000;
@@ -770,7 +763,7 @@ export function directionsStepCopy(steps, index) {
/**
* Shared-host card for the selected maneuver.
* @param {number} index
* @param {Cesium.Cartesian3} position
* @param {{x:number, y:number, z:number}} position src/geo Cartesian3.
* @param {{title:string, details:string[]}} copy
* @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
* 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
* down to Cesium and the pure step formatter.
* @param {{services: object, source: {getRoute: Function}}} options
* down to the globe contract display and the pure step formatter.
* @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.
*/
export function createDirectionsLayer({
services,
source,
createNativeRenderer,
createRenderer = createDirectionsDisplay,
}) {
if (typeof source?.getRoute !== 'function')
throw new TypeError('Directions require a route source');
@@ -883,9 +879,9 @@ export function createDirectionsLayer({
let _overlayHost = DEFAULT_OVERLAY_HOST;
// --- Module state ---
let _viewer = 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 _enabled = false;
let _mode = DEFAULT_DIRECTIONS_MODE;
@@ -903,13 +899,11 @@ export function createDirectionsLayer({
let _routeSeq = 0;
let _routeAbort = null;
let _lastUpdate = null;
let _markerA = null;
let _markerB = null;
let _routeEntity = null;
let _stepPoints = null;
/** Maneuver dot index → its anchored position, or null while unresolved. */
const _stepAnchors = new Map();
let _selectedStep = null;
let _clickHandler = null;
let _renderHeld = false;
let _listening = false;
let _removeClick = null;
let _rowControlsListener = null;
let _dataManager = null;
/** Set when arming failed because another tool holds the pointer. */
@@ -966,21 +960,10 @@ export function createDirectionsLayer({
_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) {
const cartesian = _native
? _engine.picker.pickGlobe(screenPosition)
: services.scenePick.pickGroundPosition(_viewer, screenPosition);
const cartesian = _engine
? services.scenePick.pickGroundPosition(_engine, screenPosition)
: null;
if (!cartesian) return null;
const carto = geo.cartographic.fromCartesian(cartesian);
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) {
if (!_viewer && !_native) return;
if (letter === 'a') {
removeEntity(_markerA);
_markerA = markerEntity('A', point, MARKER_A_COLOR());
} else {
removeEntity(_markerB);
_markerB = markerEntity('B', point, MARKER_B_COLOR());
}
_display?.setMarker(letter, point);
}
/** The maneuver dots as the display draws them: anchored or hidden. */
function pushSteps() {
_display?.setSteps(
stepMarkerIndices(_route?.steps).map((index) => ({
index,
position: _stepAnchors.get(index) ?? null,
})),
);
}
function clearRouteGraphics() {
services.credits?.hideOsmCredit?.(_viewer ?? _engine, 'directions');
services.credits?.hideOsmCredit?.(_engine, 'directions');
_clearStepSelection();
stopStepAnchoring();
removeEntity(_routeEntity);
_routeEntity = null;
_stepPoints?.removeAll();
syncRenderHold();
_display?.setRoute(null);
_stepAnchors.clear();
_display?.setSteps([]);
}
function stopStepAnchoring() {
@@ -1062,32 +1011,28 @@ export function createDirectionsLayer({
*/
function anchorStepPoints() {
_anchorTimer = null;
if (!_stepPoints || !_route || !_enabled) return;
if (!_display || !_route || !_enabled) return;
const steps = _route.steps;
const pending = [];
let unresolved = 0;
for (const index of stepMarkerIndices(steps)) {
const step = steps[index];
const point = findStepPoint(index);
if (!point) continue;
const height = stepMarkerHeightM(
services.ground.cachedGroundFloor(step.lat, step.lon),
services.ground.GROUND_FLOOR_LIFT_M,
);
if (height === null) {
unresolved += 1;
point.show = false;
_stepAnchors.set(index, null);
pending.push({ lat: step.lat, lon: step.lon });
continue;
}
point.position = Cesium.Cartesian3.fromDegrees(
step.lon,
step.lat,
height,
_stepAnchors.set(
index,
geo.cartesian3.fromDegrees(step.lon, step.lat, height),
);
point.show = true;
}
_native?.steps.sync();
pushSteps();
// A card already open on a step that has just been anchored moves with it.
if (_selectedStep !== null) refreshStepCard(_selectedStep);
services.render.governorRequestRender('directions-anchor');
@@ -1108,56 +1053,22 @@ export function createDirectionsLayer({
function drawRoute(route) {
clearRouteGraphics();
if (!_viewer && !_native) return;
if (_native)
_routeEntity = _native.route(route.geometry, DIRECTIONS_ROUTE_COLOR);
else {
services.annotations.ensureFlowFabricRegistered();
const positions = Cesium.Cartesian3.fromDegreesArray(
route.geometry.flat(),
);
_routeEntity = _viewer.entities.add({
id: 'directions:route',
polyline: {
positions,
width: 9,
material: new services.annotations.FlowMaterialProperty(
DIRECTIONS_ROUTE_COLOR,
),
clampToGround: true,
// BOTH: drape on 3D tiles when they are up and on terrain when they are
// not, so the keyless globe shows the route too.
classificationType: Cesium.ClassificationType.BOTH,
},
});
}
if (!_display) return;
_display.setRoute(route.geometry);
// One dot per decision; A and B already mark departure and arrival. The dots
// start hidden and appear as anchorStepPoints resolves each ground cell.
const cells = [];
for (const index of stepMarkerIndices(route.steps)) {
const step = route.steps[index];
cells.push({ lat: step.lat, lon: step.lon });
_stepPoints.add({
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,
});
_stepAnchors.set(index, null);
}
services.credits?.showOsmCredit?.(_viewer ?? _engine, 'directions');
pushSteps();
services.credits?.showOsmCredit?.(_engine, 'directions');
services.ground.warmGroundFloor(cells);
_anchorAttempts = 0;
_anchorStartedAt = Date.now();
anchorStepPoints();
syncRenderHold();
if (_viewer) services.sprites.restoreSpriteOrder(_viewer);
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.
*
* The globe click handler is the only caller in the running app; it is
* exported because a ScreenSpaceEventHandler needs a real canvas, and the
* ownership timing this function decides is exactly what has to be tested.
* exported because the ownership timing this function decides is exactly
* what has to be tested without a click.
* @param {'a'|'b'} which Which endpoint to place.
* @param {{lat: number, lon: number}} point Ground point in degrees.
* @returns {boolean} Whether the endpoint was placed.
@@ -1343,10 +1254,8 @@ export function createDirectionsLayer({
_route = null;
_status = 'idle';
_error = null;
removeEntity(_markerA);
removeEntity(_markerB);
_markerA = null;
_markerB = null;
placeMarker('a', null);
placeMarker('b', null);
clearRouteGraphics();
services.render.governorRequestRender('directions-clear');
}
@@ -1419,7 +1328,7 @@ export function createDirectionsLayer({
typeof seams?.floorFn === 'function' ? seams.floorFn : null,
// One camera owner: the UI shell's immediate-navigation facade stamps 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.
typeof seams?.runNavigation === 'function' ? seams.runNavigation : null,
typeof seams?.warmFn === 'function' ? seams.warmFn : null,
@@ -1438,25 +1347,11 @@ export function createDirectionsLayer({
// --- Step selection ---
function _clearStepSelection() {
if (_selectedStep !== null && _stepPoints) {
const point = findStepPoint(_selectedStep);
if (point) point.pixelSize = STEP_PIXEL_SIZE;
_native?.steps.sync();
}
if (_selectedStep !== null) _display?.selectStep(null);
_selectedStep = null;
_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.
*
@@ -1466,13 +1361,13 @@ export function createDirectionsLayer({
* the same shared floor, and a step whose cell is still cold has no anchor at
* all rather than one at sea level.
* @param {number} index Step index.
* @returns {Cesium.Cartesian3|null}
* @returns {{x:number, y:number, z:number}|null} src/geo Cartesian3.
*/
function stepAnchorPosition(index) {
const step = _route?.steps?.[index];
if (!step) return null;
const point = findStepPoint(index);
if (point?.show) return Cesium.Cartesian3.clone(point.position);
const drawn = _display?.stepPosition(index);
if (drawn) return drawn;
const height = stepMarkerHeightM(
services.ground.cachedGroundFloor(step.lat, step.lon),
services.ground.GROUND_FLOOR_LIFT_M,
@@ -1481,7 +1376,7 @@ export function createDirectionsLayer({
services.ground.warmGroundFloor([{ lat: step.lat, lon: step.lon }]);
return null;
}
return Cesium.Cartesian3.fromDegrees(step.lon, step.lat, height);
return geo.cartesian3.fromDegrees(step.lon, step.lat, height);
}
function refreshStepCard(index) {
@@ -1518,21 +1413,16 @@ export function createDirectionsLayer({
return false;
}
_selectedStep = index;
const point = findStepPoint(index);
if (point) point.pixelSize = STEP_SELECTED_PIXEL_SIZE;
_native?.steps.sync();
_display?.selectStep(index);
services.render.governorRequestRender('directions-select');
return true;
}
function stepIndexFromPick(picked) {
const candidates = [picked?.primitive?.id, picked?.id];
for (const candidate of candidates) {
if (typeof candidate !== 'string') continue;
const match = /^directions:step:(\d+)$/.exec(candidate);
if (match) return Number(match[1]);
}
return null;
const match =
typeof picked?.id === 'string' &&
/^directions:step:(\d+)$/.exec(picked.id);
return match ? Number(match[1]) : null;
}
function _onKeyDown(event) {
@@ -1545,58 +1435,40 @@ export function createDirectionsLayer({
}
}
function _installClickHandler(viewer) {
if (_clickHandler) return;
const handle = (click) => {
if (!_enabled) return;
// Placement acts only while this layer actually HOLDS the pointer, and the
// maneuver-dot selection below is an ambient handler like any other: it
// yields the moment a tool (this one included) owns the click.
// The lease, not the owner name: a superseded instance of this layer must
// not act on a click its replacement owns.
if (_armed && services.input.isLeaseCurrent(_pointerClaim)) {
const point = pickGround(click.position);
if (!point) 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,
);
function _onClick(click) {
if (!_enabled) return;
// Placement acts only while this layer actually HOLDS the pointer, and the
// maneuver-dot selection below is an ambient handler like any other: it
// yields the moment a tool (this one included) owns the click.
// The lease, not the owner name: a superseded instance of this layer must
// not act on a click its replacement owns.
if (_armed && services.input.isLeaseCurrent(_pointerClaim)) {
const point = pickGround(click.position);
if (!point) return;
placeDirectionsEndpoint(_armed, point);
return;
}
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')
document.addEventListener('keydown', _onKeyDown);
}
function _removeClickHandler() {
if (_clickHandler) {
_clickHandler.destroy();
_clickHandler = null;
}
_listening = false;
_removeClick?.();
_removeClick = null;
// Teardown can outlive the document (page unload, and every headless test).
if (typeof document !== 'undefined')
document.removeEventListener('keydown', _onKeyDown);
@@ -1614,31 +1486,21 @@ export function createDirectionsLayer({
updateInterval: 0,
/**
* Create the maneuver-dot collection. Called once at bootstrap.
* @param {Cesium.Viewer} viewer
* Bind the engine the display draws on. Called once at bootstrap.
* @param {unknown} _scene Unused: the layer draws through `engine`.
* @param {{engine?: object|null}} [options]
*/
init(viewer, options) {
return this.attachRenderer(viewer, options);
init(_scene, options) {
return this.attachRenderer(_scene, options);
},
attachRenderer(viewer, { engine = null } = {}) {
_viewer = viewer;
attachRenderer(_scene, { engine = null } = {}) {
_engine = engine;
if (engine?.kind === 'navara') {
_native = createNativeRenderer(engine);
_stepPoints = _native.steps;
} else {
_stepPoints = new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
viewer.scene.primitives.add(_stepPoints);
services.sprites.registerSpriteCollection('directions', _stepPoints);
services.sprites.restoreSpriteOrder(viewer);
}
_stepPoints.show = _enabled;
_display = engine ? createRenderer(engine) : null;
_display?.show(_enabled);
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, _enabled);
if (!_enabled) return;
const step = _selectedStep;
this.enable(viewer);
this.enable();
if (_a) placeMarker('a', _a);
if (_b) placeMarker('b', _b);
if (_route) drawRoute(_route);
@@ -1654,52 +1516,35 @@ export function createDirectionsLayer({
const selected = _selectedStep;
clearRouteGraphics();
_selectedStep = selected;
removeEntity(_markerA);
removeEntity(_markerB);
_markerA = _markerB = null;
_removeClickHandler();
services.picking.unregisterPickOwner('directions');
if (_native) _native.destroy();
else if (_stepPoints && _viewer) {
services.sprites.unregisterSpriteCollection('directions', _stepPoints);
_viewer.scene.primitives.remove(_stepPoints);
}
_display?.destroy();
_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.
* @param {Cesium.Viewer} viewer
*/
enable(viewer) {
/** Show the row chips and listen for placement clicks. */
enable() {
_enabled = true;
_pointerBlocked = false;
if (_stepPoints) _stepPoints.show = true;
_display?.show(true);
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, true);
_installClickHandler(viewer);
_installClickHandler();
services.picking.registerPickOwner(
'directions',
(pickedId) =>
typeof pickedId === 'string' && pickedId.startsWith('directions:'),
);
syncRenderHold();
if (viewer) services.sprites.restoreSpriteOrder(viewer);
},
/**
* Remove the route, markers and listeners.
* @param {Cesium.Viewer} viewer
*/
disable(viewer) {
/** Remove the route, markers and listeners. */
disable() {
_enabled = false;
clearAll();
_overlayHost.setVisible(DIRECTIONS_STEP_OVERLAY_SOURCE_ID, false);
_removeClickHandler();
services.picking.unregisterPickOwner('directions');
if (_stepPoints) _stepPoints.show = false;
syncRenderHold();
void viewer;
_display?.show(false);
},
/** Nothing to poll: routes are requested on placement and mode change. */
@@ -1797,12 +1642,9 @@ export function createDirectionsLayer({
}
},
/**
* Tear down entirely.
* @param {Cesium.Viewer} viewer
*/
destroy(viewer) {
if (_enabled) this.disable(viewer);
/** Tear down entirely. */
destroy() {
if (_enabled) this.disable();
// 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
// outlive the whole application.
@@ -1814,14 +1656,13 @@ export function createDirectionsLayer({
_rowControlsListener = null;
this.detachRenderer();
_overlayHost.clearSource(DIRECTIONS_STEP_OVERLAY_SOURCE_ID);
_viewer = null;
},
/**
* 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
* ScreenSpaceEventHandler needs a real canvas, and the ownership timing
* this decides is exactly what has to be tested.
* the only caller in the running app; it is on the instance because the
* ownership timing this decides is exactly what has to be tested without
* a click.
*/
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 { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js';
import * as geo from '../../geo/index.js';
import {
plausibleSurfaceHeight,
sampledSurfaceHeight,
terrainSurfaceHeight,
} from './view.js';
/** Fly the globe camera once to frame a photo after it opens. */
export function createCameraFraming({ state }) {
@@ -8,7 +12,7 @@ export function createCameraFraming({ state }) {
* nothing newer took the camera. Without it, framing flies directly.
*/
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;
function attachNavigation(next) {
@@ -27,14 +31,10 @@ export function createCameraFraming({ state }) {
* ellipsoid before tiles load, so only a plausible height counts.
*/
function groundHeightAt(lon, lat, fallback) {
const scene = state.viewer?.scene;
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
const engine = state.viewer?.engine;
let height = engine
? (engine.surface.sampleHeight(carto) ??
engine.surface.terrainHeight(carto))
: sampledSurfaceHeight(scene, carto);
if (height === null) height = scene?.globe?.getHeight?.(carto);
const engine = state.map?.engine;
const carto = geo.cartographic.fromDegrees(lon, lat);
let height = sampledSurfaceHeight(engine, carto);
if (height === null) height = terrainSurfaceHeight(engine, carto);
if (!plausibleSurfaceHeight(height)) height = fallback;
return plausibleSurfaceHeight(height) ? height : 0;
}
@@ -45,7 +45,8 @@ export function createCameraFraming({ state }) {
*/
function frame(ticket) {
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.
if (ticket.generation !== null && !navigation?.reassert(ticket.generation))
return;
@@ -57,38 +58,19 @@ export function createCameraFraming({ state }) {
const release = () => {
if (flight === current) flight = null;
};
if (state.viewer.engine) {
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,
),
engine.camera.flyToSphere(
{
offset: new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(bearing || 0),
Cesium.Math.toRadians(-32),
140,
center: geo.cartesian3.fromDegrees(
position.lon,
position.lat,
ground + 2,
),
radius: 4,
},
{
heading: geo.math.toRadians(bearing || 0),
pitch: geo.math.toRadians(-32),
range: 140,
duration: 1.6,
complete: release,
cancel: release,
@@ -101,7 +83,7 @@ export function createCameraFraming({ state }) {
if (!flight) return;
// Give up ownership first: cancelFlight delivers `cancel` synchronously.
flight = null;
state.viewer?.camera?.cancelFlight?.();
state.map?.engine?.camera.cancelFlight();
}
return { attachNavigation, begin, frame, cancel };
+35 -27
View File
@@ -1,24 +1,21 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.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. */
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
* under the photo load) and whose globe terrain is `globe`.
* A display whose surface sample is `sampled` (e.g. −14,886 m before the
* tiles under the photo load) and whose terrain is `globe`.
*/
function setup({ sampled, globe = null, altitude = 149 }) {
const viewer = fakeCesiumViewer();
Object.assign(viewer.scene, {
sampleHeightSupported: true,
sampleHeight: () => sampled,
globe: { getHeight: () => globe },
});
const harness = streetLevelMap();
harness.engine.fake.script('sampleHeight', () => sampled);
harness.engine.fake.script('terrainHeight', () => globe ?? undefined);
const state = {
services: {},
street: {
@@ -26,13 +23,15 @@ function setup({ sampled, globe = null, altitude = 149 }) {
bearing: 105,
altitude,
},
viewer,
map: harness.map,
/** What the camera follows (an aircraft), released by a reassert. */
tracked: undefined,
};
return {
framing: createCameraFraming({ state }),
state,
viewer,
flights: viewer.flights.list,
harness,
flights: harness.flights.list,
};
}
@@ -41,6 +40,11 @@ test('framing a photo stands the sphere on a plausible scene sample', () => {
framing.frame(framing.begin());
assert.equal(flights.length, 1);
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', () => {
@@ -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', () => {
const { framing, viewer } = setup({ sampled: 121 });
const { framing, harness } = setup({ sampled: 121 });
const viewer = harness;
framing.frame(framing.begin());
framing.cancel();
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', () => {
const { framing, viewer, flights } = setup({ sampled: 121 });
const { framing, harness, flights } = setup({ sampled: 121 });
framing.frame(framing.begin());
viewer.flights.land();
harness.flights.land();
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());
// A search result flies the globe elsewhere; Cesium cancels ours first.
viewer.camera.flyToBoundingSphere(null, {});
// A search result flies the globe elsewhere; the engine cancels ours first.
harness.engine.camera.flyToSphere(
{ center: geo.cartesian3.fromDegrees(0, 0), radius: 1 },
{},
);
assert.equal(flights.length, 3);
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) {
if (ticket !== generation) return false;
state.viewer.trackedEntity = undefined;
state.tracked = undefined;
return true;
},
/** 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', () => {
const { framing, state, flights } = setup({ sampled: 160 });
state.viewer.trackedEntity = { id: 'aircraft' };
state.tracked = { id: 'aircraft' };
const nav = navigationFor(state);
framing.attachNavigation(nav);
const ticket = framing.begin();
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);
assert.equal(state.viewer.trackedEntity, undefined);
assert.equal(state.tracked, undefined);
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();
// The user flies to Sacramento, and then tracks an aircraft, while it loads.
nav.handOff();
state.viewer.trackedEntity = { id: 'aircraft' };
state.tracked = { id: 'aircraft' };
framing.frame(ticket);
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', () => {
+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
* visible attribution that goes away with the imagery.
* One static on-screen credit per active provider (engine.credits): CC
* BY-SA imagery needs visible attribution that goes away with the imagery.
* `view` is the layer's display (render/display.js).
*/
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();
function show(viewer, def) {
function show(view, def) {
if (shown.has(def.id)) return;
if (viewer?.engine) {
shown.set(
def.id,
viewer.engine.credits.addStatic(def.credit.html, { onScreen: true }),
);
return;
}
if (!viewer?.creditDisplay) return;
const credits = view?.engine?.credits;
if (!credits) return;
try {
const credit = new Cesium.Credit(def.credit.html, true);
viewer.creditDisplay.addStaticCredit(credit);
shown.set(def.id, credit);
shown.set(def.id, credits.addStatic(def.credit.html, { onScreen: true }));
} catch {
/* credit display unavailable */
}
}
function hide(viewer, def) {
const credit = shown.get(def.id);
if (!credit) return;
function hide(_view, def) {
const remove = shown.get(def.id);
if (!remove) return;
try {
if (typeof credit === 'function') credit();
else viewer?.creditDisplay?.removeStaticCredit?.(credit);
remove();
} catch {
/* credit display already torn down */
}
shown.delete(def.id);
}
function hideAll(viewer) {
for (const id of [...shown.keys()]) hide(viewer, { id });
function hideAll(view) {
for (const id of [...shown.keys()]) hide(view, { id });
}
return { show, hide, hideAll };
+8 -11
View File
@@ -3,11 +3,10 @@
// from is ignored and the lit provider keeps the panel.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createStreetLevelLayer } from './index.js';
import {
fakeCesiumViewer,
fakeStreetLevelProvider as fakeProvider,
streetLevelMap,
} from '../../testSupport/streetLevelFakes.mjs';
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -25,11 +24,6 @@ async function twoProviders(t) {
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
t.mock.method(
Cesium.GroundPolylinePrimitive,
'initializeTerrainHeights',
async () => {},
);
const lookups = [];
const previous = fakeProvider({
nearestImage: (point, { signal } = {}) => {
@@ -43,10 +37,13 @@ async function twoProviders(t) {
pickPrefix: 'pmx:',
nearestImage: async () => 'pmx-1',
});
const viewer = fakeCesiumViewer();
const layer = createStreetLevelLayer({ providers: [previous, lit] });
layer.init(viewer);
layer.enable(viewer);
const harness = streetLevelMap();
const layer = createStreetLevelLayer({
providers: [previous, lit],
services: { createDisplay: () => harness.map },
});
layer.init(null, { engine: harness.engine });
layer.enable();
layer.attachViewerHost({});
t.after(() => {
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';
/** Whether the camera is low enough for a click on the ground to mean a street. */
export function groundClickHeightOk(viewer) {
if (!cameraLocation(viewer)) return false;
return cameraHeightAboveGround(viewer) <= GROUND_CLICK_MAX_HEIGHT_M;
export function groundClickHeightOk(map) {
if (!cameraLocation(map)) return false;
return cameraHeightAboveGround(map) <= GROUND_CLICK_MAX_HEIGHT_M;
}
/**
@@ -18,9 +18,9 @@ export function groundClickHeightOk(viewer) {
export function createGroundClick({ state, openNearest, isAvailable }) {
return function openAtGround(screenPosition, picked) {
const scenePick = state.services.scenePick;
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
if (!state.enabled || !state.map || !scenePick?.pickGroundPosition)
return false;
if (!scenePick.isSurfacePick(picked) || !groundClickHeightOk(state.viewer))
if (!scenePick.isSurfacePick(picked) || !groundClickHeightOk(state.map))
return false;
const providerIds = [...state.providers.values()]
.filter(
@@ -32,9 +32,10 @@ export function createGroundClick({ state, openNearest, isAvailable }) {
)
.map((entry) => entry.def.id);
if (!providerIds.length) return false;
const position = state.viewer.engine
? state.viewer.engine.picker.pickGlobe(screenPosition)
: scenePick.pickGroundPosition(state.viewer, screenPosition);
const position = scenePick.pickGroundPosition(
state.map.engine,
screenPosition,
);
const carto = position && geo.cartographic.fromCartesian(position);
if (!carto) return false;
return openNearest(
+21 -22
View File
@@ -1,10 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.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 };
/**
@@ -22,6 +23,15 @@ function harness({
ellipsoid = undefined,
} = {}) {
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 = {
enabled,
services: { scenePick },
@@ -39,17 +49,7 @@ function harness({
],
),
),
viewer: {
scene: { pickPositionSupported: true, pickPosition: () => picked },
camera: {
pickEllipsoid: () => ellipsoid,
positionCartographic: Cesium.Cartographic.fromDegrees(
-121.4944,
38.5816,
height,
),
},
},
map: { engine },
};
const openAtGround = createGroundClick({
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', () => {
const { opened, openAtGround } = harness({
picked: new Cesium.Cartesian3(500, 0, 0),
picked: geo.cartesian3.create(500, 0, 0),
ellipsoid: GROUND,
});
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', () => {
const viewer = (height) => ({
camera: {
positionCartographic: Cesium.Cartographic.fromDegrees(0, 0, height),
},
scene: { globe: { getHeight: () => 1500 } },
});
const map = (height) => {
const engine = cameraEngine({ lon: 0, lat: 0, altitude: height });
engine.fake.script('terrainHeight', () => 1500);
return { engine };
};
// 1,500 m up a plateau, 2,000 m above it: still a street.
assert.equal(groundClickHeightOk(viewer(3500)), true);
assert.equal(groundClickHeightOk(viewer(5000)), false);
assert.equal(groundClickHeightOk(map(3500)), true);
assert.equal(groundClickHeightOk(map(5000)), false);
assert.equal(groundClickHeightOk(null), false);
});
+46 -50
View File
@@ -1,5 +1,4 @@
import * as geo from '../../geo/index.js';
import { whenCesiumSdk } from '../../app/loadCesium.js';
import { createState } from './state.js';
import { createMarker } from './marker.js';
import { createCameraFraming } from './cameraFraming.js';
@@ -17,6 +16,7 @@ import {
} from './uiState.js';
import { groundUnderCamera, viewCentre } from './view.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { createStreetLevelDisplay } from './render/display.js';
import { msg, t, translateKnown } from '../../i18n/index.js';
import {
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
* host, the position marker, the click handler and nearest-image lookups.
* 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
*/
export function createStreetLevelLayer({
@@ -179,9 +181,9 @@ export function createStreetLevelLayer({
for (const entry of state.providers.values()) {
if (!entry.def.requiresMapStack) continue;
if (state.enabled && entry.on && providerAvailable(entry))
parts.credits.show(state.viewer, entry.def);
parts.credits.show(state.map, entry.def);
else {
parts.credits.hide(state.viewer, entry.def);
parts.credits.hide(state.map, entry.def);
parts.viewerHost.unmount(entry.def.id);
}
}
@@ -203,13 +205,13 @@ export function createStreetLevelLayer({
*/
function syncGroundClickReady() {
cancelGroundRetry();
const ready = state.enabled && groundClickHeightOk(state.viewer);
const ready = state.enabled && groundClickHeightOk(state.map);
if (
state.enabled &&
state.viewer &&
state.map &&
!ready &&
groundRetries < GROUND_RETRY_LIMIT &&
groundUnderCamera(state.viewer) === null
groundUnderCamera(state.map) === null
) {
groundRetries++;
groundRetry = setTimeout(syncGroundClickReady, GROUND_RETRY_MS);
@@ -226,10 +228,10 @@ export function createStreetLevelLayer({
}
let removeCameraListener = null;
function watchCamera(viewer) {
function watchCamera(view) {
removeCameraListener?.();
removeCameraListener =
viewer?.camera?.moveEnd?.addEventListener(remeasureGround) || null;
view?.engine?.camera.moveEnd.addEventListener(remeasureGround) || null;
}
const activeEntries = () =>
@@ -253,18 +255,18 @@ export function createStreetLevelLayer({
}
function activate(entry) {
if (!state.viewer) return;
entry.instance.activate(state.viewer);
if (!state.map) return;
entry.instance.activate(state.map);
// Without a known key, a switch-on asks again: the server may be back,
// or have a key now.
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) {
parts.viewerHost.unmount(entry.def.id);
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. */
@@ -277,15 +279,11 @@ export function createStreetLevelLayer({
/** Ground under the screen centre; null for sky, undefined when it cannot aim. */
function screenCentreGround() {
const canvas =
state.viewer?.engine?.host.canvas ?? state.viewer?.scene?.canvas;
const engine = state.map?.engine;
const size = engine?.host.size();
const pick = state.services.scenePick?.pickGroundPosition;
if (!pick || !canvas?.clientWidth || !canvas?.clientHeight)
return undefined;
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);
if (!pick || !size?.width || !size?.height) return undefined;
const position = pick(engine, { x: size.width / 2, y: size.height / 2 });
const carto = position && geo.cartographic.fromCartesian(position);
return carto
? {
@@ -498,16 +496,18 @@ export function createStreetLevelLayer({
};
},
init(viewer, options) {
this.attachRenderer(viewer, options);
init(scene, options) {
this.attachRenderer(scene, options);
for (const entry of state.providers.values()) checkStatus(entry);
},
/** Draws on `engine` through its display; the first argument is not read. */
attachRenderer(
viewer,
_scene,
{ engine = null, waitForCameraRestore = false } = {},
) {
const map = viewer ?? services.nativeRenderer?.(engine);
state.viewer = map;
const createDisplay = services.createDisplay ?? createStreetLevelDisplay;
const map = engine ? (createDisplay(engine) ?? null) : null;
state.map = map;
waitingForView = waitForCameraRestore;
if (!map) return;
parts.marker.ensure(map);
@@ -534,13 +534,13 @@ export function createStreetLevelLayer({
resumeViewport() {
if (!waitingForView) return;
waitingForView = false;
if (state.enabled && state.viewer) {
if (state.enabled && state.map) {
for (const entry of activeEntries()) entry.instance.refreshCoverage?.();
remeasureGround();
}
},
detachRenderer() {
const map = state.viewer;
const map = state.map;
if (!map) return;
waitingForView = true;
removeCameraListener?.();
@@ -552,26 +552,22 @@ export function createStreetLevelLayer({
if (entry.instance.detachMap) entry.instance.detachMap();
else if (entry.on) entry.instance.deactivate();
}
parts.marker.destroy(map);
parts.marker.destroy();
parts.credits.hideAll(map);
map.destroy?.();
unsubscribeMapStack?.();
unsubscribeMapStack = null;
mapStack = null;
state.viewer = null;
state.map = null;
},
enable(viewer, { signal } = {}) {
// The marker, cones, coverage and ground picks use Cesium's math and
// primitives on either globe: a Navara page loads the Cesium SDK first.
return whenCesiumSdk(() => {
state.enabled = true;
if (viewer) state.viewer = viewer;
parts.marker.setVisible(true);
parts.selection.install(state.viewer);
for (const entry of activeEntries()) activate(entry);
watchCamera(state.viewer);
remeasureGround();
notify();
}, signal);
enable() {
state.enabled = true;
parts.marker.setVisible(true);
parts.selection.install(state.map);
for (const entry of activeEntries()) activate(entry);
watchCamera(state.map);
remeasureGround();
notify();
},
disable() {
@@ -583,7 +579,7 @@ export function createStreetLevelLayer({
parts.viewerHost.unmount();
for (const entry of state.providers.values()) entry.instance.deactivate();
syncGroundClickReady();
parts.credits.hideAll(state.viewer);
parts.credits.hideAll(state.map);
parts.selection.uninstall();
parts.marker.setVisible(false);
notify();
@@ -594,17 +590,17 @@ export function createStreetLevelLayer({
},
destroy() {
const viewer = state.viewer;
const map = state.map;
layer.disable();
for (const entry of state.providers.values())
entry.instance.destroy(viewer);
parts.marker.destroy(viewer);
for (const entry of state.providers.values()) entry.instance.destroy();
parts.marker.destroy();
map?.destroy?.();
parts.framing.attachNavigation(null);
unsubscribeMapStack?.();
unsubscribeMapStack = null;
mapStack = null;
state.listeners.clear();
state.viewer = null;
state.map = null;
},
getStats() {
@@ -716,7 +712,7 @@ export function createStreetLevelLayer({
return false;
}
}
view ||= viewCentre(state.viewer);
view ||= viewCentre(state.map);
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
return false;
abortNearest();
+195 -161
View File
@@ -1,21 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import { createStreetLevelLayer } from './index.js';
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
import { createMapillaryProvider } from './providers/mapillary/index.js';
import { createKartaViewProvider } from './providers/kartaview/index.js';
import {
fakeCesiumViewer,
fakeMapillarySource,
fakePhotoViewer,
fakeStreetLevelProvider as fakeProvider,
streetLevelMap,
} from '../../testSupport/streetLevelFakes.mjs';
import { setLocale } from '../../i18n/index.js';
/**
* A layer over `source` and `photoViewer`, initialised on a stand-in viewer
* and (unless `enable` is false) enabled.
* A layer drawing on `harness` (the display on the fake engine,
* 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(
t,
@@ -30,22 +43,15 @@ async function startLayer(
const saved = globalThis.document;
// The click handler listens for Esc on the document.
globalThis.document = new EventTarget();
// Skip Cesium's one-time terrain table download for draped lines.
t.mock.method(
Cesium.GroundPolylinePrimitive,
'initializeTerrainHeights',
async () => {},
);
const viewer = fakeCesiumViewer({ view });
const layer = createStreetLevelLayer({ providers });
layer.init(viewer);
if (enable) layer.enable(viewer);
const harness = streetLevelMap({ view });
const layer = mountLayer(harness, providers);
if (enable) layer.enable();
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
await settle();
return { layer, viewer, source, photoViewer };
return { layer, harness, source, photoViewer };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -65,8 +71,8 @@ const posingViewer = () =>
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) => {
const { layer, viewer } = await startLayer(t);
const shown = () => viewer.credits.map((credit) => credit.html);
const { layer, harness } = await startLayer(t);
const shown = () => harness.credits();
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on enable');
assert.match(shown()[0], /CC BY-SA 4\.0/);
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 };
},
});
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(layer.getUIState().keyRequired, true);
up = true; // the server is reachable again
layer.disable();
layer.enable(viewer);
layer.enable();
await settle();
assert.equal(asked, 2);
assert.equal(layer.getUIState().keyRequired, false);
assert.ok(source.calls.tiles.length > 0, 'coverage loads');
// A known key is not asked again.
layer.disable();
layer.enable(viewer);
layer.enable();
await settle();
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));
}),
});
const { layer, viewer } = await startLayer(t, { source, view: true });
const { layer, harness } = await startLayer(t, { source, view: true });
assert.equal(asked, 1);
assert.equal(timeouts[0].ms, 10_000, 'the check can be cut short');
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().keyRequired, true, 'no token: KEY REQUIRED');
layer.disable();
layer.enable(viewer);
layer.enable();
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;
},
});
const { layer, viewer } = await startLayer(t, { source, view: true });
const { layer, harness } = await startLayer(t, { source, view: true });
await settle();
const stats = layer.getStats();
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();
assert.deepEqual([ui.keyRequired, ui.keyRejected], [true, true]);
layer.disable();
layer.enable(viewer);
layer.enable();
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) => {
const { layer, viewer } = await startLayer(t, {
const { layer, harness } = await startLayer(t, {
photoViewer: posingViewer(),
});
layer.attachViewerHost({});
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();
assert.equal(viewer.flights.cancelled, 1, 'closed');
assert.equal(harness.flights.cancelled, 1, 'closed');
await layer.openImage('mapillary', 'img2');
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');
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) => {
@@ -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) => {
const { layer, viewer, source, photoViewer } = await startLayer(t, {
const { layer, harness, source, photoViewer } = await startLayer(t, {
view: true,
enable: false,
});
@@ -279,18 +285,18 @@ test('switching Mapillary on while the layer is off draws nothing', async (t) =>
layer.setParams({ mapillary: true });
await settle();
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(layer.getUIState().providers[0].on, true, 'the switch is kept');
// Enabling the layer is what draws it.
layer.enable(viewer);
layer.enable();
await settle();
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) => {
const { layer, viewer } = await startLayer(t, { providers: [] });
const { layer, harness } = await startLayer(t, { providers: [] });
assert.deepEqual(layer.providerIds, []);
assert.deepEqual(layer.getUIState().providers, []);
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/,
);
layer.disable();
layer.enable(viewer);
layer.enable();
assert.deepEqual(layer.getUIState().providers, []);
});
// ── Several providers: one source at a time, Google 3D, nearest lookups ──
/**
* 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. */
/** A layer over `providers`, initialised and enabled on the fake engine. */
async function enabledLayer(t, providers = [fakeProvider()]) {
const saved = globalThis.document;
// 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(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const layer = createStreetLevelLayer({ providers });
layer.init(viewer);
layer.enable(viewer);
const harness = streetLevelMap();
const layer = mountLayer(harness, providers);
layer.enable();
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
await settle();
return { layer, viewer };
return { layer, harness };
}
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) => {
const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage });
const { layer, viewer } = await enabledLayer(t, [provider]);
const { layer, harness } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
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 });
layer.disable();
layer.enable(viewer);
layer.enable();
lookups[1].answer.resolve('img1');
assert.equal(await disabled, false, 'nor does it open once back on');
assert.deepEqual(provider.calls.open, []);
@@ -571,14 +520,13 @@ function initialisedLayer(t, providers) {
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const layer = createStreetLevelLayer({ providers });
layer.init(viewer);
const harness = streetLevelMap();
const layer = mountLayer(harness, providers);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
return { layer, viewer };
return { layer, harness };
}
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);
return instance;
};
const { layer, viewer } = initialisedLayer(t, [provider]);
const { layer, harness } = initialisedLayer(t, [provider]);
layer.attachViewerHost({});
layer.setProviderEnabled('mapillary', false);
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 });
await new Promise((resolve) => setTimeout(resolve, 40)); // past whenIdle
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.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
// Enabling the layer is what activates it, once.
layer.enable(viewer);
layer.enable();
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. */
@@ -642,11 +590,11 @@ async function withStreetView(
getApiKey: () => 'browser-key',
loader,
});
const { layer, viewer } = await enabledLayer(t, [mapillary, google]);
const { layer, harness } = await enabledLayer(t, [mapillary, google]);
const stack = fakeMapStack(mapStack);
layer.attachMapStackController(stack);
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) => {
@@ -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) => {
const { layer, viewer, stack } = await withStreetView(t, {
const { layer, harness, stack } = await withStreetView(t, {
mapStack: 'esri-imagery',
});
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 listed = () => ui().providers.map((p) => p.id);
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.equal(credited(), false, 'no Google credit on another map');
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(() => {
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'] });
const viewer = fakeViewer();
// 4 km up the ellipsoid, 2 km over a city whose tiles are still loading.
let ground;
viewer.camera.positionCartographic = { height: 4000 };
viewer.scene.globe = { show: false };
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => ground;
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
layer.init(viewer);
layer.enable(viewer);
harness.engine.fake.setPose({ height: 4000 });
harness.engine.fake.script('sampleHeight', () => ground);
const layer = mountLayer(harness, [fakeProvider()]);
layer.enable();
t.after(() => layer.destroy());
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);
});
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;
globalThis.document = new EventTarget();
t.after(() => {
globalThis.document = saved;
});
const harness = streetLevelMap();
await harness.engine.maps.setPhotorealTiles({ kind: 'photoreal' });
t.mock.timers.enable({ apis: ['setTimeout'] });
const viewer = fakeViewer();
let samples = 0;
viewer.camera.positionCartographic = { height: 4000 };
viewer.scene.globe = { show: false };
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => {
harness.engine.fake.setPose({ height: 4000 });
harness.engine.fake.script('sampleHeight', () => {
samples++;
return undefined;
};
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
layer.init(viewer);
layer.enable(viewer);
});
const layer = mountLayer(harness, [fakeProvider()]);
layer.enable();
t.after(() => layer.destroy());
t.mock.timers.tick(60_000);
const measured = samples;
assert.ok(measured > 1 && measured <= 12, `measured ${measured} times`);
viewer.camera.moveEnd.raiseEvent();
harness.engine.fake.emit('camera.moveEnd');
layer.disable();
const atDisable = samples;
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) => {
const { layer, viewer } = await withStreetView(t);
const { layer, harness } = await withStreetView(t);
layer.setProviderEnabled('google', true);
await settle();
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 },
);
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) => {
@@ -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(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0);
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined),
const harness = streetLevelMap();
const centre = geo.cartesian3.fromDegrees(-121.4937, 38.5799, 0);
harness.engine.fake.script('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;
viewer.scene.canvas.clientHeight = 600;
const layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
layer.init(null, { engine: harness.engine });
layer.enable();
t.after(() => {
layer.destroy();
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(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const harness = streetLevelMap();
// Every ground pick misses: the middle of the screen is sky.
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: () => undefined,
harness.engine.fake.script('pickPosition', undefined);
harness.engine.fake.script('pickEllipsoid', undefined);
harness.engine.fake.setSize(1000, 600);
const layer = create({
providers: [provider],
services: { scenePick, createDisplay: () => harness.map },
});
viewer.camera.pickEllipsoid = () => undefined;
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
const layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
layer.init(null, { engine: harness.engine });
layer.enable();
t.after(() => {
layer.destroy();
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 { layer, viewer } = await enabledLayer(t, [failing, other]);
const { layer, harness } = await enabledLayer(t, [failing, other]);
layer.attachViewerHost({});
await layer.openNearest({ lat: 1, lon: 1 });
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.
await other.context().actions.openImage('img-1', { frame: false });
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) => {
const provider = posingProvider();
const { layer, viewer } = await enabledLayer(t, [provider]);
const { layer, harness } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
await provider.context().actions.openImage('img-1', { frame: false });
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');
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) => {
@@ -1040,7 +985,7 @@ test('a provider’s openNearest (a line click) asks only that provider and leav
pickPrefix: 'oth:',
nearestImage: async () => 'other-near',
});
const { layer, viewer } = await enabledLayer(t, [lit, other]);
const { layer, harness } = await enabledLayer(t, [lit, other]);
layer.attachViewerHost({});
const point = { lat: 38.58, lon: -121.49 };
// 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, []);
await lit.context().actions.openNearest(point);
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) => {
@@ -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 { groundUnderCamera, viewFocus, visibleBbox } from './view.js';
import { tilesForBbox } from './tileMath.js';
import { msg } from '../../i18n/index.js';
/** Sequences per primitive: smaller batches build, and show, sooner. */
const SEQUENCE_PRIMITIVE_BATCH = 120;
/** Line alpha of the coverage; the selected sequence is drawn opaque. */
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. */
export const ZOOM_IN_HINT = msg('Zoom in to see street-level coverage');
@@ -45,48 +47,10 @@ export function freshCoverage({ keyless = false } = {}) {
blockTimer: null,
debounceTimer: null,
removeCameraListener: null,
terrainReady: null,
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) {
let count = 0;
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
* ground only, draped on terrain and 3D tiles alike. Nothing is drawn from
* orbit.
* ground only, draped on terrain and 3D tiles alike (the display's ground
* corridors, render/display.js). Nothing is drawn from orbit.
*
* @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
* A decoded tile; sequences are `{id, parts, capturedAt, isPano}`. A
* rejection may carry `keyRequired`, `keyRejected` or `retryAfterSec`.
@@ -165,12 +129,6 @@ export function createLineCoverage({
render?.governorRequestRender?.(renderKey);
}
function ensureTerrainReady() {
if (state.viewer?.engine) return;
return (state.coverage.terrainReady ||=
Cesium.GroundPolylinePrimitive.initializeTerrainHeights());
}
function tileKey({ x, y, z }) {
return `${z}/${x}/${y}`;
}
@@ -209,35 +167,25 @@ export function createLineCoverage({
return drawn;
}
/** Batched draped primitives for a tile's sequences. */
function buildSequencePrimitives(sequences) {
const instances = lineInstances(
sequences,
Cesium.Color.fromCssColorString(colors.coverage).withAlpha(0.92),
tuning.lineWidthPx,
pickPrefix,
/** The display's lines for `sequences` (`color` CSS, at `alpha`), or null. */
function drawLines(sequences, id, style) {
return (
state.map?.sequenceLines?.(sequences, {
id: `${renderKey}:${id}`,
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
* 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) {
const scene = state.viewer?.scene;
if (state.coverage.highlight) {
if (state.viewer?.engine) state.coverage.highlight.destroy();
else scene?.groundPrimitives?.remove(state.coverage.highlight);
state.coverage.highlight = null;
}
if (!sequenceId || (!state.viewer?.engine && !scene)) return;
state.coverage.highlight?.destroy();
state.coverage.highlight = null;
if (!sequenceId || !state.map) return;
// From every loaded line, drawn or not: a sequence selected after its
// tiles were drawn may have been left out by their caps.
const current = filter();
@@ -246,56 +194,36 @@ export function createLineCoverage({
const sequence = entry.sequenceList.find(({ id }) => id === sequenceId);
if (sequence && passes(sequence, current)) parts.push(...sequence.parts);
}
if (state.viewer?.engine) {
state.coverage.highlight = state.viewer.lines(
[{ id: sequenceId, parts }],
colors.selected,
tuning.lineWidthPx + 1,
pickPrefix,
);
requestRender();
return;
}
const instances = lineInstances(
state.coverage.highlight = drawLines(
[{ id: sequenceId, parts }],
Cesium.Color.fromCssColorString(colors.selected),
tuning.lineWidthPx + 1,
pickPrefix,
);
if (!instances.length) return;
state.coverage.highlight = scene.groundPrimitives.add(
linePrimitive(instances, false),
'selected',
{
color: colors.selected,
widthPx: tuning.lineWidthPx + 1,
order: HIGHLIGHT_ORDER,
},
);
requestRender();
}
function attachPrimitive(entry) {
const scene = state.viewer?.scene;
if (!scene && !state.viewer?.engine) return;
function attachPrimitive(entry, key) {
if (!state.map) return;
// Lookup, picking and counts follow what is drawn, not the whole tile.
const sequences = drawnSequences(entry);
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
if (state.viewer?.engine) {
const group = state.viewer.lines(
sequences,
colors.coverage,
tuning.lineWidthPx,
pickPrefix,
);
entry.primitives = group ? [group] : [];
return;
}
entry.primitives = buildSequencePrimitives(sequences);
for (const primitive of entry.primitives)
scene.groundPrimitives.add(primitive);
const lines = drawLines(sequences, key, {
color: colors.coverage,
alpha: COVERAGE_ALPHA,
widthPx: tuning.lineWidthPx,
order: COVERAGE_ORDER,
});
entry.primitives = lines ? [lines] : [];
}
function detachPrimitive(entry) {
const scene = state.viewer?.scene;
for (const primitive of entry.primitives || []) {
try {
if (state.viewer?.engine) primitive.destroy();
else scene?.groundPrimitives?.remove(primitive);
primitive.destroy();
} catch {
/* already gone */
}
@@ -318,12 +246,7 @@ export function createLineCoverage({
state.coverage.pending.set(key, controller);
notify();
try {
// Fetch and the one-time terrain-height table load run side by side,
// both handled from the start so an early tile failure is not unhandled.
const [decoded] = await Promise.all([
fetchTile(tile, { signal: controller.signal }),
ensureTerrainReady(),
]);
const decoded = await fetchTile(tile, { signal: controller.signal });
// 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.
if (
@@ -341,7 +264,7 @@ export function createLineCoverage({
? sequences
: sequences.sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0));
entry.total = sequences.length;
attachPrimitive(entry);
attachPrimitive(entry, key);
state.coverage.tiles.set(key, entry);
state.coverage.lastError = null;
// 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 zoom for its height above ground, nearest the camera first.
*/
function wantedTiles(viewer) {
const ground = groundUnderCamera(viewer);
const cameraHeight =
viewer.engine?.camera.pose().cartographic.height ??
viewer.camera?.positionCartographic?.height;
function wantedTiles(map) {
const ground = groundUnderCamera(map);
const cameraHeight = map.engine?.camera.height();
const height = Number.isFinite(cameraHeight)
? cameraHeight - (ground ?? 0)
: null;
@@ -436,28 +357,28 @@ export function createLineCoverage({
height * tuning.viewRangePerHeight,
),
};
const bbox = visibleBbox(viewer, ranged);
const bbox = visibleBbox(map, ranged);
if (!bbox) return { hint: NO_GROUND_HINT };
// Ranked from between the camera's ground point and the screen centre's:
// a tilted view's box centre can sit kilometres past both.
const tiles = tilesForBbox(bbox, zoom, {
limit: tuning.maxTiles,
from: viewFocus(viewer, ranged),
from: viewFocus(map, ranged),
});
return { zoom, tiles };
}
/** Recompute the tile set for the current camera and reconcile primitives. */
function refresh() {
const viewer = state.viewer;
const map = state.map;
if (
!viewer ||
!map ||
state.context.waitingForView?.() ||
!state.context.isActive() ||
state.coverage.blocked
)
return;
const { zoom, tiles, hint } = wantedTiles(viewer);
const { zoom, tiles, hint } = wantedTiles(map);
if (hint) {
state.coverage.hint = hint;
clear();
@@ -485,10 +406,11 @@ export function createLineCoverage({
state.coverage.debounceTimer = setTimeout(refresh, tuning.moveDebounceMs);
}
function attach(viewer) {
function attach(map) {
detach();
const remove = viewer.camera.changed.addEventListener(scheduleRefresh);
const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh);
const { camera } = map.engine;
const remove = camera.changed.addEventListener(scheduleRefresh);
const removeEnd = camera.moveEnd.addEventListener(scheduleRefresh);
state.coverage.removeCameraListener = () => {
remove();
removeEnd();
@@ -515,9 +437,9 @@ export function createLineCoverage({
/** Rebuild every loaded tile from its decoded lines (after a filter change). */
function rebuild() {
for (const entry of state.coverage.tiles.values()) {
for (const [key, entry] of state.coverage.tiles) {
detachPrimitive(entry);
attachPrimitive(entry);
attachPrimitive(entry, key);
}
highlight();
requestRender();
+86 -108
View File
@@ -1,6 +1,5 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
coverageZoom,
createLineCoverage,
@@ -10,7 +9,11 @@ import {
import { resolveFilter } from './filter.js';
import { encodeCoverageTile } from './providers/mapillary/coverageFixture.mjs';
import { decodeCoverageTile } from './providers/mapillary/decode.js';
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
import {
cameraEngine,
coverageMap,
streetLevelMap,
} from '../../testSupport/streetLevelFakes.mjs';
import {
COLORS,
COVERAGE_TUNING,
@@ -50,26 +53,9 @@ function createCoverage({ state, source, zoomFor = coverageZoomForHeight }) {
}
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));
/** A scene collection that remembers what is on the globe. */
function collection() {
const items = new Set();
return {
items,
add(primitive) {
items.add(primitive);
return primitive;
},
remove(primitive) {
return items.delete(primitive);
},
};
}
/** Ground sets of the Mapillary coverage (and its highlight). */
const PREFIX = 'mapillary-coverage:';
/** The centre of the z14 tile holding a point, so a small view is one tile. */
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. */
function deferredSource() {
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 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 state = {
viewer,
map: viewer.map,
services: {},
enabled: true,
providerOn: true,
@@ -141,9 +104,8 @@ function setup({ zoomFor } = {}) {
},
coverage: {
...freshCoverage(),
// The key status is in; skip Cesium's one-time terrain table download.
// The key status is in.
blocked: null,
terrainReady: Promise.resolve(),
},
sequence: { selectedId: null },
};
@@ -162,9 +124,7 @@ function setup({ zoomFor } = {}) {
],
});
const tileKey = `14/${centre.tile.x}/${centre.tile.y}`;
const onGlobe = () =>
viewer.scene.groundPrimitives.items.size +
viewer.scene.primitives.items.size;
const onGlobe = () => viewer.groundSets().length + viewer.moverSets().length;
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 () => {
const { viewer, source, coverage, bytes } = setup();
const { viewer, source, coverage, bytes, centre, tileKey } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const { groundPrimitives, primitives } = viewer.scene;
assert.equal(primitives.items.size, 0);
assert.equal(groundPrimitives.items.size, 1);
const [line] = groundPrimitives.items;
assert.ok(line instanceof Cesium.GroundPolylinePrimitive);
assert.equal(line.classificationType, Cesium.ClassificationType.BOTH);
assert.equal(viewer.moverSets().length, 0);
const sets = viewer.groundSets(PREFIX);
assert.equal(sets.length, 1);
const [line] = sets;
assert.ok(line.id.startsWith(`${PREFIX}${tileKey}#`), line.id);
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();
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 () => {
@@ -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);
source.calls[0].resolve(bytes);
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, '');
coverage.clear();
});
@@ -276,11 +265,11 @@ test('from orbit nothing is requested or drawn, and the hint says to zoom in', a
coverage.refresh();
source.calls[0].resolve(bytes);
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;
coverage.refresh();
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(coverage.stats().hint, ZOOM_IN_HINT);
viewer.view.height = 900;
@@ -447,24 +436,10 @@ test('a sequence that crosses a tile edge is counted once', async () => {
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
* 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) {
const z = 14;
@@ -475,20 +450,21 @@ function denverStreetView(aheadM) {
const lat = (tile.south + tile.north) / 2;
const ground = 1600;
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);
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,
lat,
height: ground + above,
altitude: ground + above,
heading: 90,
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();
const keys = context.source.calls.map((call) => call.key);
/** 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();
source.calls[0].resolve(bytes);
await settle();
const lines = () => [...viewer.scene.groundPrimitives.items];
const lines = () => viewer.groundSets(PREFIX);
assert.equal(lines().length, 1, 'the coverage');
coverage.highlight('seq-1');
assert.equal(lines().length, 2);
const highlight = lines().at(-1);
assert.equal(state.coverage.highlight, highlight, 'drawn last, on top');
assert.equal(highlight.classificationType, Cesium.ClassificationType.BOTH);
assert.deepEqual(state.coverage.highlight.ids(), [highlight.id]);
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');
assert.equal(lines().length, 1, 'nothing to highlight off the map');
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);
await settle();
assert.ok(state.coverage.highlight, 'highlighted once its tile is in');
assert.equal(
[...viewer.scene.groundPrimitives.items].at(-1),
state.coverage.highlight,
);
const [coverageSet, highlightSet] = viewer.groundSets(PREFIX);
assert.deepEqual(state.coverage.highlight.ids(), [highlightSet.id]);
assert.ok(highlightSet.options.order > coverageSet.options.order);
coverage.clear();
});
@@ -566,12 +550,12 @@ test("a zoom change drops the old zoom's lines", async () => {
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const [old] = viewer.scene.groundPrimitives.items;
const [old] = viewer.groundSets(PREFIX);
assert.ok(old, 'the z14 lines are drawn');
viewer.view.height = 3000; // zoom out to z13
coverage.refresh();
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');
coverage.clear();
});
@@ -600,12 +584,8 @@ test('a rate limit holds every refresh inside its wait, not only the first', asy
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();
let terrainLoaded;
state.coverage.terrainReady = new Promise((resolve) => {
terrainLoaded = resolve;
});
const unhandled = [];
const onUnhandled = (reason) => unhandled.push(reason);
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.deepEqual(unhandled, []);
} finally {
terrainLoaded();
await settle();
process.off('unhandledRejection', onUnhandled);
}
});
+24 -48
View File
@@ -1,7 +1,5 @@
import * as Cesium from 'cesium';
import { positionMarkerGlyph, viewConeMarkerGlyph } from './glyphs.js';
import { COLORS, POSITION_PICK_ID } from './policy.js';
import { createHorizonCull } from './view.js';
import { COLORS, MARKER_SPRITE_ORDER, POSITION_PICK_ID } from './policy.js';
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 }) {
const { render, sprites } = state.services;
const { render } = state.services;
function requestRender() {
render?.governorRequestRender?.('street-level-marker');
}
// It skips the depth test: hide it while it is behind the globe.
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: () => (state.marker.billboard ? [state.marker.billboard] : []),
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,
function ensure(view) {
if (state.marker.collection || !view?.sprites) return;
state.marker.collection = view.sprites(SPRITE_ID, {
order: MARKER_SPRITE_ORDER,
});
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`. */
@@ -56,51 +44,39 @@ export function createMarker({ state }) {
if (!position) {
collection.removeAll();
state.marker.billboard = null;
horizon.stop();
requestRender();
return;
}
const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat);
const look = markerLook(style);
if (state.marker.billboard && state.marker.look !== look.key) {
collection.remove?.(state.marker.billboard);
collection.remove(state.marker.billboard);
state.marker.billboard = null;
}
const place = {
lon: position.lon,
lat: position.lat,
bearing: bearing || 0,
};
if (!state.marker.billboard) {
state.marker.look = look.key;
state.marker.billboard = collection.add({
id: POSITION_PICK_ID,
position: cartesian,
pickId: POSITION_PICK_ID,
image: look.image,
imageId: `sl-position:${look.key}`,
width: look.size,
height: look.size,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
imageKey: `sl-position:${look.key}`,
sizePx: look.size,
...place,
});
} else {
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]);
} else collection.move(state.marker.billboard, place);
requestRender();
}
function setVisible(visible) {
if (state.marker.collection) state.marker.collection.show = visible;
state.marker.collection?.show(visible);
requestRender();
}
function destroy(viewer) {
horizon.stop();
const collection = state.marker.collection;
if (!collection) return;
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
if (viewer?.engine) collection.destroy();
else viewer?.scene?.primitives?.remove(collection);
function destroy() {
state.marker.collection?.destroy();
state.marker.collection = null;
state.marker.billboard = null;
}
+102 -90
View File
@@ -1,112 +1,124 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.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. */
function setup() {
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
const preRender = new Set();
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 SET = 'street-level:marker';
/** The marker on the display of `harness` (an engine looking down on Sacramento). */
function setup(harness) {
const state = {
services: {},
viewer,
marker: {
// Stands in for the billboard collection; ensure() keeps it.
collection: {
show: true,
add: (options) => ({ ...options }),
remove() {},
removeAll() {},
},
billboard: null,
},
map: harness.map,
marker: { collection: null, billboard: null, look: null },
};
const marker = createMarker({ state });
const frame = () => {
for (const listener of [...preRender]) listener();
marker.ensure(harness.map);
/** 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 { state, view, preRender, marker, frame } = setup();
// Placed on the far side of the Earth from the camera.
marker.set({ lon: 58.51, lat: -38.58 }, 90);
const billboard = state.marker.billboard;
assert.equal(billboard.show, false, 'not drawn through the globe');
// 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');
});
const degreesOf = (position) => {
const carto = geo.cartographic.fromCartesian(position);
return {
lon: geo.math.toDegrees(carto.longitude),
lat: geo.math.toDegrees(carto.latitude),
height: carto.height,
};
};
test('destroying the marker stops the horizon cull', () => {
const { marker, preRender } = setup();
marker.set({ lon: -121.49, lat: 38.58 }, 0);
assert.equal(preRender.size, 1);
marker.destroy();
assert.equal(preRender.size, 0);
});
for (const create of STREET_LEVEL_MAPS) {
const name = create().name;
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', () => {
const { state, marker } = setup();
marker.set(SPOT, 45);
const billboard = state.marker.billboard;
assert.equal(
billboard.heightReference,
Cesium.HeightReference.CLAMP_TO_GROUND,
);
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45));
// Moving it keeps the same billboard.
marker.set({ lon: -121.5, lat: 38.6 }, 90);
assert.equal(state.marker.billboard, billboard);
const placed = Cesium.Cartographic.fromCartesian(billboard.position);
assert.ok(Math.abs(Cesium.Math.toDegrees(placed.longitude) + 121.5) < 1e-9);
assert.equal(billboard.rotation, -Cesium.Math.toRadians(90));
});
test(`the marker sits on the ground, over the scene, turned to the bearing (${name})`, () => {
const harness = create();
const { state, marker, drawn } = setup(harness);
marker.set({ lon: -121.4944, lat: 38.5816 }, 90);
let sprite = drawn();
const [set] = harness.moverSets(SET);
assert.equal(set.options.kind, 'sprite');
assert.equal(set.options.depthTest, 'always');
assert.equal(set.options.order, MARKER_SPRITE_ORDER);
assert.equal(set.options.pickable, true);
assert.equal(set.options.sprite.sizePx, 40);
assert.equal(sprite.pickId, POSITION_PICK_ID);
const placed = degreesOf(sprite.position);
assert.ok(Math.abs(placed.lon + 121.4944) < 1e-9);
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', () => {
const { state, marker } = setup();
const removed = [];
state.marker.collection.remove = (billboard) => removed.push(billboard);
test('a Street View photo gets a blue view cone; a Mapillary photo GEV’s pin', () => {
const harness = STREET_LEVEL_MAPS[0]();
const { state, marker, drawn } = setup(harness);
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;
assert.equal(cone.imageId, 'sl-position:view-cone:#4285f4');
assert.equal(
cone.rotation,
-Cesium.Math.toRadians(45),
assert.equal(cone.imageKey, 'sl-position:view-cone:#4285f4');
assert.equal(cone.sizePx, 48);
assert.ok(
Math.abs(drawn().rotation + Math.PI / 4) < 1e-6,
'it turns with the view',
);
// Dragging the panorama keeps the look and the billboard.
marker.set({ lon: -121.49, lat: 38.58 }, 90, streetView);
// Dragging the panorama keeps the look and the sprite.
marker.set({ lon: -121.4944, lat: 38.5816 }, 90, streetView);
assert.equal(state.marker.billboard, cone);
// A Mapillary photo next (its provider has no marker): the pin.
marker.set({ lon: -121.49, lat: 38.58 }, 90, undefined);
assert.equal(state.marker.billboard.imageId, 'sl-position:pin');
assert.deepEqual(removed, [cone]);
marker.set({ lon: -121.4944, lat: 38.5816 }, 90, undefined);
assert.equal(state.marker.billboard.imageKey, 'sl-position:pin');
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. */
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. */
export const PROVIDER_COLORS = Object.freeze({
mapillary: '#05cb63',
@@ -94,7 +94,7 @@ export function createKartaViewProvider({
const state = {
context,
services: context.services,
viewer: null,
map: null,
coverage: freshCoverage({ keyless: true }),
sequence: {
selectedId: null,
@@ -445,23 +445,23 @@ export function createKartaViewProvider({
return { configured: true };
},
init(cesiumViewer) {
state.viewer = cesiumViewer;
cones.ensureCollections(cesiumViewer);
init(map) {
state.map = map;
cones.ensureCollections(map);
cones.setVisible(false);
},
activate(cesiumViewer) {
state.viewer = cesiumViewer;
activate(map) {
state.map = map;
cones.setVisible(true);
coverage.attach(cesiumViewer);
coverage.attach(map);
},
refreshCoverage: () => coverage.refresh(),
detachMap() {
coverage.releaseGraphics();
cones.releaseCollection(state.viewer);
state.viewer = null;
cones.releaseCollection();
state.map = null;
},
repaintMap() {
coverage.rebuild();
@@ -479,11 +479,11 @@ export function createKartaViewProvider({
masks.clear();
},
destroy(cesiumViewer) {
destroy() {
coverage.detach();
coverage.clear();
coverage.unblock();
cones.destroy(cesiumViewer);
cones.destroy();
tiles.clear();
masks.clear();
},
@@ -15,7 +15,8 @@ import {
import { validateProviders } from '../../registry.js';
import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs';
import {
coverageViewer,
coverageMap,
streetLevelMap,
tileCentre,
} 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) {
const Cesium = await import('cesium');
const drawing = new Proxy({}, { get: () => () => {}, set: () => true });
const saved = globalThis.document;
globalThis.document = {
createElement: () => ({ getContext: () => drawing }),
};
t.after(() => (globalThis.document = saved));
let collection = null;
env.instance.init({
scene: {
frameState: { mode: Cesium.SceneMode.SCENE3D },
updateHeight: () => () => {},
getHeight: () => undefined,
primitives: { add: (c) => (collection = c), remove() {} },
preRender: { addEventListener: () => () => {} },
},
camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) },
});
const harness = streetLevelMap();
// The cones are drawn hidden until the provider is switched on; they are
// read whether shown or not.
let group = null;
const sprites = harness.map.sprites;
harness.map.sprites = (...args) => (group = sprites(...args));
env.instance.init(harness.map);
// The cones paint on the next frame.
const ids = () => {
group?.show(true);
harness.frame();
return harness
.moverSets('street-level:kartaview-cones')
.flatMap((set) => set.instances)
.map((cone) => cone.pickId);
};
const cones = () => {
const indexes = [];
for (let i = 0; i < collection.length; i++)
indexes.push(Number(collection.get(i).id.split('-').at(-1)));
const indexes = ids().map((id) => Number(id.split('-').at(-1)));
return [Math.min(...indexes), Math.max(...indexes)];
};
cones.count = () => collection?.length ?? 0;
cones.count = () => ids().length;
return cones;
}
@@ -733,10 +736,7 @@ function coverageSetup(
return { query };
};
const centre = tileCentre(-83.0458, 42.3314);
const viewer = coverageViewer(
{ lon: centre.lon, lat: centre.lat, height },
t,
);
const harness = coverageMap({ lon: centre.lon, lat: centre.lat, height });
const tiles = [];
let hold = null;
env.source.getTile = (tile) => {
@@ -751,11 +751,12 @@ function coverageSetup(
};
return hold ? hold.then(() => answer) : Promise.resolve(answer);
};
env.instance.init(viewer);
t.after(() => env.instance.destroy(viewer));
env.instance.init(harness.map);
t.after(() => env.instance.destroy());
return {
...env,
viewer,
harness,
map: harness.map,
centre,
tiles,
masks,
@@ -779,8 +780,8 @@ function coverageSetup(
}
/** The camera came to rest: coverage refreshes once its debounce is over. */
async function moved(viewer) {
viewer.camera.moveEnd.raiseEvent();
async function moved(harness) {
harness.moveEnd();
await new Promise((resolve) =>
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.
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.deepEqual(env.masks, ['fieldOfView=360']);
// 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();
assert.ok(await until(() => env.instance.coverageStats().loading));
assert.equal(
env.viewer.scene.groundPrimitives.items.size,
env.harness.groundSets('kartaview-coverage:').length,
0,
'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)}`,
);
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.
env.instance.setFilter();
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) => {
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.deepEqual(env.tiles, [], 'nothing asked from up there');
const release = env.holdTiles();
env.viewer.view.height = 900;
await moved(env.viewer);
env.harness.view.height = 900;
await moved(env.harness);
assert.ok(await until(() => env.instance.coverageStats().loading));
assert.equal(env.instance.coverageStats().hint, '', 'not while it loads');
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));
await moved(env.viewer);
await moved(env.harness);
assert.ok(await until(() => env.instance.coverageStats().count === 1));
assert.equal(env.tiles.length, 1, 'kept while on');
// Switched off: the decoded lines are not kept, so on again asks again.
env.instance.deactivate();
env.instance.activate(env.viewer);
env.instance.activate(env.map);
assert.ok(await until(() => env.instance.coverageStats().count === 1));
assert.equal(env.tiles.length, 2);
});
@@ -40,7 +40,7 @@ function createProviderState(context) {
return {
context,
services: context.services,
viewer: null,
map: null,
coverage: freshCoverage(),
sequence: {
selectedId: null,
@@ -137,24 +137,24 @@ export function createMapillaryProvider({ source, photoViewer = null }) {
return { configured };
},
init(viewer) {
state.viewer = viewer;
parts.sequences.ensureCollections(viewer);
init(map) {
state.map = map;
parts.sequences.ensureCollections(map);
parts.sequences.setVisible(false);
},
activate(viewer) {
state.viewer = viewer;
activate(map) {
state.map = map;
parts.sequences.setVisible(true);
parts.coverage.attach(viewer);
parts.coverage.attach(map);
},
/** Clear lines and cones, forget refusals. */
refreshCoverage: () => parts.coverage.refresh(),
detachMap() {
parts.coverage.releaseGraphics();
parts.sequences.releaseCollection(state.viewer);
state.viewer = null;
parts.sequences.releaseCollection();
state.map = null;
},
repaintMap() {
parts.coverage.rebuild();
@@ -168,10 +168,10 @@ export function createMapillaryProvider({ source, photoViewer = null }) {
parts.sequences.setVisible(false);
},
destroy(viewer) {
destroy() {
parts.coverage.detach();
parts.coverage.clear();
parts.sequences.destroy(viewer);
parts.sequences.destroy();
},
/** The filter changed: redraw. Each draw reads it from the context. */
@@ -1,14 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createMapillaryProvider } from './index.js';
import { mapillaryImageUrl } from './policy.js';
import { sequenceIdFromPick } from '../../lineCoverage.js';
import { thinImages } from '../../sequenceCones.js';
import { validateProviders } from '../../registry.js';
import {
SACRAMENTO_VIEW,
cameraEngine,
fakeMapillarySource,
rayCamera,
streetLevelMap,
} from '../../../../testSupport/streetLevelFakes.mjs';
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. */
function streetViewer() {
const collection = () => ({
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() {},
},
};
}
/** The display looking straight down on downtown Sacramento from 900 m. */
const streetMap = () =>
streetLevelMap({ engine: cameraEngine(SACRAMENTO_VIEW) }).map;
test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => {
// Skip Cesium's one-time terrain table download for draped lines.
t.mock.method(
Cesium.GroundPolylinePrimitive,
'initializeTerrainHeights',
async () => {},
);
test('coverage asks for no tiles before the key status is known, and none without a key', async () => {
for (const configured of [false, true]) {
let answer;
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 viewer = streetViewer();
instance.init(viewer);
const map = streetMap();
instance.init(map);
const status = instance.status();
instance.activate(viewer);
instance.activate(map);
assert.equal(tiles.length, 0, 'nothing before the status answers');
answer({ 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(instance.coverageStats().keyRequired, true);
}
instance.destroy(viewer);
instance.destroy();
}
});
@@ -164,7 +164,7 @@ export function createPanoramaxProvider({
const state = {
context,
services: context.services,
viewer: null,
map: null,
coverage: freshCoverage({ keyless: true }),
sequence: {
selectedId: null,
@@ -256,9 +256,7 @@ export function createPanoramaxProvider({
* from high up the click may land tens of metres off the line.
*/
function lineClickRadius() {
const height =
state.viewer?.engine?.camera.pose().cartographic.height ??
state.viewer?.camera?.positionCartographic?.height;
const height = state.map?.engine?.camera.height();
if (!Number.isFinite(height)) return NEAREST_RADIUS_M;
return Math.min(
LINE_CLICK_RADIUS_MAX_M,
@@ -443,23 +441,23 @@ export function createPanoramaxProvider({
return { configured: true };
},
init(cesiumViewer) {
state.viewer = cesiumViewer;
cones.ensureCollections(cesiumViewer);
init(map) {
state.map = map;
cones.ensureCollections(map);
cones.setVisible(false);
},
activate(cesiumViewer) {
state.viewer = cesiumViewer;
activate(map) {
state.map = map;
cones.setVisible(true);
coverage.attach(cesiumViewer);
coverage.attach(map);
},
refreshCoverage: () => coverage.refresh(),
detachMap() {
coverage.releaseGraphics();
cones.releaseCollection(state.viewer);
state.viewer = null;
cones.releaseCollection();
state.map = null;
},
repaintMap() {
coverage.rebuild();
@@ -478,11 +476,11 @@ export function createPanoramaxProvider({
pictureLists.clear();
},
destroy(cesiumViewer) {
destroy() {
coverage.detach();
coverage.clear();
coverage.unblock();
cones.destroy(cesiumViewer);
cones.destroy();
},
/** 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 { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs';
import {
coverageViewer,
cameraEngine,
coverageMap,
streetLevelMap,
tileCentre,
} from '../../../../testSupport/streetLevelFakes.mjs';
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/);
});
/** 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) {
const Cesium = await import('cesium');
// The cone glyph draws on a canvas: any drawing call is fine.
const drawing = new Proxy({}, { get: () => () => {}, set: () => true });
const saved = globalThis.document;
@@ -357,26 +358,33 @@ async function withCones(t, env) {
createElement: () => ({ getContext: () => drawing }),
};
t.after(() => (globalThis.document = saved));
let collection = null;
env.instance.init({
scene: {
frameState: { mode: Cesium.SceneMode.SCENE3D },
updateHeight: () => () => {},
getHeight: () => undefined,
primitives: { add: (c) => (collection = c), remove() {} },
preRender: { addEventListener: () => () => {} },
},
camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) },
});
const harness = streetLevelMap();
// The cones are drawn hidden until the provider is switched on; they are
// read whether shown or not.
let group = null;
const sprites = harness.map.sprites;
harness.map.sprites = (...args) => (group = sprites(...args));
env.instance.init(harness.map);
// The cones paint on the next frame.
const pickIds = () => {
group?.show(true);
harness.frame();
return harness
.moverSets('street-level:panoramax-cones')
.flatMap((set) => set.instances)
.map((cone) => String(cone.pickId));
};
return {
count: () => collection?.length ?? 0,
ids: () =>
Array.from({ length: collection?.length ?? 0 }, (_, i) =>
String(collection.get(i).id).replace(PICK_PREFIX.image, ''),
),
count: () => pickIds().length,
ids: () => pickIds().map((id) => 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. */
const pictureTiles = (items) => (tile) => {
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 }),
];
const { instance, source } = setup({ items });
const height = (h) =>
instance.init({
camera: { positionCartographic: { height: h } },
scene: {
primitives: { add() {}, remove() {} },
preRender: { addEventListener: () => () => {} },
},
});
const height = (h) => instance.init(mapAt(h));
const click = () =>
instance.handlePick(`${PICK_PREFIX.sequence}line`, {
groundPoint: () => east(0),
@@ -473,7 +474,8 @@ test('a line clicked from high up looks for its sequence further off; the radius
'line-0',
'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);
click();
assert.equal(
@@ -636,12 +638,13 @@ function coverageSetup(t, { sequences = [], filter, height = 900 } = {}) {
},
{ filter },
);
const viewer = coverageViewer({ ...centre, height }, t);
env.instance.init(viewer);
t.after(() => env.instance.destroy(viewer));
const harness = coverageMap({ ...centre, height });
env.instance.init(harness.map);
t.after(() => env.instance.destroy());
return {
...env,
viewer,
harness,
map: harness.map,
centre,
holdTiles() {
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' }),
],
});
env.instance.activate(env.viewer);
env.instance.activate(env.map);
assert.ok(await until(() => env.instance.coverageStats().count === 2));
assert.equal(env.instance.coverageStats().keyRequired, false);
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'),
],
});
filtered.instance.activate(filtered.viewer);
filtered.instance.activate(filtered.map);
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) => {
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));
const ground = env.viewer.scene.groundPrimitives.items;
const drawn = ground.size;
const ground = () => env.harness.groundSets('panoramax-coverage:').length;
const drawn = ground();
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();
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) => {
const env = coverageSetup(t);
const release = env.holdTiles();
env.instance.activate(env.viewer);
env.instance.activate(env.map);
assert.ok(await until(() => env.instance.coverageStats().loading));
assert.equal(env.instance.coverageStats().hint, '');
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) => {
const env = coverageSetup(t, { height: 200_000, sequences: [line('a')] });
env.instance.activate(env.viewer);
env.instance.activate(env.map);
assert.ok(
await until(() => env.instance.coverageStats().hint === ZOOM_IN_HINT),
);
assert.equal(env.instance.coverageStats().count, 0);
assert.equal(env.instance.coverageStats().loading, false);
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) => {
@@ -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 },
sequences: [line('flat-only')],
});
env.instance.activate(env.viewer);
env.instance.activate(env.map);
assert.ok(
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 }),
];
const { instance, source } = setup({ items });
instance.init({
camera: { positionCartographic: { height: 300 } },
scene: {
primitives: { add() {}, remove() {} },
preRender: { addEventListener: () => () => {} },
},
});
instance.init(mapAt(300));
instance.handlePick(`${PICK_PREFIX.sequence}clicked`, {
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}`);
return getTile(tile, options);
};
env.instance.activate(env.viewer);
env.instance.activate(env.map);
assert.ok(await until(() => env.instance.coverageStats().count === 1));
const z14 = asked.length;
// Up a zoom band and back down: the z14 tile comes from memory.
// (Activating again refreshes at once, without the camera debounce.)
env.viewer.view.height = 3_000;
env.instance.activate(env.viewer);
env.harness.view.height = 3_000;
env.instance.activate(env.map);
assert.ok(await until(() => asked.some((key) => key.startsWith('13/'))));
assert.ok(await until(() => env.instance.coverageStats().count === 1));
const withZ13 = asked.length;
env.viewer.view.height = 900;
env.instance.activate(env.viewer);
env.harness.view.height = 900;
env.instance.activate(env.map);
assert.ok(await until(() => env.instance.coverageStats().count === 1));
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(asked.length, withZ13, 'nothing fetched again');
assert.ok(z14 > 0);
// Off and on: the memory went back, so the tile is fetched again.
env.instance.deactivate();
env.instance.activate(env.viewer);
env.instance.activate(env.map);
assert.ok(await until(() => env.instance.coverageStats().count === 1));
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';
/** 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 }) {
const { picking, input } = state.services;
/** The id carries a provider's prefix: that provider handles the pick. */
function onClick(click) {
const viewer = state.viewer;
if (!viewer || !state.enabled) return;
const engine = state.map?.engine;
if (!engine || !state.enabled || !click?.position) return;
if (input?.isPointerFree && !input.isPointerFree()) return;
const picked = viewer.engine
? click.picked
: viewer.scene.pick(
click.position,
CLICK_PICK_WINDOW_PX,
CLICK_PICK_WINDOW_PX,
);
const picked = engine.picker.pick(
click.position,
CLICK_PICK_WINDOW_PX,
CLICK_PICK_WINDOW_PX,
);
const id = picking?.resolvePickId
? picking.resolvePickId(picked)
: picked?.id;
@@ -29,20 +31,21 @@ export function createSelection({ state, parts }) {
// The position marker is the core's own: nothing to select.
if (!route.instance) return;
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). */
function groundPointAt(viewer, screenPosition) {
const position = viewer.engine
? viewer.engine.picker.pickGlobe(screenPosition)
: state.services.scenePick?.pickGroundPosition?.(viewer, screenPosition);
const carto = position && Cesium.Cartographic.fromCartesian(position);
function groundPointAt(engine, screenPosition) {
const position = state.services.scenePick?.pickGroundPosition?.(
engine,
screenPosition,
);
const carto = position && geo.cartographic.fromCartesian(position);
return carto
? {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
lon: geo.math.toDegrees(carto.longitude),
lat: geo.math.toDegrees(carto.latitude),
}
: null;
}
@@ -67,20 +70,9 @@ export function createSelection({ state, parts }) {
parts.clearSequences();
}
function install(viewer) {
if (state.clickHandler) return;
if (viewer.engine) {
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,
);
}
function install(map) {
if (state.clickHandler || !map?.engine) return;
state.clickHandler = map.engine.input.on('click', onClick);
document.addEventListener('keydown', onKeyDown);
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, parts.router.ownsPick);
}
@@ -88,7 +80,7 @@ export function createSelection({ state, parts }) {
function uninstall() {
// Nothing to undo for a layer that was never enabled.
if (!state.clickHandler) return;
if (!state.clickHandler.isDestroyed()) state.clickHandler.destroy();
state.clickHandler();
state.clickHandler = null;
document.removeEventListener('keydown', onKeyDown);
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID);
+91 -34
View File
@@ -1,11 +1,21 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import { createSelection } from './selection.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;
globalThis.document = new EventTarget();
// 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 = [];
/** What the pointer is over: a pick id, or nothing. */
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 = {
viewer,
map: map.map,
enabled: true,
clickHandler: null,
services: { picking: null, input: null, ...services },
@@ -72,16 +74,19 @@ function harness({ selected = false, services = {}, providers = [] } = {}) {
},
};
const selection = createSelection({ state, parts });
selection.install(viewer);
const action = (type) =>
state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]);
selection.install(map.map);
return {
state,
map,
sequences,
opened,
groundClicks,
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. */
key(key, target, { defaultPrevented = false } = {}) {
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();
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 {
h.scene.under = 'mly:seq:abc';
h.click();
assert.deepEqual(h.sequences.selects, ['abc']);
h.scene.under = 'mly:img:42';
assert.deepEqual(h.sequences.selects, [], 'the tool keeps it');
free = true;
h.state.enabled = false;
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'],
);
assert.deepEqual(h.sequences.selects, [], 'the layer is off');
h.state.enabled = true;
h.click();
assert.deepEqual(h.sequences.selects, ['abc']);
} finally {
h.restore();
}
@@ -228,9 +281,9 @@ test('a click on a provider line hands it the ground under the click, measured o
],
services: {
scenePick: {
pickGroundPosition: (viewer, position) => {
groundPicks.push(position);
return Cesium.Cartesian3.fromDegrees(-83.0458, 42.3314, 190);
pickGroundPosition: (engine, position) => {
groundPicks.push({ engine, position });
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();
assert.ok(Math.abs(point.lon + 83.0458) < 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 {
h.restore();
}
+29 -58
View File
@@ -1,12 +1,16 @@
import * as Cesium from 'cesium';
import { imageConeGlyph } from './glyphs.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';
/** Cones closer than this to the previous kept one are dropped. */
const IMAGE_CONE_MIN_SPACING_M = 3;
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.
@@ -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
* ground. A cone's pick id is `pickPrefix` + the image id; 360° images draw
* Image cones for one selected sequence, any provider's, on the ground
* (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`
* 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`).
* @param {{coverage: string}} options.colors
* @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 {(sequenceId: string|null) => void} [options.onHighlight]
* Highlight the sequence's coverage line (null: none).
@@ -60,7 +64,7 @@ export function createSequenceCones({
onHighlight = () => {},
passes = passesImageryFilter,
}) {
const { render, sprites } = state.services;
const { render } = state.services;
function requestRender() {
render?.governorRequestRender?.(renderKey);
@@ -70,41 +74,22 @@ export function createSequenceCones({
state.context.notify();
}
/** Drawn cone billboards. */
let cones = [];
// The cones skip the depth test: hide the ones behind the globe.
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,
function ensureCollections(view) {
if (state.sequence.collection || !view?.sprites) return;
state.sequence.collection = view.sprites(spriteId, {
order: CONE_SPRITE_ORDER,
});
viewer.scene.primitives.add(state.sequence.collection);
sprites?.registerSpriteCollection?.(spriteId, state.sequence.collection);
}
function clearCones() {
state.sequence.collection?.removeAll();
state.sequence.images = [];
cones = [];
horizon.stop();
}
function renderCones(images) {
const collection = state.sequence.collection;
if (!collection) return;
collection.removeAll();
cones = [];
// One colour per source; 360° images keep the ring shape.
const cone = imageConeGlyph({ size: 32, color: colors.coverage });
const ring = imageConeGlyph({
@@ -116,24 +101,17 @@ export function createSequenceCones({
const filter = state.context.getFilter();
for (const image of images) {
if (!passes(image, filter)) continue;
const billboard = collection.add({
id: `${pickPrefix}${image.id}`,
position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
collection.add({
pickId: `${pickPrefix}${image.id}`,
lon: image.lon,
lat: image.lat,
bearing: image.compassAngle,
image: image.isPano ? ring : cone,
imageId: image.isPano ? `${spriteId}:pano` : `${spriteId}:flat`,
width: IMAGE_CONE_SIZE_PX,
height: IMAGE_CONE_SIZE_PX,
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),
imageKey: image.isPano ? 'pano' : 'flat',
sizePx: IMAGE_CONE_SIZE_PX,
scaleByDistance: IMAGE_CONE_SCALE_BY_DISTANCE,
});
cones.push(billboard);
}
if (state.viewer?.engine) collection.changed();
else horizon.update();
requestRender();
}
@@ -142,7 +120,7 @@ export function createSequenceCones({
* reloads a selected one (a provider that loads part of a sequence moved on).
*/
async function select(sequenceId, { force = false } = {}) {
if (!sequenceId || (!state.viewer && !state.context.waitingForView?.()))
if (!sequenceId || (!state.map && !state.context.waitingForView?.()))
return;
// Already on screen, or already on its way.
if (
@@ -208,24 +186,17 @@ export function createSequenceCones({
}
function setVisible(visible) {
if (state.sequence.collection) state.sequence.collection.show = visible;
state.sequence.collection?.show(visible);
requestRender();
}
function releaseCollection(viewer) {
horizon.stop();
cones = [];
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 releaseCollection() {
state.sequence.collection?.destroy();
state.sequence.collection = null;
}
function destroy(viewer) {
function destroy() {
state.sequence.abort?.abort();
releaseCollection(viewer);
releaseCollection();
state.sequence.images = [];
state.sequence.selectedId = null;
}
+135 -64
View File
@@ -1,26 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.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));
/** 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;
},
};
}
const SPRITES = 'street-level:test-cones';
/** A source whose sequence lookups the test answers, one call at a time. */
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();
/** Every highlight asked of the coverage: a sequence id, or null for none. */
const highlights = [];
const state = {
services: {},
viewer: {},
map: harness.map,
filter: { pano: 'all', sinceDays: 0 },
context: {
getFilter: () => resolveFilter(state.filter),
@@ -64,7 +59,7 @@ function setup() {
sequence: {
selectedId: null,
images: [],
collection: cones(),
collection: null,
loading: false,
error: null,
abort: null,
@@ -87,13 +82,18 @@ function setup() {
},
colors: { coverage: '#05cb63' },
pickPrefix: 'mly:img:',
spriteId: 'street-level:test-cones',
spriteId: SPRITES,
renderKey: 'test-sequence',
onHighlight: (id) => highlights.push(id),
});
const drawn = () =>
state.sequence.collection.items.map((cone) => cone.id.split(':').at(-1));
return { state, source, sequences, highlights, drawn };
sequences.ensureCollections(harness.map);
/** The cones drawn after a frame, in drawing order. */
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 () => {
@@ -160,38 +160,61 @@ test('clearSelection cancels the lookup and drops the highlight', async () => {
assert.deepEqual(drawn(), [], 'the late answer draws nothing');
});
test('cones are clamped to the ground and turned to their compass angle', async () => {
const { state, source, sequences } = setup();
sequences.select('A');
source.last('A').resolve(records('A', 2));
await settle();
const items = state.sequence.collection.items;
assert.equal(items.length, 2);
for (const cone of items) {
assert.equal(cone.heightReference, Cesium.HeightReference.CLAMP_TO_GROUND);
assert.equal(cone.rotation, -Cesium.Math.toRadians(90));
}
});
for (const create of STREET_LEVEL_MAPS) {
const name = create().name;
test(`cones sit on the ground, over the scene, turned to their compass angle (${name})`, async () => {
const harness = create();
const { source, sequences, shown } = setup(harness);
sequences.select('A');
source.last('A').resolve([
...records('A', 2),
{
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 () => {
const { state, source, sequences } = setup();
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
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);
},
},
},
};
const harness = streetLevelMap({ view: true });
const { source, sequences, shown } = setup(harness);
sequences.select('A');
// One image under the camera, one near its antipode.
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();
const [near, far] = state.sequence.collection.items;
assert.equal(near.show, true, 'the near side stays visible');
assert.equal(far.show, false, 'the far side does not show through');
const ids = () => shown().map((cone) => cone.pickId);
assert.deepEqual(
ids(),
['mly:img:A-0'],
'the far side does not show through',
);
// Fly round to the other hemisphere: the two swap.
view.lon = 58.51;
view.lat = -38.58;
for (const listener of [...preRender]) listener();
assert.equal(near.show, false);
assert.equal(far.show, true);
harness.engine.fake.setPose({
longitude: geo.math.toRadians(58.51),
latitude: geo.math.toRadians(-38.58),
height: 20_000_000,
});
assert.deepEqual(ids(), ['mly:img:A-far']);
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 }) {
return {
services,
viewer: null,
map: null,
enabled: false,
listeners: new Set(),
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';
const engineOf = (view) => view?.engine ?? null;
/** The camera's Cartographic (radians, metres), or null without an engine. */
export const cameraLocation = (view) =>
view?.engine?.camera.pose().cartographic ??
view?.camera?.positionCartographic;
const canvasOf = (view) => view?.engine?.host.canvas ?? view?.scene?.canvas;
engineOf(view)?.camera.pose().cartographic ?? null;
/** 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. */
export function metresBetween(a, b) {
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
* underground before tiles load).
*/
export function sampledSurfaceHeight(scene, carto) {
export function sampledSurfaceHeight(engine, carto) {
let height = null;
try {
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto);
if (engine?.surface?.supported) height = engine.surface.sampleHeight(carto);
} catch {
/* not sampleable yet */
}
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
* Google 3D (globe hidden) the rendered surface counts, minus any roof above
* the camera.
*/
export function groundUnderCamera(viewer) {
const carto = cameraLocation(viewer);
const scene = viewer?.scene;
if (carto && viewer?.engine) {
const surface = viewer.engine.surface;
const h = surface.sampleHeight(carto) ?? surface.terrainHeight(carto);
return plausibleSurfaceHeight(h) && h < carto.height ? h : null;
export function groundUnderCamera(view) {
const engine = engineOf(view);
const carto = cameraLocation(view);
if (!carto) return null;
if (!tilesShown(engine)) {
const ground = terrainSurfaceHeight(engine, carto);
if (ground !== null) return ground;
}
if (!carto || !scene) return null;
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);
const sampled = sampledSurfaceHeight(engine, carto);
return sampled !== null && sampled < carto.height ? sampled : null;
}
/** The ellipsoid `height` metres above `ellipsoid` (the ground at that height). */
function raisedEllipsoid(ellipsoid, height) {
if (!Number.isFinite(height) || Math.abs(height) < 1) return ellipsoid;
const { x, y, z } = ellipsoid.radii;
return new Cesium.Ellipsoid(x + height, y + height, z + height);
/** WGS84 raised by `height` metres (the ground at that height), else WGS84. */
function raisedEllipsoid(height) {
const wgs84 = geo.ellipsoid.WGS84;
if (!Number.isFinite(height) || Math.abs(height) < 1)
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.
* `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;
try {
cartesian = engine
? engine.picker.pickGlobe(point)
: camera.pickEllipsoid(point, ellipsoid);
const { origin, direction } = engine.camera.pickRay(point);
cartesian = intersectEllipsoid(origin, direction, height);
} catch {
cartesian = null;
}
if (!cartesian) return null;
if (
Number.isFinite(maxRange) &&
(engine?.camera.livePosition || camera.positionWC) &&
Cesium.Cartesian3.distance(
cartesian,
engine?.camera.livePosition ?? camera.positionWC,
) > maxRange
geo.cartesian3.distance(cartesian, engine.camera.livePosition) > maxRange
)
return null;
return (
Cesium.Cartographic.fromCartesian(
cartesian,
engine ? Cesium.Ellipsoid.WGS84 : ellipsoid,
) || null
);
return geo.cartographic.fromCartesian(cartesian, ellipsoid) || null;
}
/** Camera height (m) above the ground under it. */
export function cameraHeightAboveGround(viewer) {
const carto = cameraLocation(viewer);
export function cameraHeightAboveGround(view) {
const carto = cameraLocation(view);
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. */
@@ -111,32 +130,22 @@ const BBOX_GRID = 5;
* to the ground (Denver is 1,600 m up); `maxRange` (m) drops far hits.
*/
export function visibleBbox(
viewer,
view,
{ groundHeight = null, maxRange = null } = {},
) {
const scene = viewer?.scene;
const camera = viewer?.camera;
if ((!scene && !viewer?.engine) || !camera) return null;
const canvas = canvasOf(viewer);
const width = canvas?.clientWidth || canvas?.width || 0;
const height = canvas?.clientHeight || canvas?.height || 0;
const engine = engineOf(view);
if (!engine) return null;
const { width, height } = canvasSize(view);
const hits = [];
if (width > 0 && height > 0) {
const ellipsoid = raisedEllipsoid(
scene?.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
groundHeight,
);
const point = new Cesium.Cartesian2();
const ellipsoid = raisedEllipsoid(groundHeight);
for (let i = 0; i <= BBOX_GRID; i++) {
for (let j = 0; j <= BBOX_GRID; j++) {
point.x = (width * i) / BBOX_GRID;
point.y = (height * j) / BBOX_GRID;
const carto = groundHit(
camera,
point,
engine,
{ x: (width * i) / BBOX_GRID, y: (height * j) / BBOX_GRID },
ellipsoid,
maxRange,
viewer.engine,
);
if (carto) hits.push(carto);
}
@@ -148,8 +157,8 @@ export function visibleBbox(
let east = -Infinity;
let north = -Infinity;
for (const carto of hits) {
const lon = Cesium.Math.toDegrees(carto.longitude);
const lat = Cesium.Math.toDegrees(carto.latitude);
const lon = geo.math.toDegrees(carto.longitude);
const lat = geo.math.toDegrees(carto.latitude);
west = Math.min(west, lon);
east = Math.max(east, lon);
south = Math.min(south, lat);
@@ -162,7 +171,7 @@ export function visibleBbox(
let west360 = Infinity;
let east360 = -Infinity;
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;
west360 = Math.min(west360, 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. */
export function cameraNadir(viewer) {
const carto = cameraLocation(viewer);
export function cameraNadir(view) {
const carto = cameraLocation(view);
if (!carto) return null;
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
lon: geo.math.toDegrees(carto.longitude),
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
* screen centre's ground, or the nadir when that is out of `maxRange`.
*/
export function viewFocus(
viewer,
{ groundHeight = null, maxRange = null } = {},
) {
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;
export function viewFocus(view, { groundHeight = null, maxRange = null } = {}) {
const nadir = cameraNadir(view);
const { width, height } = canvasSize(view);
if (!nadir || !width || !height) return nadir;
const hit = groundHit(
viewer.camera,
new Cesium.Cartesian2(width / 2, height / 2),
raisedEllipsoid(
scene?.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
groundHeight,
),
engineOf(view),
{ x: width / 2, y: height / 2 },
raisedEllipsoid(groundHeight),
maxRange,
viewer.engine,
);
if (!hit) return nadir;
const ahead = {
lon: Cesium.Math.toDegrees(hit.longitude),
lat: Cesium.Math.toDegrees(hit.latitude),
lon: geo.math.toDegrees(hit.longitude),
lat: geo.math.toDegrees(hit.latitude),
};
return {
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. */
export function viewCentre(viewer) {
if (!viewer) return null;
const bbox = visibleBbox(viewer);
export function viewCentre(view) {
if (!engineOf(view)) return null;
const bbox = visibleBbox(view);
if (bbox) {
// 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);
@@ -305,10 +232,5 @@ export function viewCentre(viewer) {
if (lon > 180) lon -= 360;
return { lat: (bbox[1] + bbox[3]) / 2, lon };
}
const carto = cameraLocation(viewer);
if (!carto) return null;
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
};
return cameraNadir(view);
}
+56 -219
View File
@@ -1,108 +1,97 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
cameraNadir,
createHorizonCull,
groundUnderCamera,
metresBetween,
viewCentre,
viewFocus,
visibleBbox,
} from './view.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). */
function viewer({ globeShown, globeHeight }) {
return {
camera: {
positionCartographic: {
longitude: -104.99 * RAD,
latitude: 39.74 * RAD,
height: 1900,
},
},
scene: { globe: { show: globeShown, getHeight: () => globeHeight } },
};
/**
* The display's view of a fake engine (view.js reads `engine` only): 300 m
* above a street 1,600 m up (Denver-like), the terrain at `terrain` and the
* rendered surface at `sample`; `tiles` shows Google 3D (the globe hidden).
*/
async function denver({ terrain, sample, tiles = false }) {
const engine = cameraEngine({ lon: -104.99, lat: 39.74, altitude: 1900 });
engine.fake.script('terrainHeight', () => terrain);
engine.fake.script('sampleHeight', () => sample);
if (tiles) await engine.maps.setPhotorealTiles({ kind: 'photoreal' });
return { engine };
}
test('a shown globe answers the ground under the camera', () => {
assert.equal(
groundUnderCamera(viewer({ globeShown: true, globeHeight: 1600 })),
1600,
);
test('a shown globe answers the ground under the camera', async () => {
assert.equal(groundUnderCamera(await denver({ terrain: 1600 })), 1600);
});
test('a hidden globe (Google 3D) or a missing sample gives no ground', () => {
// A hidden globe's getHeight is ignored even when it returns a number.
test('a hidden globe (Google 3D) or a missing sample gives no ground', async () => {
// A hidden globe's terrain is ignored even when it returns a number.
assert.equal(
groundUnderCamera(viewer({ globeShown: false, globeHeight: 0 })),
null,
);
assert.equal(
groundUnderCamera(viewer({ globeShown: true, globeHeight: undefined })),
groundUnderCamera(await denver({ terrain: 0, tiles: true })),
null,
);
assert.equal(groundUnderCamera(await denver({ terrain: undefined })), null);
assert.equal(groundUnderCamera({}), null);
assert.equal(groundUnderCamera(null), null);
});
/** The Denver viewer with Google 3D: no globe, a sampleable rendered surface. */
function google3d(sample) {
const view = viewer({ globeShown: false, globeHeight: 0 });
view.scene.sampleHeightSupported = true;
view.scene.sampleHeight = () => sample;
return view;
}
/** The Denver view with Google 3D: no globe, a sampleable rendered surface. */
const google3d = (sample) => denver({ terrain: 0, sample, tiles: true });
test('on Google 3D the rendered surface answers the ground under the camera', () => {
assert.equal(groundUnderCamera(google3d(1612)), 1612);
test('on Google 3D the rendered surface answers the ground under the camera', async () => {
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.
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.
assert.equal(groundUnderCamera(google3d(1950)), null);
assert.equal(groundUnderCamera(google3d(undefined)), null);
assert.equal(groundUnderCamera(await google3d(1950)), null);
assert.equal(groundUnderCamera(await google3d(undefined)), null);
});
/** A camera whose screen rays land on a grid of lon/lat points. */
function gridViewer(lons, lats) {
const points = [];
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 },
},
};
}
/** Straight down from `altitude` over `lon`/`lat` on a 100 px canvas. */
const downAt = (lon, lat, altitude) => ({
engine: cameraEngine({ lon, lat, altitude, width: 100, height: 100 }),
});
test('a view across the date line is a narrow box with west > east', () => {
const viewer = gridViewer([178, 179, -179, -178], [-1, 0, 1]);
const bbox = visibleBbox(viewer);
assert.ok(bbox[0] > 177 && bbox[0] < 179, `west ${bbox[0]}`);
assert.ok(bbox[2] < -177 && bbox[2] > -179, `east ${bbox[2]}`);
// About ±1° around 180° from 200 km up.
const bbox = visibleBbox(downAt(180, 0, 200_000));
assert.ok(bbox[0] > 178 && bbox[0] < 180, `west ${bbox[0]}`);
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', () => {
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] < 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 }) {
return {
scene: {
canvas: { clientWidth: w, clientHeight: h },
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
},
camera: rayCamera({
engine: cameraEngine({
lon,
lat,
altitude: ground + agl,
@@ -233,155 +222,3 @@ test('metresBetween measures the short way round the date line', () => {
) < 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 { flowBucket, flowSpeedScale } from '../../data/trafficFlowStyle.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 {
SPEED_MPS,
@@ -10,8 +11,7 @@ import {
CREEP_MOVE_MS,
CREEP_STOP_MS,
CREEP_BURST,
HEAT_JAM_BASE_ALPHA,
HEAT_JAM_PULSE_ALPHA,
SIM_DOT_COLOR,
} from './policy.js';
export function createAnimation({
@@ -21,10 +21,7 @@ export function createAnimation({
source,
}) {
function syncRenderDemand() {
const needed = Boolean(
layerState._enabled &&
(layerState._dots.length || layerState._heatJamPrim),
);
const needed = Boolean(layerState._enabled && layerState._dots.length);
// Height easing only runs in a held frame. Settle it before releasing, so
// a population emptied mid-correction cannot report loading forever.
if (!needed && layerState._heightPending) parts.retention.finishHeights();
@@ -33,11 +30,15 @@ export function createAnimation({
if (needed) {
layerState._lastAnimTime = 0;
services.render.holdContinuousRender('traffic');
if (layerState._engine) layerState._engine.render.request('traffic');
else layerState._viewer?.scene?.requestRender?.();
layerState._engine?.render.request('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.
*
@@ -45,7 +46,7 @@ export function createAnimation({
* randomized speed (base +/-30%), and given a direction (alternating
* 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.
* @param {number} altitude - Camera altitude (used if budgetCount is null).
* @param {number|null} [budgetCount=null] - Pre-allocated dot count. Falls back
@@ -82,7 +83,7 @@ export function createAnimation({
? presetDotOutline(layerState._stylePreset, bucket)
: null;
const outlineColor = outlineSpec
? new Cesium.Color(
? new Rgba(
outlineSpec.rgba[0] / 255,
outlineSpec.rgba[1] / 255,
outlineSpec.rgba[2] / 255,
@@ -140,7 +141,7 @@ export function createAnimation({
: -1;
// Compute initial Cartesian3 position via linear interpolation
Cesium.Cartesian3.lerp(
geo.cartesian3.lerp(
road.waypoints[segIdx],
road.waypoints[segIdx + 1],
t,
@@ -151,31 +152,30 @@ export function createAnimation({
// depth override made distant roads look like vehicles crossing roofs.
const jamProminent = bucket === 'jam' && parts.style.jamDensityOn();
const id = layerState._nextDotId++;
const point = layerState._pointCollection.add({
position: Cesium.Cartesian3.clone(layerState._scratchLerp),
// The dot's look, which the display draws (depth-tested: it respects
// the sampled mesh at every viewing distance).
const point = {
position: geo.cartesian3.clone(layerState._scratchLerp),
pixelSize,
// No flow data → today's exact simulated white.
color: flowColor || Cesium.Color.WHITE.withAlpha(0.85),
scaleByDistance: new Cesium.NearFarScalar(
color: flowColor || SIM_DOT_COLOR,
show: true,
scaleByDistance: nearFarScale(
100,
1.5,
layerState._fadeScaleFar,
jamProminent ? JAM_DOT_FAR_SCALE : 0.3,
),
translucencyByDistance: new Cesium.NearFarScalar(
translucencyByDistance: nearFarScale(
100,
1.0,
layerState._fadeTransFar,
0.0,
),
// Respect the sampled mesh at every viewing distance.
disableDepthTestDistance: 0,
// Preset dark halo (spread only when present — the keyless/normal
// path passes the exact shipped option set).
...(outlineSpec
? { outlineColor, outlineWidth: outlineSpec.width }
: {}),
});
// Preset dark halo (none on the keyless/normal path).
outlineColor: outlineSpec ? outlineColor : null,
outlineWidth: outlineSpec ? outlineSpec.width : 0,
};
layerState._bucketCounts[bucket || 'sim'] += 1;
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:
* 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
* crossings and faded end-of-road departures.
* 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.
*/
@@ -306,27 +306,12 @@ export function createAnimation({
if (now < dot.stoppedUntil) break;
}
// Lerp between pre-computed Cartesian3 waypoints (no trig needed).
// Pass the scratch directly: PointPrimitive's position setter clones the
// value into its own storage (and skips the VBO dirty flag when equal), so
// the extra defensive clone here allocated 360–720k Cartesian3/s of pure
// garbage across 6000 dots. (perf item 5)
// Lerp between pre-computed waypoints (no trig needed), in place: a
// fresh position per dot per frame is 360–720k objects a second across
// 6000 dots.
const a = dot.waypoints[dot.segIdx];
const b = dot.waypoints[dot.segIdx + 1];
Cesium.Cartesian3.lerp(a, b, dot.t, layerState._scratchLerp);
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);
geo.cartesian3.lerp(a, b, dot.t, dot.point.position);
}
layerState._animFrame++;
@@ -355,24 +340,22 @@ export function createAnimation({
/** Remove all point primitives and reset dot/road arrays and counters. */
function clearDots() {
services.credits?.hideOsmCredit?.(
layerState._engine ?? layerState._viewer,
'traffic',
);
services.credits?.hideOsmCredit?.(layerState._engine, 'traffic');
parts.retention.clear();
if (layerState._dots.length) layerState._motion.rebuilds++;
if (layerState._pointCollection) layerState._pointCollection.removeAll();
parts.rendering.removeHeatLines();
layerState._dots = [];
layerState._roads = [];
layerState._count = 0;
layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 };
layerState._closedRoads = 0;
drawDots();
syncRenderDemand();
}
return {
spawnDotsForRoad,
animate,
drawDots,
maybeStopLight,
clearDots,
syncRenderDemand,
+1 -1
View File
@@ -208,7 +208,7 @@ export function createControls({ state: layerState, services, parts, source }) {
* @param {Object} [options]
* @param {number} [options.maxCount] - Maximum objects to return (defaults to all).
* @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 = {}) {
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 { createControls } from './controls.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 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 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: {} });
retention.easeHeights(1 / 60);
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 }) },
};
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: {} });
let loadingFrames = 0, maxPixels = 0;
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 }) },
};
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);
assert.ok(before.y > 0 && before.y < 800);
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) {
const state = createState({ services: {} });
const calls = { samples: 0, selections: 0 };
@@ -194,17 +235,7 @@ function flowFixture(source) {
_enabled: true,
_roadMode: 'osm',
_parseRoads: (data) => data.roads,
_viewer: {
scene: {
globe: {
show: true,
getHeight: () => {
calls.samples++;
return 100;
},
},
},
},
_engine: terrainEngine(calls),
});
const parts = {
viewport: {
+34 -45
View File
@@ -1,5 +1,5 @@
import { roadIdentity } from './retention.js';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import {
prepareRoadSurfaces,
roadHeightStations,
@@ -20,17 +20,18 @@ import { nextTask } from '../../sources/vectorTiles.js';
* the traffic layer's kilometre range). Null without a camera.
*/
function groundDistanceFrom(camera, groundHeight = 0) {
const view = camera && Cesium.Cartographic.fromCartesian(camera);
const view = camera && geo.cartographic.fromCartesian(camera);
if (!view) return null;
const clearance = view.height - (groundHeight || 0) - DOT_HEIGHT_OFFSET;
const lat = Cesium.Math.toDegrees(view.latitude);
const lon = Cesium.Math.toDegrees(view.longitude);
const lat = geo.math.toDegrees(view.latitude);
const lon = geo.math.toDegrees(view.longitude);
const east = 111320 * Math.cos(view.latitude);
return ([roadLon, roadLat]) =>
Math.hypot((roadLon - lon) * east, (roadLat - lat) * 111320, clearance);
}
export function createFlow({ state: layerState, services, parts, source }) {
const scratchScreen = geo.cartesian2.create();
const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits;
const { fetchFlowForBounds } = source;
@@ -95,10 +96,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
layerState._flowStatusKnown = true;
if (layerState._liveMode) {
console.log('[Data:Traffic] TomTom key present — live flow mode');
registerDynamicCredit(
layerState._engine ?? layerState._viewer,
TOMTOM_CREDIT,
);
registerDynamicCredit(layerState._engine, TOMTOM_CREDIT);
}
})
.catch((e) => {
@@ -291,15 +289,16 @@ export function createFlow({ state: layerState, services, parts, source }) {
// Separate whole-graph preparation from tile completion and publication.
await nextTask();
if (!current()) return false;
const scene = layerState._trafficScene?.() ?? layerState._viewer?.scene;
if (!scene) return false;
const engine = layerState._engine;
if (!engine) return false;
const size = engine.host.size();
// The enable-time estimate can predate the first local mesh (especially
// after an intercontinental jump). Once previews have measured roads, use
// that local evidence to select the detail view at its real elevation.
const localHeights = [];
for (const prepared of preparedRoads.values()) {
const height =
Cesium.Cartographic.fromCartesian(prepared.waypoints[0]).height -
geo.cartographic.fromCartesian(prepared.waypoints[0]).height -
DOT_HEIGHT_OFFSET;
if (Number.isFinite(height) && Math.abs(height) <= 9000)
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
// while older coarse samples are being replaced. It is selection-only.
let depthHeight;
if (scene.pickPositionSupported && scene.canvas?.clientWidth) {
const probe = new Cesium.Cartesian2(
scene.canvas.clientWidth / 2,
scene.canvas.clientHeight * 0.6,
);
if (engine.picker.pickPositionSupported && size.width) {
const probe = geo.cartesian2.create(size.width / 2, size.height * 0.6);
try {
const hit = scene.pickPosition(probe);
const height = hit && Cesium.Cartographic.fromCartesian(hit).height;
const hit = engine.picker.pickPosition(probe);
const height = hit && geo.cartographic.fromCartesian(hit).height;
if (Number.isFinite(height) && Math.abs(height) <= 9000)
depthHeight = height;
} catch {
@@ -333,7 +329,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
const prepared = !refining && preparedRoads.get(road.key);
if (prepared) {
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 (
(!road.surfaceReady || refining) &&
(localHeights.length || depthHeight !== undefined)
@@ -342,7 +338,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
// Opposite source directions may share waypoint arrays. Provisional
// selection must not overwrite an already measured sibling.
road.waypoints = road.waypoints.map((point) =>
Cesium.Cartesian3.clone(point),
geo.cartesian3.clone(point),
);
for (let i = 0; i < road.coords.length; 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
? Math.max(floor, layerState._viewHeightEstimate)
: layerState._viewHeightEstimate;
Cesium.Cartesian3.fromDegrees(
geo.cartesian3.fromDegrees(
lon,
lat,
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
// 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.
const viewCamera =
layerState._engine?.camera.livePosition ??
layerState._viewer?.camera?.positionWC;
const viewCamera = engine.camera.livePosition;
const groundDistance = groundDistanceFrom(
viewCamera,
layerState._viewHeightEstimate,
@@ -390,22 +384,20 @@ export function createFlow({ state: layerState, services, parts, source }) {
)
return false;
// 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,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const point of road.waypoints) {
const screen = scene.project
? scene.project(point, {})
: Cesium.SceneTransforms.worldToWindowCoordinates(scene, point);
const screen = engine.projector.worldToWindow(point, scratchScreen);
if (!screen) continue;
minX = Math.min(minX, screen.x);
maxX = Math.max(maxX, screen.x);
minY = Math.min(minY, 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) {
road.baseDensityWeight ??= road.densityWeight || 1;
road.densityWeight =
@@ -414,9 +406,9 @@ export function createFlow({ state: layerState, services, parts, source }) {
}
return (
maxX >= -100 &&
minX <= scene.canvas.clientWidth + 100 &&
minX <= size.width + 100 &&
maxY >= -100 &&
minY <= scene.canvas.clientHeight + 100
minY <= size.height + 100
);
});
const budgets = parts.model.allocateRoadDotBudgets(
@@ -425,14 +417,12 @@ export function createFlow({ state: layerState, services, parts, source }) {
roadDotBudget(altitude),
);
const admitted = selected.filter((r, i) => budgets[i] > 0);
const camera =
layerState._engine?.camera.livePosition ??
layerState._viewer?.camera?.positionWC;
const camera = engine.camera.livePosition;
if (camera)
admitted.sort(
(a, b) =>
Cesium.Cartesian3.distanceSquared(camera, a.waypoints[0]) -
Cesium.Cartesian3.distanceSquared(camera, b.waypoints[0]),
geo.cartesian3.distanceSquared(camera, a.waypoints[0]) -
geo.cartesian3.distanceSquared(camera, b.waypoints[0]),
);
// The wide pass is a fast preview when detailed tiles follow. Preparing
// 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);
if (prepared) {
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++)
road.segmentDist[i] = prepared.segmentDist[i];
road.surfaceReady = true;
@@ -530,9 +520,9 @@ export function createFlow({ state: layerState, services, parts, source }) {
if (!current()) return false;
let prepared = await prepareRoadSurfaces(
unresolved,
scene,
engine,
services.ground,
[layerState._pointCollection],
layerState._display?.excluded() ?? [],
signal,
options,
);
@@ -552,9 +542,9 @@ export function createFlow({ state: layerState, services, parts, source }) {
if (!current()) return false;
prepared = await prepareRoadSurfaces(
prepared.pending,
scene,
engine,
services.ground,
[layerState._pointCollection],
layerState._display?.excluded() ?? [],
signal,
options,
);
@@ -566,8 +556,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
current() &&
!preview &&
!label.includes('refined') &&
scene.postRender &&
(!trafficSurfaceReady(scene) ||
(!trafficSurfaceReady(engine) ||
prepared.pending.length ||
ready.some((road) => road.surfaceSettled === false))
) {
@@ -578,12 +567,12 @@ export function createFlow({ state: layerState, services, parts, source }) {
if (layerState._surfaceRefineRemove === stop)
layerState._surfaceRefineRemove = null;
};
const remove = scene.postRender.addEventListener(() => {
const remove = engine.frames.postRender.addEventListener(() => {
if (!current()) {
stop();
return;
}
if (!trafficSurfaceReady(scene)) return;
if (!trafficSurfaceReady(engine)) return;
stop();
// Revalidate only local samples taken at a coarser rendered LOD. This
// 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 { 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. */
export function createReticleFootprint() {
const pixels = Array.from({ length: 25 }, () => geo.cartesian2.create());
const hits = Array.from({ length: 25 }, () => geo.cartesian3.create());
const carto = geo.cartographic.create();
let ray;
return (viewer, altitude, engine) => {
const camera = engine?.camera ?? viewer?.camera;
if (!camera?.getPickRay && !camera?.pickRay) return null;
const pose = engine?.camera.pose();
const nadir = pose?.cartographic ?? camera.positionCartographic;
const canvas = engine?.host.canvas ?? viewer.scene.canvas;
return (engine, altitude) => {
const camera = engine?.camera;
if (!camera?.pickRay) return null;
const pose = camera.pose();
const nadir = pose.cartographic;
const canvas = engine.host.canvas;
const near = {
lat: geo.math.toDegrees(nadir.latitude),
lon: geo.math.toDegrees(nadir.longitude),
@@ -91,9 +88,7 @@ export function createReticleFootprint() {
p = pixels[i];
p.x = left + (side * col) / 4;
p.y = top + (side * row) / 4;
const hit = engine
? camera.pickEllipsoid(p)
: camera.pickEllipsoid(p, Cesium.Ellipsoid.WGS84, hits[i]);
const hit = camera.pickEllipsoid(p);
let dx, dy;
if (hit) {
geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84, carto);
@@ -102,9 +97,7 @@ export function createReticleFootprint() {
dlon -= 360 * Math.round(dlon / 360);
dx = dlon * 111.195 * Math.cos(nadir.latitude);
} else {
const picked = engine
? camera.pickRay(p)
: camera.getPickRay(p, (ray ??= new Cesium.Ray())) || ray;
const picked = camera.pickRay(p);
if (!picked) continue;
const d = picked.direction;
dx =
@@ -162,8 +155,8 @@ export function createReticleFootprint() {
nadir.latitude,
nadir.longitude,
nadir.height / 10000,
pose?.heading ?? camera.heading,
pose?.pitch ?? camera.pitch,
pose.heading,
pose.pitch,
]
.map((v) => Number(v || 0).toFixed(7))
.join(','),
+28 -26
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import {
convexFootprint,
footprintTiles,
@@ -33,33 +33,35 @@ test('oblique horizon bearings are bounded and tile caps hold at city latitudes'
[51.507, -0.128],
[70, 10],
]) {
const camera = {
positionCartographic: Cesium.Cartographic.fromDegrees(lon, lat, 300),
heading: 0,
pitch: -Math.PI / 12,
pickEllipsoid(p, e, result) {
if (p.y < 300) return undefined;
return Cesium.Cartesian3.fromDegrees(
lon + (p.x - 640) / 100000,
lat + (800 - p.y) / 50000,
0,
e,
result,
);
},
getPickRay(p, ray) {
ray.direction.x =
-Math.sin((lat * Math.PI) / 180) * Math.cos((lon * Math.PI) / 180);
ray.direction.y =
-Math.sin((lat * Math.PI) / 180) * Math.sin((lon * Math.PI) / 180);
ray.direction.z = Math.cos((lat * Math.PI) / 180);
return ray;
// An engine-shaped stub: the camera's pose and its ellipsoid and ray
// picks (the sky above the 300 px line), and the canvas.
const engine = {
camera: {
pose: () => ({
cartographic: geo.cartographic.fromDegrees(lon, lat, 300),
heading: 0,
pitch: -Math.PI / 12,
}),
pickEllipsoid(p) {
if (p.y < 300) return undefined;
return geo.cartesian3.fromDegrees(
lon + (p.x - 640) / 100000,
lat + (800 - p.y) / 50000,
0,
);
},
pickRay: () => ({
origin: geo.cartesian3.create(),
direction: geo.cartesian3.create(
-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()(
{ camera, scene: { canvas: { width: 1280, height: 800 } } },
300,
);
const result = createReticleFootprint()(engine, 300);
assert.ok(result.coverage.coarse.length <= 16);
assert.ok(result.coverage.detail.length <= 16);
assert.ok(
+34 -39
View File
@@ -1,10 +1,12 @@
// Traffic republished on the incoming globe after a switch.
import test from 'node:test';
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 { createFlow } from './flow.js';
import { createLifecycle } from './lifecycle.js';
import { observeTrafficSurface } from './surface.js';
import { DOT_HEIGHT_OFFSET } from './policy.js';
import { nextTask } from '../../sources/vectorTiles.js';
@@ -29,43 +31,35 @@ function road(offset, height) {
key: `road:${offset}`,
surfaceReady: true,
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],
};
}
/** An incoming Navara-like surface whose finer tiles arrive later. */
function navaraSurface() {
const listeners = new Set();
const surface = {
ready: false,
height: COARSE,
samples: 0,
surfaceKey: 'terrain',
canvas: { clientWidth: 1000, clientHeight: 800 },
project: () => ({ x: 500, y: 500 }),
sampleHeightSupported: true,
sampleHeight: () => {
surface.samples++;
return surface.height;
},
postRender: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
frame: () => [...listeners].forEach((listener) => listener()),
engine: {
camera: {
pose: () => ({
cartographic: C.Cartographic.fromDegrees(LON, LAT, 1800),
}),
livePosition: C.Cartesian3.fromDegrees(LON, LAT, 1800),
},
},
};
/**
* An incoming globe (terrain, no photoreal tiles) whose finer tiles arrive
* later: the camera 1 800 m over Austin, every road on screen, no rendered
* depth, and the surface observed as the layer's bound scene observes it.
*/
function incomingGlobe(t) {
const engine = createFakeEngine({ width: 1000, height: 800 });
engine.fake.maps.imagery = { kind: 'osm' };
engine.fake.setPose({
longitude: geo.math.toRadians(LON),
latitude: geo.math.toRadians(LAT),
height: 1800,
});
const surface = { engine, ready: false, height: COARSE, samples: 0 };
engine.fake.script('tilesLoaded', () => surface.ready);
engine.fake.script('terrainHeight', () => {
surface.samples++;
return surface.height;
});
engine.fake.script('worldToWindow', () => ({ x: 500, y: 500 }));
engine.fake.script('pickPosition', undefined);
t.after(observeTrafficSurface(engine));
surface.frame = () => engine.fake.frame();
return surface;
}
@@ -80,7 +74,6 @@ function fixture(surface) {
_roadSource: 'OpenStreetMap',
_viewHeightEstimate: GROUND,
_engine: surface.engine,
_trafficScene: () => surface,
_activeFetchAbort: new AbortController(),
});
const flow = createFlow({
@@ -105,7 +98,7 @@ function fixture(surface) {
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
// its outcome task by task instead of for a fixed time, which a loaded test
// runner can outlast.
@@ -113,12 +106,13 @@ const settle = async (done) => {
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 weightsFor = async (sampledHeight) => {
const surface = navaraSurface();
const surface = incomingGlobe(t);
surface.ready = true;
surface.height = GROUND;
surface.frame();
const { flow, weights } = fixture(surface);
const roads = offsets.map((offset) => road(offset, sampledHeight));
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]);
});
test('the handoff pass on a loading globe refines once its surface is ready', async () => {
const surface = navaraSurface();
test('the handoff pass on a loading globe refines once its surface is ready', async (t) => {
const surface = incomingGlobe(t);
surface.frame();
const { flow, paints } = fixture(surface);
const roads = [road(0.004, GROUND), road(0.008, GROUND)];
await flow.applyFlowThenRender(
+11 -2
View File
@@ -11,9 +11,14 @@ import { createControls } from './controls.js';
import { createLifecycle } from './lifecycle.js';
import { createRetention } from './retention.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 (
![
'requestRoads',
@@ -24,6 +29,10 @@ export function createTrafficLayer({ services, source }) {
].every((key) => typeof source?.[key] === 'function')
)
throw new TypeError('A traffic source is required');
const services = {
...given,
createRenderer: given?.createRenderer ?? createTrafficDisplay,
};
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
+10 -9
View File
@@ -270,18 +270,19 @@ export function createIngestion({
layerState._viewHeightEstimate = 0;
const centerLat = (clamped.south + clamped.north) / 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);
let estimated = services.ground?.cachedGroundFloor?.(centerLat, centerLon);
if (!Number.isFinite(estimated)) {
if (!Number.isFinite(estimated) && engine) {
try {
estimated = scene?.engine
? scene.sampleHeight(location)
: scene?.globe?.show
? scene.globe.getHeight?.(location)
: scene?.sampleHeightSupported
? scene.sampleHeight(location, [layerState._pointCollection])
: undefined;
estimated = !engine.maps.photorealActive()
? engine.surface.terrainHeight(location)
: engine.surface.supported
? engine.surface.sampleHeight(
location,
layerState._display?.excluded() ?? [],
)
: undefined;
} catch {
/* the local mesh can still be loading */
}
+71 -123
View File
@@ -1,11 +1,9 @@
import { observeTrafficSurface } from './surface.js';
import * as Cesium from 'cesium';
import { TRAFFIC_TIMING_ENABLED } from './policy.js';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
} from '../../data/cameraSensitivity.js';
import { whenCesiumSdk } from '../../app/loadCesium.js';
export function createLifecycle({
state: layerState,
@@ -22,19 +20,19 @@ export function createLifecycle({
}
function bindScene() {
const viewer = layerState._viewer,
engine = layerState._engine;
const camera = engine?.camera ?? viewer.camera;
const frames = engine?.frames ?? viewer.scene;
if (viewer)
layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene);
layerState._pointCollection.show = true;
const engine = layerState._engine;
if (!engine) return;
const camera = engine.camera;
layerState._surfaceFrameRemover = observeTrafficSurface(engine);
layerState._display?.show(true);
layerState._lastAnimTime = 0;
parts.animation.syncRenderDemand();
layerState._preRenderRemover = frames.preRender.addEventListener(() => {
parts.animation.animate();
layerState._native?.points.flush();
});
layerState._preRenderRemover = engine.frames.preRender.addEventListener(
() => {
parts.animation.animate();
parts.animation.drawDots();
},
);
if (TRAFFIC_TIMING_ENABLED) {
parts.timing.clearTrafficTimingEntries();
layerState._trafficTimingCurrentAnchor = null;
@@ -49,11 +47,11 @@ export function createLifecycle({
layerState._arrivalRemover = camera.moveEnd.addEventListener(() =>
parts.viewport.onCameraChanged({ immediate: true }),
);
claimCameraSensitivity(engine ?? camera, 'traffic', 0.05);
claimCameraSensitivity(engine, 'traffic', 0.05);
}
function unbindScene() {
const camera = layerState._engine?.camera ?? layerState._viewer?.camera;
const camera = layerState._engine?.camera;
layerState._surfaceFrameRemover?.();
layerState._preRenderRemover?.();
layerState._trafficTimingMoveEndRemover?.();
@@ -72,19 +70,20 @@ export function createLifecycle({
layerState._arrivalRemover =
layerState._trafficTimingMoveEndRemover =
null;
releaseCameraSensitivity(layerState._engine ?? camera, 'traffic');
if (layerState._engine)
releaseCameraSensitivity(layerState._engine, 'traffic');
}
const methods = {
/**
* One-time initialisation. Creates the PointPrimitiveCollection and adds it
* to the scene (hidden). The collection is never removed and re-added — only
* toggled via `.show` to avoid destroy-on-remove errors.
* One-time initialisation. Attaches the engine's traffic display (hidden
* until the layer is enabled).
*
* @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) {
this.attachRenderer(viewer, options);
init(_viewer, options) {
this.attachRenderer(_viewer, options);
layerState._dots = [];
layerState._roads = [];
layerState._count = 0;
@@ -129,38 +128,28 @@ export function createLifecycle({
},
attachRenderer(
viewer,
_viewer,
{ engine = null, waitForCameraRestore = false } = {},
) {
layerState._restoringViewport = waitForCameraRestore;
layerState._viewer = viewer;
layerState._engine = engine;
layerState._native =
!viewer && engine ? services.nativeRenderer(engine) : null;
layerState._heatSupported = null;
const points =
layerState._native?.points ??
new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
if (viewer) viewer.scene.primitives.add(points);
layerState._pointCollection = points;
for (const dot of layerState._dots) dot.point = points.add(dot.point);
points.show = layerState._enabled;
layerState._display = engine ? services.createRenderer(engine) : null;
layerState._display?.show(layerState._enabled);
if (!waitForCameraRestore) readyForSurfaceWork();
if (layerState._enabled) {
bindScene();
parts.animation.drawDots();
parts.rendering.rebuildHeatLines(layerState._roads);
if (
layerState._dots.length &&
layerState._roads.some((r) => !r.directFlow)
)
services.credits?.showOsmCredit?.(engine ?? viewer, 'traffic', {
services.credits?.showOsmCredit?.(engine, 'traffic', {
openMapTiles: true,
});
if (layerState._liveMode)
services.credits?.registerDynamicCredit?.(
engine ?? viewer,
engine,
services.credits.TOMTOM_CREDIT,
);
parts.viewport.onCameraChanged({ immediate: true });
@@ -198,7 +187,7 @@ export function createLifecycle({
parts.viewport.onCameraChanged({ immediate: true });
},
detachRenderer() {
if (!layerState._pointCollection) return;
if (!layerState._engine) return;
unbindScene();
clearTimeout(layerState._fetchTimeout);
layerState._surfaceRefineRemove?.();
@@ -206,95 +195,62 @@ export function createLifecycle({
layerState._surfaceRevision = (layerState._surfaceRevision || 0) + 1;
layerState._preparedRoads?.clear();
parts.rendering.removeHeatLines();
services.credits?.hideOsmCredit?.(
layerState._engine ?? layerState._viewer,
'traffic',
);
for (const dot of layerState._dots) {
const p = dot.point;
dot.point = {
position: Cesium.Cartesian3.clone(p.position),
pixelSize: p.pixelSize,
color: p.color,
show: p.show,
outlineColor: p.outlineColor,
outlineWidth: p.outlineWidth,
scaleByDistance: p.scaleByDistance,
translucencyByDistance: p.translucencyByDistance,
disableDepthTestDistance: 0,
};
}
layerState._viewer?.scene.primitives.remove(layerState._pointCollection);
layerState._native?.destroy();
services.credits?.hideOsmCredit?.(layerState._engine, 'traffic');
// The dots keep their look (`dot.point`) for the next engine.
layerState._display?.destroy();
if (layerState._renderHeld)
services.render.releaseContinuousRender('traffic');
layerState._renderHeld = false;
layerState._pointCollection =
layerState._viewer =
layerState._engine =
layerState._native =
null;
layerState._engine = layerState._display = null;
layerState._waitForScene ??= new Promise((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
* initial viewport check.
*
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
*/
enable(viewer, { signal } = {}) {
// The simulation's math is Cesium's on either globe: a Navara page
// loads the Cesium SDK first.
return whenCesiumSdk(() => {
if (layerState._enabled) return;
if (viewer) layerState._viewer = viewer;
layerState._enabled = true;
if (layerState._roadMode !== 'tomtom') source.prefetch?.();
bindScene();
// Enabling is explicit intent, not a camera gesture that needs settling.
parts.viewport.onCameraChanged({ immediate: true });
enable() {
if (layerState._enabled) return;
layerState._enabled = 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
// moves): when the persisted layer state re-enables traffic during the
// intro flyTo, the initial check bails at high altitude — and a camera
// 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
// failed first fetch left the viewport unloaded while parked.
clearInterval(layerState._enableKickTimer);
layerState._enableKickTimer = setInterval(() => {
if (
!layerState._enabled ||
layerState._lastUpdate ||
layerState._roadRetryStopped ||
layerState._retryAttempts >= 3
) {
clearInterval(layerState._enableKickTimer);
layerState._enableKickTimer = null;
return;
}
if (!layerState._fetching && !layerState._retryTimer)
parts.viewport.onCameraChanged();
}, 1500);
}, signal);
// Boot-order guard (otherwise the layer sits empty until the user
// moves): when the persisted layer state re-enables traffic during the
// intro flyTo, the initial check bails at high altitude — and a camera
// 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
// failed first fetch left the viewport unloaded while parked.
clearInterval(layerState._enableKickTimer);
layerState._enableKickTimer = setInterval(() => {
if (
!layerState._enabled ||
layerState._lastUpdate ||
layerState._roadRetryStopped ||
layerState._retryAttempts >= 3
) {
clearInterval(layerState._enableKickTimer);
layerState._enableKickTimer = null;
return;
}
if (!layerState._fetching && !layerState._retryTimer)
parts.viewport.onCameraChanged();
}, 1500);
},
/**
* Disable the traffic layer. Cancels pending fetches, clears all dots,
* unsubscribes from events, and hides the point collection.
*
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
* unsubscribes from events, and hides the vehicles.
*/
disable(viewer) {
disable() {
layerState._enabled = false;
layerState._surfaceInput = null;
services.credits?.hideOsmCredit?.(
layerState._engine ?? layerState._viewer,
'traffic',
);
services.credits?.hideOsmCredit?.(layerState._engine, 'traffic');
parts.animation.syncRenderDemand();
clearTimeout(layerState._fetchTimeout);
clearInterval(layerState._enableKickTimer);
@@ -317,26 +273,18 @@ export function createLifecycle({
layerState._flowOldestTileAt = null;
unbindScene();
if (layerState._pointCollection) layerState._pointCollection.show = false;
layerState._display?.show(false);
},
/**
* Permanently tear down the layer. Disables it, removes the point collection
* from the scene, and clears the tile cache.
*
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
* Permanently tear down the layer. Disables it, destroys the display and
* clears the tile cache.
*/
destroy(viewer) {
this.disable(viewer);
if (layerState._pointCollection) {
layerState._viewer?.scene.primitives.remove(
layerState._pointCollection,
);
layerState._native?.destroy();
layerState._native = null;
layerState._pointCollection = null;
}
destroy() {
this.disable();
parts.rendering.removeHeatLines();
layerState._display?.destroy();
layerState._display = null;
layerState._tileCache.clear();
if (layerState._styleListenerBound && typeof window !== 'undefined') {
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 { t, translateKnown } from '../../i18n/index.js';
import {
@@ -10,6 +11,7 @@ import {
DOT_HEIGHT_OFFSET,
DENSITY_MULT,
JAM_DOT_FAR_SCALE,
SIM_DOT_COLOR,
} from './policy.js';
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
? floor
: 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
const segmentDist = [];
for (let i = 0; i < waypoints.length - 1; i++) {
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.point.color = bucket
? layerState._activeBucketColors[bucket]
: Cesium.Color.WHITE.withAlpha(0.85);
: SIM_DOT_COLOR;
dot.nominalSize =
parts.style.baseDotSize(dot.road?.type, bucket) +
(bucket === 'jam' ? 1 : 0) +
@@ -386,7 +388,7 @@ export function createModel({ state: layerState, services, parts, source }) {
moving: Math.random() < 0.4,
until: now + Math.random() * 2000,
};
dot.point.scaleByDistance = new Cesium.NearFarScalar(
dot.point.scaleByDistance = nearFarScale(
100,
1.5,
layerState._fadeScaleFar,
+83 -46
View File
@@ -10,6 +10,33 @@ import {
DOT_CHANGE_BUDGET,
roadIdentity,
} 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) {
const frames = [];
@@ -28,8 +55,9 @@ function setup(t) {
const state = createState({ services: {} });
state._enabled = true;
state._liveMode = true;
state._pointCollection = new C.PointPrimitiveCollection();
state._viewer = { scene: {} };
const engine = engineAt(450);
state._engine = engine;
state._display = createTrafficDisplay(engine);
const parts = {
rendering: {
removeHeatLines() {},
@@ -78,15 +106,17 @@ function setup(t) {
const fn = frames.shift();
fn?.();
parts.animation.animate();
parts.animation.drawDots();
};
const settle = () => {
for (let i = 0; i < 100 && frames.length; i++) frame();
};
t.after(() => {
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) => {
@@ -126,6 +156,13 @@ test('incremental tiles, reordered snapshots and flow changes retain existing do
assert.equal(target.point.show, true);
assert.equal(target.id, before[0].id);
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) => {
@@ -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.equal(new Set(h.state._dots.map((d) => d.id)).size, 3000);
h.parts.animation.clearDots();
assert.equal(fillCount(h.engine), 0, 'a clear is drawn without a 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.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]) {
let samples = 0;
const paints = [];
const scene = {
globe: {
show: true,
tilesLoaded: true,
getHeight: () => {
samples++;
return measured ? 100 : undefined;
},
},
};
const engine = flowEngineAt(450);
engine.fake.script('terrainHeight', () => {
samples++;
return measured ? 100 : undefined;
});
const coords = [
[0, 0],
[0.001, 0],
@@ -301,7 +334,7 @@ test('major previews merge and never retry missing mesh ahead of the detail pass
_dots: [],
_detailRoadsReady: true,
_motion: { added: 0 },
_viewer: { scene },
_engine: engine,
_enabled: true,
_loadGeneration: 1,
_roadSource: 'OpenStreetMap',
@@ -431,12 +464,11 @@ test('measured preview heights select elevated detail roads before surface admis
],
segmentDist: [111],
});
const scene = {
globe: { show: true, tilesLoaded: true, getHeight: () => 750 },
};
const engine = flowEngineAt(1250);
engine.fake.script('terrainHeight', 750);
const state = {
_dots: [],
_viewer: { scene },
_engine: engine,
_enabled: true,
_loadGeneration: 1,
_roadSource: 'OpenStreetMap',
@@ -510,16 +542,14 @@ test('provisional opposite-direction heights cannot overwrite a measured sibling
segmentDist: [111],
});
const selections = [];
const scene = {
globe: {
show: true,
tilesLoaded: true,
getHeight: (c) => 750 + C.Math.toDegrees(c.longitude) * 10000,
},
};
const engine = flowEngineAt(1250);
engine.fake.script(
'terrainHeight',
(c) => 750 + C.Math.toDegrees(c.longitude) * 10000,
);
const state = {
_dots: [],
_viewer: { scene },
_engine: engine,
_enabled: true,
_loadGeneration: 1,
_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 };
for (let i = 0; i < 20 && h.state._dots.length; i++) h.frame();
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._heightPending, 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) => {
const h = setup(t);
h.state._viewer.scene = {
canvas: { clientWidth: 1280, clientHeight: 2000 },
camera: {},
frameState: {},
};
h.engine.fake.setSize(1280, 2000);
const scratch = new Set();
t.mock.method(
C.SceneTransforms,
'worldToWindowCoordinates',
(_scene, point, result) => {
scratch.add(result);
result.x = 0;
result.y = C.Cartographic.fromCartesian(point).height * 10;
return result;
},
);
h.engine.fake.script('worldToWindow', (point, result) => {
scratch.add(result);
result.x = 0;
result.y = C.Cartographic.fromCartesian(point).height * 10;
return result;
});
h.parts.retention.reconcile(h.roads(), 450, true);
h.settle();
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.settle();
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 };
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) => {
+100 -119
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
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';
function deferred() {
@@ -13,42 +14,35 @@ function deferred() {
function setup(t, requestRoads, getStatus = async () => ({ hasKey: false })) {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
const camera = {
positionCartographic: Cesium.Cartographic.fromDegrees(
-97.744,
30.267,
3200,
),
get positionWC() {
return Cesium.Cartesian3.fromRadians(
this.positionCartographic.longitude,
this.positionCartographic.latitude,
this.positionCartographic.height,
);
},
changed: new Cesium.Event(),
moveEnd: new Cesium.Event(),
percentageChanged: 0.5,
computeViewRectangle() {
const { longitude, latitude } = this.positionCartographic;
return new Cesium.Rectangle(
longitude - 0.0002,
latitude - 0.0002,
longitude + 0.0002,
latitude + 0.0002,
);
},
pickEllipsoid: () => null,
};
const viewer = {
camera,
scene: {
canvas: { width: 100, height: 100 },
globe: { show: true, tilesLoaded: true, getHeight: () => 0 },
preRender: new Cesium.Event(),
primitives: { add: (value) => value, remove: () => true },
},
};
const engine = createFakeEngine({
width: 100,
height: 100,
canvas: { width: 100, height: 100 },
});
const camera = engine.camera;
// Without a pick ray the layer bounds its fetch by the view rectangle
// (no reticle footprint); a test that wants the footprint puts it back.
const pickRay = camera.pickRay;
camera.pickRay = undefined;
const place = (lon, lat, height) =>
engine.fake.setPose({
longitude: geo.math.toRadians(lon),
latitude: geo.math.toRadians(lat),
height,
});
place(-97.744, 30.267, 3200);
// A view rectangle of ±0.0002 rad around the camera.
engine.fake.script('viewRectangle', () => {
const { longitude, latitude } = camera.pose().cartographic;
return {
west: longitude - 0.0002,
south: latitude - 0.0002,
east: longitude + 0.0002,
north: latitude + 0.0002,
};
});
engine.fake.script('pickEllipsoid', undefined);
engine.fake.script('terrainHeight', 0);
const layer = createTrafficLayer({
services: {
credits: {},
@@ -62,16 +56,12 @@ function setup(t, requestRoads, getStatus = async () => ({ hasKey: false })) {
resetFlowTileCache() {},
},
});
layer.init(viewer);
t.after(() => layer.destroy(viewer));
layer.init(null, { engine });
t.after(() => layer.destroy());
const move = (lon, lat, height = 3200) => {
camera.positionCartographic = Cesium.Cartographic.fromDegrees(
lon,
lat,
height,
);
camera.changed.raiseEvent();
camera.moveEnd.raiseEvent();
place(lon, lat, height);
engine.fake.emit('camera.changed');
engine.fake.emit('camera.moveEnd');
};
const tick = async (ms) => {
t.mock.timers.tick(ms);
@@ -81,7 +71,7 @@ function setup(t, requestRoads, getStatus = async () => ({ hasKey: false })) {
t.mock.timers.tick(0);
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) {
return {
@@ -103,12 +93,12 @@ function roads(bounds) {
test('traffic recovers a failed destination request after another city has loaded', async (t) => {
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)
throw new Error('temporary Overpass outage');
return roads(bounds);
});
layer.enable(viewer);
layer.enable();
await tick(400);
await tick(2000);
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) => {
const pending = [];
const { layer, viewer, move, tick } = setup(t, (bounds, { signal }) => {
const { layer, move, tick } = setup(t, (bounds, { signal }) => {
const result = deferred();
pending.push({ ...result, bounds, signal });
return result.promise;
});
layer.enable(viewer);
layer.enable();
await tick(400);
move(-0.1276, 51.5072);
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);
pending[0].resolve(roads(pending[0].bounds));
await tick(0);
layer.disable(viewer);
layer.disable();
assert.equal(
pending[1].signal.aborted,
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) => {
const pending = [];
const { layer, viewer, move, tick } = setup(t, (bounds, { signal }) => {
const { layer, move, tick } = setup(t, (bounds, { signal }) => {
const result = deferred();
pending.push({ ...result, bounds, signal });
return result.promise;
});
layer.enable(viewer);
layer.enable();
move(-40, 40, 1000000);
await tick(400);
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) => {
const seen = [];
const { layer, viewer, tick } = setup(t, async (bounds) => {
const { layer, engine, place, tick } = setup(t, async (bounds) => {
seen.push(bounds);
return roads(bounds);
});
layer.enable(viewer);
layer.enable();
await tick(400);
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
-0.1276,
51.5072,
3200,
);
viewer.camera.moveEnd.raiseEvent();
place(-0.1276, 51.5072, 3200);
engine.fake.emit('camera.moveEnd');
await tick(400);
assert.ok(seen.some((bounds) => bounds.south > 50));
layer.disable(viewer);
assert.equal(viewer.camera.moveEnd.numberOfListeners, 0);
assert.equal(viewer.camera.changed.numberOfListeners, 0);
layer.disable();
assert.equal(engine.camera.moveEnd.numberOfListeners, 0);
assert.equal(engine.camera.changed.numberOfListeners, 0);
});
test('parked failures back off and disabling cancels the scheduled retry', async (t) => {
let calls = 0;
const { layer, viewer, tick } = setup(t, async () => {
const { layer, tick } = setup(t, async () => {
calls++;
throw new Error('temporary Overpass outage');
});
layer.enable(viewer);
layer.enable();
await tick(400);
assert.equal(calls, 1);
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(400);
assert.equal(calls, 2, 'second failure waits longer than the first');
layer.disable(viewer);
layer.disable();
await tick(60000);
assert.equal(calls, 2);
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 }),
};
});
h.layer.enable(h.viewer);
h.layer.enable();
await h.tick(400);
await h.tick(60_000);
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) => {
let status = 406;
const { layer, viewer, tick } = setup(t, async () => ({ ok: false, status }));
layer.enable(viewer);
const { layer, tick } = setup(t, async () => ({ ok: false, status }));
layer.enable();
await tick(400);
assert.equal(
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) => {
const { layer, viewer, tick } = setup(t, async () => {
const { layer, tick } = setup(t, async () => {
throw new Error('socket hang up');
});
layer.enable(viewer);
layer.enable();
await tick(400);
assert.equal(layer.getStats().error, 'OpenFreeMap tiles unavailable');
});
test('a stalled TomTom status never blocks road acquisition or first simulated dots', async (t) => {
let calls = 0;
const { layer, viewer, tick } = setup(
const { layer, tick } = setup(
t,
async (box) => {
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
// load path; the real source status cancellation case is in source.test.
layer.enable(viewer);
layer.enable();
await tick(400);
await tick(2000);
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) => {
let calls = 0;
const { layer, viewer, tick } = setup(t, async (box) => {
const { layer, tick } = setup(t, async (box) => {
calls++;
return roads(box);
});
layer.enable(viewer);
layer.enable();
await tick(0);
assert.ok(calls > 0, 'the first load starts before 320 ms');
});
test('moveEnd starts the destination request without another gesture debounce', async (t) => {
let calls = 0;
const { layer, viewer, move, tick } = setup(t, async (bounds) => {
const { layer, move, tick } = setup(t, async (bounds) => {
calls++;
return roads(bounds);
});
layer.enable(viewer);
layer.enable();
await tick(400);
const before = calls;
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) => {
const { layer, viewer, tick } = setup(t, async (bounds) => roads(bounds));
viewer.camera.viewMatrix = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY);
viewer.camera.frustum = {
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);
const { layer, engine, tick } = setup(t, async (bounds) => roads(bounds));
const { postRender, preRender } = engine.frames;
assert.equal(postRender.numberOfListeners, 0);
for (let i = 0; i < 3; i++) {
layer.enable(viewer);
layer.enable(viewer);
assert.equal(viewer.scene.postRender.numberOfListeners, 1);
viewer.scene.postRender.raiseEvent();
layer.enable();
layer.enable();
assert.equal(postRender.numberOfListeners, 1);
engine.fake.emit('frames.postRender');
await tick(1);
layer.disable(viewer);
assert.equal(viewer.scene.postRender.numberOfListeners, 0);
layer.disable();
assert.equal(postRender.numberOfListeners, 0);
await tick(5000);
assert.equal(
viewer.scene.postRender.numberOfListeners,
postRender.numberOfListeners,
0,
'late work cannot resubscribe',
);
}
layer.enable(viewer);
assert.equal(viewer.scene.postRender.numberOfListeners, 1);
layer.destroy(viewer);
assert.equal(viewer.scene.postRender.numberOfListeners, 0);
assert.equal(viewer.scene.preRender.numberOfListeners, 0);
layer.enable();
assert.equal(postRender.numberOfListeners, 1);
layer.destroy();
assert.equal(postRender.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) => {
const calls = [];
const { layer, viewer, tick } = setup(t, async (bounds) => {
const { layer, tick } = setup(t, async (bounds) => {
calls.push(bounds);
return roads(bounds);
});
layer.enable(viewer);
layer.enable();
await tick(1000);
await tick(2000);
assert.ok(layer.getStats().count > 0);
const before = calls.length;
layer.disable(viewer);
layer.enable(viewer);
layer.disable();
layer.enable();
await tick(1000);
await tick(2000);
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) => {
const seen = [];
const { layer, viewer, tick } = setup(
const { layer, engine, camera, pickRay, tick } = setup(
t,
async (bounds, { majorOnly, 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' });
let reach = 0.005;
const camera = viewer.camera;
camera.getPickRay = () => null;
camera.heading = 0;
camera.pitch = -Math.PI / 2;
camera.pickEllipsoid = (pixel, ellipsoid, result) =>
Cesium.Cartesian3.fromDegrees(
camera.pickRay = pickRay;
// Angles only: the camera stays where it is.
const turn = (angles) =>
engine.fake.setPose({ position: camera.pose().position, ...angles });
turn({ heading: 0, pitch: -Math.PI / 2 });
engine.fake.script('pickEllipsoid', (pixel) =>
geo.cartesian3.fromDegrees(
-97.744 + (pixel.x - 50) / 10000,
30.267 + (pixel.y / 100) * reach,
0,
ellipsoid,
result,
);
layer.enable(viewer);
),
);
layer.enable();
await tick(400);
assert.equal(seen.length, 1);
const firstKey = seen[0].coverage.key;
camera.changed.raiseEvent();
engine.fake.emit('camera.changed');
// The final flight step occurs below the changed-event threshold.
reach = 0.08;
camera.pitch = -Math.PI / 12;
turn({ pitch: -Math.PI / 12 });
await tick(400);
assert.equal(seen.length, 2);
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,
'sample the final view when the debounce fires',
);
camera.moveEnd.raiseEvent();
engine.fake.emit('camera.moveEnd');
await tick(400);
assert.equal(
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) => {
let calls = 0;
const { layer, viewer, tick } = setup(t, async (bounds) => {
const { layer, tick } = setup(t, async (bounds) => {
calls++;
const response = roads(bounds);
const data = await response.json();
return { ok: true, json: async () => ({ ...data, partial: true }) };
});
layer.enable(viewer);
layer.enable();
await tick(400);
await tick(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,
@@ -108,7 +108,7 @@ export const SIZE_BY_TYPE = {
* Live-flow bucket colors (thresholds live in `trafficFlowStyle.js`):
* green free flow / amber slow / red jam, all at 0.9 alpha.
* 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 = {
@@ -117,6 +117,9 @@ export const FLOW_BUCKET_COLORS = {
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) ────────
/** @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;
/** @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');
/** @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);
+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 { 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 state = { _enabled: true, _dots: [], _motion: { rebuilds: 0 } };
const animation = createAnimation({
@@ -17,7 +17,7 @@ test('traffic render demand follows animated population and jam lines across emp
retention: { clear() {} },
rendering: {
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']]);
animation.clearDots();
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();
assert.deepEqual(calls.at(-1), ['hold', 'traffic']);
state._enabled = false;
@@ -39,6 +44,7 @@ test('traffic render demand follows animated population and jam lines across emp
state._enabled = true;
animation.clearDots();
assert.equal(state._renderHeld, false);
assert.equal(state._heatLineCount, 0, 'a clear removes the heat lines');
state._dots.push({});
animation.syncRenderDemand();
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 { trafficStyleProfile } from '../../data/trafficPresetStyle.js';
import {
@@ -34,38 +35,20 @@ export function createRendering({
: roads;
}
/** Remove both heat-line ground primitives from the scene. */
/** Remove both heat-line sets from the display. */
function removeHeatLines() {
if (layerState._native) {
layerState._heatJamPrim?.destroy();
layerState._heatSlowPrim?.destroy();
layerState._heatJamPrim = layerState._heatSlowPrim = null;
}
if (layerState._heatJamPrim) {
layerState._viewer?.scene.groundPrimitives.remove(
layerState._heatJamPrim,
);
layerState._heatJamPrim = null;
}
if (layerState._heatSlowPrim) {
layerState._viewer?.scene.groundPrimitives.remove(
layerState._heatSlowPrim,
);
layerState._heatSlowPrim = null;
}
layerState._display?.clearHeat();
layerState._heatLineCount = 0;
parts.animation.syncRenderDemand?.();
}
/**
* Rebuild the congestion heat-line underlay (jam-viz heatline prototype):
* slow/jam roads drape a corridor line onto the rendered 3D tiles — glowing
* pulsing red for jam, faint flat amber for slow — with the dots animating
* on top. Two batched GroundPolylinePrimitives (one per bucket) so the jam
* batch pulses through one shared material. Capped at HEAT_LINE_CAP (jam
* first, longest first); overflow is logged. No-op in sim mode, when the
* heatline mode is off, or without ground-primitive support.
* slow/jam roads drape a corridor line onto the rendered 3D tiles — red for
* jam, faint flat amber for slow — with the dots animating on top. One
* display set per bucket. Capped at HEAT_LINE_CAP (jam first, longest
* first); overflow is logged. No-op in sim mode, when the heatline mode is
* off, or without a display.
*
* @param {Array} roads - Road objects visible in the current render.
*/
@@ -73,22 +56,11 @@ export function createRendering({
function rebuildHeatLines(roads) {
removeHeatLines();
if (
(!layerState._viewer && !layerState._native) ||
!layerState._display ||
!layerState._liveMode ||
!parts.style.heatlineOn()
)
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 = [];
for (const road of roads) {
@@ -105,80 +77,27 @@ export function createRendering({
);
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
// luminance like the dots: jam = white glow, slow = faint gray.
// luminance like the dots: jam = white, slow = faint gray.
const monoHeat =
layerState._presetDots === 'on' &&
trafficStyleProfile(layerState._stylePreset) === 'mono';
const jamLineColor = monoHeat ? Cesium.Color.WHITE : HEAT_JAM_COLOR;
const jamLineColor = monoHeat ? WHITE : HEAT_JAM_COLOR;
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;
if (layerState._native) {
const roadsFor = (bucket) =>
kept.filter((item) => item.bucket === bucket).map((item) => item.road);
layerState._heatJamPrim = layerState._native.makeHeat(
roadsFor('jam'),
jamLineColor,
HEAT_LINE_JAM_WIDTH,
HEAT_JAM_BASE_ALPHA,
);
layerState._heatSlowPrim = layerState._native.makeHeat(
roadsFor('slow'),
slowLineColor,
HEAT_LINE_SLOW_WIDTH,
slowLineColor.alpha,
);
layerState._heatLineCount = kept.length;
parts.animation.syncRenderDemand();
return;
}
const jamInstances = instancesFor('jam', HEAT_LINE_JAM_WIDTH);
if (jamInstances.length) {
layerState._heatJamPrim = layerState._viewer.scene.groundPrimitives.add(
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,
}),
}),
}),
);
}
const roadsFor = (bucket) =>
kept.filter((item) => item.bucket === bucket).map((item) => item.road);
layerState._display.setHeat('jam', roadsFor('jam'), {
color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA).rgba(),
widthPx: HEAT_LINE_JAM_WIDTH,
});
layerState._display.setHeat('slow', roadsFor('slow'), {
color: slowLineColor.rgba(),
widthPx: HEAT_LINE_SLOW_WIDTH,
});
layerState._heatLineCount = kept.length;
parts.animation.syncRenderDemand?.();
if (candidates.length > kept.length) {
console.log(
`[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
// low altitude. Probe three roads and stretch the curves accordingly.
let areaDist = altitude;
if ((layerState._viewer || layerState._engine) && filteredRoads.length) {
if (layerState._engine && filteredRoads.length) {
const probes = [
filteredRoads[0],
filteredRoads[Math.floor(filteredRoads.length / 2)],
@@ -238,11 +157,7 @@ export function createRendering({
if (wp)
areaDist = Math.max(
areaDist,
Cesium.Cartesian3.distance(
layerState._engine?.camera.livePosition ??
layerState._viewer.camera.positionWC,
wp,
),
geo.cartesian3.distance(layerState._engine.camera.livePosition, wp),
);
}
}
@@ -259,18 +174,10 @@ export function createRendering({
: null;
parts.retention.reconcile(filteredRoads, altitude, replace);
if (layerState._dots.length && filteredRoads.some((r) => !r.directFlow))
services.credits?.showOsmCredit?.(
layerState._engine ?? layerState._viewer,
'traffic',
{
openMapTiles: true,
},
);
else
services.credits?.hideOsmCredit?.(
layerState._engine ?? layerState._viewer,
'traffic',
);
services.credits?.showOsmCredit?.(layerState._engine, 'traffic', {
openMapTiles: true,
});
else services.credits?.hideOsmCredit?.(layerState._engine, 'traffic');
parts.model.recolorDotsInPlace(label);
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';
export const DOT_CHANGE_BUDGET = 768;
@@ -15,28 +15,22 @@ export function createRetention({ state, parts }) {
const heightStep = { x: 0, y: 0, z: 0 };
const screenFrom = { x: 0, y: 0 };
const screenTo = { x: 0, y: 0 };
const eye = { x: 0, y: 0, z: 0 };
function projectSafely(scene, point, screen) {
if (scene.project) return Boolean(scene.project(point, screen));
if (scene.camera.viewMatrix) {
Cesium.Matrix4.multiplyByPoint(scene.camera.viewMatrix, point, eye);
if (eye.z >= -(scene.camera.frustum?.near || 0)) return false;
}
// The window position of a point in front of the camera (undefined behind
// it or in front of the near plane, as Cesium's worldToWindowCoordinates).
function projectSafely(engine, point, screen) {
return (
Boolean(
Cesium.SceneTransforms.worldToWindowCoordinates(scene, point, screen),
) &&
Boolean(engine.projector.worldToWindow(point, screen)) &&
Number.isFinite(screen.x) &&
Number.isFinite(screen.y)
);
}
function nearCanvas(scene, point, screen) {
function nearCanvas(engine, size, point, screen) {
return (
projectSafely(scene, point, screen) &&
projectSafely(engine, point, screen) &&
screen.x >= -100 &&
screen.x <= scene.canvas.clientWidth + 100 &&
screen.x <= size.width + 100 &&
screen.y >= -100 &&
screen.y <= scene.canvas.clientHeight + 100
screen.y <= size.height + 100
);
}
let timer = null;
@@ -51,9 +45,10 @@ export function createRetention({ state, parts }) {
};
function pump() {
timer = null;
if (!state._enabled || !state._pointCollection) return;
if (!state._enabled || !state._display) return;
let budget = DOT_CHANGE_BUDGET;
let pending = false;
let removed = false;
const now = Date.now();
// Removing a road or reducing density fades only surplus dots. A retained
// 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--) {
const dot = record.dots[i];
if (!dot.retiring || now - dot.retiring < DOT_FADE_MS) continue;
state._pointCollection.remove(dot.point);
const last = state._dots.pop();
if (last !== dot) {
state._dots[dot.index] = last;
@@ -77,6 +71,7 @@ export function createRetention({ state, parts }) {
}
record.dots.splice(i, 1);
budget--;
removed = true;
state._motion.removed++;
}
let active = 0;
@@ -112,6 +107,9 @@ export function createRetention({ state, parts }) {
if (!record.target && !record.dots.length)
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();
state._populationPending = pending;
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.
const road = {
...candidate,
waypoints: candidate.waypoints.map((p) => Cesium.Cartesian3.clone(p)),
waypoints: candidate.waypoints.map((p) => geo.cartesian3.clone(p)),
segmentDist: candidate.segmentDist.slice(),
source: candidate,
targetWaypoints: candidate.waypoints.map((p) =>
Cesium.Cartesian3.clone(p),
geo.cartesian3.clone(p),
),
heightMoving: false,
};
@@ -156,7 +154,7 @@ export function createRetention({ state, parts }) {
record.road.densityWeight = candidate.densityWeight;
for (let i = 0; i < candidate.waypoints.length; i++) {
if (
!Cesium.Cartesian3.equalsEpsilon(
!geo.cartesian3.equalsEpsilon(
record.road.targetWaypoints[i],
candidate.waypoints[i],
0,
@@ -164,7 +162,7 @@ export function createRetention({ state, parts }) {
)
)
record.road.heightMoving = state._heightPending = true;
Cesium.Cartesian3.clone(
geo.cartesian3.clone(
candidate.waypoints[i],
record.road.targetWaypoints[i],
);
@@ -215,10 +213,9 @@ export function createRetention({ state, parts }) {
}
function easeHeights(dt) {
const blend = Math.min(1, dt * 12);
const scene = state._trafficScene?.() ?? state._viewer?.scene;
const canProject =
scene?.canvas?.clientHeight &&
(scene.project || (scene.camera && scene.frameState));
const engine = state._engine;
const size = engine?.host.size();
const canProject = Boolean(size?.height);
state._heightPending = false;
for (let r = 0; r < state._roads.length; r++) {
const road = state._roads[r];
@@ -227,25 +224,26 @@ export function createRetention({ state, parts }) {
for (let i = 0; i < road.waypoints.length; i++) {
const point = road.waypoints[i],
target = road.targetWaypoints[i];
if (Cesium.Cartesian3.distanceSquared(point, target) < 0.0001)
Cesium.Cartesian3.clone(target, point);
if (geo.cartesian3.distanceSquared(point, target) < 0.0001)
geo.cartesian3.clone(target, point);
else {
const visible = canProject && nearCanvas(scene, point, screenFrom);
const visible =
canProject && nearCanvas(engine, size, point, screenFrom);
let step = visible
? blend
: Math.min(
blend,
(120 * dt) / Cesium.Cartesian3.distance(point, target),
(120 * dt) / geo.cartesian3.distance(point, target),
);
if (visible) {
let safe = false;
for (let attempt = 0; attempt < 16; attempt++) {
Cesium.Cartesian3.lerp(point, target, step, heightStep);
if (!projectSafely(scene, heightStep, screenTo)) {
geo.cartesian3.lerp(point, target, step, heightStep);
if (!projectSafely(engine, heightStep, screenTo)) {
step *= 0.5;
continue;
}
const pixels = Cesium.Cartesian2.distance(screenFrom, screenTo);
const pixels = geo.cartesian2.distance(screenFrom, screenTo);
if (pixels <= 12) {
safe = true;
break;
@@ -255,12 +253,12 @@ export function createRetention({ state, parts }) {
if (!safe) step = 0;
}
state._heightPending ||= !canProject || visible;
Cesium.Cartesian3.lerp(point, target, step, point);
geo.cartesian3.lerp(point, target, step, point);
moving = true;
}
}
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 + 1],
);
@@ -280,9 +278,9 @@ export function createRetention({ state, parts }) {
for (const road of roads) {
if (!road.heightMoving) continue;
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++)
road.segmentDist[i] = Cesium.Cartesian3.distance(
road.segmentDist[i] = geo.cartesian3.distance(
road.waypoints[i],
road.waypoints[i + 1],
);
+14 -27
View File
@@ -13,19 +13,20 @@ export function createState({ services }) {
layerState._roadModeUrlOverride = layerState._roadMode;
// ─── Module State ──────────────────────────────────────────
/** @type {Cesium.Viewer|null} */
/** @type {import('../../globe/contract.js').GlobeEngine|null} */
layerState._viewer = 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._nextDotId = 1;
@@ -37,7 +38,7 @@ export function createState({ services }) {
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 = [];
@@ -171,27 +172,13 @@ export function createState({ services }) {
layerState._jamViz = 'density';
/**
* Congestion heat-lines are GroundPolylinePrimitive batches draped onto the
* rendered 3D tiles (ClassificationType.CESIUM_3D_TILE). One
* primitive per bucket so the jam batch can pulse via a single shared
* material uniform.
* @type {Cesium.GroundPolylinePrimitive|null}
* Congestion heat-lines are ground corridors draped onto the rendered 3D
* tiles, one display set per bucket (jam, slow).
* @type {number} Heat-lines in the current render (stats).
*/
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;
/** @type {boolean|null} GroundPolylinePrimitive.isSupported, checked once. */
layerState._heatSupported = null;
/** @type {number} Altitude of the last render, for late-flow heat rebuilds. */
layerState._lastRenderAltitude = 0;
@@ -220,7 +207,7 @@ export function createState({ services }) {
* Effective per-bucket dot colors: preset override when one applies, else
* the shipped FLOW_BUCKET_COLORS. Rebuilt on style/param change only —
* 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 };
+4 -4
View File
@@ -4,7 +4,7 @@ import {
presetDotRgba,
presetDotOutline,
} from '../../data/trafficPresetStyle.js';
import * as Cesium from 'cesium';
import { Rgba } from '../shared/color.js';
import {
SIZE_BY_TYPE,
STYLED_MIN_BASE_PX,
@@ -58,7 +58,7 @@ export function createStyle({ state: layerState, services, parts, source }) {
? presetDotRgba(layerState._stylePreset, bucket)
: null;
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];
}
}
@@ -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
* scene, so brightness alone cannot separate a dot from a bright road —
* 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).
*/
@@ -78,7 +78,7 @@ export function createStyle({ state: layerState, services, parts, source }) {
? presetDotOutline(layerState._stylePreset, bucket)
: null;
if (spec) {
point.outlineColor = new Cesium.Color(
point.outlineColor = new Rgba(
spec.rgba[0] / 255,
spec.rgba[1] / 255,
spec.rgba[2] / 255,
+79 -130
View File
@@ -1,40 +1,25 @@
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';
function surfaceTilesReady(scene) {
if (scene.globe?.show) return scene.globe.tilesLoaded !== false;
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;
}
// Every surface read here goes through the globe contract (engine.surface,
// engine.picker, engine.projector, engine.maps), the same on every engine.
const renderedFrames = new WeakMap();
/** Observe completed frames only while an enabled traffic consumer owns them. */
export function observeTrafficSurface(scene) {
if (!scene.postRender?.addEventListener || !scene.camera?.viewMatrix)
return () => {};
let frame = renderedFrames.get(scene);
export function observeTrafficSurface(engine) {
if (!engine?.frames?.postRender?.addEventListener) return () => {};
let frame = renderedFrames.get(engine);
if (!frame) {
frame = {
view: new Cesium.Matrix4(),
projection: new Cesium.Matrix4(),
viewProjection: geo.matrix4.create(),
owners: 0,
};
renderedFrames.set(scene, frame);
frame.remove = scene.postRender.addEventListener(() => {
Cesium.Matrix4.clone(scene.camera.viewMatrix, frame.view);
Cesium.Matrix4.clone(
scene.camera.frustum.projectionMatrix,
frame.projection,
);
frame.surface = trafficSurfaceKey(scene);
frame.ready = surfaceTilesReady(scene);
renderedFrames.set(engine, frame);
frame.remove = engine.frames.postRender.addEventListener(() => {
engine.projector.viewProjection(frame.viewProjection);
frame.surface = trafficSurfaceKey(engine);
frame.ready = engine.surface.tilesLoaded();
});
}
frame.owners++;
@@ -44,30 +29,27 @@ export function observeTrafficSurface(scene) {
released = true;
if (--frame.owners === 0) {
frame.remove();
renderedFrames.delete(scene);
renderedFrames.delete(engine);
}
};
}
function currentRenderedFrame(scene) {
if (scene.engine) return { ready: scene.ready };
if (!scene.postRender?.addEventListener || !scene.camera?.viewMatrix)
return { ready: surfaceTilesReady(scene) };
const frame = renderedFrames.get(scene);
const scratchViewProjection = geo.matrix4.create();
function currentRenderedFrame(engine) {
const frame = renderedFrames.get(engine);
return frame &&
frame.surface === trafficSurfaceKey(scene) &&
Cesium.Matrix4.equals(frame.view, scene.camera.viewMatrix) &&
Cesium.Matrix4.equals(
frame.projection,
scene.camera.frustum.projectionMatrix,
frame.surface === trafficSurfaceKey(engine) &&
geo.matrix4.equals(
frame.viewProjection,
engine.projector.viewProjection(scratchViewProjection),
)
? frame
: null;
}
/** Only a completed render of this camera can certify cached surface heights. */
export function trafficSurfaceReady(scene) {
return currentRenderedFrame(scene)?.ready === true;
export function trafficSurfaceReady(engine) {
return currentRenderedFrame(engine)?.ready === true;
}
/** 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
* 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
* or hillside projected onto a different geographic point. Translucent traffic
* is absent from Cesium's default pickPosition depth pass.
* or hillside projected onto a different geographic point. Traffic itself is
* 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 (
!scene.pickPositionSupported ||
scene.pickTranslucentDepth ||
!currentRenderedFrame(scene) ||
!scene.canvas?.clientWidth ||
!scene.frameState
!engine.picker.pickPositionSupported ||
!currentRenderedFrame(engine) ||
!size.width
)
return undefined;
let height = validHeight(estimate) ? estimate : 0;
const point = new Cesium.Cartesian3();
const screen = new Cesium.Cartesian2();
const point = geo.cartesian3.create();
const screen = geo.cartesian2.create();
for (let attempt = 0; attempt < 4; attempt++) {
Cesium.Cartesian3.fromDegrees(lon, lat, height, undefined, point);
geo.cartesian3.fromDegrees(lon, lat, height, undefined, point);
if (
!Cesium.SceneTransforms.worldToWindowCoordinates(scene, point, screen) ||
!engine.projector.worldToWindow(point, screen) ||
screen.x < 0 ||
screen.y < 0 ||
screen.x >= scene.canvas.clientWidth ||
screen.y >= scene.canvas.clientHeight
screen.x >= size.width ||
screen.y >= size.height
)
return undefined;
let hit;
try {
hit = scene.pickPosition(screen);
hit = engine.picker.pickPosition(screen);
} catch {
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;
const dx =
(Cesium.Math.toDegrees(location.longitude) - lon) *
(geo.math.toDegrees(location.longitude) - lon) *
Math.cos((lat * Math.PI) / 180) *
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;
height = location.height;
}
return undefined;
}
// Per-scene, coordinate-keyed LRU. Never writes raw samples into shared ground floors.
const sceneCaches = new WeakMap();
// Per-engine, coordinate-keyed LRU. Never writes raw samples into shared ground floors.
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();
let nextSurfaceId = 1;
const surfaceId = (object) => {
@@ -199,45 +180,26 @@ const surfaceId = (object) => {
};
/**
* Name what heights are sampled against: the terrain provider when the globe
* is shown, otherwise the set of visible 3D tilesets. A map-provider switch
* Name what heights are sampled against: the photoreal tiles shown, or the
* 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.
*/
const surfaceKeys = new WeakMap();
export function trafficSurfaceKey(scene) {
if (scene.engine) return scene.surfaceKey;
let state = surfaceKeys.get(scene);
export function trafficSurfaceKey(engine) {
let state = surfaceKeys.get(engine);
if (!state) {
state = { globe: null, terrain: null, tiles: [], key: '' };
surfaceKeys.set(scene, state);
state = { tiles: false, source: null, key: '' };
surfaceKeys.set(engine, state);
}
const globe = Boolean(scene.globe?.show);
const terrain = globe ? scene.globe.terrainProvider : null;
let changed = state.globe !== globe || state.terrain !== terrain;
let count = 0;
if (!globe) {
for (let i = 0; i < (scene.primitives?.length || 0); i++) {
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;
const tiles = engine.maps.photorealActive();
const current = engine.maps.current();
const source = tiles ? current.photoreal : current.baseImagery;
if (state.tiles !== tiles || state.source !== source || !state.key) {
state.tiles = tiles;
state.source = source;
// Allocate only when the actual map surface changes, never per frame.
state.key = globe
? `globe:${surfaceId(terrain)}`
: `tiles:${state.tiles.map(surfaceId).join(',')}`;
state.key = `${tiles ? 'tiles' : 'globe'}:${surfaceId(source)}`;
}
return state.key;
}
@@ -265,7 +227,7 @@ const nextSlice = (hasSurface) =>
/** Prepare only admitted roads in bounded slices; locally missing surfaces defer that road. */
export async function prepareRoadSurfaces(
roads,
scene,
engine,
ground,
excluded,
signal,
@@ -274,21 +236,15 @@ export async function prepareRoadSurfaces(
// 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
// closer band than its sample is re-sampled against the finer mesh.
const surface = trafficSurfaceKey(scene);
let cache = sceneCaches.get(scene);
const surface = trafficSurfaceKey(engine);
let cache = heightCaches.get(engine);
if (!cache || cache.surface !== surface) {
cache = { surface, heights: new Map() };
sceneCaches.set(scene, cache);
heightCaches.set(engine, cache);
}
const cameraCarto =
scene.engine?.camera.pose().cartographic ??
scene.camera?.positionCartographic;
const cameraLon = cameraCarto
? Cesium.Math.toDegrees(cameraCarto.longitude)
: 0;
const cameraLat = cameraCarto
? Cesium.Math.toDegrees(cameraCarto.latitude)
: 0;
const cameraCarto = engine.camera.pose().cartographic;
const cameraLon = cameraCarto ? geo.math.toDegrees(cameraCarto.longitude) : 0;
const cameraLat = cameraCarto ? geo.math.toDegrees(cameraCarto.latitude) : 0;
const cameraHeight = cameraCarto ? cameraCarto.height : NaN;
const start = performance.now();
const metrics = {
@@ -301,7 +257,7 @@ export async function prepareRoadSurfaces(
maxSliceMs: 0,
};
let sliceStart = performance.now();
const carto = new Cesium.Cartographic();
const carto = geo.cartographic.create();
const ready = [],
pending = [];
for (const road of roads) {
@@ -314,7 +270,7 @@ export async function prepareRoadSurfaces(
const [lon, lat] = road.coords[i];
const key = `${lon.toFixed(6)},${lat.toFixed(6)}`;
const cached = cache.heights.get(key);
const settled = trafficSurfaceReady(scene);
const settled = trafficSurfaceReady(engine);
const band = detailBand(cameraLon, cameraLat, cameraHeight, lon, lat);
let height =
cached &&
@@ -330,38 +286,31 @@ export async function prepareRoadSurfaces(
cache.heights.set(key, cached);
} else {
if (!settled) settledRoad = false;
carto.longitude = Cesium.Math.toRadians(lon);
carto.latitude = Cesium.Math.toRadians(lat);
carto.longitude = geo.math.toRadians(lon);
carto.latitude = geo.math.toRadians(lat);
carto.height = 0;
const floor = ground?.cachedGroundFloor?.(lat, lon);
let sampled;
// sampleHeight reads the locally rendered mesh, not unresolved/offscreen
// tiles. Global tilesLoaded can stay false while this street is usable.
if (scene.engine) sampled = scene.sampleHeight(carto);
else if (scene.globe?.show) sampled = scene.globe.getHeight?.(carto);
// The globe answers from its terrain. Photoreal tiles answer from the
// rendered depth first, then from sampleHeight, which reads the
// locally rendered mesh, not unresolved/offscreen tiles: global
// tilesLoaded can stay false while this street is usable.
const photoreal = engine.maps.photorealActive();
if (!photoreal) sampled = engine.surface.terrainHeight(carto);
else {
const estimate = Cesium.Cartographic.fromCartesian(
const estimate = geo.cartographic.fromCartesian(
road.waypoints[i],
)?.height;
sampled = renderedRoadHeight(scene, lon, lat, estimate);
sampled = renderedRoadHeight(engine, lon, lat, estimate);
if (validHeight(sampled)) metrics.depthHits++;
}
if (
!scene.engine &&
!scene.globe?.show &&
!validHeight(sampled) &&
scene.sampleHeightSupported
) {
if (photoreal && !validHeight(sampled) && engine.surface.supported) {
const sampleStart = performance.now();
metrics.sampleCount++;
const shown = (excluded || []).filter((p) => p?.show);
for (const primitive of shown) primitive.show = false;
try {
sampled = scene.sampleHeight(carto, excluded);
sampled = engine.surface.sampleHeight(carto, excluded);
} catch {
/* local mesh missing */
} finally {
for (const primitive of shown) primitive.show = true;
}
metrics.sampleMs += performance.now() - sampleStart;
}
@@ -408,7 +357,7 @@ export async function prepareRoadSurfaces(
const height = heights[a] + (heights[b] - heights[a]) * t;
const [lon, lat] = road.coords[i];
const floor = ground?.cachedGroundFloor?.(lat, lon);
Cesium.Cartesian3.fromDegrees(
geo.cartesian3.fromDegrees(
lon,
lat,
(validHeight(floor) ? Math.max(height, floor) : height) +
@@ -418,7 +367,7 @@ export async function prepareRoadSurfaces(
);
}
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 + 1],
);
+167 -175
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
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 {
detailBand,
renderedRoadHeight,
@@ -13,9 +14,26 @@ import {
} from './surface.js';
const road = (coords) => ({
coords,
waypoints: coords.map(() => new C.Cartesian3()),
waypoints: coords.map(() => geo.cartesian3.create()),
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', () => {
const chunks = roadSurfaceChunks([
[0, 0],
@@ -46,7 +64,7 @@ test('surface points preserve bends, bound spacing, and split rather than decima
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];
let samples = 0;
const r = road([
@@ -56,125 +74,134 @@ test('per-vertex mesh heights follow terrain, reject invalid samples, and leave
[0.003, 0],
[0.004, 0],
]);
const tileset = { show: true, tilesLoaded: true };
const scene = {
globe: { show: false },
primitives: { length: 1, get: () => tileset },
sampleHeightSupported: true,
sampleHeight: () => heights[samples++],
};
const engine = surfaceEngine();
let loaded = true;
engine.fake.script('tilesLoaded', () => loaded);
engine.fake.script('sampleHeight', () => heights[samples++]);
t.after(observeTrafficSurface(engine));
engine.fake.frame();
const ground = {
cachedGroundFloor: () => 50,
reportMeshFloorCell: () => assert.fail('raw sample poisoned shared floor'),
};
let metrics;
const prepared = await prepareRoadSurfaces([r], scene, ground, [], null, {
const prepared = await prepareRoadSurfaces([r], engine, ground, [], null, {
onMetrics: (value) => (metrics = value),
});
assert.equal(metrics.sampleCount, 5);
assert.equal(prepared.pending.length, 1);
assert.equal(samples, 5);
const actual = r.waypoints.map((p) =>
Math.round(C.Cartographic.fromCartesian(p).height),
);
const actual = r.waypoints.map((p) => Math.round(heightOf(p)));
assert.deepEqual(actual, [103, 203, 53, 53, 53]);
tileset.tilesLoaded = false;
assert.equal(trafficSurfaceReady(scene), false);
scene.sampleHeight = () => 75;
const resumed = await prepareRoadSurfaces([r], scene, ground, []);
loaded = false;
engine.fake.frame();
assert.equal(trafficSurfaceReady(engine), false);
engine.fake.script('sampleHeight', () => 75);
const resumed = await prepareRoadSurfaces([r], engine, ground, []);
assert.equal(
resumed.ready.length,
1,
'other loading tiles do not block a local mesh',
);
let picks = 0;
scene.sampleHeight = () => {
engine.fake.script('sampleHeight', () => {
picks++;
return 75;
};
await prepareRoadSurfaces([r], scene, ground, []);
});
await prepareRoadSurfaces([r], engine, ground, []);
assert.equal(picks, 0, 'validated coordinate heights survive cache hits');
const abort = new AbortController();
abort.abort();
await assert.rejects(
prepareRoadSurfaces([r], scene, ground, [], abort.signal),
prepareRoadSurfaces([r], engine, ground, [], abort.signal),
{ 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 () => {
const r = road([
[0, 0],
[0.001, 0],
]);
let calls = 0;
await prepareRoadSurfaces(
[r],
{
globe: { show: true, tilesLoaded: true, getHeight: () => ++calls * 100 },
sampleHeight: () => assert.fail('mesh sampled'),
},
{ cachedGroundFloor: () => 20 },
[],
);
const engine = surfaceEngine({ photoreal: false });
engine.fake.script('terrainHeight', () => ++calls * 100);
engine.fake.script('sampleHeight', () => assert.fail('mesh sampled'));
await prepareRoadSurfaces([r], engine, { cachedGroundFloor: () => 20 }, []);
assert.equal(calls, 2);
assert.deepEqual(
r.waypoints.map((p) => Math.round(C.Cartographic.fromCartesian(p).height)),
r.waypoints.map((p) => Math.round(heightOf(p))),
[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([
[0, 0],
[0.001, 0],
]);
const tileset = { show: true, tilesLoaded: true };
let mesh = 40,
samples = 0;
const camera = (height) => ({
positionCartographic: { longitude: 0, latitude: 0, height },
});
const scene = {
globe: { show: false },
primitives: { length: 1, get: () => tileset },
sampleHeightSupported: true,
sampleHeight: () => (samples++, mesh),
camera: camera(3000),
const engine = surfaceEngine();
engine.fake.script('sampleHeight', () => (samples++, mesh));
t.after(observeTrafficSurface(engine));
const camera = (height) => {
lookFrom(engine, height);
engine.fake.frame();
};
const heightAt = () =>
Math.round(C.Cartographic.fromCartesian(r.waypoints[0]).height);
const heightAt = () => Math.round(heightOf(r.waypoints[0]));
// 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(heightAt(), 43);
// Same detail band on a revisit: zero samples.
await prepareRoadSurfaces([r], scene, null, []);
await prepareRoadSurfaces([r], engine, null, []);
assert.equal(samples, 2);
// Zooming out never re-samples a finer height either.
scene.camera = camera(6000);
await prepareRoadSurfaces([r], scene, null, []);
camera(6000);
await prepareRoadSurfaces([r], engine, null, []);
assert.equal(samples, 2);
// Closer view, finer mesh: re-sampled and the waypoint follows it.
mesh = 47;
scene.camera = camera(250);
await prepareRoadSurfaces([r], scene, null, []);
camera(250);
await prepareRoadSurfaces([r], engine, null, []);
assert.equal(samples, 4);
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');
// A different photoreal tileset (map provider switch) invalidates all,
// even from a coarser view that would otherwise reuse the finer sample.
const other = { show: true, tilesLoaded: true };
scene.primitives.get = () => other;
scene.camera = camera(3000);
// Different photoreal tiles (map provider switch) invalidate all, even
// from a coarser view that would otherwise reuse the finer sample.
engine.fake.maps.photoreal = { kind: 'photoreal' };
camera(3000);
mesh = 30;
await prepareRoadSurfaces([r], scene, null, []);
await prepareRoadSurfaces([r], engine, null, []);
assert.equal(samples, 6);
assert.equal(heightAt(), 33);
// A finer sample that fails keeps the coarser measured height.
scene.camera = camera(250);
camera(250);
mesh = NaN;
const kept = await prepareRoadSurfaces([r], scene, null, []);
const kept = await prepareRoadSurfaces([r], engine, null, []);
assert.equal(samples, 8);
assert.equal(kept.ready.length, 1);
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 () => {
const tileset = { show: true, tilesLoaded: true };
let height = 20,
samples = 0;
const scene = {
postRender: new C.Event(),
camera: {
viewMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY),
frustum: { projectionMatrix: C.Matrix4.clone(C.Matrix4.IDENTITY) },
},
globe: { show: false },
primitives: { length: 1, get: () => tileset },
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;
const engine = surfaceEngine();
engine.fake.script('sampleHeight', () => (samples++, height));
lookFrom(engine, 3000);
assert.equal(trafficSurfaceReady(engine), false);
const removeSurfaceObserver = observeTrafficSurface(engine);
engine.fake.frame();
assert.equal(trafficSurfaceReady(engine), true);
lookFrom(engine, 3100);
assert.equal(
trafficSurfaceReady(scene),
trafficSurfaceReady(engine),
false,
'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.001, 0],
]);
await prepareRoadSurfaces([r], scene, null, []);
await prepareRoadSurfaces([r], engine, null, []);
assert.equal(r.surfaceSettled, false);
height = 80;
scene.postRender.raiseEvent();
await prepareRoadSurfaces([r], scene, null, []);
engine.fake.frame();
await prepareRoadSurfaces([r], engine, null, []);
assert.equal(samples, 4, 'the first fresh render revalidates coarse heights');
assert.equal(r.surfaceSettled, true);
assert.ok(
Math.abs(C.Cartographic.fromCartesian(r.waypoints[0]).height - 83) < 0.01,
);
assert.ok(Math.abs(heightOf(r.waypoints[0]) - 83) < 0.01);
height = 100;
await prepareRoadSurfaces([r, r], scene, null, [], null, {
await prepareRoadSurfaces([r, r], engine, null, [], null, {
revalidate: true,
});
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',
);
removeSurfaceObserver();
assert.ok(
Math.abs(C.Cartographic.fromCartesian(r.waypoints[0]).height - 103) < 0.01,
);
await prepareRoadSurfaces([r], scene, null, []);
assert.ok(Math.abs(heightOf(r.waypoints[0]) - 103) < 0.01);
await prepareRoadSurfaces([r], engine, null, []);
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(
samples,
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);
const r = road(coords);
let picks = 0;
await prepareRoadSurfaces(
[r],
{
globe: {
show: true,
tilesLoaded: true,
getHeight: (c) => {
picks++;
return C.Math.toDegrees(c.longitude) * 1000;
},
},
},
null,
[],
);
const engine = surfaceEngine({ photoreal: false });
engine.fake.script('terrainHeight', (c) => {
picks++;
return toDegrees(c.longitude) * 1000;
});
await prepareRoadSurfaces([r], engine, null, []);
assert.equal(picks, indices.length);
for (let i = 0; i < coords.length; i++) {
const c = C.Cartographic.fromCartesian(r.waypoints[i]);
assert.ok(Math.abs(C.Math.toDegrees(c.longitude) - coords[i][0]) < 1e-9);
assert.ok(Math.abs(C.Math.toDegrees(c.latitude) - coords[i][1]) < 1e-9);
const c = geo.cartographic.fromCartesian(r.waypoints[i]);
assert.ok(Math.abs(toDegrees(c.longitude) - coords[i][0]) < 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);
}
});
test('rendered road depth is geographically checked, bounded, and avoids mesh redraws', async (t) => {
t.mock.method(
C.SceneTransforms,
'worldToWindowCoordinates',
(scene, point, out) => {
out.x = 100;
out.y = 100;
return out;
},
const engine = surfaceEngine();
engine.fake.script('worldToWindow', () => ({ x: 100, y: 100 }));
engine.fake.script('pickPosition', () =>
geo.cartesian3.fromDegrees(-97, 30, 100),
);
const scene = {
globe: { show: false },
frameState: {},
canvas: { clientWidth: 800, clientHeight: 600 },
pickPositionSupported: true,
sampleHeightSupported: true,
pickPosition: () => C.Cartesian3.fromDegrees(-97, 30, 100),
sampleHeight: () =>
assert.fail('valid depth must not draw another pick frustum'),
};
assert.ok(Math.abs(renderedRoadHeight(scene, -97, 30, 0) - 100) < 0.001);
engine.fake.script('sampleHeight', () =>
assert.fail('valid depth must not draw another pick frustum'),
);
assert.equal(
renderedRoadHeight(engine, -97, 30, 0),
undefined,
'no rendered frame of this camera yet',
);
t.after(observeTrafficSurface(engine));
engine.fake.frame();
assert.ok(Math.abs(renderedRoadHeight(engine, -97, 30, 0) - 100) < 0.001);
const r = road([
[-97, 30],
[-97, 30.000005],
]);
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.sampleCount, 0);
let calls = 0;
scene.pickPosition = () => {
engine.fake.script('pickPosition', () => {
calls++;
return C.Cartesian3.fromDegrees(-97.001, 30, 150);
};
return geo.cartesian3.fromDegrees(-97.001, 30, 150);
});
assert.equal(
renderedRoadHeight(scene, -97, 30, 0),
renderedRoadHeight(engine, -97, 30, 0),
undefined,
'foreground roof is not the road coordinate',
);
assert.equal(calls, 4);
scene.pickPosition = () => C.Cartesian3.fromDegrees(-97, 30, 10000);
assert.equal(renderedRoadHeight(scene, -97, 30, 0), undefined);
scene.pickPosition = () => {
engine.fake.script('pickPosition', () =>
geo.cartesian3.fromDegrees(-97, 30, 10000),
);
assert.equal(renderedRoadHeight(engine, -97, 30, 0), undefined);
engine.fake.script('pickPosition', () => {
throw new Error('depth unavailable');
};
assert.equal(renderedRoadHeight(scene, -97, 30, 0), undefined);
scene.pickTranslucentDepth = true;
assert.equal(renderedRoadHeight(scene, -97, 30, 0), undefined);
});
assert.equal(renderedRoadHeight(engine, -97, 30, 0), undefined);
engine.fake.script('pickPosition', () =>
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) => {
const tiles = [
{ show: true, tilesLoaded: true },
{ show: true, tilesLoaded: false },
];
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);
test('surface keys reuse identity without building arrays or strings on unchanged frames', () => {
const engine = surfaceEngine();
const first = trafficSurfaceKey(engine);
const remove = observeTrafficSurface(engine);
const originalMap = Array.prototype.map;
const originalJoin = Array.prototype.join;
let maps = 0,
@@ -372,8 +361,8 @@ test('surface keys reuse identity without building arrays or strings on unchange
};
try {
for (let i = 0; i < 1000; i++) {
scene.postRender.raiseEvent();
assert.equal(trafficSurfaceKey(scene), first);
engine.fake.emit('frames.postRender', i);
assert.equal(trafficSurfaceKey(engine), first);
}
} finally {
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(joins, 0);
tiles[1].show = false;
const hidden = trafficSurfaceKey(scene);
assert.notEqual(hidden, first);
tiles[1] = { show: true, tilesLoaded: true };
engine.fake.maps.photoreal = { kind: 'photoreal' };
assert.notEqual(
trafficSurfaceKey(scene),
trafficSurfaceKey(engine),
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();
assert.equal(scene.postRender.numberOfListeners, 0);
assert.equal(engine.frames.postRender.numberOfListeners, 0);
assert.equal(
trafficSurfaceReady(scene),
trafficSurfaceReady(engine),
false,
'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 { 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
* about 500 ms after stillness, so fetch has typically already started and
* Mark the camera's diagnostic moveEnd notification. The engine emits this
* once the camera has been still a while (Cesium: 500 ms), so fetch has typically already started and
* never waits for this boundary.
*/
@@ -250,12 +250,12 @@ export function createTiming({ state: layerState, services, parts, source }) {
(Number.isFinite(floor) && Math.abs(floor) <= 9000
? floor
: layerState._viewHeightEstimate || 0) + DOT_HEIGHT_OFFSET;
return Cesium.Cartesian3.fromDegrees(lng, lat, h);
return geo.cartesian3.fromDegrees(lng, lat, h);
});
const segmentDist = [];
for (let i = 0; i < waypoints.length - 1; i++) {
segmentDist.push(
Cesium.Cartesian3.distance(waypoints[i], waypoints[i + 1]),
geo.cartesian3.distance(waypoints[i], waypoints[i + 1]),
);
}
/* TRACE_ONLY_BEGIN */
@@ -316,7 +316,7 @@ export function createTiming({ state: layerState, services, parts, 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(
state,
@@ -324,9 +324,9 @@ export function createTiming({ state: layerState, services, parts, source }) {
renderId,
renderMetrics,
) {
if (!layerState._viewer?.scene) return;
if (!layerState._engine) return;
let remove = null;
remove = layerState._viewer.scene.postRender.addEventListener(() => {
remove = layerState._engine.frames.postRender.addEventListener(() => {
remove?.();
layerState._trafficTimingPostRenderRemovers?.delete(remove);
const visibleTime = performance.now();
+6 -21
View File
@@ -1,5 +1,4 @@
import { createReticleFootprint } from './footprint.js';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import {
deriveFetchCenter,
@@ -24,9 +23,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
*/
function getCameraAltitude() {
const carto =
layerState._engine?.camera.pose().cartographic ??
layerState._viewer?.camera.positionCartographic;
const carto = layerState._engine?.camera.pose().cartographic;
return carto ? carto.height : Infinity;
}
@@ -37,9 +34,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
*/
function getViewBounds() {
const rect =
layerState._engine?.camera.viewRectangle() ??
layerState._viewer?.camera.computeViewRectangle();
const rect = layerState._engine?.camera.viewRectangle();
if (!rect) return null;
return {
south: geo.math.toDegrees(rect.south),
@@ -64,25 +59,19 @@ export function createViewport({ state: layerState, services, parts, source }) {
*/
function getFetchCenter() {
const carto =
layerState._engine?.camera.pose().cartographic ??
layerState._viewer?.camera.positionCartographic;
const carto = layerState._engine?.camera.pose().cartographic;
if (!carto) return null;
const nadirLat = geo.math.toDegrees(carto.latitude);
const nadirLon = geo.math.toDegrees(carto.longitude);
let hitLat;
let hitLon;
const canvas =
layerState._engine?.host.canvas ?? layerState._viewer.scene.canvas;
const canvas = layerState._engine.host.canvas;
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
if (width > 0 && height > 0) {
const hit = (
layerState._engine?.camera ?? layerState._viewer.camera
).pickEllipsoid(
const hit = layerState._engine.camera.pickEllipsoid(
geo.cartesian2.create(width / 2, height / 2),
Cesium.Ellipsoid.WGS84,
);
if (hit) {
const hitCarto = geo.cartographic.fromCartesian(hit);
@@ -219,11 +208,7 @@ export function createViewport({ state: layerState, services, parts, source }) {
return;
const alt = getCameraAltitude();
if (alt > ACTIVATION_ALTITUDE) return onCameraChanged();
const footprint = reticleFootprint(
layerState._viewer,
alt,
layerState._engine,
);
const footprint = reticleFootprint(layerState._engine, alt);
const bounds = footprint || getViewBounds();
if (!bounds) return;
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');
const app = harness(t);
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({
state: { _pointCollection: {}, _loadGeneration: 0 },
state: { _engine: trafficEngine, _loadGeneration: 0 },
services: {
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';
// Existing entity graphics only. Remove entries as these callers migrate.
const legacyFiles = new Set([
'src/scenes/dataPacks/presentation.js',
'src/layers/directions/index.js',
]);
const legacyFiles = new Set(['src/scenes/dataPacks/presentation.js']);
const root = fileURLToPath(new URL('../', import.meta.url));
const propertyName = (node) => node?.name ?? node?.value;
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 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 {
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
* handler, camera events with Cesium's flight bookkeeping (`flights` counts
* them, lists them and can `land` the current one), primitive lists and a
* credit display. With `view`, the camera looks straight down on Sacramento
* from 900 m, so coverage asks for tiles.
* What an engine's ground sets and mover sets were given, by id: the
* options (with the contract's defaults), a ground set's committed items, a
* mover set's committed instances (the fields of every set() before its
* last commit), and whether each is still live.
*/
export function fakeCesiumViewer({ view = false } = {}) {
const credits = [];
export function watchDrawing(engine) {
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;
const flights = {
started: 0,
cancelled: 0,
/** Every flight asked for, oldest first: `{ sphere, options }`. */
list: [],
land() {
const current = flight;
@@ -151,127 +286,139 @@ export function fakeCesiumViewer({ view = false } = {}) {
current?.options.complete?.();
},
};
const stopFlight = () => {
const stop = () => {
const current = flight;
flight = null;
current?.options.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,
...(view ? { clientWidth: 100, clientHeight: 100 } : {}),
});
const camera = Object.assign(
view
? rayCamera({
lon: -121.4944,
lat: 38.5816,
altitude: 900,
pitch: -90,
width: 100,
height: 100,
})
: {},
{
changed: new Cesium.Event(),
moveStart: new Cesium.Event(),
moveEnd: new Cesium.Event(),
flyToBoundingSphere(sphere, options = {}) {
stopFlight();
flight = { sphere, options };
flights.list.push(flight);
flights.started++;
},
cancelFlight() {
if (stopFlight()) flights.cancelled++;
},
},
);
engine.camera.flyToSphere = (sphere, options = {}) => {
stop();
flight = { sphere, options };
flights.list.push(flight);
flights.started++;
};
engine.camera.cancelFlight = () => {
if (stop()) flights.cancelled++;
};
return flights;
}
/**
* The Street Level display on the fake engine (`view`: over Sacramento, so
* coverage asks for tiles), with what it drew (watchDrawing), its credits'
* HTML, flight bookkeeping and `frame()` (one engine frame: sprites paint
* on preRender). `engine` replaces the fake one.
*/
export function streetLevelMap({ view = false, engine = null } = {}) {
const globe =
engine ?? (view ? cameraEngine(SACRAMENTO_VIEW) : createFakeEngine());
const drawing = watchDrawing(globe);
const flights = watchFlights(globe);
const map = createStreetLevelDisplay(globe);
return {
credits,
engine: globe,
map,
flights,
scene: {
canvas,
primitives: { add: (p) => p, remove() {} },
groundPrimitives: { 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,
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);
},
...drawing,
credits: () => (globe.fake?.credits ?? []).map((credit) => credit.html),
frame: () => globe.fake.frame(),
/** A click at `position`, with `picked` under it (undefined: the map). */
click(picked, position = { x: 50, y: 50 }) {
globe.fake.script('pick', picked);
globe.fake.input('click', { position });
globe.fake.unscript('pick');
},
};
}
/**
* A pinhole camera `altitude` m above WGS84, at `heading` (deg from north) and
* `pitch` (deg, negative down); `pickEllipsoid` hits whatever ellipsoid it gets.
* The Cesium engine over a real Cesium camera on a stub scene, with fake
* 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({
lon,
lat,
altitude,
heading = 0,
pitch,
width = 1600,
height = 900,
fovY = 60,
fovX = null,
}) {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, altitude);
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(position);
const h = heading * RAD;
const p = pitch * RAD;
// East-north-up axes of the view: forward, right and up.
const forward = [
Math.sin(h) * Math.cos(p),
Math.cos(h) * Math.cos(p),
Math.sin(p),
];
const right = [Math.cos(h), -Math.sin(h), 0];
const up = [
-Math.sin(h) * Math.sin(p),
-Math.cos(h) * Math.sin(p),
Math.cos(p),
];
const halfY = Math.tan((fovY / 2) * RAD);
const halfX =
fovX == null ? halfY * (width / height) : Math.tan((fovX / 2) * RAD);
export function cesiumStreetLevelEngine({ width = 100, height = 100 } = {}) {
const handlers = createFakeEventHandlers();
const primitive = (type) => (options) => {
const value = {
type,
options,
ready: false,
show: true,
destroyed: false,
destroy() {
value.destroyed = true;
},
isDestroyed: () => value.destroyed,
};
return value;
};
const { engine, viewer, frame } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
groundFactories: { fills: primitive('fills'), lines: primitive('lines') },
},
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
viewer.scene.canvas.clientWidth = width;
viewer.scene.canvas.clientHeight = height;
let native;
viewer.scene.pick = () => native;
viewer.scene.drillPick = () => (native ? [native] : []);
return {
positionWC: position,
positionCartographic: Cesium.Cartographic.fromDegrees(lon, lat, altitude),
pickEllipsoid(point, ellipsoid = Cesium.Ellipsoid.WGS84) {
const sx = ((2 * point.x) / width - 1) * halfX;
const sy = (1 - (2 * point.y) / height) * halfY;
const local = [0, 1, 2].map(
(i) => forward[i] + sx * right[i] + sy * up[i],
);
const direction = Cesium.Cartesian3.normalize(
Cesium.Matrix4.multiplyByPointAsVector(
frame,
new Cesium.Cartesian3(...local),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
const ray = new Cesium.Ray(position, direction);
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined;
engine,
viewer,
frame,
/** Poses the camera over `lon`/`lat` (degrees), looking straight down. */
lookDown({ lon, lat, altitude }) {
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(lon, lat, altitude),
});
},
click(picked, position = { x: 50, y: 50 }) {
native = picked;
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(position.x, position.y),
});
native = 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. */
export function tileCentre(lon, lat, z = 14) {
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`
* (`{lon, lat, height}`, mutable), enough for line coverage to attach,
* refresh and draw. Cesium's one-time terrain-height download is skipped.
* The display on a fake engine looking straight down on a 0.004° square
* around `view` (`{lon, lat, height}`, mutable: a change moves the camera),
* 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) {
const initialize = Cesium.GroundPolylinePrimitive.initializeTerrainHeights;
Cesium.GroundPolylinePrimitive.initializeTerrainHeights = () =>
Promise.resolve();
t?.after(() => {
Cesium.GroundPolylinePrimitive.initializeTerrainHeights = initialize;
export function coverageMap(start) {
const engine = createFakeEngine({ width: 100, height: 100 });
const at = { ...start };
const pose = () =>
engine.fake.setPose({
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.
Cesium.ContextLimits._minimumAliasedLineWidth = 1;
Cesium.ContextLimits._maximumAliasedLineWidth = 10;
const listeners = () => {
const set = new Set();
return {
set,
addEventListener(listener) {
set.add(listener);
return () => set.delete(listener);
},
};
pose();
engine.camera.pickRay = (point) => {
const origin = geo.cartesian3.fromDegrees(at.lon, at.lat, at.height);
const target = geo.cartesian3.fromDegrees(
at.lon - 0.002 + (point.x / 100) * 0.004,
at.lat + 0.002 - (point.y / 100) * 0.004,
);
const direction = geo.cartesian3.normalize(
geo.cartesian3.subtract(target, origin, geo.cartesian3.create()),
geo.cartesian3.create(),
);
return { origin, direction };
};
const harness = streetLevelMap({ engine });
return {
...harness,
view,
scene: {
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,
},
moveEnd: () => engine.fake.emit('camera.moveEnd'),
};
}