mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
## 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.
352 lines
11 KiB
JavaScript
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,
|
|
};
|
|
}
|