Files
gods-eye-view/src/layers/transit/trails.js
T
Bilawal Sidhu 0bb61f53c6 fix(transit): keep the real silhouettes under sensor presets, make the selected trail visible, put Transit in Movement (#596)
## Changes

Follow-up to #587 after the owner's first look at the merged transit layer on Google 3D tiles.

- **Sensor presets keep the real silhouettes.** Under NVG, thermal (white-hot, black-hot, Ironbow) and noir, vehicles are the same silhouettes as in normal view, filled opaque white with a 2 px dark halo, at the CRT display sizes (26 px fleet, 39 px selected). The solid rounded "signature" bodies are gone.
- **The selected vehicle's trail is visible.** The retained history is now draped onto 3D tiles and terrain (`GroundPolylinePrimitive`, `ClassificationType.BOTH`, the way the traffic heat-lines are drawn), 3 px core over a 5 px dark backing, per-segment age colour, a short draped head clipped to the sprite. Two defects kept it off screen before: Cesium's per-instance attribute getters return copies, so the renderer's show/colour edits were discarded, and a marker did not recover after the camera jumped to it, which hid the trail with it. Trail geometry is now prepared on selection independent of floor resolution and marker visibility; sparse long intervals (rail) keep a bounded marker corridor instead of dropping the interval; classification survives map-source changes.
- **Transit sits in the Movement panel group** between Street Traffic and Bike Share (the panel orders by `PANEL_GROUPS`, not by catalog order; the catalog is moved too).

## How it was verified

- `npm run format:check` (854 files), `npm run check:boundaries`, `npm test` (4,062 passing), `npm run build` clean; tracking regression 109/109 in a real browser on Google 3D tiles.
- Browser harness (`scripts/qa-transit.mjs`), keyed on Google 3D tiles and keyless: keyed 194 pass, with the trail pixel check passing at 300, 600 and 900 m; the remaining keyed failures are the per-frame allocation gate already recorded as a follow-up from #587 (CPU and frame time within budget), four readability lines at threshold edges, and the card-anchor check now counting frames where the selected marker is legitimately off screen; keyless 193 pass with the remaining misses being threshold edges on the thin tram silhouette (centre 0.83–0.88 against 0.85) and one bus ring 0.28 against 0.25 on a pure-white roof.
- New harness checks: `Google 3D selected trail visible at 6 of 8 projected samples` (repeated off/on framebuffer captures at 300/600/900 m, samples outside the sprite footprint, a background-change negative control; fails on main, passes here), a camera-arrival check (select a moving vehicle, `setView` onto it, marker shown and trail body drawn within 2 s), `PANEL_ORDER` pinned, raster tests comparing every sensor raster's mask and aspect ratio to the normal silhouette.
- Direct look on a keyed build: a live CapMetro bus at 30 m/s selected through the layer API draws its draped trail along the highway lane (harness capture `keyed-600m-trail-visible.jpg`).
- Adversarial review by Codex Astra before the last two fix passes (stack-switch classification, marker corridor under the vertex cap, pixel-gate loophole), all three fixed with regressions.

Docs: `docs/CURRENT-STATE.md` and `CHANGELOG.md` updated in this PR. No data-source changes.
2026-09-15 13:26:41 -05:00

352 lines
11 KiB
JavaScript

import { getRegisteredTransitFeed } from '../../data/transitFeeds.js';
import * as Cesium from 'cesium';
import {
readFix,
correctHeight,
FIX_FLAGS,
mergeHistory,
} from '../../data/contactPlayback.js';
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
import { fixDistanceM } from './movement.js';
import { transitStyleProfile } from '../../data/transitPresetStyle.js';
const MODE_COLOR = {
bus: '#5EF08A',
tram: '#FFC24A',
subway: '#FF4538',
rail: '#D9A6FF',
ferry: '#5FD6FF',
unknown: '#D8DDE5',
};
// Final Cesium batching uses 3,152 bytes per two-lane edge.
// The 1,278-instance body plus 64 bytes/instance reserve stays below 2 MiB.
export const TRAIL_VERTEX_LIMIT = 640;
const STEP_M = 25;
/** Shared prepared paths. Floors are aligned to work with Google 3D tiles. */
export function createTrails({ state, services, parts, source }) {
let historyRequest = null;
let requestCount = 0,
abortCount = 0;
let renderer = null,
selected = null,
fadeTimer = null,
revision = 0;
function ensureRenderer() {
if (!renderer && state._viewer)
renderer = createContactTrailRenderer(state._viewer.scene);
return renderer;
}
function style() {
renderer?.setStyle(
transitStyleProfile(state._stylePreset) === 'mono'
? '#FFFFFF'
: MODE_COLOR[selected?.mode] || MODE_COLOR.unknown,
);
}
function releaseSelectionGeometry(entry) {
entry.trailSegments = [];
entry.trailVertices = 0;
entry.trailTruncated = false;
entry.pathRevision = -1;
entry.markerPathSeq = NaN;
pruneMarkerPaths(entry, entry.sample);
}
function pruneMarkerPaths(entry, sample) {
if (entry === selected || entry.markerPathSeq === sample.fromSeq) return;
entry.markerPathSeq = sample.fromSeq;
for (const seq of entry.displayPaths?.keys() || [])
if (seq < sample.fromSeq) entry.displayPaths.delete(seq);
}
/** Runs only after reports, selection, or surface rereads. */
function prepareEntry(entry, collect = null) {
if (!entry.track?.count) return;
if (entry !== selected) {
entry.trailSegments = [];
entry.trailVertices = 0;
}
const draped = entry === selected && ensureRenderer()?.diagnostics().ground;
const paths = new Map(),
all = [],
a = {},
b = {};
let vertices = 0,
signature = '';
const floor = (point) => {
const answer = parts.height.requestHeight(point.lat, point.lon, entry);
if (collect) collect(point, answer);
return answer.height;
};
// Selected active corridor gets first access to both surface budgets.
if (collect && entry.key === state._selectedKey) {
readFix(
entry.track,
Math.max(0, entry.sample.fromSeq - entry.track.baseSeq),
a,
);
readFix(
entry.track,
Math.max(0, entry.sample.toSeq - entry.track.baseSeq),
b,
);
const n = Math.min(
TRAIL_VERTEX_LIMIT - 1,
Math.max(1, Math.ceil(fixDistanceM(a, b) / STEP_M)),
);
for (let i = 0; i <= n; i++)
floor({
lat: a.lat + ((b.lat - a.lat) * i) / n,
lon: a.lon + ((b.lon - a.lon) * i) / n,
});
}
for (let i = 0; i < entry.track.count; i++) {
readFix(entry.track, i, a);
const h = floor(a);
if (h !== null) correctHeight(entry.track, a.seq, h);
}
const order = [];
const activeIndex = Math.max(
0,
Math.min(
entry.track.count - 2,
entry.sample.fromSeq - entry.track.baseSeq,
),
);
for (let i = activeIndex; i + 1 < entry.track.count; i++) order.push(i);
if (entry === selected)
for (let i = activeIndex - 1; i >= 0; i--) order.push(i);
entry.trailTruncated = false;
let markerVertices = 0;
let markerIntervals = Math.max(0, entry.track.count - 1 - activeIndex);
for (const i of order) {
readFix(entry.track, i, a);
readFix(entry.track, i + 1, b);
const markerCorridor = i >= activeIndex;
if (markerCorridor) markerIntervals--;
if (a.epoch !== b.epoch || b.flags & FIX_FLAGS.BREAK) continue;
if (entry !== selected && b.t < entry.sample.displayT) continue;
const desired = Math.max(1, Math.ceil(fixDistanceM(a, b) / STEP_M));
// Reserve endpoints for every future marker interval. Sparse reports
// coarsen the active path instead of losing the vehicle to the body cap.
const n = markerCorridor
? Math.min(
desired,
TRAIL_VERTEX_LIMIT - markerVertices - 2 * markerIntervals - 1,
)
: desired;
const retainBody = vertices + n + 1 <= TRAIL_VERTEX_LIMIT;
if (!retainBody || n < desired) entry.trailTruncated = true;
if (!markerCorridor && !retainBody) continue;
if (markerCorridor) markerVertices += n + 1;
const positions = [],
heights = [];
let valid = true;
for (let k = 0; k <= n; k++) {
const fraction = k / n;
const point = {
lat: a.lat + (b.lat - a.lat) * fraction,
lon: a.lon + (((b.lon - a.lon + 540) % 360) - 180) * fraction,
};
const h = floor(point);
heights.push(h);
if (h === null) {
valid = false;
// Ground polylines consume longitude/latitude and drape themselves.
// Unknown floors still disqualify the marker corridor.
positions.push(
draped ? Cesium.Cartesian3.fromDegrees(point.lon, point.lat) : null,
);
} else
positions.push(
Cesium.Cartesian3.fromDegrees(point.lon, point.lat, h),
);
}
signature += `${a.seq}/${b.seq}:${heights.join(',')};`;
if (retainBody) vertices += n + 1;
if (valid) paths.set(a.seq, { positions, toSeq: b.seq });
if (!valid && !draped) continue;
if (entry === selected && retainBody && fixDistanceM(a, b) > 0) {
for (let k = 0; k < n; k++)
all.push({
fromSeq: a.seq,
toSeq: b.seq,
fromT: a.t + ((b.t - a.t) * k) / n,
toT: a.t + ((b.t - a.t) * (k + 1)) / n,
positions: [positions[k], positions[k + 1]],
});
}
}
const changed =
entry.pathSignature !== signature ||
entry.pathRevision !== entry.track.revision;
if (changed) {
entry.displayPaths = paths;
entry.pathSignature = signature;
entry.pathRevision = entry.track.revision;
state._heightDirty.add(entry);
if (entry === selected) {
all.sort((a, b) => a.fromT - b.fromT);
ensureRenderer()?.replaceHistory({
segments: all,
revision: ++revision,
});
entry.trailVertices = vertices;
entry.trailSegments = all;
services.render.governorRequestRender('transit-trail-history');
}
}
return changed;
}
/** Allocation-free sampling of the exact path also used by the trail. */
function samplePosition(
entry,
sample,
out,
nearGround = parts.height.nearGround(),
) {
pruneMarkerPaths(entry, sample);
if (!nearGround) {
const a = entry.fromCart,
b = entry.toCart,
f = sample.fraction;
out.x = a.x + (b.x - a.x) * f;
out.y = a.y + (b.y - a.y) * f;
out.z = a.z + (b.z - a.z) * f;
return out;
}
if (sample.fromSeq === sample.toSeq) {
if (!Number.isFinite(entry.segment.from.h)) return null;
Cesium.Cartesian3.clone(entry.fromCart, out);
return out;
}
const path = entry.displayPaths?.get(sample.fromSeq);
if (!path || path.toSeq !== sample.toSeq) return null;
const scaled = sample.fraction * (path.positions.length - 1);
const index = Math.min(path.positions.length - 2, Math.floor(scaled));
// Keep scalar interpolation in this frame function. Passing the changing
// fraction to the general Cesium helper boxes it in sampled V8 stacks.
const a = path.positions[index],
b = path.positions[index + 1],
f = scaled - index;
out.x = a.x + (b.x - a.x) * f;
out.y = a.y + (b.y - a.y) * f;
out.z = a.z + (b.z - a.z) * f;
return out;
}
function update() {
if (!selected || !renderer) return;
renderer.setVisible(
state._enabled && state._vehicles.get(selected.key) === selected,
);
renderer.setDisplaySample(selected.sample, selected.marker?.position);
}
function select(entry) {
clear();
selected = entry;
ensureRenderer();
style();
entry.pathRevision = -1;
prepareEntry(entry);
update();
const feed = getRegisteredTransitFeed(entry.feedId);
if (feed?.historyRetention) {
const controller = new AbortController();
historyRequest = controller;
requestCount++;
source
.getHistory(entry.feedId, entry.record.id, {
signal: controller.signal,
})
.then((payload) => {
if (
controller.signal.aborted ||
!state._enabled ||
selected !== entry ||
historyRequest !== controller
)
return;
const mapping = new Map();
for (const epoch of payload.epochs) {
let id = null;
for (const [candidate, context] of entry.track.epochs) {
if (
context.trip === epoch.trip &&
context.route === epoch.route &&
context.mode === epoch.mode
) {
id = candidate;
break;
}
}
if (id === null) {
id = Math.max(0, ...entry.track.epochs.keys()) + 1;
entry.track.epochs.set(id, {
trip: epoch.trip,
route: epoch.route,
mode: epoch.mode,
});
}
mapping.set(epoch.id, id);
}
mergeHistory(
entry.track,
payload.fixes.map(([t, lat, lon, flags, epoch]) => ({
t,
lat,
lon,
flags,
epoch: mapping.get(epoch),
})),
);
entry.track.truncated ||= payload.truncated;
entry.pathRevision = -1;
parts.height.anchorFloors();
prepareEntry(entry);
update();
services.render.governorRequestRender('transit-trail-backfill');
})
.catch(() => {
// Session history remains usable if the optional retained read fails.
})
.finally(() => {
if (historyRequest === controller) historyRequest = null;
});
}
fadeTimer = setInterval(() => {
if (!state._enabled || selected !== entry) return;
parts.rendering.sampleIdle(entry);
update();
services.render.governorRequestRender('transit-trail-age');
}, 1000);
}
function clear() {
if (historyRequest) {
historyRequest.abort();
abortCount++;
historyRequest = null;
}
if (fadeTimer != null) clearInterval(fadeTimer);
fadeTimer = null;
const previous = selected;
selected = null;
if (previous) releaseSelectionGeometry(previous);
renderer?.destroy();
renderer = null;
}
return {
prepareEntry,
samplePosition,
select,
clear,
update,
style,
destroy: clear,
requestDiagnostics: () => ({
requestCount,
abortCount,
pending: historyRequest !== null,
}),
diagnostics: () => renderer?.diagnostics() || null,
};
}