mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
feat(rail): add a Rail layer with keyless live trains
Live trains from 30 keyless operator feeds: GPS positions from 29 and Irish Rail's station reports, shown as the operators publish them. 28 work out of the box; Renfe long and medium distance and KTMB appear once their pack is built, because their vehicles name no route. Packs are opt-in (scripts/build-rail-paths.mjs): the operator's own route lines, stop names, next stops and trip-update delays for live trains. Nothing downloads when the app starts, no train is placed from a timetable, and no request carries a deployer's key.
This commit is contained in:
1 parent
14db73f201
commit
830c080803
114 files changed
+26639
-26
No files matched your search
@@ -0,0 +1,724 @@
|
||||
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';
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = categoryShown(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 = categoryShown(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 =
|
||||
categoryShown(entry) &&
|
||||
sample?.phase === 'playing' &&
|
||||
sample.segmentSpeedMps > 0;
|
||||
if (moving) {
|
||||
state._moving.add(entry);
|
||||
return;
|
||||
}
|
||||
state._moving.delete(entry);
|
||||
if (categoryShown(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 = categoryShown(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();
|
||||
}
|
||||
|
||||
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 = categoryShown(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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user