fix(cockpit): a provider's subject rides where it is drawn, and the documented provider rides a moving train

- A frame with no speedMps gets an exact anchor: an inertial one ran on at
  zero speed and closed at 0.75 m/s, so the camera fell kilometres behind a
  moving train. The docs example now passes speedMps and anchor: 'exact',
  a name for its HUD, and isEntryAllowed for a train bound to its line.
- Transit's snapshot height is the floor where playback draws the vehicle,
  not the newest report's floor uphill, and null while that floor is cold,
  so a view waits for the surface instead of standing on the ellipsoid.
- Rail's direction follows playback once it has placed the train: a report
  that runs back (a reversal, or GPS jitter) no longer turns the view
  while the drawn train still runs forward.
- A frame's id is trimmed as the candidate's is.
- A double-click on the selected vessel no longer enters an application's
  subject that took entry after it.
- The docs import from gods-eye-view/..., the package's name.
This commit is contained in:
Sameh Khamis committed 2026-11-13 18:23:50 -08:00
1 parent 974023510f
commit 4bb7b411e9
10 files changed
+161 -21

No files matched your search

+9 -3
View File
@@ -252,8 +252,8 @@ the last; a worded member (labels, entry face, look) that throws keeps its
last good words.
```js
import { getLocale, onLocaleChange } from '@gods-eye-view/core/i18n';
import { pointAtS } from '@gods-eye-view/core/layers/rail/paths';
import { getLocale, onLocaleChange } from 'gods-eye-view/i18n';
import { pointAtS } from 'gods-eye-view/layers/rail/paths';
const words = {
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
@@ -281,6 +281,8 @@ const ride = {
}
);
},
// Only a train bound to its line can be ridden: read() needs its path.
isEntryAllowed: () => Boolean(rail.getSelectedTrain()?.path),
hold: (id) => rail.setCockpitSubject(id),
read(nowMs, ctx) {
const train = rail.getCockpitSubject();
@@ -291,8 +293,12 @@ const ride = {
latitude: point.lat,
longitude: point.lon,
heightM: train.position.railHeightM, // null stands it on the shared floor
name: train.label,
headingDeg:
train.path.direction < 0 ? point.bearingDeg + 180 : point.bearingDeg,
speedMps: train.motion.reportedSpeedMps ?? train.motion.derivedSpeedMps,
// The layer already moves the train smoothly: sit on it.
anchor: 'exact',
eye: { upM: 3.5 },
};
},
@@ -309,7 +315,7 @@ createApplicationControls({ /* ... */ services: { cockpitSubjects: [ride] } });
The `./i18n` entry gives `getLocale()` and `onLocaleChange()`:
Core translates only its own strings, so a provider words its labels, entry
face and HUD for the page's language (labels may be functions, as above; the
HUD is read again on a change). `@gods-eye-view/core/layers/rail/paths` is the
HUD is read again on a change). `gods-eye-view/layers/rail/paths` is the
Rail layer's pure path module (`createRailPath`, `pointAtS`, `projectOnPath`)
for a provider placing a train along its line. The frame, HUD view and
candidate fields are documented in `src/cockpitSubjectProviders.js`; the Rail
+9 -4
View File
@@ -2241,7 +2241,8 @@ at most `COCKPIT_GROUND_WAIT_TIMEOUT_MS`, after which the view ends and the
subject is released: no height stands in for a floor never read), slews toward `headingDeg` at the provider's rate, and may
turn off the floor clamps (`groundClamp: false`, for a subject inside a
structure) or keep the anchor exactly on the subject (`anchor: 'exact'`, for a
layer that already moves it smoothly); `cameraIntervalMs: 0` updates the
layer that already moves it smoothly; a frame with no `speedMps` is always
exact, since an inertial anchor runs on at the frame's speed); `cameraIntervalMs: 0` updates the
camera every rendered frame. A frame that is null, malformed or for another
subject ends the view (`restoreTracking: false`), as an aircraft tracked
meanwhile and the provider's layer turning off do; the exit control asks the
@@ -2252,7 +2253,7 @@ picture under the HUD (with passing light streaks, which stop under reduced
motion), arrow-key look-around when the provider asks for it, and a progress
line along the bottom edge (`src/cockpitProgress.js`: close ticks merge,
labels never overlap). Core translates none of the provider's text: a host
words it for the page's language (`@gods-eye-view/core/i18n`:
words it for the page's language (`gods-eye-view/i18n`:
`getLocale`, `onLocaleChange`). Its entry face, labels and look may be
functions, read again when they are shown and on a change of language, as the
HUD view is.
@@ -2268,14 +2269,18 @@ feed held, clicks and Escape left to the view; the held copy stays the one
drawn of its train while held, another feed's copy hidden, and it keeps
moving, still selected, when its category is turned off) and
`focusTrain(key)` (false for a train the layer does not draw); its pure path
module is exported as `@gods-eye-view/core/layers/rail/paths`. That export
module is exported as `gods-eye-view/layers/rail/paths`. That export
and these read APIs are experimental and may change without notice. A snapshot read again while nothing in it changed is
the one read before, and its `path.line` is a frozen copy. Transit
offers the same for a vehicle (`getSelectedVehicle`, `getCockpitSubject`,
`setCockpitSubject`, `focusVehicle`): a held vehicle keeps playing when it is
out of the camera's view, and its sprite, 3D model, card, trail and detection
box are hidden; released, it is out of play until a visibility sweep finds it in
view. `docs/APPLICATION.md` (Cockpit subjects) shows a minimal provider. `src/ui/cockpitSubjectView.test.mjs`,
view. Its snapshot's `position.groundHeightM` is the floor where playback draws
it (its segment's two reports' floors at the playback fraction), not the newest
report's, and null while that floor is unknown. Rail's `path.direction` is the
way the drawn train runs: once playback has placed it, a report that runs back
turns it only when playback reaches that report. `docs/APPLICATION.md` (Cockpit subjects) shows a minimal provider. `src/ui/cockpitSubjectView.test.mjs`,
`src/cockpitSubjectProviders.test.mjs`, `src/cockpitProgress.test.mjs`,
`src/layers/rail/cockpitSubject.test.mjs` and the Transit lifecycle tests
cover them.
+11 -4
View File
@@ -337,7 +337,9 @@ function candidateOf(raw) {
* A `heightM` that is not finite or lies outside
* COCKPIT_SUBJECT_MIN_HEIGHT_M..COCKPIT_SUBJECT_MAX_HEIGHT_M reads as null:
* the eye then stands on the shared floor, or where it last stood while that
* is cold.
* is cold. The anchor is `'inertial'` only when asked for and the frame
* gives a speed; otherwise it is `'exact'`. An id is compared with `heldId`
* trimmed, as `cockpitSubjectCandidate` trims it.
* @param {unknown} raw What `read()` returned.
* @param {string} heldId The subject the view holds.
* @returns {object|null} `{id, name, latitude, longitude, heightM, headingDeg,
@@ -354,7 +356,8 @@ export function cockpitSubjectFrame(raw, heldId) {
}
function frameOf(raw, heldId) {
if (!isObject(raw) || raw.id !== heldId) return null;
if (!isObject(raw) || typeof raw.id !== 'string' || raw.id.trim() !== heldId)
return null;
const latitude = raw.latitude;
const longitude = raw.longitude;
if (!finite(latitude) || !finite(longitude) || Math.abs(latitude) > 90)
@@ -364,6 +367,8 @@ function frameOf(raw, heldId) {
const heading = (value) =>
finite(value) ? ((value % 360) + 360) % 360 : null;
const headingDeg = heading(raw.headingDeg);
const speedMps =
finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null;
return {
id: heldId,
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(heldId, 64),
@@ -377,10 +382,12 @@ function frameOf(raw, heldId) {
: null,
headingDeg,
courseDeg: heading(raw.courseDeg) ?? headingDeg,
speedMps: finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null,
speedMps,
stale: raw.stale === true,
groundClamp: raw.groundClamp !== false,
anchor: raw.anchor === 'exact' ? 'exact' : 'inertial',
// An inertial anchor runs on at the frame's speed; with none it would
// close on a moving subject at the correction's floor and fall behind.
anchor: raw.anchor === 'exact' || speedMps === null ? 'exact' : 'inertial',
eye: {
upM: eye.upM,
forwardM:
+13 -2
View File
@@ -196,9 +196,17 @@ test('a frame is the held subject at a finite place with an eye height, else nul
speedMps: null,
stale: false,
groundClamp: true,
anchor: 'inertial',
// No speed to run an inertial anchor on: it sits on the subject.
anchor: 'exact',
eye: { upM: 3.5, forwardM: 0, pitchDeg: 0, minClearanceM: 0 },
});
assert.equal(
cockpitSubjectFrame(frame({ speedMps: 30 }), 'a1').anchor,
'inertial',
'with a speed it runs on',
);
// A held id is trimmed (cockpitSubjectCandidate), and so is a frame's.
assert.equal(cockpitSubjectFrame(frame({ id: ' a1 ' }), 'a1')?.id, 'a1');
for (const bad of [
null,
frame({ id: 'other' }),
@@ -253,7 +261,10 @@ test('a frame is the held subject at a finite place with an eye height, else nul
'a1',
);
assert.equal(loose.groundClamp, true);
assert.equal(loose.anchor, 'inertial');
assert.equal(
cockpitSubjectFrame(frame({ anchor: 'EXACT', speedMps: 5 }), 'a1').anchor,
'inertial',
);
assert.equal(loose.courseDeg, 90);
assert.equal(loose.speedMps, null);
});
+16 -5
View File
@@ -63,10 +63,15 @@ function createBinding(shapeIndex, path, via, s, fix) {
path,
via,
/**
* Which way the train runs along the shape: +1 along it, -1 against it,
* 0 until two reports or a playback segment tell. Kept until a rebind.
* Which way the drawn train runs along the shape: +1 along it, -1
* against it, 0 until two reports or a playback segment tell. Kept until
* a rebind. Once playback has placed the train, only playback changes
* it: the newest reports run ahead of the drawn train, so a reversal
* they show turns it when playback reaches it.
*/
direction: 0,
/** The direction the last placed playback segment ran, 0 before one. */
drawDirection: 0,
/** Arc and time of the newest report. */
fixS: s,
fixT: fix.t,
@@ -154,7 +159,8 @@ export function followFix(binding, fix) {
pathWindowM(fix.t - binding.fixT),
);
if (!hit || hit.offsetM > PATH_OFF_TRACK_M) return false;
binding.direction = directionOf(hit.s - binding.fixS, binding.direction);
if (!binding.drawDirection)
binding.direction = directionOf(hit.s - binding.fixS, binding.direction);
binding.fixS = hit.s;
binding.fixT = fix.t;
binding.fixLat = fix.lat;
@@ -246,8 +252,13 @@ export function placeOnPath(binding, sample, segment, out) {
cache.sFrom = sFrom;
cache.sTo = sTo;
cache.ok = Number.isFinite(sFrom) && Number.isFinite(sTo);
if (cache.ok)
binding.direction = directionOf(sTo - sFrom, binding.direction ?? 0);
if (cache.ok) {
binding.drawDirection = directionOf(
sTo - sFrom,
binding.drawDirection || binding.direction || 0,
);
binding.direction = binding.drawDirection;
}
}
if (!cache.ok) return false;
const f = Number.isFinite(sample.fraction)
+28
View File
@@ -234,3 +234,31 @@ test('a binding learns which way its train runs, and keeps it through a stop', (
);
assert.equal(rebound?.direction ?? 0, 0);
});
test('a report that runs back turns the drawn train only when playback does', () => {
const p = pack();
const ids = { tripId: 'full', routeId: 'main' };
const binding = bindTrainPath(p, ids, fix(0, 300, 0));
const forward = {
from: { seq: 1, ...fix(0, 300, 0) },
to: { seq: 2, ...fix(10_000, 500, 0) },
};
const sample = (fraction) => ({ fromSeq: 1, toSeq: 2, fraction });
assert.ok(placeOnPath(binding, sample(0.2), forward, {}));
assert.equal(binding.direction, 1);
// The newest report lies behind the last one (a reversal, or GPS jitter)
// while playback still draws the forward segment.
assert.ok(followFix(binding, fix(10_000, 500, 0)));
assert.ok(followFix(binding, fix(20_000, 450, 0)));
assert.ok(placeOnPath(binding, sample(0.6), forward, {}));
assert.equal(binding.direction, 1, 'the drawn train still runs forward');
// Playback reaches the reversed segment: now it turns.
const back = {
from: { seq: 2, ...fix(10_000, 500, 0) },
to: { seq: 3, ...fix(20_000, 450, 0) },
};
assert.ok(
placeOnPath(binding, { fromSeq: 2, toSeq: 3, fraction: 0.5 }, back, {}),
);
assert.equal(binding.direction, -1);
});
+20 -3
View File
@@ -77,6 +77,25 @@ export function createTransitCockpitSubject({ state, parts, services }) {
return reuse(previous, next);
}
/**
* The floor under the vehicle where playback draws it: its segment's two
* reports' floors at the playback fraction, without the shared lift. Null
* while either floor is unknown, as on cold terrain, so a view waits for
* the surface. Not `entry.heightM`, the floor at the newest report, which
* playback has not reached yet.
*/
function drawnFloorOf(entry) {
const segment = entry.segment;
if (!segment || entry.heightPending) return null;
const from = segment.from?.h;
const to = segment.to?.h;
if (!Number.isFinite(from) || !Number.isFinite(to)) return null;
const f = Number.isFinite(segment.fraction)
? Math.max(0, Math.min(1, segment.fraction))
: 1;
return from + (to - from) * f - liftM;
}
/** The vehicle as a frozen snapshot (see the module comment), or null. */
function vehicleView(entry) {
if (!entry?.record) return null;
@@ -91,9 +110,7 @@ export function createTransitCockpitSubject({ state, parts, services }) {
const position = draft.position;
position.latitude = finiteOrNull(sample?.lat) ?? record.lat;
position.longitude = finiteOrNull(sample?.lon) ?? record.lon;
position.groundHeightM = Number.isFinite(entry.heightM)
? entry.heightM - liftM
: null;
position.groundHeightM = drawnFloorOf(entry);
position.atMs = finiteOrNull(sample?.displayT);
const motion = draft.motion;
+30
View File
@@ -753,6 +753,27 @@ test('disable aborts in-flight work and a response that lands late is ignored',
assert.equal(app.vehicles().length, 1);
});
test('a held vehicle with no floor yet tells its view no height', async (t) => {
// Not the ellipsoid: a first-person view waits for the surface instead.
const app = harness(t, { floorAt: () => undefined });
app.serve('mbta', () => ({
status: 200,
body: snapshot(
'mbta',
'MBTA',
[vehicle('bus-1', 42.36, -71.06, Date.now() / 1000)],
{ fetchedAt: Date.now() },
),
}));
app.layer.enable(app.viewer);
await app.layer.update();
app.settle();
const entry = app.vehicles()[0];
assert.equal(entry.heightPending, true, 'it has nothing to stand on');
assert.ok(app.layer.setCockpitSubject(entry.key) !== false);
assert.equal(app.layer.getCockpitSubject().position.groundHeightM, null);
});
test('a vehicle waits for its floor rather than standing on the ellipsoid', async (t) => {
// The shared ground-floor system answers from the rendered mesh cell or the
// real DEM cell; until one of them is warm there is no honest height to draw
@@ -1800,11 +1821,19 @@ test('a segment climbs from the ground it left to the ground it reaches', async
assert.ok(Math.abs(entry.heightM - 101.5) < 1, 'the destination is uphill');
const height = () =>
Cesium.Cartographic.fromCartesian(entry.marker.position).height;
// A first-person view reads the floor where the vehicle is drawn, not the
// newest report's floor uphill.
app.layer.setCockpitSubject(entry.key);
const floor = () => app.layer.getCockpitSubject().position.groundHeightM;
app.run(400);
assert.ok(
height() < 30,
`still on the ground it is drawn on, got ${height().toFixed(1)} m`,
);
assert.ok(
Math.abs(floor() + 1.5 - height()) < 1,
`its snapshot stands where it is drawn, got ${floor().toFixed(1)} m`,
);
// Halfway along the segment it is halfway up; at the end, on the high ground.
const firstFixAt = start - AGE_MS;
@@ -1818,6 +1847,7 @@ test('a segment climbs from the ground it left to the ground it reaches', async
Math.abs(height() - 101.5) < 1.5,
`and arrives on the high ground, got ${height().toFixed(1)} m`,
);
assert.ok(Math.abs(floor() - 100) < 1.5, 'and so does its snapshot');
});
test('a lone impossible fix leaves the displayed vehicle and health untouched', async (t) => {
const app = harness(t, { floorAt: () => 5 });
+3
View File
@@ -41,6 +41,9 @@ function subjectWorldPosition(kind) {
}
if (kind === 'satellite')
return this.services.satellitesLayer?.getCockpitPosition?.() ?? null;
// An application's subject has no position before its view is entered:
// the selected vessel's sprite must not stand in for it.
if (kind !== 'vessel') return null;
const info = this.readVesselInfo();
if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude))
return null;
+22
View File
@@ -1114,6 +1114,28 @@ test('a subject first seen together with a built-in one comes after it', async (
assert.equal(store.presence.entryKind, 'vessel');
});
test("a double-click on the selected vessel does not enter an application's subject", async (t) => {
const clock = manualClock(t, 10_000);
const ride = subjectProvider();
ride.subject.candidate = null;
const { cockpit, store, viewer } = fixture(t, {
providers: [ride],
vessel: VESSEL,
});
cockpit.syncEntry();
// The application's subject is selected after the vessel: it takes entry.
clock.advance(500);
ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 1 };
cockpit.syncEntry();
assert.equal(cockpit.resolveEntryKind(), 'example-kind');
// Every world position lands under the pointer, the vessel's sprite too.
viewer.scene.cartesianToCanvasCoordinates = (_position, result = {}) =>
Object.assign(result, { x: 400, y: 300 });
assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }), false);
await settle();
assert.notEqual(store.presence.active, true);
});
test('a provider view with look turns with the arrow keys and words its readout', async (t) => {
const clock = manualClock(t, 10_000);
const ride = subjectProvider();