Files
gods-eye-view/src/layers/transit/lifecycle.js
T

305 lines
10 KiB
JavaScript

import { destroyTrack } from '../../data/contactPlayback.js';
import * as Cesium from 'cesium';
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';
/**
* Init / enable / disable / update / destroy for one Transit layer instance.
* Everything acquired here is released here — primitives, listeners, the sprite
* registration, the pick owner, the render hold, and the camera sensitivity
* this layer borrowed.
* @param {object} context
* @returns {object}
*/
export function createLifecycle({ state, services, parts }) {
const {
registerSpriteCollection,
unregisterSpriteCollection,
restoreSpriteOrder,
} = services.sprites;
const { registerPickOwner, unregisterPickOwner } = services.picking;
const { releaseContinuousRender } = services.render;
/**
* Borrow the camera's change sensitivity through the shared claim ledger.
*
* `camera.changed` fires on a percentage of the view moving, and this layer
* needs a finer trigger than the default to notice it has entered a feed's
* coverage. It is a single shared number, so the claim is registered by name
* rather than written directly: comparing the VALUE on the way out could not
* tell this layer's 0.05 from anybody else's, and handing back the coarse
* default while Bikeshare was still relying on a fine one was exactly the
* bug that produced.
*/
function borrowCameraSensitivity(viewer) {
claimCameraSensitivity(
viewer?.camera,
'transit',
CAMERA_PERCENTAGE_CHANGED,
);
}
/** Give the claim up. The camera keeps whatever anyone else still asks for. */
function returnCameraSensitivity(viewer) {
releaseCameraSensitivity(viewer?.camera, 'transit');
}
/**
* Follow the active post-FX style so the sprites can be restyled for it.
* The map style arrives on `gev:style-change`; a cockpit vision override
* (NVG/thermal inside the cockpit view, which sets no map style) arrives on
* `gev:vision-change` with the effective style and wins while it lasts.
* Bound once per instance and removed on destroy. Read the document's
* current style first: a persisted style restores before layers register.
*/
function bindStyleEvents() {
if (typeof window === 'undefined') return;
if (state._styleListeners.length > 0) return;
parts.rendering.setStylePreset(
document?.documentElement?.dataset?.gevStyle || 'normal',
);
const onStyle = (event) => {
if (state._cockpitVision) return;
parts.rendering.setStylePreset(event?.detail?.style);
};
const onVision = (event) => {
state._cockpitVision = event?.detail?.cockpit === true;
parts.rendering.setStylePreset(event?.detail?.style);
};
window.addEventListener('gev:style-change', onStyle);
window.addEventListener('gev:vision-change', onVision);
state._styleListeners.push(
['gev:style-change', onStyle],
['gev:vision-change', onVision],
);
}
function unbindStyleEvents() {
if (typeof window === 'undefined') return;
for (const [name, listener] of state._styleListeners) {
window.removeEventListener(name, listener);
}
state._styleListeners.length = 0;
state._cockpitVision = false;
}
let visibilityDocument = null,
hidden = false;
function onVisibilityChange() {
if (visibilityDocument.visibilityState === 'hidden') hidden = true;
else if (hidden) {
hidden = false;
parts.rendering.resumePlayback();
parts.viewport.runProximityCheck();
}
}
function bindVisibility() {
if (visibilityDocument || typeof document === 'undefined') return;
visibilityDocument = document;
hidden = document.visibilityState === 'hidden';
visibilityDocument.addEventListener('visibilitychange', onVisibilityChange);
}
function unbindVisibility() {
visibilityDocument?.removeEventListener(
'visibilitychange',
onVisibilityChange,
);
visibilityDocument = null;
hidden = false;
}
const methods = {
id: 'transit',
name: 'Transit',
icon: '🚌',
source: 'GTFS-RT',
/**
* Create the point collection. Called once at bootstrap.
* @param {Cesium.Viewer} viewer
*/
init(viewer) {
state._viewer = viewer;
state._markers = new Cesium.BillboardCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
viewer.scene.primitives.add(state._markers);
registerSpriteCollection('transit', state._markers);
state._animatedMarkers = new Cesium.BillboardCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
viewer.scene.primitives.add(state._animatedMarkers);
registerSpriteCollection('transit-motion', state._animatedMarkers);
state._animatedMarkers.show = false;
state._markers.show = false;
state._enabled = false;
state._activeFeeds.clear();
state._feedStatus.clear();
state._inFlight.clear();
for (const entry of state._vehicles.values()) {
entry.detectContact = null;
parts.rendering.cancelWake(entry);
destroyTrack(entry.track);
}
state._vehicles.clear();
parts.height.clear();
state._selectedKey = null;
state._lastUpdate = null;
state._error = null;
state._limitWarned = false;
state._altitudeGateOpen = false;
state._moving.clear();
state._heightDirty.clear();
state._rotationPose = null;
state._rotationDirty = false;
state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, false);
restoreSpriteOrder(viewer);
bindStyleEvents();
console.log(
`[Data:Transit] Initialized with ${TRANSIT_ENABLED_FEEDS.length} GTFS-RT feeds`,
);
},
/**
* Show vehicles, watch the camera, and poll every feed in range.
* @param {Cesium.Viewer} viewer
*/
enable(viewer) {
state._enabled = true;
bindVisibility();
state._generation += 1;
state._error = null;
state._markers.show = true;
state._animatedMarkers.show = true;
state._maintenanceTimer = setInterval(
parts.rendering.maintainPresentation,
250,
);
state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, true);
parts.selection.installClickHandler(viewer);
registerPickOwner('transit', (pickedId) => state._vehicles.has(pickedId));
if (!state._cameraChangedAttached) {
viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged);
borrowCameraSensitivity(viewer);
state._cameraChangedAttached = true;
}
if (!state._preRenderRemove) {
state._preRenderRemove = viewer.scene.preRender.addEventListener(
parts.rendering.onPreRender,
);
}
parts.viewport.runProximityCheck();
restoreSpriteOrder(viewer);
},
/**
* Hide everything, stop polling, drop all vehicles.
* @param {Cesium.Viewer} viewer
*/
disable(viewer) {
state._enabled = false;
unbindVisibility();
parts.testing._stopTransitProbeForTest();
state._qaFixtureFloors?.clear();
state._generation += 1;
clearInterval(state._maintenanceTimer);
state._maintenanceTimer = null;
clearTimeout(state._visibilityTimer);
state._visibilityTimer = null;
state._detectCache = null;
state._detectBuiltAt = -Infinity;
state._visible.clear();
clearTimeout(state._cameraDebounceTimer);
state._cameraDebounceTimer = null;
parts.selection.clearSelection();
state._overlayHost.setVisible(TRANSIT_SELECTED_OVERLAY_SOURCE_ID, false);
parts.selection.removeClickHandler();
unregisterPickOwner('transit');
if (state._cameraChangedAttached) {
viewer.camera.changed.removeEventListener(
parts.viewport.onCameraChanged,
);
returnCameraSensitivity(viewer);
state._cameraChangedAttached = false;
}
if (state._preRenderRemove) {
state._preRenderRemove();
state._preRenderRemove = null;
}
parts.ingestion.abortAllInFlight();
state._activeFeeds.clear();
state._feedStatus.clear();
for (const entry of state._vehicles.values()) {
entry.detectContact = null;
parts.rendering.cancelWake(entry);
destroyTrack(entry.track);
}
state._vehicles.clear();
parts.height.clear();
state._markers?.removeAll();
state._animatedMarkers?.removeAll();
if (state._animatedMarkers) state._animatedMarkers.show = false;
if (state._markers) state._markers.show = false;
state._altitudeGateOpen = false;
state._moving.clear();
state._heightDirty.clear();
state._rotationPose = null;
state._rotationDirty = false;
if (state._renderHeld) {
releaseContinuousRender('transit');
state._renderHeld = false;
}
},
/**
* Manager tick (every TRANSIT_POLL_MS): re-poll every active feed, and
* sweep aged vehicles even when nothing is active, so a layer left over a
* quiet region does not keep an old fleet on screen.
* @returns {Promise<void>}
*/
async update() {
if (!state._enabled) return;
parts.ingestion.sweepAgedVehicles(Date.now());
if (state._activeFeeds.size === 0) return;
const generation = state._generation;
await Promise.all(
[...state._activeFeeds.values()].map((feed) =>
parts.ingestion.pollFeed(feed, generation),
),
);
},
/**
* Tear down the collection entirely and release every shared registration.
* @param {Cesium.Viewer} viewer
*/
destroy(viewer) {
this.disable(viewer);
if (state._markers) {
unregisterSpriteCollection('transit', state._markers);
viewer?.scene?.primitives?.remove(state._markers);
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);
parts.height.clear();
unbindStyleEvents();
state._dataManager = null;
state._viewer = null;
},
};
return { methods };
}