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:
Denis Groshev committed 2026-11-11 16:06:22 +01:00
1 parent 14db73f201
commit 830c080803
114 files changed
+26639 -26

No files matched your search

+724
View File
@@ -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,
};
}