refactor(transit): isolate native rendering from playback

This commit is contained in:
German Khokhlov committed 2026-10-29 17:12:18 +04:00
1 parent 694616d5c2
commit b8560e5b26
21 files changed
+465 -256

No files matched your search

+2
View File
@@ -828,6 +828,8 @@
"src/layers/transit/policy.js", "src/layers/transit/policy.js",
"src/layers/transit/queries.js", "src/layers/transit/queries.js",
"src/layers/transit/rendering.js", "src/layers/transit/rendering.js",
"src/layers/transit/render/cesium.js",
"src/layers/transit/render/cesium.test.mjs",
"src/layers/transit/selection.js", "src/layers/transit/selection.js",
"src/layers/transit/state.js", "src/layers/transit/state.js",
"src/layers/transit/testing.js", "src/layers/transit/testing.js",
+6 -6
View File
@@ -2459,6 +2459,7 @@
"src/data/transitPresetStyle.js", "src/data/transitPresetStyle.js",
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
"src/layers/transit/height.js", "src/layers/transit/height.js",
"src/layers/transit/render/cesium.js",
"src/layers/transit/index.js", "src/layers/transit/index.js",
"src/layers/transit/ingestion.js", "src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js", "src/layers/transit/lifecycle.js",
@@ -3172,6 +3173,7 @@
"src/data/transitPresetStyle.js", "src/data/transitPresetStyle.js",
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
"src/layers/transit/height.js", "src/layers/transit/height.js",
"src/layers/transit/render/cesium.js",
"src/layers/transit/index.js", "src/layers/transit/index.js",
"src/layers/transit/ingestion.js", "src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js", "src/layers/transit/lifecycle.js",
@@ -3848,6 +3850,8 @@
"src/data/transitProxy.js", "src/data/transitProxy.js",
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
"src/layers/transit/height.js", "src/layers/transit/height.js",
"src/layers/shared/color.js",
"src/layers/transit/render/cesium.js",
"src/layers/transit/index.js", "src/layers/transit/index.js",
"src/layers/transit/ingestion.js", "src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js", "src/layers/transit/lifecycle.js",
@@ -3903,14 +3907,13 @@
"transit-layer": { "transit-layer": {
"exports": ["./layers/transit"], "exports": ["./layers/transit"],
"modules": [ "modules": [
"src/data/iconOrientation.js",
"src/data/render/cesiumProjection.js",
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/data/transitFeeds.js", "src/data/transitFeeds.js",
"src/data/transitIcons.js", "src/data/transitIcons.js",
"src/data/transitPresetStyle.js", "src/data/transitPresetStyle.js",
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
"src/layers/transit/height.js", "src/layers/transit/height.js",
"src/layers/shared/color.js",
"src/layers/transit/index.js", "src/layers/transit/index.js",
"src/layers/transit/ingestion.js", "src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js", "src/layers/transit/lifecycle.js",
@@ -3921,10 +3924,8 @@
"src/layers/transit/state.js", "src/layers/transit/state.js",
"src/layers/transit/testing.js", "src/layers/transit/testing.js",
"src/layers/transit/viewport.js", "src/layers/transit/viewport.js",
"src/data/cameraSensitivity.js",
"src/data/contactPlayback.js", "src/data/contactPlayback.js",
"src/data/contactTrailRenderer.js", "src/data/contactTrailRenderer.js",
"src/data/render/cesiumContactTrailRenderer.js",
"src/data/transitProxy.js", "src/data/transitProxy.js",
"src/data/gtfsRealtime.js", "src/data/gtfsRealtime.js",
"src/layers/transit/trails.js", "src/layers/transit/trails.js",
@@ -3952,8 +3953,7 @@
"src/geo/check.js", "src/geo/check.js",
"src/geo/debug.js", "src/geo/debug.js",
"src/geo/developerError.js", "src/geo/developerError.js",
"src/globe/contract.js", "src/globe/contract.js"
"src/globe/cesium/engineFromViewer.js"
], ],
"external": ["cesium", "pbf"] "external": ["cesium", "pbf"]
}, },
+10
View File
@@ -1,3 +1,13 @@
export function transitQaBase(value) {
if (!value) throw new Error('Transit QA requires an explicit QA_BASE_URL');
const url = new URL(value);
if (
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(url.port)
)
throw new Error('Transit QA may not use a reserved port');
return url.href.replace(/\/$/, '');
}
/** Bound page promises as well as CDP itself; labels identify a stalled section. */ /** Bound page promises as well as CDP itself; labels identify a stalled section. */
export function boundPageEvaluations(page, defaultTimeoutMs = 30000) { export function boundPageEvaluations(page, defaultTimeoutMs = 30000) {
const evaluate = page.evaluate.bind(page); const evaluate = page.evaluate.bind(page);
+10 -1
View File
@@ -1,15 +1,24 @@
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
import { transitQaBase } from './qa-transit-browser.mjs';
/** Prove parked and moving headings in the real scene, without live feeds. */ /** Prove parked and moving headings in the real scene, without live feeds. */
export async function runTransitHeadingRegression(base, check) { export async function runTransitHeadingRegression(base, check) {
base = transitQaBase(base);
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
headless: true, headless: true,
executablePath: executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox', '--disable-background-timer-throttling'], args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--use-angle=metal',
'--enable-gpu',
],
}); });
try { try {
const page = await browser.newPage(); const page = await browser.newPage();
await blockPaidQaMaps(page);
await page.setViewport({ width: 1280, height: 800 }); await page.setViewport({ width: 1280, height: 800 });
await page.goto( await page.goto(
`${base}/?welcome=0#lat=42.3601&lon=-71.0589&alt=1200&heading=0&pitch=-90`, `${base}/?welcome=0#lat=42.3601&lon=-71.0589&alt=1200&heading=0&pitch=-90`,
+7 -2
View File
@@ -2,21 +2,26 @@
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
import { transitQaBase } from './qa-transit-browser.mjs';
const base = process.env.QA_BASE_URL || 'http://localhost:4305'; const base = transitQaBase(process.env.QA_BASE_URL);
const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery'; const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
await mkdir(shots, { recursive: true }); await mkdir(shots, { recursive: true });
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
headless: process.env.QA_HEADFUL === '1' ? false : true, headless: true,
executablePath: executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: [ args: [
'--use-angle=metal',
'--enable-gpu',
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--disable-renderer-backgrounding',
], ],
}); });
try { try {
const page = await browser.newPage(); const page = await browser.newPage();
await blockPaidQaMaps(page);
await page.setViewport({ width: 1920, height: 1080 }); await page.setViewport({ width: 1920, height: 1080 });
const errors = []; const errors = [];
page.on('pageerror', (error) => errors.push(error.message)); page.on('pageerror', (error) => errors.push(error.message));
+10 -4
View File
@@ -6,6 +6,7 @@ import {
restoreRetainedTransit, restoreRetainedTransit,
sampleTransitPixels, sampleTransitPixels,
readTransitFleetStyle, readTransitFleetStyle,
transitQaBase,
} from './qa-transit-browser.mjs'; } from './qa-transit-browser.mjs';
import { import {
BOSTON, BOSTON,
@@ -22,7 +23,7 @@ import {
/** /**
* Browser proof for the Transit layer against a running app. * Browser proof for the Transit layer against a running app.
* *
* npm run qa:transit (defaults to http://localhost:4173) * QA_BASE_URL=http://localhost:4305 npm run qa:transit
* QA_BASE_URL=http://localhost:4305 node scripts/qa-transit.mjs * QA_BASE_URL=http://localhost:4305 node scripts/qa-transit.mjs
* *
* Drives the real layer toggle and the real CONTEXT chooser, watches the real * Drives the real layer toggle and the real CONTEXT chooser, watches the real
@@ -36,6 +37,7 @@ import {
*/ */
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
import { createTransitHistory } from '../server/providers/transitHistory.js'; import { createTransitHistory } from '../server/providers/transitHistory.js';
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js'; import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
import { transitModeTier } from '../src/data/transitPresetStyle.js'; import { transitModeTier } from '../src/data/transitPresetStyle.js';
@@ -50,9 +52,9 @@ import {
reduceFleet, reduceFleet,
} from '../src/layers/transit/qaMetrics.js'; } from '../src/layers/transit/qaMetrics.js';
const BASE = process.env.QA_BASE_URL || 'http://localhost:4173'; const BASE = transitQaBase(process.env.QA_BASE_URL);
const SHOTS = process.env.QA_SHOTS || 'qa-shots/transit'; const SHOTS = process.env.QA_SHOTS || 'qa-shots/transit';
const TAG = process.env.QA_TAG || 'keyed'; const TAG = process.env.QA_TAG || 'keyless';
/** Which sections to run: all, or a comma list of cities,playback,disable,context. */ /** Which sections to run: all, or a comma list of cities,playback,disable,context. */
const SECTIONS = new Set((process.env.QA_SECTIONS || 'all').split(',')); const SECTIONS = new Set((process.env.QA_SECTIONS || 'all').split(','));
const runs = (section) => SECTIONS.has('all') || SECTIONS.has(section); const runs = (section) => SECTIONS.has('all') || SECTIONS.has(section);
@@ -260,12 +262,14 @@ historyProbe.clear();
await mkdir(SHOTS, { recursive: true }); await mkdir(SHOTS, { recursive: true });
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
headless: process.env.QA_HEADFUL === '1' ? false : true, headless: true,
protocolTimeout: 150000, protocolTimeout: 150000,
executablePath: executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: [ args: [
'--no-sandbox', '--no-sandbox',
'--use-angle=metal',
'--enable-gpu',
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--disable-renderer-backgrounding',
'--window-size=1920,1080', '--window-size=1920,1080',
@@ -273,6 +277,7 @@ const browser = await puppeteer.launch({
], ],
}); });
const page = await browser.newPage(); const page = await browser.newPage();
await blockPaidQaMaps(page);
boundPageEvaluations(page); boundPageEvaluations(page);
await page.setViewport({ width: 1920, height: 1080 }); await page.setViewport({ width: 1920, height: 1080 });
const pageErrors = []; const pageErrors = [];
@@ -1018,6 +1023,7 @@ try {
if (runs('history')) { if (runs('history')) {
console.log('\n== retained history reload =='); console.log('\n== retained history reload ==');
const recovery = await browser.newPage(); const recovery = await browser.newPage();
await blockPaidQaMaps(recovery);
boundPageEvaluations(recovery); boundPageEvaluations(recovery);
try { try {
await recovery.goto(view(BOSTON), { waitUntil: 'domcontentloaded' }); await recovery.goto(view(BOSTON), { waitUntil: 'domcontentloaded' });
+11 -1
View File
@@ -1,4 +1,6 @@
import { createTransitLayer } from '../../layers/transit/index.js'; import { createTransitLayer } from '../../layers/transit/index.js';
import { createTransitCesiumRenderer } from '../../layers/transit/render/cesium.js';
import { rendererFor } from './renderers.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
import * as sprites from '../../data/spriteOrder.js'; import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js'; import * as picking from '../../data/pickRegistry.js';
@@ -22,12 +24,20 @@ import {
* @param {{surface: object}} options * @param {{surface: object}} options
* @returns {object} * @returns {object}
*/ */
export function createApplicationTransit({ surface, source }) { export function createApplicationTransit({
surface,
source,
rendererFactory = ({ engine, ...context }) =>
engine?.kind === 'navara'
? rendererFor('transit', engine, context)
: createTransitCesiumRenderer(context),
}) {
if (!surface?.groundFloor || !surface?.meshFloor) if (!surface?.groundFloor || !surface?.meshFloor)
throw new TypeError('Transit needs the application surface services'); throw new TypeError('Transit needs the application surface services');
const { groundFloor, meshFloor } = surface; const { groundFloor, meshFloor } = surface;
return createTransitLayer({ return createTransitLayer({
source, source,
rendererFactory,
services: { services: {
render, render,
sprites, sprites,
+9 -2
View File
@@ -1,4 +1,5 @@
import { createTransitLayer } from '../layers/transit/index.js'; import { createTransitLayer as createLayer } from '../layers/transit/index.js';
import { createTransitCesiumRenderer } from '../layers/transit/render/cesium.js';
import * as render from '../renderGovernor.js'; import * as render from '../renderGovernor.js';
import * as sprites from './spriteOrder.js'; import * as sprites from './spriteOrder.js';
import * as picking from './pickRegistry.js'; import * as picking from './pickRegistry.js';
@@ -13,6 +14,13 @@ import {
} from './groundFloor.js'; } from './groundFloor.js';
import { sampleMeshFloorCells } from './meshFloorSampler.js'; import { sampleMeshFloorCells } from './meshFloorSampler.js';
export function createTransitLayer(options) {
return createLayer({
rendererFactory: createTransitCesiumRenderer,
...options,
});
}
const layer = createTransitLayer({ const layer = createTransitLayer({
services: { services: {
render, render,
@@ -51,6 +59,5 @@ export {
transitDetectionMetric, transitDetectionMetric,
transitVehicleKey, transitVehicleKey,
vehicleFixAgeMs, vehicleFixAgeMs,
createTransitLayer,
} from '../layers/transit/index.js'; } from '../layers/transit/index.js';
export default layer; export default layer;
+6 -22
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import { import {
FLOOR_REREAD_ATTEMPTS, FLOOR_REREAD_ATTEMPTS,
FLOOR_REREAD_MS, FLOOR_REREAD_MS,
@@ -18,13 +17,10 @@ export function createHeight({ state, services, parts }) {
coarseFloorCoord, coarseFloorCoord,
GROUND_FLOOR_LIFT_M, GROUND_FLOOR_LIFT_M,
} = services.ground; } = services.ground;
// The application's rendered-mesh sampler. Optional so a layer built on a
// bare floor stub still runs; the application always supplies it.
const sampleMeshFloorCells = services.mesh?.sampleMeshFloorCells || null;
/** The camera's subpoint in degrees, for the sampler's proximity gate. */ /** The camera's subpoint in degrees, for the sampler's proximity gate. */
function viewerSubpoint() { function viewerSubpoint() {
const carto = state._viewer?.camera?.positionCartographic; const carto = parts.renderer?.cameraCartographic();
if (!carto) return { viewerLat: undefined, viewerLon: undefined }; if (!carto) return { viewerLat: undefined, viewerLon: undefined };
return { return {
viewerLat: (carto.latitude * 180) / Math.PI, viewerLat: (carto.latitude * 180) / Math.PI,
@@ -33,7 +29,7 @@ export function createHeight({ state, services, parts }) {
} }
function cameraAltitude() { function cameraAltitude() {
const carto = state._viewer?.camera?.positionCartographic; const carto = parts.renderer?.cameraCartographic();
return carto && Number.isFinite(carto.height) ? carto.height : Infinity; return carto && Number.isFinite(carto.height) ? carto.height : Infinity;
} }
@@ -53,7 +49,6 @@ export function createHeight({ state, services, parts }) {
* @param {number} lon * @param {number} lon
* @returns {{cell: string, height: number|null, prior: number|null}} * @returns {{cell: string, height: number|null, prior: number|null}}
*/ */
const terrainPoint = new Cesium.Cartographic();
function requestHeight(lat, lon, entry = null) { function requestHeight(lat, lon, entry = null) {
const coarse = coarseFloorCoord(lat, lon); const coarse = coarseFloorCoord(lat, lon);
const cell = `${coarse.lat},${coarse.lon}`; const cell = `${coarse.lat},${coarse.lon}`;
@@ -63,20 +58,9 @@ export function createHeight({ state, services, parts }) {
return { cell, height: fixtureFloor, prior: null }; return { cell, height: fixtureFloor, prior: null };
// On a terrain globe, use the loaded surface under the marker. This is // On a terrain globe, use the loaded surface under the marker. This is
// synchronous cache work and does not launch a terrain request. // synchronous cache work and does not launch a terrain request.
const globe = state._viewer?.scene?.globe; const rendered = parts.renderer?.terrainHeight(lat, lon);
if (globe?.show === true) { if (Number.isFinite(rendered))
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint); return { cell, height: rendered + GROUND_FLOOR_LIFT_M, prior: null };
const rendered = globe.getHeight?.(terrainPoint);
const flat =
state._viewer?.terrainProvider instanceof
Cesium.EllipsoidTerrainProvider;
if (Number.isFinite(rendered) || flat)
return {
cell,
height: (flat ? 0 : rendered) + GROUND_FLOOR_LIFT_M,
prior: null,
};
}
const floor = cachedGroundFloor(lat, lon); const floor = cachedGroundFloor(lat, lon);
if (Number.isFinite(floor)) { if (Number.isFinite(floor)) {
return { cell, height: floor + GROUND_FLOOR_LIFT_M, prior: null }; return { cell, height: floor + GROUND_FLOOR_LIFT_M, prior: null };
@@ -204,7 +188,7 @@ export function createHeight({ state, services, parts }) {
for (let i = 0; i < dem.length; i += 8) for (let i = 0; i < dem.length; i += 8)
warmGroundFloor(dem.slice(i, i + 8)); warmGroundFloor(dem.slice(i, i + 8));
for (let i = 0; i < mesh.length; i += 8) for (let i = 0; i < mesh.length; i += 8)
sampleMeshFloorCells?.(state._viewer?.scene, mesh.slice(i, i + 8), { parts.renderer?.sampleMeshFloorCells(mesh.slice(i, i + 8), {
excludeObjects: markers, excludeObjects: markers,
...viewerSubpoint(), ...viewerSubpoint(),
}); });
+2 -1
View File
@@ -27,6 +27,7 @@ import { TRANSIT_POLL_MS } from './policy.js';
export function createTransitLayer({ export function createTransitLayer({
services, services,
source = createTransitSource(), source = createTransitSource(),
rendererFactory,
}) { }) {
if (!services?.overlays || !services?.render || !services?.sprites) { if (!services?.overlays || !services?.render || !services?.sprites) {
throw new TypeError( throw new TypeError(
@@ -43,7 +44,7 @@ export function createTransitLayer({
} }
const state = createState({ services }); const state = createState({ services });
const parts = {}; const parts = {};
const context = { state, services, parts, source }; const context = { state, services, parts, source, rendererFactory };
parts.trails = createTrails(context); parts.trails = createTrails(context);
parts.height = createHeight(context); parts.height = createHeight(context);
parts.selection = createSelection(context); parts.selection = createSelection(context);
+1 -3
View File
@@ -48,7 +48,6 @@ export const ORDER_REJECTS_BEFORE_RESET = 3;
*/ */
export function createIngestion({ state, services, parts, source }) { export function createIngestion({ state, services, parts, source }) {
const { governorRequestRender } = services.render; const { governorRequestRender } = services.render;
const { registerDynamicCredit, transitFeedCredit } = services.credits;
function feedStatus(feedId) { function feedStatus(feedId) {
let status = state._feedStatus.get(feedId); let status = state._feedStatus.get(feedId);
@@ -397,8 +396,7 @@ export function createIngestion({ state, services, parts, source }) {
`[Data:Transit] ${feed.id}: ${rejectedFixes} fix(es) implied impossible travel and were not animated`, `[Data:Transit] ${feed.id}: ${rejectedFixes} fix(es) implied impossible travel and were not animated`,
); );
} }
if (state._viewer) parts.renderer?.addCredit(feed);
registerDynamicCredit(state._viewer, transitFeedCredit(feed));
if ( if (
state._selectedKey && state._selectedKey &&
state._vehicles.get(state._selectedKey)?.feedId === feed.id state._vehicles.get(state._selectedKey)?.feedId === feed.id
+45 -53
View File
@@ -1,13 +1,5 @@
import { destroyTrack } from '../../data/contactPlayback.js'; import { destroyTrack } from '../../data/contactPlayback.js';
import * as Cesium from 'cesium'; import { TRANSIT_SELECTED_OVERLAY_SOURCE_ID } from './policy.js';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
} from '../../data/cameraSensitivity.js';
import {
CAMERA_PERCENTAGE_CHANGED,
TRANSIT_SELECTED_OVERLAY_SOURCE_ID,
} from './policy.js';
import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js'; import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js';
/** /**
@@ -18,12 +10,13 @@ import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js';
* @param {object} context * @param {object} context
* @returns {object} * @returns {object}
*/ */
export function createLifecycle({ state, services, parts }) { export function createLifecycle({
const { state,
registerSpriteCollection, services,
unregisterSpriteCollection, parts,
restoreSpriteOrder, source,
} = services.sprites; rendererFactory,
}) {
const { registerPickOwner, unregisterPickOwner } = services.picking; const { registerPickOwner, unregisterPickOwner } = services.picking;
const { releaseContinuousRender } = services.render; const { releaseContinuousRender } = services.render;
@@ -38,17 +31,13 @@ export function createLifecycle({ state, services, parts }) {
* default while Bikeshare was still relying on a fine one was exactly the * default while Bikeshare was still relying on a fine one was exactly the
* bug that produced. * bug that produced.
*/ */
function borrowCameraSensitivity(viewer) { function borrowCameraSensitivity() {
claimCameraSensitivity( parts.renderer.claimCamera();
viewer?.camera,
'transit',
CAMERA_PERCENTAGE_CHANGED,
);
} }
/** Give the claim up. The camera keeps whatever anyone else still asks for. */ /** Give the claim up. The camera keeps whatever anyone else still asks for. */
function returnCameraSensitivity(viewer) { function returnCameraSensitivity() {
releaseCameraSensitivity(viewer?.camera, 'transit'); parts.renderer?.releaseCamera();
} }
/** /**
@@ -124,20 +113,29 @@ export function createLifecycle({ state, services, parts }) {
* Create the point collection. Called once at bootstrap. * Create the point collection. Called once at bootstrap.
* @param {Cesium.Viewer} viewer * @param {Cesium.Viewer} viewer
*/ */
init(viewer) { init(viewer, options = {}) {
if (parts.renderer)
throw new Error('Transit layer is already initialized');
if (typeof rendererFactory !== 'function')
throw new TypeError('A transit renderer factory is required');
const renderer = rendererFactory({
state,
services,
parts,
source,
engine: options.engine,
});
state._viewer = viewer; state._viewer = viewer;
state._markers = new Cesium.BillboardCollection({ parts.renderer = renderer;
blendOption: Cesium.BlendOption.TRANSLUCENT, try {
}); renderer.attach(viewer);
viewer.scene.primitives.add(state._markers); [state._markers, state._animatedMarkers] = renderer.createMarkers();
registerSpriteCollection('transit', state._markers); } catch (error) {
state._animatedMarkers = new Cesium.BillboardCollection({ renderer.destroy();
blendOption: Cesium.BlendOption.TRANSLUCENT, parts.renderer = null;
}); state._viewer = null;
viewer.scene.primitives.add(state._animatedMarkers); throw error;
registerSpriteCollection('transit-motion', state._animatedMarkers); }
state._animatedMarkers.show = false;
state._markers.show = false;
state._enabled = false; state._enabled = false;
state._activeFeeds.clear(); state._activeFeeds.clear();
state._feedStatus.clear(); state._feedStatus.clear();
@@ -159,7 +157,7 @@ export function createLifecycle({ state, services, parts }) {
state._rotationPose = null; state._rotationPose = null;
state._rotationDirty = false; state._rotationDirty = false;
state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, false); state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, false);
restoreSpriteOrder(viewer); parts.renderer.restoreOrder();
bindStyleEvents(); bindStyleEvents();
console.log( console.log(
`[Data:Transit] Initialized with ${TRANSIT_ENABLED_FEEDS.length} GTFS-RT feeds`, `[Data:Transit] Initialized with ${TRANSIT_ENABLED_FEEDS.length} GTFS-RT feeds`,
@@ -185,17 +183,19 @@ export function createLifecycle({ state, services, parts }) {
parts.selection.installClickHandler(viewer); parts.selection.installClickHandler(viewer);
registerPickOwner('transit', (pickedId) => state._vehicles.has(pickedId)); registerPickOwner('transit', (pickedId) => state._vehicles.has(pickedId));
if (!state._cameraChangedAttached) { if (!state._cameraChangedAttached) {
viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged); state._cameraChangedRemove = parts.renderer.onCameraChanged(
parts.viewport.onCameraChanged,
);
borrowCameraSensitivity(viewer); borrowCameraSensitivity(viewer);
state._cameraChangedAttached = true; state._cameraChangedAttached = true;
} }
if (!state._preRenderRemove) { if (!state._preRenderRemove) {
state._preRenderRemove = viewer.scene.preRender.addEventListener( state._preRenderRemove = parts.renderer.onPreRender(
parts.rendering.onPreRender, parts.rendering.onPreRender,
); );
} }
parts.viewport.runProximityCheck(); parts.viewport.runProximityCheck();
restoreSpriteOrder(viewer); parts.renderer.restoreOrder();
}, },
/** /**
@@ -222,9 +222,8 @@ export function createLifecycle({ state, services, parts }) {
parts.selection.removeClickHandler(); parts.selection.removeClickHandler();
unregisterPickOwner('transit'); unregisterPickOwner('transit');
if (state._cameraChangedAttached) { if (state._cameraChangedAttached) {
viewer.camera.changed.removeEventListener( state._cameraChangedRemove?.();
parts.viewport.onCameraChanged, state._cameraChangedRemove = null;
);
returnCameraSensitivity(viewer); returnCameraSensitivity(viewer);
state._cameraChangedAttached = false; state._cameraChangedAttached = false;
} }
@@ -281,16 +280,9 @@ export function createLifecycle({ state, services, parts }) {
*/ */
destroy(viewer) { destroy(viewer) {
this.disable(viewer); this.disable(viewer);
if (state._markers) { parts.renderer?.destroy();
unregisterSpriteCollection('transit', state._markers); parts.renderer = null;
viewer?.scene?.primitives?.remove(state._markers); state._markers = state._animatedMarkers = null;
state._markers = null;
}
if (state._animatedMarkers) {
unregisterSpriteCollection('transit-motion', state._animatedMarkers);
viewer?.scene?.primitives?.remove(state._animatedMarkers);
state._animatedMarkers = null;
}
state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID); state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID);
parts.height.clear(); parts.height.clear();
unbindStyleEvents(); unbindStyleEvents();
+18 -1
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { createTransitLayer } from './index.js'; import { createTransitLayer } from './index.js';
import { createTransitCesiumRenderer } from './render/cesium.js';
import { import {
FEED_EVICT_AFTER_MS, FEED_EVICT_AFTER_MS,
FEED_STALE_AFTER_MS, FEED_STALE_AFTER_MS,
@@ -264,6 +265,7 @@ function harness(
/** Every call the floor cycle makes to the rendered-mesh sampler. */ /** Every call the floor cycle makes to the rendered-mesh sampler. */
const meshCalls = []; const meshCalls = [];
const layer = createTransitLayer({ const layer = createTransitLayer({
rendererFactory: createTransitCesiumRenderer,
source, source,
services: { services: {
ground, ground,
@@ -937,6 +939,7 @@ test('destroy releases every shared registration this layer took', async (t) =>
test('two layers built from the factory share no state', async (t) => { test('two layers built from the factory share no state', async (t) => {
const app = harness(t); const app = harness(t);
const second = createTransitLayer({ const second = createTransitLayer({
rendererFactory: createTransitCesiumRenderer,
services: { services: {
ground: groundStub(() => 12), ground: groundStub(() => 12),
render: { render: {
@@ -2922,6 +2925,13 @@ test('mesh refinement reaches all 100 retained DEM cells within five poll budget
}, },
}, },
parts: { parts: {
renderer: {
cameraCartographic: () => state._viewer.camera.positionCartographic,
terrainHeight: () => undefined,
sampleMeshFloorCells(points) {
for (const point of points) probed.add(point.cell);
},
},
trails: { trails: {
prepareEntry(e, collect) { prepareEntry(e, collect) {
if (collect) if (collect)
@@ -3931,6 +3941,9 @@ test('parked transit reprojects held and reported courses at the bounded camera
_rotationDirty: false, _rotationDirty: false,
}; };
const noop = () => {}; const noop = () => {};
const renderer = createTransitCesiumRenderer({ state, services: {} });
renderer.attach(state._viewer);
t.after(() => renderer.destroy());
const rendering = createRendering({ const rendering = createRendering({
state, state,
services: { services: {
@@ -3940,7 +3953,11 @@ test('parked transit reprojects held and reported courses at the bounded camera
releaseContinuousRender: noop, releaseContinuousRender: noop,
}, },
}, },
parts: { height: { nearGround: () => false }, trails: { update: noop } }, parts: {
renderer,
height: { nearGround: () => false },
trails: { update: noop },
},
}); });
rendering.onPreRender(); rendering.onPreRender();
const before = [held.marker.rotation, reportedOnly.marker.rotation]; const before = [held.marker.rotation, reportedOnly.marker.rotation];
+216
View File
@@ -0,0 +1,216 @@
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
cameraPoseSignature,
perspectiveProjectedRotation,
} from '../../../data/iconOrientation.js';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
} from '../../../data/cameraSensitivity.js';
import { createContactTrailRenderer } from '../../../data/render/cesiumContactTrailRenderer.js';
import { CAMERA_PERCENTAGE_CHANGED } from '../policy.js';
/** Native resource owner; feed/playback/selection policy stays in the layer. */
export function createTransitCesiumRenderer({ state, services }) {
let viewer = null;
const banks = [];
const removers = new Set();
const terrainPoint = new Cesium.Cartographic();
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
const sphere = new Cesium.BoundingSphere(undefined, 5);
const cameraPosition = geo.cartesian3.create();
const own = (remove) => {
const release = () => {
if (removers.delete(release)) remove();
};
removers.add(release);
return release;
};
return {
attach(host) {
viewer = host;
},
get ready() {
return Boolean(viewer);
},
createMarkers() {
try {
for (const id of ['transit', 'transit-motion']) {
const collection = new Cesium.BillboardCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
banks.push({ id, collection });
viewer.scene.primitives.add(collection);
services.sprites.registerSpriteCollection(id, collection);
collection.show = false;
}
return banks.map(({ collection }) => collection);
} catch (error) {
this.destroy();
throw error;
}
},
restoreOrder: () => services.sprites.restoreSpriteOrder(viewer),
cameraCartographic: () => viewer?.camera?.positionCartographic,
viewRectangle: () =>
viewer?.camera?.computeViewRectangle?.(viewer.scene.globe?.ellipsoid),
cameraSignature: () =>
viewer?.scene?.camera ? cameraPoseSignature(viewer.scene.camera) : null,
rotation: (position, course, previous) =>
perspectiveProjectedRotation(viewer.scene, position, course, previous),
visibilityTester() {
const camera = viewer?.camera;
if (!camera) return null;
const position =
camera.positionWC ||
geo.ellipsoid.WGS84.cartographicToCartesian(
camera.positionCartographic,
cameraPosition,
);
occluder.cameraPosition = position;
const frustum = camera.frustum?.computeCullingVolume(
position,
camera.directionWC,
camera.upWC,
);
const planes = frustum?.planes;
return {
hasFrustum: Boolean(frustum),
test(point) {
sphere.center = point;
let inFrustum = true;
if (planes) {
for (const p of planes) {
if (
p.x * point.x + p.y * point.y + p.z * point.z + p.w <
-sphere.radius
) {
inFrustum = false;
break;
}
}
} else if (frustum)
inFrustum =
frustum.computeVisibility(sphere) !== Cesium.Intersect.OUTSIDE;
return { horizon: occluder.isPointVisible(point), inFrustum };
},
};
},
claimCamera() {
claimCameraSensitivity(
viewer?.camera,
'transit',
CAMERA_PERCENTAGE_CHANGED,
);
},
releaseCamera() {
releaseCameraSensitivity(viewer?.camera, 'transit');
},
onCameraChanged: (fn) => own(viewer.camera.changed.addEventListener(fn)),
onPreRender: (fn) => own(viewer.scene.preRender.addEventListener(fn)),
onPostRender: (fn) => own(viewer.scene.postRender.addEventListener(fn)),
terrainHeight(lat, lon) {
const globe = viewer?.scene?.globe;
if (globe?.show !== true) return undefined;
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
const rendered = globe.getHeight?.(terrainPoint);
return viewer?.terrainProvider instanceof Cesium.EllipsoidTerrainProvider
? 0
: rendered;
},
sampleMeshFloorCells: (points, options) =>
services.mesh?.sampleMeshFloorCells?.(viewer?.scene, points, options),
addCredit(feed) {
if (viewer)
services.credits.registerDynamicCredit(
viewer,
services.credits.transitFeedCredit(feed),
);
},
createTrailRenderer: () => createContactTrailRenderer(viewer.scene),
bindClick(listener, pointerFree) {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((click) => {
if (!pointerFree()) return;
const scene = viewer.scene;
const position = Cesium.Cartesian2.clone(click.position);
const style = state._stylePreset;
const stage = scene.postProcessStages?.getStageByName?.(
`godsEyeView_${style}`,
);
if (
stage?.enabled &&
(style === 'thermal' || style === 'surveillance')
) {
const intensity = stage.uniforms.intensity;
const canvas = scene.canvas,
w = canvas.clientWidth,
h = canvas.clientHeight;
let u = position.x / w,
v = 1 - position.y / h;
if (style === 'surveillance') {
const x = u * 2 - 1,
y = v * 2 - 1,
r2 = x * x + y * y;
const d = 1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
u = (x * d + 1) / 2;
v = (y * d + 1) / 2;
}
const grid = 1 + (stage.uniforms.pixelation - 1) * intensity;
u +=
((Math.floor((u * canvas.width) / grid) * grid) / canvas.width -
u) *
intensity;
v +=
((Math.floor((v * canvas.height) / grid) * grid) / canvas.height -
v) *
intensity;
position.x = u * w;
position.y = (1 - v) * h;
}
const picked = scene.pick(position);
listener({
id: typeof picked?.id === 'string' ? picked.id : null,
primitiveId:
typeof picked?.primitive?.id === 'string'
? picked.primitive.id
: null,
x: position.x,
y: position.y,
collection:
picked?.primitive?._billboardCollection === state._animatedMarkers
? 'animated'
: picked?.primitive?._billboardCollection === state._markers
? 'stationary'
: null,
});
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
const remove = own(() => handler.destroy());
return {
destroy: remove,
getInputAction: (type) => handler.getInputAction(type),
};
},
frameProbe() {
return {
canvas: viewer.scene.canvas,
gl: viewer.scene.context._gl,
uploads: banks.map(({ collection }) => ({
target: collection,
method: 'update',
})),
};
},
destroy() {
for (const remove of [...removers]) remove();
this.releaseCamera();
for (const { id, collection } of banks.splice(0)) {
services.sprites.unregisterSpriteCollection(id, collection);
viewer?.scene?.primitives?.remove(collection);
if (!collection.isDestroyed()) collection.destroy();
}
viewer = null;
},
};
}
+58
View File
@@ -0,0 +1,58 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createTransitCesiumRenderer } from './cesium.js';
test('failed native bank registration releases both acquired collections', () => {
const acquired = [],
registered = new Map();
const primitives = new Cesium.PrimitiveCollection();
const renderer = createTransitCesiumRenderer({
state: {},
services: {
sprites: {
registerSpriteCollection(id, collection) {
acquired.push(collection);
registered.set(id, collection);
if (id === 'transit-motion') throw new Error('registration failed');
},
unregisterSpriteCollection: (id) => registered.delete(id),
},
},
});
renderer.attach({ scene: { primitives } });
assert.throws(() => renderer.createMarkers(), /registration failed/);
assert.equal(primitives.length, 0);
assert.equal(registered.size, 0);
assert.equal(acquired.length, 2);
assert.ok(acquired.every((collection) => collection.isDestroyed()));
renderer.destroy();
primitives.destroy();
});
test('failed scene insertion also destroys the uninserted collection', (t) => {
let acquired;
t.mock.method(Cesium.BillboardCollection.prototype, 'destroy', function () {
acquired = this;
this.isDestroyed = () => true;
});
const renderer = createTransitCesiumRenderer({
state: {},
services: { sprites: { unregisterSpriteCollection() {} } },
});
renderer.attach({
scene: {
primitives: {
add() {
throw new Error('insertion failed');
},
remove() {
return false;
},
},
},
});
assert.throws(() => renderer.createMarkers(), /insertion failed/);
assert.ok(acquired?.isDestroyed());
renderer.destroy();
});
+14 -55
View File
@@ -1,9 +1,5 @@
import * as Cesium from 'cesium'; import { Rgba, rgbaFromCss } from '../shared/color.js';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import {
cameraPoseSignature,
perspectiveProjectedRotation,
} from '../../data/iconOrientation.js';
import { import {
updatePlayback, updatePlayback,
applyDisplayCourse, applyDisplayCourse,
@@ -33,7 +29,7 @@ import {
export const MODE_CESIUM_COLORS = Object.fromEntries( export const MODE_CESIUM_COLORS = Object.fromEntries(
Object.entries(TRANSIT_MODE_COLORS).map(([mode, css]) => [ Object.entries(TRANSIT_MODE_COLORS).map(([mode, css]) => [
mode, mode,
Cesium.Color.fromCssColorString(css), rgbaFromCss(css),
]), ]),
); );
@@ -198,12 +194,7 @@ export function createRendering({ state, services, parts }) {
const key = `${transitStyleProfile(style)}:${mode}`; const key = `${transitStyleProfile(style)}:${mode}`;
let color = _presetColors.get(key); let color = _presetColors.get(key);
if (!color) { if (!color) {
color = new Cesium.Color( color = new Rgba(rgba[0] / 255, rgba[1] / 255, rgba[2] / 255, rgba[3]);
rgba[0] / 255,
rgba[1] / 255,
rgba[2] / 255,
rgba[3],
);
_presetColors.set(key, color); _presetColors.set(key, color);
} }
return color; return color;
@@ -303,11 +294,10 @@ export function createRendering({ state, services, parts }) {
* changed somebody's heading. * changed somebody's heading.
*/ */
function refreshRotations(now) { function refreshRotations(now) {
const scene = state._viewer?.scene; if (!parts.renderer?.ready) return;
if (!scene?.camera) return;
if (now - state._rotationAt < ROTATION_REFRESH_MS) return; if (now - state._rotationAt < ROTATION_REFRESH_MS) return;
state._rotationAt = now; state._rotationAt = now;
const pose = cameraPoseSignature(scene.camera); const pose = parts.renderer.cameraSignature();
const cameraChanged = const cameraChanged =
state._rotationPose !== pose || state._rotationPose !== pose ||
state._rotationRevision !== state._cameraRevision; state._rotationRevision !== state._cameraRevision;
@@ -345,8 +335,7 @@ export function createRendering({ state, services, parts }) {
// camera-basis helper aircraft use is orthographic — fine at altitude, // camera-basis helper aircraft use is orthographic — fine at altitude,
// and wrong by twenty-odd degrees for a street-level contact away from // and wrong by twenty-odd degrees for a street-level contact away from
// the centre of an obliquely pitched view, which is most of them. // the centre of an obliquely pitched view, which is most of them.
entry.marker.rotation = perspectiveProjectedRotation( entry.marker.rotation = parts.renderer.rotation(
scene,
entry.marker.position, entry.marker.position,
course, course,
entry.marker.rotation, entry.marker.rotation,
@@ -424,8 +413,7 @@ export function createRendering({ state, services, parts }) {
const selected = state._vehicles.get(state._selectedKey); const selected = state._vehicles.get(state._selectedKey);
if (selected && state._visible.has(selected)) { if (selected && state._visible.has(selected)) {
selected.marker.rotation = Number.isFinite(selected.courseDeg) selected.marker.rotation = Number.isFinite(selected.courseDeg)
? perspectiveProjectedRotation( ? parts.renderer.rotation(
state._viewer.scene,
selected.marker.position, selected.marker.position,
selected.courseDeg, selected.courseDeg,
selected.marker.rotation, selected.marker.rotation,
@@ -436,9 +424,6 @@ export function createRendering({ state, services, parts }) {
syncRenderHold(); syncRenderHold();
} }
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
const sphere = new Cesium.BoundingSphere(undefined, 5);
const cameraPosition = geo.cartesian3.create();
const candidatePosition = geo.cartesian3.create(); const candidatePosition = geo.cartesian3.create();
/** Visibility work runs off the frame path, coalesced at four sweeps/second. */ /** Visibility work runs off the frame path, coalesced at four sweeps/second. */
function requestVisibility() { function requestVisibility() {
@@ -461,22 +446,8 @@ export function createRendering({ state, services, parts }) {
} }
state._visibilityAt = now; state._visibilityAt = now;
state._visibilityDirty = false; state._visibilityDirty = false;
const viewer = state._viewer, const visibilityTest = parts.renderer?.visibilityTester();
camera = viewer?.camera; if (!visibilityTest) return 0;
if (!camera) return 0;
const position =
camera.positionWC ||
geo.ellipsoid.WGS84.cartographicToCartesian(
camera.positionCartographic,
cameraPosition,
);
occluder.cameraPosition = position;
const frustum = camera.frustum?.computeCullingVolume(
position,
camera.directionWC,
camera.upWC,
);
const planes = frustum?.planes;
let changed = false; let changed = false;
for (const entry of state._vehicles.values()) { for (const entry of state._vehicles.values()) {
if (!entry.marker) continue; if (!entry.marker) continue;
@@ -487,7 +458,7 @@ export function createRendering({ state, services, parts }) {
entry.sample ||= {}; entry.sample ||= {};
updatePlayback(entry, now, performance.now()); updatePlayback(entry, now, performance.now());
} }
sphere.center = const position =
state._visible.has(entry) || !entry.sample state._visible.has(entry) || !entry.sample
? entry.marker.position ? entry.marker.position
: cartesianFor( : cartesianFor(
@@ -500,24 +471,12 @@ export function createRendering({ state, services, parts }) {
// view. The current frustum is authoritative; the horizon still rejects // view. The current frustum is authoritative; the horizon still rejects
// the far side when the rectangle is missing or stale. // the far side when the rectangle is missing or stale.
const bounds = vehicleNearView(state._viewBounds, entry); const bounds = vehicleNearView(state._viewBounds, entry);
const horizon = occluder.isPointVisible(sphere.center); const { horizon, inFrustum } = visibilityTest.test(position);
let inFrustum = true; const visible =
if (planes) { (visibilityTest.hasFrustum || bounds) && horizon && inFrustum;
for (let i = 0; i < planes.length; i++) {
const p = planes[i],
c = sphere.center;
if (p.x * c.x + p.y * c.y + p.z * c.z + p.w < -sphere.radius) {
inFrustum = false;
break;
}
}
} else if (frustum)
inFrustum =
frustum.computeVisibility(sphere) !== Cesium.Intersect.OUTSIDE;
const visible = (frustum || bounds) && horizon && inFrustum;
const visibility = (entry.visibility ||= {}); const visibility = (entry.visibility ||= {});
visibility.bounds = bounds; visibility.bounds = bounds;
visibility.boundsApplied = !frustum; visibility.boundsApplied = !visibilityTest.hasFrustum;
visibility.occluder = horizon; visibility.occluder = horizon;
visibility.frustum = inFrustum; visibility.frustum = inFrustum;
visibility.noSample = noSample; visibility.noSample = noSample;
+9 -72
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import { import {
SELECTED_CARD_REFRESH_MS, SELECTED_CARD_REFRESH_MS,
TRANSIT_SELECTED_OVERLAY_SOURCE_ID, TRANSIT_SELECTED_OVERLAY_SOURCE_ID,
@@ -114,79 +113,17 @@ export function createSelection({ state, services, parts }) {
function installClickHandler(viewer) { function installClickHandler(viewer) {
if (state._clickHandler) return; if (state._clickHandler) return;
state._clickHandler = new Cesium.ScreenSpaceEventHandler( state._clickHandler = parts.renderer.bindClick((picked) => {
viewer.scene.canvas, state._lastPickForTest = picked;
); const key = state._vehicles.has(picked.primitiveId)
state._clickHandler.setInputAction((click) => { ? picked.primitiveId
// Ambient selection never competes for a pointer a tool has claimed: : picked.id;
// placing a draw vertex on top of a bus must not also select the bus. if (typeof key === 'string' && state._vehicles.has(key)) {
if (!isPointerFree()) return; selectVehicle(key);
const scene = viewer.scene; return;
// Pick buffers precede post-processing. Map a sensor pixel back through
// the active shader's barrel distortion and quantization before picking.
const position = Cesium.Cartesian2.clone(click.position);
const style = state._stylePreset;
const stage = scene.postProcessStages?.getStageByName?.(
`godsEyeView_${style}`,
);
if (stage?.enabled && (style === 'thermal' || style === 'surveillance')) {
const intensity = stage.uniforms.intensity;
const canvas = scene.canvas,
w = canvas.clientWidth,
h = canvas.clientHeight;
let u = position.x / w,
v = 1 - position.y / h;
if (style === 'surveillance') {
const x = u * 2 - 1,
y = v * 2 - 1,
r2 = x * x + y * y;
const d = 1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
u = (x * d + 1) / 2;
v = (y * d + 1) / 2;
}
const grid = 1 + (stage.uniforms.pixelation - 1) * intensity;
u +=
((Math.floor((u * canvas.width) / grid) * grid) / canvas.width - u) *
intensity;
v +=
((Math.floor((v * canvas.height) / grid) * grid) / canvas.height -
v) *
intensity;
position.x = u * w;
position.y = (1 - v) * h;
}
const picked = scene.pick(position);
state._lastPickForTest = {
id: typeof picked?.id === 'string' ? picked.id : null,
primitiveId:
typeof picked?.primitive?.id === 'string'
? picked.primitive.id
: null,
x: position.x,
y: position.y,
collection:
picked?.primitive?._billboardCollection === state._animatedMarkers
? 'animated'
: picked?.primitive?._billboardCollection === state._markers
? 'stationary'
: null,
};
if (picked) {
const primitiveId = picked.primitive?.id;
if (
typeof primitiveId === 'string' &&
state._vehicles.has(primitiveId)
) {
selectVehicle(primitiveId);
return;
}
if (typeof picked.id === 'string' && state._vehicles.has(picked.id)) {
selectVehicle(picked.id);
return;
}
} }
if (state._selectedKey) clearSelection(); if (state._selectedKey) clearSelection();
}, Cesium.ScreenSpaceEventType.LEFT_CLICK); }, isPointerFree);
document.addEventListener('keydown', onKeyDown); document.addEventListener('keydown', onKeyDown);
} }
+1
View File
@@ -37,6 +37,7 @@ export function createState({ services }) {
state._generation = 0; state._generation = 0;
state._cameraChangedAttached = false; state._cameraChangedAttached = false;
state._cameraChangedRemove = null;
state._cameraDebounceTimer = null; state._cameraDebounceTimer = null;
state._altitudeGateOpen = false; state._altitudeGateOpen = false;
/** Camera sensitivity as we found it, so disable() can hand it back. */ /** Camera sensitivity as we found it, so disable() can hand it back. */
+16 -16
View File
@@ -1,4 +1,4 @@
import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js';
import { recordFix, updatePlayback, attachFloorToPlace } from './movement.js'; import { recordFix, updatePlayback, attachFloorToPlace } from './movement.js';
import { seek, setRate, mergeHistory } from '../../data/contactPlayback.js'; import { seek, setRate, mergeHistory } from '../../data/contactPlayback.js';
import { getRegisteredTransitFeed } from '../../data/transitFeeds.js'; import { getRegisteredTransitFeed } from '../../data/transitFeeds.js';
@@ -97,8 +97,8 @@ export function createTesting({ state, parts }) {
entry.displayPaths.set(fixes[i].seq, { entry.displayPaths.set(fixes[i].seq, {
toSeq: fixes[i + 1].seq, toSeq: fixes[i + 1].seq,
positions: [ positions: [
Cesium.Cartesian3.fromDegrees(fixes[i].lon, fixes[i].lat, 20), geo.cartesian3.fromDegrees(fixes[i].lon, fixes[i].lat, 20),
Cesium.Cartesian3.fromDegrees( geo.cartesian3.fromDegrees(
fixes[i + 1].lon, fixes[i + 1].lon,
fixes[i + 1].lat, fixes[i + 1].lat,
20, 20,
@@ -139,8 +139,8 @@ export function createTesting({ state, parts }) {
entry.displayPaths.set(fixes[i].seq, { entry.displayPaths.set(fixes[i].seq, {
toSeq: fixes[i + 1].seq, toSeq: fixes[i + 1].seq,
positions: [ positions: [
Cesium.Cartesian3.fromDegrees(fixes[i].lon, fixes[i].lat, 20), geo.cartesian3.fromDegrees(fixes[i].lon, fixes[i].lat, 20),
Cesium.Cartesian3.fromDegrees( geo.cartesian3.fromDegrees(
fixes[i + 1].lon, fixes[i + 1].lon,
fixes[i + 1].lat, fixes[i + 1].lat,
20, 20,
@@ -161,12 +161,12 @@ export function createTesting({ state, parts }) {
/** Measure inside Cesium postRender with fixed typed storage; no sample objects per frame. */ /** Measure inside Cesium postRender with fixed typed storage; no sample objects per frame. */
_measureTransitFramesForTest(durationMs = 12000) { _measureTransitFramesForTest(durationMs = 12000) {
stopProbe?.(); stopProbe?.();
const scene = state._viewer.scene; const probe = parts.renderer.frameProbe();
const capacity = 20000, const capacity = 20000,
stride = 12, stride = 12,
data = new Float64Array(capacity * stride); data = new Float64Array(capacity * stride);
const previousPositions = new Float64Array(state._vehicles.size * 3); const previousPositions = new Float64Array(state._vehicles.size * 3);
const gl = scene.context._gl; const gl = probe.gl;
let active = false, let active = false,
upload = 0, upload = 0,
cpu = 0, cpu = 0,
@@ -195,9 +195,9 @@ export function createTesting({ state, parts }) {
gl[name] = original; gl[name] = original;
}); });
} }
for (const collection of [state._markers, state._animatedMarkers]) { for (const { target, method } of probe.uploads) {
const original = collection.update; const original = target[method];
collection.update = function (frame) { target[method] = function (frame) {
active = true; active = true;
try { try {
return original.call(this, frame); return original.call(this, frame);
@@ -206,11 +206,11 @@ export function createTesting({ state, parts }) {
} }
}; };
saved.push(() => { saved.push(() => {
collection.update = original; target[method] = original;
}); });
} }
state._preRenderRemove?.(); state._preRenderRemove?.();
const removePre = scene.preRender.addEventListener(() => { const removePre = parts.renderer.onPreRender(() => {
upload = 0; upload = 0;
const start = performance.now(); const start = performance.now();
parts.rendering.onPreRender(); parts.rendering.onPreRender();
@@ -224,7 +224,7 @@ export function createTesting({ state, parts }) {
removePost?.(); removePost?.();
for (const restore of saved) restore(); for (const restore of saved) restore();
if (state._enabled) if (state._enabled)
state._preRenderRemove = scene.preRender.addEventListener( state._preRenderRemove = parts.renderer.onPreRender(
parts.rendering.onPreRender, parts.rendering.onPreRender,
); );
stopProbe = null; stopProbe = null;
@@ -247,12 +247,12 @@ export function createTesting({ state, parts }) {
resolve({ resolve({
rows, rows,
dpr: devicePixelRatio, dpr: devicePixelRatio,
width: scene.canvas.clientWidth, width: probe.canvas.clientWidth,
height: scene.canvas.clientHeight, height: probe.canvas.clientHeight,
}); });
}; };
stopProbe = finish; stopProbe = finish;
removePost = scene.postRender.addEventListener(() => { removePost = parts.renderer.onPostRender(() => {
const now = performance.now(); const now = performance.now();
if (count >= capacity) return; if (count >= capacity) return;
const k = count++ * stride; const k = count++ * stride;
+2 -3
View File
@@ -6,7 +6,6 @@ import {
FIX_FLAGS, FIX_FLAGS,
mergeHistory, mergeHistory,
} from '../../data/contactPlayback.js'; } from '../../data/contactPlayback.js';
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
import { fixDistanceM } from './movement.js'; import { fixDistanceM } from './movement.js';
import { transitStyleProfile } from '../../data/transitPresetStyle.js'; import { transitStyleProfile } from '../../data/transitPresetStyle.js';
@@ -33,8 +32,8 @@ export function createTrails({ state, services, parts, source }) {
fadeTimer = null, fadeTimer = null,
revision = 0; revision = 0;
function ensureRenderer() { function ensureRenderer() {
if (!renderer && state._viewer) if (!renderer && parts.renderer?.ready)
renderer = createContactTrailRenderer(state._viewer.scene); renderer = parts.renderer.createTrailRenderer();
return renderer; return renderer;
} }
function style() { function style() {
+12 -14
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { import {
ACTIVATION_ENTER_ALTITUDE_M, ACTIVATION_ENTER_ALTITUDE_M,
@@ -18,23 +17,25 @@ import { transitFeedsInRange } from '../../data/transitFeeds.js';
export function createViewport({ state, services, parts }) { export function createViewport({ state, services, parts }) {
const { governorRequestRender } = services.render; const { governorRequestRender } = services.render;
function getCameraAltitude(viewer = state._viewer) { function getCameraAltitude() {
const carto = viewer?.camera?.positionCartographic; const carto = parts.renderer?.cameraCartographic();
return carto && Number.isFinite(carto.height) ? carto.height : Infinity; return carto && Number.isFinite(carto.height) ? carto.height : Infinity;
} }
function getCameraCenterLatLon(viewer = state._viewer) { function getCameraCenterLatLon() {
const rect = viewer?.camera?.computeViewRectangle?.( const rect = parts.renderer?.viewRectangle();
viewer.scene.globe?.ellipsoid,
);
if (rect) { if (rect) {
const center = Cesium.Rectangle.center(rect); const east = rect.east < rect.west ? rect.east + Math.PI * 2 : rect.east;
const center = {
latitude: (rect.south + rect.north) / 2,
longitude: geo.math.negativePiToPi((rect.west + east) / 2),
};
return { return {
lat: geo.math.toDegrees(center.latitude), lat: geo.math.toDegrees(center.latitude),
lon: geo.math.toDegrees(center.longitude), lon: geo.math.toDegrees(center.longitude),
}; };
} }
const carto = viewer?.camera?.positionCartographic; const carto = parts.renderer?.cameraCartographic();
if (carto) { if (carto) {
return { return {
lat: geo.math.toDegrees(carto.latitude), lat: geo.math.toDegrees(carto.latitude),
@@ -54,10 +55,7 @@ export function createViewport({ state, services, parts }) {
* animating. Cheap, and only on a settled camera — never per frame. * animating. Cheap, and only on a settled camera — never per frame.
*/ */
function refreshViewBounds() { function refreshViewBounds() {
const viewer = state._viewer; const rect = parts.renderer?.viewRectangle();
const rect = viewer?.camera?.computeViewRectangle?.(
viewer.scene.globe?.ellipsoid,
);
state._viewBounds = rect state._viewBounds = rect
? inflateViewBounds({ ? inflateViewBounds({
south: geo.math.toDegrees(rect.south), south: geo.math.toDegrees(rect.south),
@@ -76,7 +74,7 @@ export function createViewport({ state, services, parts }) {
} }
function runProximityCheck() { function runProximityCheck() {
if (!state._enabled || !state._viewer) return; if (!state._enabled || !parts.renderer?.ready) return;
refreshViewBounds(); refreshViewBounds();
const altitude = getCameraAltitude(); const altitude = getCameraAltitude();
state._altitudeGateOpen = altitudeGateOpen(altitude); state._altitudeGateOpen = altitudeGateOpen(altitude);