mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
feat(transit): live public-transit vehicles from open GTFS-Realtime feeds (#587)
Supersedes #218 by @spcpza, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Contributor: @spcpza. His original commit is the base of this branch, and his own re-port onto main (2026-09-15, `5b69011`) was consulted while finishing the height work. That port reads every vehicle's surface from the app's shared ground-floor system (`cachedGroundFloor` / `warmGroundFloor` / `GROUND_FLOOR_LIFT_M`) rather than sampling the scene per layer, keeps a vehicle hidden until its cell answers, warms a bounded batch per poll on a timer instead of per frame, and renders at the ellipsoid above the altitude gate. That is the sounder primitive — one floor system for the app, with the rooftop-versus-street question already settled against a datum-correct DEM prior — and this branch's height work was rebuilt on it, replacing a bespoke cell cache, ground prior and rooftop rule written here. Credit for that direction is his. Future work (not in this PR), both written up in docs/CURRENT-STATE.md: - Readable route names. The realtime feeds carry only `route_id`; `route_short_name` / `route_long_name` live in each operator's static GTFS `routes.txt`, and headsigns in `trips.txt`. Doing it properly wants a server-side metadata loader beside the transit proxy with fixed registered static sources, bounded indexes built once and persisted, a slow conditional refresh, and a versioned route dictionary the browser caches. Shipped here: ids are never rewritten, and a cut one carries an ellipsis instead of reading as a typo. - Below-ground rendering. Subway vehicles are drawn on the surface directly above themselves, because the feeds carry no depth and a train nobody can see is not worth drawing; the card says so plainly. Real treatment wants OSM tunnel geometry per city and a ghosted sprite tethered to the surface. The owner recorded it and left it. Round four and after (2026-09-15). The owner tested round three under FLIR and NVG, and then asked for the design to be taken further: elegant in performance, beautiful on camera, trails with a cache for public transport, Cesium-native, and a core other moving layers can adopt. Codex Astra reviewed the plan (it confirmed the speed fault: a glide started from the drawn position at receipt time but spanned the gap between report times, so with MBTA reporting every ~10 s against a 15 s poll every second glide ran at 1.5x; it rejected a queued-segment patch), then wrote the design, then implemented it in commit-sized passes with adversarial review between them and real-browser harness runs by the integrator. What this PR now contains beyond the contributor's original port: - Delayed playback in a contact-agnostic core (`src/data/contactPlayback.js`): each vehicle keeps a typed, bounded fix history and a display clock that runs at one second per second through it, a feed-derived lag behind real time, holding at its newest fix rather than extrapolating and never sprinting to catch up. Marker, course, motion word, card and trail all read the same displayed sample. The clock has an explicit time-offset and seek API so a rewind is a later feature, not a rewrite; flights and vessels are untouched. - A selected-vehicle trail (`src/data/contactTrailRenderer.js`, `src/layers/transit/trails.js`): the retained history drawn with batched Cesium primitives, clipped at display time so the head is exactly the sprite, faded by age, in the mode colour (white under sensor presets), on the same surface path as the marker. - Bounded proxy-side history (`server/providers/transitHistory.js`): fifteen minutes and at most 128 fixes per vehicle with hard byte ceilings, MBTA only for now (opt-in per feed), served by a small endpoint that never fetches upstream, so a selected trail survives a browser reload while the server keeps running. - Scene work bounded to what is visible and moving, an occluder, cached DETECT candidates, holds on the render loop only while something visible moves. - Sensor presets: white-hot haloed sprites under NVG/FLIR/noir (black-hot inverts), saturated mode colours under CRT, per-mode bracket tiers in every theme, the selected card keeping its mode colour. - A discriminating browser harness (`scripts/qa-transit.mjs`) that samples postRender frames, reads the real card anchor, and measures fleet budgets at 800 and 3,000 vehicles. Not built, recorded as follow-ups: curated route display names (lowest priority per the owner), fleet-wide tails, route snapping, a rewind UI, adoption of the playback core by flights and vessels. Verified at merge (2026-09-15 morning): unit suite 3,896 passing, build clean, format and boundary gates clean, tracking regression 109/109, browser harness keyless 200 pass / 0 fail (13 sensor checks unexercised because the live view offered fewer than six isolated sprites; every judged sprite passed), keyed on Google 3D tiles 198 pass with the per-frame allocation gate as the only failure. Follow-ups recorded: the keyed build's playback sampler still allocates per frame (CPU and frame time stay within budget; the 3,000-vehicle metro view sits at a 27.6 ms frame interval against a 25 ms budget), the shared detection overlay's per-paint allocation, friendlier route names, fleet-wide tails, a rewind UI, and adopting the playback core for vessels and flights. Co-authored-by: spcpza <[email protected]>
This commit is contained in:
1 parent
1ad565c8af
commit
76c20be277
78 files changed
+19842
-186
No files matched your search
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
presetSpriteScale,
|
||||
presetSpriteOutlinePx,
|
||||
transitStyleProfile,
|
||||
} from './transitPresetStyle.js';
|
||||
// src/data/transitIcons.js
|
||||
/**
|
||||
* Front-up transit-vehicle silhouettes, one per mode, as SVG data URIs for
|
||||
* Cesium billboards.
|
||||
*
|
||||
* Same house language as `aircraftIcons.js`: a 96-unit viewBox with the glyph
|
||||
* centred at (48,48), drawn nose toward -Y so the screen-projected rotation in
|
||||
* `iconOrientation.js` can spin the whole glyph, filled WHITE with a dark
|
||||
* hairline edge so the tint pipeline (billboard.color = the mode colour, plus
|
||||
* `.withAlpha` fades) keeps working. No glyph carries a colour of its own; a
|
||||
* hardcoded fill would fight the tint that tells a person what they are looking
|
||||
* at.
|
||||
*
|
||||
* These are plan views, the way a vehicle reads from a camera above a street —
|
||||
* not side elevations. Each mode has a DISTINCT outline so it is recognisable
|
||||
* at the ~18 px the fleet renders at, by shape and not only by colour: a bus is
|
||||
* short and blunt, a tram is long and slender with articulation joints, a metro
|
||||
* car is squared off, a mainline train has a tapered nose, and a ferry is a
|
||||
* hull with a bow.
|
||||
*
|
||||
* Interior detail is drawn with the same dark hairline as the edge — windscreen
|
||||
* lines, articulation joints, a deck line — because a solid dark panel would
|
||||
* darken the tint and a white-on-white panel would vanish.
|
||||
*/
|
||||
|
||||
const VIEW = 96;
|
||||
const C = VIEW / 2; // 48 — glyph centre
|
||||
|
||||
/** Hairline dark edge, matching the aircraft glyph weight at this box size. */
|
||||
const STROKE =
|
||||
'stroke="rgba(0,0,0,0.34)" stroke-width="1.5" stroke-linejoin="round"';
|
||||
/** Interior panel lines: the same ink, thinner, capped so joints read cleanly. */
|
||||
const DETAIL =
|
||||
'stroke="rgba(0,0,0,0.30)" stroke-width="1.5" stroke-linecap="round" fill="none"';
|
||||
|
||||
/**
|
||||
* Each body is drawn in a centred frame (origin 0,0 = glyph centre), front
|
||||
* toward -Y, in the 96-unit space (half-extent up to ~45).
|
||||
*/
|
||||
const BODIES = {
|
||||
// ── Bus: short, blunt, square-shouldered. The shape everything else reads
|
||||
// against, and the one most people will see most often.
|
||||
bus: `
|
||||
<path d="M-17,-28 C-17,-32 -14,-34 0,-34 C14,-34 17,-32 17,-28
|
||||
L17,28 C17,32 14,34 0,34 C-14,34 -17,32 -17,28 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-11,-24 L11,-24" ${DETAIL}/>
|
||||
<path d="M-11,20 L11,20" ${DETAIL}/>`,
|
||||
|
||||
// ── Tram / light rail: longer and slimmer than a bus, with the two
|
||||
// articulation joints that make a modern low-floor tram unmistakable.
|
||||
tram: `
|
||||
<path d="M-10,-38 C-10,-41 -8,-42.5 0,-42.5 C8,-42.5 10,-41 10,-38
|
||||
L10,38 C10,41 8,42.5 0,42.5 C-8,42.5 -10,41 -10,38 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-10,-13 L10,-13" ${DETAIL}/>
|
||||
<path d="M-10,13 L10,13" ${DETAIL}/>
|
||||
<path d="M-8,-34 L8,-34" ${DETAIL}/>`,
|
||||
|
||||
// ── Metro / subway: squared off at both ends, wider than a tram, a single
|
||||
// cab line at the front. Reads as rolling stock, not a road vehicle.
|
||||
subway: `
|
||||
<path d="M-11.5,-40 L11.5,-40 L11.5,40 L-11.5,40 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-11.5,-32 L11.5,-32" ${DETAIL}/>
|
||||
<path d="M-11.5,0 L11.5,0" ${DETAIL}/>
|
||||
<path d="M-11.5,32 L11.5,32" ${DETAIL}/>`,
|
||||
|
||||
// ── Mainline / commuter rail: the tapered nose of an intercity set, longer
|
||||
// than anything else in the set.
|
||||
rail: `
|
||||
<path d="M0,-45 C5,-45 9.5,-41 10.5,-34 L10.5,40
|
||||
C10.5,43 8.5,44.5 0,44.5 C-8.5,44.5 -10.5,43 -10.5,40
|
||||
L-10.5,-34 C-9.5,-41 -5,-45 0,-45 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-9,-30 L9,-30" ${DETAIL}/>
|
||||
<path d="M-10.5,6 L10.5,6" ${DETAIL}/>`,
|
||||
|
||||
// ── Ferry: a hull. Pointed bow, square stern, superstructure amidships —
|
||||
// the only glyph in the set that is not a box on rails or rubber.
|
||||
ferry: `
|
||||
<path d="M0,-44 C6,-38 12,-26 12.5,-14 L12.5,34
|
||||
C12.5,37 11,38 0,38 C-11,38 -12.5,37 -12.5,34
|
||||
L-12.5,-14 C-12,-26 -6,-38 0,-44 Z"
|
||||
fill="white" ${STROKE}/>
|
||||
<path d="M-7.5,-6 L7.5,-6 L7.5,20 L-7.5,20 Z" ${DETAIL}/>`,
|
||||
|
||||
// ── Unknown: a plain rounded marker. Deliberately featureless, so an
|
||||
// unclassified vehicle never pretends to be a mode it is not.
|
||||
unknown: `
|
||||
<path d="M-10,-26 C-10,-30 -7,-32 0,-32 C7,-32 10,-30 10,-26
|
||||
L10,26 C10,30 7,32 0,32 C-7,32 -10,30 -10,26 Z"
|
||||
fill="white" ${STROKE}/>`,
|
||||
};
|
||||
|
||||
/** The modes this module can draw. */
|
||||
export const TRANSIT_ICON_KINDS = Object.freeze(Object.keys(BODIES));
|
||||
|
||||
const _iconCache = new Map();
|
||||
|
||||
const _b64 = (s) =>
|
||||
typeof btoa === 'function'
|
||||
? btoa(s)
|
||||
: Buffer.from(s, 'utf8').toString('base64');
|
||||
|
||||
/**
|
||||
* Fleet raster size. Same reasoning as the aircraft fleet glyphs: Cesium's
|
||||
* billboard atlas has no mipmaps, so a texture far larger than its on-screen
|
||||
* footprint is GPU-minified into mush. Transit billboards render around 14–20
|
||||
* CSS px, so 48 px of source covers the band on a Retina display with very
|
||||
* little minification.
|
||||
*/
|
||||
const FLEET_RASTER_PX = 48;
|
||||
/** The selected vehicle draws bigger and deserves a crisper source. */
|
||||
export const SELECTED_ICON_PX = 96;
|
||||
|
||||
/**
|
||||
* On-screen size the fleet glyph is authored for. A halo width asked for in
|
||||
* screen pixels is converted to viewBox units against this, so "two pixels of
|
||||
* ring" means two pixels on an eighteen-pixel billboard.
|
||||
*/
|
||||
export const FLEET_SCREEN_PX = 20;
|
||||
|
||||
/**
|
||||
* How a halo changes the glyph's frame. A thick outer stroke would clip at
|
||||
* the 96-unit box (the tram already reaches 42.5 of 48), so the viewBox is
|
||||
* padded by the stroke and the billboard has to grow by the same ratio to
|
||||
* keep the body its shipped size on screen.
|
||||
*
|
||||
* @param {number} haloScreenPx Ring width in final screen pixels (0 = none).
|
||||
* @param {number} [screenPx] Billboard size the ring is specified against.
|
||||
* @returns {{units: number, pad: number, ratio: number}} Stroke width in
|
||||
* viewBox units (the visible ring is half of it, outside the body), the
|
||||
* padding added on every side, and the frame growth factor to apply to the
|
||||
* billboard's width and height.
|
||||
*/
|
||||
export function haloFrame(haloScreenPx, screenPx = FLEET_SCREEN_PX) {
|
||||
const ring = Number.isFinite(haloScreenPx) ? Math.max(0, haloScreenPx) : 0;
|
||||
if (ring === 0) return { units: 0, pad: 0, ratio: 1 };
|
||||
// Half the stroke lies outside the body, so a ring of R px needs 2R of
|
||||
// stroke. One screen pixel is VIEW/screenPx units.
|
||||
const units = (2 * ring * VIEW) / Math.max(1, screenPx);
|
||||
const pad = Math.ceil(units / 2) + 1;
|
||||
return { units, pad, ratio: (VIEW + 2 * pad) / VIEW };
|
||||
}
|
||||
|
||||
/** Number of raster variants ever built — a bound a test can hold the cache to. */
|
||||
export function transitIconCacheSize() {
|
||||
return _iconCache.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data URI for one mode's silhouette, lazily built and cached per kind, size
|
||||
* and halo.
|
||||
*
|
||||
* With a halo, the body is drawn twice: first as a wide dark stroke, then as
|
||||
* the shipped white fill with its hairline edge on top, so the ring sits
|
||||
* OUTSIDE the silhouette and the interior detail is untouched. The dark ring
|
||||
* is what survives NVG's auto-gain and FLIR's luma mapping when the bright
|
||||
* core alone would merge with a bright road.
|
||||
*
|
||||
* @param {string} kind One of TRANSIT_ICON_KINDS; anything else draws `unknown`.
|
||||
* @param {number} [px] Raster size.
|
||||
* @param {{haloScreenPx?: number, screenPx?: number}} [options]
|
||||
* @returns {string} `data:image/svg+xml;base64,...`
|
||||
*/
|
||||
export function transitIcon(kind, px = FLEET_RASTER_PX, options = {}) {
|
||||
const k = BODIES[kind] ? kind : 'unknown';
|
||||
const selected = px > FLEET_RASTER_PX;
|
||||
px = selected ? SELECTED_ICON_PX : FLEET_RASTER_PX;
|
||||
const profile = transitStyleProfile(
|
||||
options.style ||
|
||||
(options.haloScreenPx >= 2
|
||||
? 'thermal'
|
||||
: options.haloScreenPx === 1.25
|
||||
? 'retro'
|
||||
: 'normal'),
|
||||
);
|
||||
const style =
|
||||
profile === 'mono' ? 'thermal' : profile === 'crt' ? 'retro' : 'normal';
|
||||
const displayPx = (selected ? 30 : 20) * presetSpriteScale(style, selected);
|
||||
const frame = haloFrame(presetSpriteOutlinePx(style, selected), displayPx);
|
||||
const key = `${k}:${selected ? 1 : 0}:${profile}`;
|
||||
let uri = _iconCache.get(key);
|
||||
if (!uri) {
|
||||
const size = VIEW + 2 * frame.pad;
|
||||
// Sensor signatures use a broad rounded envelope, without dark interior
|
||||
// joints or a second outline eating into the hot core at display resolution.
|
||||
const halfW = k === 'bus' || k === 'ferry' || k === 'unknown' ? 48 : 47;
|
||||
const halfH = 48;
|
||||
const radius = k === 'subway' ? 8 : k === 'rail' ? 12 : 14;
|
||||
const capsule = `<path d="M${-halfW + radius},${-halfH} H${halfW - radius} Q${halfW},${-halfH} ${halfW},${-halfH + radius} V${halfH - radius} Q${halfW},${halfH} ${halfW - radius},${halfH} H${-halfW + radius} Q${-halfW},${halfH} ${-halfW},${halfH - radius} V${-halfH + radius} Q${-halfW},${-halfH} ${-halfW + radius},${-halfH} Z" fill="white" />`;
|
||||
const body = profile === 'mono' ? capsule : BODIES[k];
|
||||
const halo =
|
||||
frame.units > 0
|
||||
? body.replace(
|
||||
/<path d="([^"]+)"\s+fill="white" [^/]*\/>/,
|
||||
(_m, d) =>
|
||||
`<path d="${d}" fill="none" stroke="${profile === 'mono' ? '#000000' : '#05080C'}" stroke-opacity="${profile === 'mono' ? 1 : 0.95}" stroke-width="${frame.units.toFixed(2)}" stroke-linejoin="round"/>`,
|
||||
)
|
||||
: '';
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(px * frame.ratio)}" height="${Math.round(px * frame.ratio)}" viewBox="${-frame.pad} ${-frame.pad} ${size} ${size}">` +
|
||||
`<g transform="translate(${C},${C})">${halo}${body}</g></svg>`;
|
||||
uri = 'data:image/svg+xml;base64,' + _b64(svg);
|
||||
_iconCache.set(key, uri);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
Reference in new issue
Block a user