mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
890 lines
30 KiB
JavaScript
890 lines
30 KiB
JavaScript
import * as Cesium from 'cesium';
|
|
import { applyDisplayCourse, updatePlayback } from '../transit/movement.js';
|
|
import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js';
|
|
import {
|
|
COLD_CELL_FLUSH_MS,
|
|
COLD_CELL_RETRY_MS,
|
|
TRACK_MAX_GRADE,
|
|
TRACK_PRIOR_ABOVE_M,
|
|
TRACK_PRIOR_BELOW_M,
|
|
TRACK_SAMPLE_GRID,
|
|
TRACK_SAMPLE_MAX_CAMERA_M,
|
|
TRACK_SAMPLE_MAX_AGE_MS,
|
|
TRACK_SAMPLE_MAX_DIST_KM,
|
|
TRACK_SAMPLE_REFRESH_MS,
|
|
TRACK_SAMPLE_RETRY_MS,
|
|
TRACK_SAMPLES_PER_FLUSH,
|
|
TRACK_SETTLE_MPS,
|
|
TRACK_SNAP_M,
|
|
COLD_CELLS_PER_FLUSH,
|
|
FLOOR_CELLS_BURST,
|
|
FLOOR_CELLS_PER_MINUTE,
|
|
FLOOR_WARM_MAX_DIST_KM,
|
|
MARKER_PIXEL_SIZE,
|
|
RAIL_CATEGORY_COLORS,
|
|
SELECTED_PIXEL_SIZE,
|
|
UNSELECTED_TRAIN_ALPHA,
|
|
} from './policy.js';
|
|
import { placeOnPath } from './pathBinding.js';
|
|
import {
|
|
fleetModelSet,
|
|
loadFleetModelSet,
|
|
transitModelSpec,
|
|
} from '../../data/fleetModels.js';
|
|
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
|
|
|
|
/** How often the trains near enough for a model are gathered again (wall ms). */
|
|
const MODEL_CANDIDATES_MS = 500;
|
|
/** Each category's colour, painted where a train model marks it. */
|
|
const CATEGORY_TINTS = Object.fromEntries(
|
|
Object.entries(RAIL_CATEGORY_COLORS).map(([category, css]) => [
|
|
category,
|
|
Cesium.Color.fromCssColorString(css),
|
|
]),
|
|
);
|
|
|
|
const SPRITE_PX = 64;
|
|
/** Billboard tints; the setter copies, so sharing one instance is safe. */
|
|
const FULL_TINT = Cesium.Color.WHITE;
|
|
const DIM_TINT = new Cesium.Color(1, 1, 1, UNSELECTED_TRAIN_ALPHA);
|
|
|
|
/**
|
|
* Draw one category/mode sprite: a filled disc in the category colour with a
|
|
* dark rim and a white glyph for what runs on the track (a train front, a
|
|
* tram with its pantograph, or the metro "M"). Drawn once per pair and reused.
|
|
* @param {string} category
|
|
* @param {string} mode
|
|
* @returns {HTMLCanvasElement}
|
|
*/
|
|
function drawSprite(category, mode) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = SPRITE_PX;
|
|
canvas.height = SPRITE_PX;
|
|
const g = canvas.getContext('2d');
|
|
const c = SPRITE_PX / 2;
|
|
g.beginPath();
|
|
g.arc(c, c, c - 3, 0, Math.PI * 2);
|
|
g.fillStyle = RAIL_CATEGORY_COLORS[category] || RAIL_CATEGORY_COLORS.local;
|
|
g.fill();
|
|
g.lineWidth = 4;
|
|
g.strokeStyle = 'rgba(10, 14, 20, 0.85)';
|
|
g.stroke();
|
|
g.fillStyle = '#ffffff';
|
|
g.strokeStyle = '#ffffff';
|
|
if (mode === 'metro') {
|
|
g.font = 'bold 34px sans-serif';
|
|
g.textAlign = 'center';
|
|
g.textBaseline = 'middle';
|
|
g.fillText('M', c, c + 2);
|
|
return canvas;
|
|
}
|
|
// Train front: body, windscreen cut out, two lamps.
|
|
g.beginPath();
|
|
g.roundRect(c - 13, c - 14, 26, 28, 6);
|
|
g.fill();
|
|
g.fillStyle = RAIL_CATEGORY_COLORS[category] || RAIL_CATEGORY_COLORS.local;
|
|
g.fillRect(c - 9, c - 9, 18, 9);
|
|
g.beginPath();
|
|
g.arc(c - 7, c + 7, 2.5, 0, Math.PI * 2);
|
|
g.arc(c + 7, c + 7, 2.5, 0, Math.PI * 2);
|
|
g.fill();
|
|
if (mode === 'tram') {
|
|
g.lineWidth = 3;
|
|
g.beginPath();
|
|
g.moveTo(c - 6, c - 14);
|
|
g.lineTo(c, c - 22);
|
|
g.lineTo(c + 6, c - 14);
|
|
g.stroke();
|
|
}
|
|
return canvas;
|
|
}
|
|
|
|
/**
|
|
* Markers, ground height and the per-frame pass for one Rail layer.
|
|
* @param {object} context
|
|
* @returns {object}
|
|
*/
|
|
export function createRendering({ state, services, parts }) {
|
|
const {
|
|
governorRequestRender,
|
|
holdContinuousRender,
|
|
releaseContinuousRender,
|
|
} = services.render;
|
|
const {
|
|
cachedGroundFloor,
|
|
warmGroundFloor,
|
|
GROUND_FLOOR_LIFT_M,
|
|
neighborFloorM = null,
|
|
coarseFloorCoord = null,
|
|
} = services.ground;
|
|
const sprites = new Map();
|
|
const terrainPoint = new Cesium.Cartographic();
|
|
const scratchPosition = new Cesium.Cartesian3();
|
|
/** Scratch for the on-line position; place() runs every frame. */
|
|
const pathPoint = { lat: NaN, lon: NaN, bearingDeg: NaN };
|
|
|
|
function spriteFor(category, mode) {
|
|
const id = `${category}:${mode}`;
|
|
if (!sprites.has(id)) sprites.set(id, drawSprite(category, mode));
|
|
return sprites.get(id);
|
|
}
|
|
|
|
/** Whether ground matters at this camera (Transit's gate, shared). */
|
|
function nearGround() {
|
|
const height = state._viewer?.camera?.positionCartographic?.height;
|
|
return !Number.isFinite(height) || height <= HEIGHT_SAMPLE_MAX_ALTITUDE_M;
|
|
}
|
|
|
|
/** Kilometres from the camera's subpoint, or Infinity without a camera. */
|
|
function cameraKm(lat, lon) {
|
|
const carto = state._viewer?.camera?.positionCartographic;
|
|
if (!carto) return Infinity;
|
|
const camLat = Cesium.Math.toDegrees(carto.latitude);
|
|
const camLon = Cesium.Math.toDegrees(carto.longitude);
|
|
const kLon = Math.cos((camLat * Math.PI) / 180);
|
|
return Math.hypot(lat - camLat, (lon - camLon) * kLon) * 111.32;
|
|
}
|
|
|
|
/**
|
|
* A number naming the cell of 1/`perDegree` degree that holds a point. A
|
|
* string key would be built for every moving train on every frame.
|
|
*/
|
|
function cellKey(lat, lon, perDegree) {
|
|
return (
|
|
Math.round(lat * perDegree) * (362 * perDegree) +
|
|
Math.round(lon * perDegree)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Cells rail may still ask the floor service for: refilled at
|
|
* FLOOR_CELLS_PER_MINUTE, never holding more than FLOOR_CELLS_BURST.
|
|
*/
|
|
const floorBudget = { cells: FLOOR_CELLS_BURST, at: Date.now() };
|
|
function takeFloorBudget(wanted) {
|
|
const now = Date.now();
|
|
floorBudget.cells = Math.min(
|
|
FLOOR_CELLS_BURST,
|
|
floorBudget.cells +
|
|
((now - floorBudget.at) / 60_000) * FLOOR_CELLS_PER_MINUTE,
|
|
);
|
|
floorBudget.at = now;
|
|
const granted = Math.max(
|
|
0,
|
|
Math.min(wanted, Math.floor(floorBudget.cells)),
|
|
);
|
|
floorBudget.cells -= granted;
|
|
return granted;
|
|
}
|
|
/**
|
|
* Floor cells a train (or a stop of the selected trip) reached while they
|
|
* were cold, warmed in small batches off the frame path. `asked`
|
|
* remembers when a cell was last requested, so a frame loop over a cold
|
|
* cell does not re-request it.
|
|
*/
|
|
const coldCells = new Map();
|
|
const asked = new Map();
|
|
let coldTimer = null;
|
|
// Track heights on photorealistic tiles, sampled from the rendered mesh at
|
|
// points on the train's own line: the mesh under a draped line is the
|
|
// track bed or the deck. A floor cell's height is taken at the cell's
|
|
// centre, which can be a roof, a cutting's rim or a hillside well above
|
|
// the rails, so it is only the fallback. Keyed by bucket of
|
|
// 1/TRACK_SAMPLE_GRID degree; each sample keeps the camera height it was
|
|
// read from (`camM`, above the floor), when (`at`), and when it was last
|
|
// read again (`readAt`).
|
|
const trackSamples = new Map();
|
|
const trackQueue = new Map();
|
|
const trackAsked = new Map();
|
|
const trackProbe = new Cesium.Cartographic();
|
|
function cameraCartographic() {
|
|
return state._viewer?.camera?.positionCartographic ?? null;
|
|
}
|
|
/**
|
|
* Whether the photorealistic tiles have finished streaming for this view:
|
|
* there is a shown tileset, and every shown one reports its tiles loaded.
|
|
*/
|
|
function tilesLoaded(scene) {
|
|
const primitives = scene.primitives;
|
|
let found = false;
|
|
for (let i = 0; i < (primitives?.length || 0); i++) {
|
|
const primitive = primitives.get(i);
|
|
if (!primitive?.show || typeof primitive.tilesLoaded !== 'boolean')
|
|
continue;
|
|
if (!primitive.tilesLoaded) return false;
|
|
found = true;
|
|
}
|
|
return found;
|
|
}
|
|
function queueTrackSample(lat, lon, key, priority) {
|
|
const now = Date.now();
|
|
if (now - (trackAsked.get(key) ?? -Infinity) < TRACK_SAMPLE_RETRY_MS)
|
|
return;
|
|
// The floor under it is the gross check against coarse tiles; with no
|
|
// floor yet, wait (the cold-cell queue is warming it). From higher up,
|
|
// the tiles near the camera are coarse.
|
|
const prior = cachedGroundFloor(lat, lon);
|
|
const height = cameraCartographic()?.height;
|
|
if (
|
|
!Number.isFinite(prior) ||
|
|
!Number.isFinite(height) ||
|
|
height - prior > TRACK_SAMPLE_MAX_CAMERA_M
|
|
)
|
|
return;
|
|
if (trackAsked.size > 8192) trackAsked.clear();
|
|
trackAsked.set(key, now);
|
|
trackQueue.set(key, { lat, lon, priority });
|
|
coldTimer ??= setTimeout(flushColdCells, COLD_CELL_FLUSH_MS);
|
|
}
|
|
function flushTrackSamples() {
|
|
const scene = state._viewer?.scene;
|
|
const carto = cameraCartographic();
|
|
if (
|
|
!trackQueue.size ||
|
|
typeof scene?.sampleHeight !== 'function' ||
|
|
!Number.isFinite(carto?.height) ||
|
|
!tilesLoaded(scene)
|
|
) {
|
|
trackQueue.clear();
|
|
return;
|
|
}
|
|
const camLat = Cesium.Math.toDegrees(carto.latitude);
|
|
const camLon = Cesium.Math.toDegrees(carto.longitude);
|
|
const kLon = Math.cos((camLat * Math.PI) / 180);
|
|
const excludeObjects = state._markers ? [state._markers] : [];
|
|
const now = performance.now();
|
|
// The selected train first, then the rest in the order they asked.
|
|
const items = [...trackQueue].sort(
|
|
(a, b) => Number(b[1].priority) - Number(a[1].priority),
|
|
);
|
|
trackQueue.clear();
|
|
let sampled = 0;
|
|
for (const [key, point] of items) {
|
|
if (sampled >= TRACK_SAMPLES_PER_FLUSH) break;
|
|
const dKm =
|
|
Math.hypot(point.lat - camLat, (point.lon - camLon) * kLon) * 111.32;
|
|
if (dKm > TRACK_SAMPLE_MAX_DIST_KM) continue;
|
|
const prior = cachedGroundFloor(point.lat, point.lon);
|
|
if (!Number.isFinite(prior)) continue;
|
|
const camM = carto.height - prior;
|
|
if (camM > TRACK_SAMPLE_MAX_CAMERA_M) continue;
|
|
let height;
|
|
try {
|
|
sampled += 1;
|
|
Cesium.Cartographic.fromDegrees(point.lon, point.lat, 0, trackProbe);
|
|
height = scene.sampleHeight(trackProbe, excludeObjects);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const kept = trackSamples.get(key);
|
|
if (kept) kept.readAt = now;
|
|
if (
|
|
!Number.isFinite(height) ||
|
|
height < prior - TRACK_PRIOR_BELOW_M ||
|
|
height > prior + TRACK_PRIOR_ABOVE_M
|
|
)
|
|
continue;
|
|
// A reading from higher up saw coarser tiles: it replaces one read
|
|
// from lower down only once that one is old.
|
|
if (kept) {
|
|
if (camM > kept.camM && now - kept.at < TRACK_SAMPLE_MAX_AGE_MS)
|
|
continue;
|
|
kept.h = height;
|
|
kept.camM = camM;
|
|
kept.at = now;
|
|
} else trackSamples.set(key, { h: height, camM, at: now, readAt: now });
|
|
}
|
|
if (trackSamples.size > 50_000) trackSamples.clear();
|
|
if (sampled) governorRequestRender('rail-track-height');
|
|
}
|
|
function flushColdCells() {
|
|
coldTimer = null;
|
|
const queued = [...coldCells.values()].sort(
|
|
(a, b) => Number(b.priority) - Number(a.priority) || a.km - b.km,
|
|
);
|
|
coldCells.clear();
|
|
const granted = takeFloorBudget(
|
|
Math.min(queued.length, COLD_CELLS_PER_FLUSH),
|
|
);
|
|
// Cells left over are asked for again after COLD_CELL_RETRY_MS.
|
|
if (granted) warmGroundFloor(queued.slice(0, granted));
|
|
flushTrackSamples();
|
|
}
|
|
/**
|
|
* Queue a cold floor cell for warming. Only cells within
|
|
* FLOOR_WARM_MAX_DIST_KM of the camera are, unless `anyDistance` (the
|
|
* selected trip's stops, which are drawn wherever the camera is).
|
|
*/
|
|
function queueColdCell(lat, lon, priority = false, anyDistance = false) {
|
|
if (!nearGround()) return;
|
|
const km = cameraKm(lat, lon);
|
|
if (km > FLOOR_WARM_MAX_DIST_KM && !anyDistance) return;
|
|
const key = cellKey(lat, lon, 1000);
|
|
const now = Date.now();
|
|
if (now - (asked.get(key) ?? -Infinity) < COLD_CELL_RETRY_MS) return;
|
|
if (asked.size > 4096) asked.clear();
|
|
asked.set(key, now);
|
|
coldCells.set(key, { lat, lon, km, priority });
|
|
coldTimer ??= setTimeout(flushColdCells, COLD_CELL_FLUSH_MS);
|
|
}
|
|
|
|
/** Layer disabled: drop the queued work and the samples taken so far. */
|
|
function reset() {
|
|
clearTimeout(coldTimer);
|
|
coldTimer = null;
|
|
coldCells.clear();
|
|
asked.clear();
|
|
trackQueue.clear();
|
|
trackAsked.clear();
|
|
trackSamples.clear();
|
|
}
|
|
|
|
/** floorHeight's answer, reused by every call: read it before the next. */
|
|
const floorAnswer = { h: 0, q: 0 };
|
|
function answer(h, q) {
|
|
floorAnswer.h = h;
|
|
floorAnswer.q = q;
|
|
return floorAnswer;
|
|
}
|
|
|
|
/**
|
|
* Height to draw a train at: the rendered terrain under it on a terrain
|
|
* globe, else the shared ground floor (which tracks the photorealistic mesh
|
|
* where one is loaded). Where that cell is not warm yet, a neighbouring
|
|
* cell's floor, else the floor this train last stood on, while the cell is
|
|
* warmed: a train running onto unsampled ground keeps its height instead of
|
|
* dropping to the ellipsoid, which lies tens to hundreds of metres under
|
|
* the photorealistic mesh (about 150 m around Chicago). Only a train with
|
|
* no floor yet, or a stop, stands on the ellipsoid, until its cell is warm.
|
|
* @param {number} lat
|
|
* @param {number} lon
|
|
* @param {{floorM?: number, key?: string}|null} [entry] the train, which
|
|
* keeps its floor
|
|
* @param {boolean} [stop] a stop of the selected trip: its cell is warmed
|
|
* first, however far from the camera
|
|
* @returns {{h: number, q: number}} The height, and how good it is: 3 the
|
|
* rendered terrain, 2 the cell's floor, 1 a neighbouring cell's or the
|
|
* train's last floor, 0 the ellipsoid. The same object every call.
|
|
*/
|
|
/** The last coarse cell asked for a stop (stops have no entry). */
|
|
const stopCell = { kLat: NaN, kLon: NaN, cell: null };
|
|
|
|
/**
|
|
* The point's coarse floor cell, kept on the train (and once for stops)
|
|
* while the point stays in the same 0.001° cell: a train gliding across a
|
|
* cold cell asks the floor service for it once, not every frame.
|
|
* @param {number} lat
|
|
* @param {number} lon
|
|
* @param {object|null} entry
|
|
* @returns {{lat: number, lon: number}}
|
|
*/
|
|
function coarseCellOf(lat, lon, entry) {
|
|
const memo = entry
|
|
? (entry.coarseCell ??= { kLat: NaN, kLon: NaN, cell: null })
|
|
: stopCell;
|
|
const kLat = Math.round(lat * 1000);
|
|
const kLon = Math.round(lon * 1000);
|
|
if (memo.kLat !== kLat || memo.kLon !== kLon) {
|
|
memo.kLat = kLat;
|
|
memo.kLon = kLon;
|
|
memo.cell = coarseFloorCoord(lat, lon);
|
|
}
|
|
return memo.cell;
|
|
}
|
|
|
|
function floorHeight(lat, lon, entry = null, stop = false) {
|
|
const globe = state._viewer?.scene?.globe;
|
|
if (globe?.show === true) {
|
|
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
|
|
const rendered = globe.getHeight?.(terrainPoint);
|
|
if (Number.isFinite(rendered))
|
|
return answer(rendered + GROUND_FLOOR_LIFT_M, 3);
|
|
}
|
|
const floor = cachedGroundFloor(lat, lon);
|
|
if (Number.isFinite(floor)) {
|
|
if (entry) entry.floorM = floor;
|
|
return answer(floor + GROUND_FLOOR_LIFT_M, 2);
|
|
}
|
|
queueColdCell(
|
|
lat,
|
|
lon,
|
|
stop || (entry !== null && entry.key === state._selectedKey),
|
|
stop,
|
|
);
|
|
const near =
|
|
neighborFloorM && coarseFloorCoord
|
|
? neighborFloorM(coarseCellOf(lat, lon, entry))
|
|
: null;
|
|
if (Number.isFinite(near)) return answer(near + GROUND_FLOOR_LIFT_M, 1);
|
|
return Number.isFinite(entry?.floorM)
|
|
? answer(entry.floorM + GROUND_FLOOR_LIFT_M, 1)
|
|
: answer(0, 0);
|
|
}
|
|
function heightFor(lat, lon, entry = null) {
|
|
return floorHeight(lat, lon, entry).h;
|
|
}
|
|
|
|
/**
|
|
* Where a stop of the selected trip stands, and how good that height is
|
|
* (floorHeight's quality): a stop read before its cell was warm is read
|
|
* again until it stands on a real floor (routeDisplay.js).
|
|
* @param {number} lat
|
|
* @param {number} lon
|
|
* @returns {{h: number, q: number}}
|
|
*/
|
|
function stopHeight(lat, lon) {
|
|
const { h, q } = floorHeight(lat, lon, null, true);
|
|
return { h, q };
|
|
}
|
|
|
|
/**
|
|
* The height a train is drawn at. Its target is the track sample under it
|
|
* on photorealistic tiles (else the floor chain of heightFor), and the
|
|
* drawn height moves toward it no faster than a railway climbs:
|
|
* TRACK_MAX_GRADE of the distance moved, plus TRACK_SETTLE_MPS over time.
|
|
* A single high sample (a roof, an overpass) is a bump, cell steps are
|
|
* ramps, and a followed camera rides smoothly. The drawn height snaps when
|
|
* a better source replaces a fallback (quality 0-1 to 2-3, or any floor
|
|
* replacing the ellipsoid, 0 to 1), and after a jump of more than
|
|
* TRACK_SNAP_M.
|
|
* @param {object} entry The train.
|
|
* @param {number} lat
|
|
* @param {number} lon
|
|
* @returns {number}
|
|
*/
|
|
function displayHeightFor(entry, lat, lon) {
|
|
const globe = state._viewer?.scene?.globe;
|
|
const now = performance.now();
|
|
let targetH = NaN;
|
|
let targetQ = 0;
|
|
if (globe?.show !== true && nearGround()) {
|
|
const key = cellKey(lat, lon, TRACK_SAMPLE_GRID);
|
|
const sampled = trackSamples.get(key);
|
|
if (sampled) {
|
|
targetH = sampled.h + GROUND_FLOOR_LIFT_M;
|
|
targetQ = 3;
|
|
}
|
|
// A sample is read again now and then: from a lower camera, it
|
|
// replaces one read from coarser tiles.
|
|
if (!sampled || now - sampled.readAt >= TRACK_SAMPLE_REFRESH_MS)
|
|
queueTrackSample(lat, lon, key, entry.key === state._selectedKey);
|
|
}
|
|
if (!Number.isFinite(targetH)) {
|
|
const floor = floorHeight(lat, lon, entry);
|
|
targetH = floor.h;
|
|
targetQ = floor.q;
|
|
}
|
|
const prev = entry.drawnHeight;
|
|
let h = targetH;
|
|
if (prev && Number.isFinite(prev.h)) {
|
|
const kLon = Math.cos((lat * Math.PI) / 180);
|
|
const moved =
|
|
Math.hypot(lat - prev.lat, (lon - prev.lon) * kLon) * 111_320;
|
|
const better =
|
|
(prev.q === 0 && targetQ > 0) || (prev.q <= 1 && targetQ >= 2);
|
|
if (moved <= TRACK_SNAP_M && !better) {
|
|
const dt = Math.max(0, (now - prev.at) / 1000);
|
|
const step = TRACK_MAX_GRADE * moved + TRACK_SETTLE_MPS * dt;
|
|
h = prev.h + Math.max(-step, Math.min(step, targetH - prev.h));
|
|
}
|
|
}
|
|
// Written in place: this runs for every moving train on every frame.
|
|
if (prev) {
|
|
prev.h = h;
|
|
prev.q = targetQ;
|
|
prev.lat = lat;
|
|
prev.lon = lon;
|
|
prev.at = now;
|
|
} else entry.drawnHeight = { h, q: targetQ, lat, lon, at: now };
|
|
return h;
|
|
}
|
|
|
|
/** Ask the floor service for the cells under one feed's trains; off the frame path. */
|
|
function warmFloors(feedId) {
|
|
for (const entry of state._trains.values()) {
|
|
if (entry.feedId !== feedId) continue;
|
|
// A parked train's height was read when it was placed; floors warmed
|
|
// since (or finer terrain tiles) land on it here, once per poll. Moving
|
|
// trains re-read their height every frame already.
|
|
if (entry.marker && !state._moving.has(entry)) place(entry);
|
|
const { lat, lon } = entry.record;
|
|
if (!Number.isFinite(cachedGroundFloor(lat, lon)))
|
|
queueColdCell(lat, lon, entry.key === state._selectedKey);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a train is drawn: its category is on, and it is not a copy of a
|
|
* train another feed draws better (dedupe.js).
|
|
*/
|
|
function categoryShown(entry) {
|
|
return (
|
|
state._categories[entry.category] !== false && entry.duplicate !== true
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether a train's icon is drawn: shown, not drawn by its model, and no
|
|
* first-person view rides it (cockpitSubject.js). A held train keeps its
|
|
* playback; only its icon hides.
|
|
*/
|
|
function markerShown(entry) {
|
|
return (
|
|
categoryShown(entry) &&
|
|
!entry.modelOwnsVisual &&
|
|
entry.key !== state._cockpitSubjectKey
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether the train's playback runs: it is drawn, or a first-person view
|
|
* rides it, which keeps it moving, hidden, in a category turned off.
|
|
*/
|
|
function playing(entry) {
|
|
return categoryShown(entry) || entry.key === state._cockpitSubjectKey;
|
|
}
|
|
|
|
/**
|
|
* A train bound to its railway line is drawn on the line, at the arc its
|
|
* playback has reached; any other train (or a bound train whose reports do
|
|
* not fit the line) is drawn on the time-interpolated straight-line point.
|
|
*/
|
|
function place(entry) {
|
|
const sample = entry.sample;
|
|
const onPath =
|
|
entry.path && placeOnPath(entry.path, sample, entry.segment, pathPoint);
|
|
const lat = onPath
|
|
? pathPoint.lat
|
|
: Number.isFinite(sample?.lat)
|
|
? sample.lat
|
|
: entry.record.lat;
|
|
const lon = onPath
|
|
? pathPoint.lon
|
|
: Number.isFinite(sample?.lon)
|
|
? sample.lon
|
|
: entry.record.lon;
|
|
// Write into scratch, never into `marker.position`: the getter returns the
|
|
// billboard's own vector, so filling it in place makes the setter see an
|
|
// unchanged position and skip the update that actually moves the sprite.
|
|
entry.marker.position = Cesium.Cartesian3.fromDegrees(
|
|
lon,
|
|
lat,
|
|
displayHeightFor(entry, lat, lon),
|
|
undefined,
|
|
scratchPosition,
|
|
);
|
|
entry.marker.show = markerShown(entry);
|
|
}
|
|
|
|
/** While a train is selected, every other train is drawn see-through. */
|
|
function tintFor(entry) {
|
|
return state._selectedKey !== null && entry.key !== state._selectedKey
|
|
? DIM_TINT
|
|
: FULL_TINT;
|
|
}
|
|
|
|
function paint(entry) {
|
|
if (!entry.marker) return;
|
|
const selected = entry.key === state._selectedKey;
|
|
const size = selected ? SELECTED_PIXEL_SIZE : MARKER_PIXEL_SIZE;
|
|
entry.marker.image = spriteFor(entry.category, entry.railMode);
|
|
entry.marker.width = size;
|
|
entry.marker.height = size;
|
|
entry.marker.color = tintFor(entry);
|
|
entry.marker.show = markerShown(entry);
|
|
}
|
|
|
|
/** Re-tint every train after the selection appeared, moved or cleared. */
|
|
function paintAll() {
|
|
for (const entry of state._trains.values()) paint(entry);
|
|
}
|
|
|
|
function addMarker(entry) {
|
|
const marker = state._markers.add({
|
|
id: entry.key,
|
|
position: Cesium.Cartesian3.ZERO,
|
|
image: spriteFor(entry.category, entry.railMode),
|
|
width: MARKER_PIXEL_SIZE,
|
|
height: MARKER_PIXEL_SIZE,
|
|
color: tintFor(entry),
|
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
});
|
|
entry.marker = marker;
|
|
place(entry);
|
|
return marker;
|
|
}
|
|
|
|
function cancelWake(entry) {
|
|
if (entry?.wakeTimer) clearTimeout(entry.wakeTimer);
|
|
if (entry) entry.wakeTimer = null;
|
|
}
|
|
|
|
/**
|
|
* Track whether a train is mid-glide, so the frame pass walks only those. A
|
|
* train parked at a fix whose next segment starts later is woken by a timer
|
|
* at that boundary, not by polling every frame.
|
|
*/
|
|
function schedulePlayback(entry) {
|
|
cancelWake(entry);
|
|
if (!entry.marker) return;
|
|
place(entry);
|
|
const sample = entry.sample;
|
|
const moving =
|
|
playing(entry) &&
|
|
sample?.phase === 'playing' &&
|
|
sample.segmentSpeedMps > 0;
|
|
if (moving) {
|
|
state._moving.add(entry);
|
|
return;
|
|
}
|
|
state._moving.delete(entry);
|
|
if (playing(entry) && Number.isFinite(sample?.nextWakeMonoMs)) {
|
|
entry.wakeTimer = setTimeout(
|
|
() => {
|
|
entry.wakeTimer = null;
|
|
if (!state._enabled || !state._trains.has(entry.key)) return;
|
|
updatePlayback(entry, Date.now(), performance.now());
|
|
schedulePlayback(entry);
|
|
syncRenderHold();
|
|
governorRequestRender('rail-motion-boundary');
|
|
},
|
|
Math.max(1, sample.nextWakeMonoMs - performance.now()),
|
|
);
|
|
}
|
|
}
|
|
|
|
function syncRenderHold() {
|
|
const working = state._enabled && state._moving.size > 0;
|
|
if (working && !state._renderHeld) {
|
|
holdContinuousRender('rail');
|
|
state._renderHeld = true;
|
|
} else if (!working && state._renderHeld) {
|
|
releaseContinuousRender('rail');
|
|
state._renderHeld = false;
|
|
governorRequestRender('rail-settled');
|
|
}
|
|
}
|
|
|
|
const horizon = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84);
|
|
const surfaceScratch = new Cesium.Cartesian3();
|
|
|
|
/** Whether a point on the Earth is on the camera's side of it. */
|
|
function facesCamera(position) {
|
|
const viewer = state._viewer;
|
|
const cameraPosition = viewer?.camera?.positionWC;
|
|
if (
|
|
!position ||
|
|
!cameraPosition ||
|
|
viewer.scene?.mode !== Cesium.SceneMode.SCENE3D
|
|
)
|
|
return true;
|
|
const surface = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(
|
|
position,
|
|
surfaceScratch,
|
|
);
|
|
if (!surface) return true;
|
|
horizon.cameraPosition = cameraPosition;
|
|
return horizon.isPointVisible(surface);
|
|
}
|
|
|
|
/**
|
|
* Markers are drawn over the terrain, never behind it, and the selected
|
|
* train is drawn at any distance (viewport.js): from far out, the globe
|
|
* would not hide it on its far side, so this pass does, with its stops.
|
|
*/
|
|
function cullSelected() {
|
|
const entry = state._trains.get(state._selectedKey);
|
|
if (!entry?.marker) return;
|
|
const shown = markerShown(entry) && facesCamera(entry.marker.position);
|
|
if (entry.marker.show !== shown) entry.marker.show = shown;
|
|
parts.routes.cullStops(facesCamera);
|
|
}
|
|
|
|
function onPreRender() {
|
|
advanceMoving();
|
|
if (state._enabled && state._selectedKey !== null) cullSelected();
|
|
if (state._enabled) updateModels();
|
|
}
|
|
|
|
// Under the DISPLAY rail's 3D toggle, trains near the camera are drawn as
|
|
// the project's procedural fleet models, chosen by category and mode,
|
|
// painted in their category colour where the model marks it, on the
|
|
// aircraft layers' icon-to-model rules (services.fleet). The fleet set
|
|
// loads once, up front, when the layer starts.
|
|
let fleetModels = null;
|
|
/** Items drawn last frame, and the map the next frame's are gathered into. */
|
|
let modelItems = new Map();
|
|
let spareItems = new Map();
|
|
let modelCandidatesAtMs = -Infinity;
|
|
|
|
/** Load the fleet set and create the layer's models (on enable). */
|
|
function startModels() {
|
|
const fleet = services.fleet;
|
|
if (!fleet?.policy || !state._viewer) return;
|
|
loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => {
|
|
/* models stay off; trains keep their icons */
|
|
});
|
|
fleetModels ||= createFleetModelLayer({
|
|
viewer: state._viewer,
|
|
display: fleet.display,
|
|
policy: fleet.policy,
|
|
resolveAsset: fleet.resolveAsset,
|
|
});
|
|
}
|
|
|
|
/** Hand a train's visual back to its icon. */
|
|
function handBack(entry) {
|
|
if (!entry.modelOwnsVisual) return;
|
|
entry.modelOwnsVisual = false;
|
|
if (entry.marker) entry.marker.show = markerShown(entry);
|
|
}
|
|
|
|
function modelItem(entry, set) {
|
|
let item = modelItems.get(entry.key);
|
|
if (!item || item.entry !== entry)
|
|
item = {
|
|
entry,
|
|
pickId: entry.key,
|
|
modelOwnsVisual: false,
|
|
// Markers are drawn this far above the track; a model stands on it.
|
|
groundLiftM: GROUND_FLOOR_LIFT_M,
|
|
};
|
|
const specKey = `${entry.category}|${entry.railMode}`;
|
|
if (item.specKey !== specKey) {
|
|
item.specKey = specKey;
|
|
item.spec = transitModelSpec(set, {
|
|
railCategory: entry.category,
|
|
railMode: entry.railMode,
|
|
});
|
|
item.tint = CATEGORY_TINTS[entry.category] || CATEGORY_TINTS.local;
|
|
}
|
|
item.position = entry.marker.position;
|
|
item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0;
|
|
item.show = categoryShown(entry);
|
|
return item;
|
|
}
|
|
|
|
function updateModels() {
|
|
if (!fleetModels) return;
|
|
if (!fleetModels.active() || state._trains.size === 0) {
|
|
if (modelItems.size) {
|
|
for (const item of modelItems.values()) handBack(item.entry);
|
|
modelItems.clear();
|
|
fleetModels.frame(modelItems, performance.now());
|
|
}
|
|
return;
|
|
}
|
|
const set = fleetModelSet();
|
|
const now = performance.now();
|
|
const next = spareItems;
|
|
next.clear();
|
|
if (now - modelCandidatesAtMs >= MODEL_CANDIDATES_MS) {
|
|
modelCandidatesAtMs = now;
|
|
const camera = state._viewer?.camera;
|
|
const policy = services.fleet.policy;
|
|
const keepM =
|
|
services.fleet.display?.()?.models3dMode === 'all'
|
|
? policy.keepAllM
|
|
: policy.keepM;
|
|
const limitSq = keepM * keepM * 1.44;
|
|
if (camera?.positionWC)
|
|
for (const entry of state._trains.values()) {
|
|
if (!entry.marker || !categoryShown(entry)) continue;
|
|
if (
|
|
Cesium.Cartesian3.distanceSquared(
|
|
camera.positionWC,
|
|
entry.marker.position,
|
|
) > limitSq
|
|
)
|
|
continue;
|
|
next.set(entry.key, modelItem(entry, set));
|
|
}
|
|
} else {
|
|
for (const [key, item] of modelItems)
|
|
if (state._trains.get(key) === item.entry && item.entry.marker)
|
|
next.set(key, modelItem(item.entry, set));
|
|
}
|
|
for (const [key, item] of modelItems)
|
|
if (next.get(key) !== item) handBack(item.entry);
|
|
spareItems = modelItems;
|
|
modelItems = next;
|
|
fleetModels.frame(modelItems, now);
|
|
for (const item of modelItems.values()) {
|
|
const entry = item.entry;
|
|
if (item.modelOwnsVisual) {
|
|
entry.modelOwnsVisual = true;
|
|
entry.marker.show = false;
|
|
} else handBack(entry);
|
|
}
|
|
if (fleetModels.settling) governorRequestRender('rail-models');
|
|
}
|
|
|
|
/** Drop every model (layer disabled); `destroy` also removes the collection. */
|
|
function stopModels(destroy = false) {
|
|
for (const item of modelItems.values()) item.entry.modelOwnsVisual = false;
|
|
modelItems.clear();
|
|
spareItems.clear();
|
|
modelCandidatesAtMs = -Infinity;
|
|
if (destroy) {
|
|
fleetModels?.destroy();
|
|
fleetModels = null;
|
|
} else fleetModels?.clear();
|
|
}
|
|
|
|
function advanceMoving() {
|
|
if (!state._enabled || state._moving.size === 0) {
|
|
syncRenderHold();
|
|
return;
|
|
}
|
|
if (globalThis.document?.visibilityState === 'hidden') return;
|
|
const now = Date.now();
|
|
const monoNow = performance.now();
|
|
for (const entry of state._moving) {
|
|
if (!entry.marker) {
|
|
state._moving.delete(entry);
|
|
continue;
|
|
}
|
|
updatePlayback(entry, now, monoNow);
|
|
applyDisplayCourse(entry, monoNow);
|
|
place(entry);
|
|
if (
|
|
entry.sample.phase !== 'playing' ||
|
|
entry.sample.segmentSpeedMps === 0
|
|
)
|
|
schedulePlayback(entry);
|
|
}
|
|
syncRenderHold();
|
|
}
|
|
|
|
/** Re-apply category visibility to every marker after a toggle. */
|
|
function applyCategoryVisibility() {
|
|
for (const entry of state._trains.values()) {
|
|
if (!entry.marker) continue;
|
|
entry.marker.show = markerShown(entry);
|
|
schedulePlayback(entry);
|
|
}
|
|
syncRenderHold();
|
|
parts.routes.applyNetwork();
|
|
governorRequestRender('rail-categories');
|
|
}
|
|
|
|
return {
|
|
addMarker,
|
|
paint,
|
|
paintAll,
|
|
heightFor,
|
|
stopHeight,
|
|
displayHeightFor,
|
|
cancelWake,
|
|
schedulePlayback,
|
|
syncRenderHold,
|
|
onPreRender,
|
|
warmFloors,
|
|
applyCategoryVisibility,
|
|
categoryShown,
|
|
reset,
|
|
startModels,
|
|
stopModels,
|
|
};
|
|
}
|