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
+160 -20

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. last good words.
```js ```js
import { getLocale, onLocaleChange } from '@gods-eye-view/core/i18n'; import { getLocale, onLocaleChange } from 'gods-eye-view/i18n';
import { pointAtS } from '@gods-eye-view/core/layers/rail/paths'; import { pointAtS } from 'gods-eye-view/layers/rail/paths';
const words = { const words = {
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' }, 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), hold: (id) => rail.setCockpitSubject(id),
read(nowMs, ctx) { read(nowMs, ctx) {
const train = rail.getCockpitSubject(); const train = rail.getCockpitSubject();
@@ -291,8 +293,12 @@ const ride = {
latitude: point.lat, latitude: point.lat,
longitude: point.lon, longitude: point.lon,
heightM: train.position.railHeightM, // null stands it on the shared floor heightM: train.position.railHeightM, // null stands it on the shared floor
name: train.label,
headingDeg: headingDeg:
train.path.direction < 0 ? point.bearingDeg + 180 : point.bearingDeg, 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 }, eye: { upM: 3.5 },
}; };
}, },
@@ -309,7 +315,7 @@ createApplicationControls({ /* ... */ services: { cockpitSubjects: [ride] } });
The `./i18n` entry gives `getLocale()` and `onLocaleChange()`: The `./i18n` entry gives `getLocale()` and `onLocaleChange()`:
Core translates only its own strings, so a provider words its labels, entry 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 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`) Rail layer's pure path module (`createRailPath`, `pointAtS`, `projectOnPath`)
for a provider placing a train along its line. The frame, HUD view and for a provider placing a train along its line. The frame, HUD view and
candidate fields are documented in `src/cockpitSubjectProviders.js`; the Rail 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 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 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 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 camera every rendered frame. A frame that is null, malformed or for another
subject ends the view (`restoreTracking: false`), as an aircraft tracked subject ends the view (`restoreTracking: false`), as an aircraft tracked
meanwhile and the provider's layer turning off do; the exit control asks the 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 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, line along the bottom edge (`src/cockpitProgress.js`: close ticks merge,
labels never overlap). Core translates none of the provider's text: a host 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 `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 functions, read again when they are shown and on a change of language, as the
HUD view is. 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 drawn of its train while held, another feed's copy hidden, and it keeps
moving, still selected, when its category is turned off) and moving, still selected, when its category is turned off) and
`focusTrain(key)` (false for a train the layer does not draw); its pure path `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 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 the one read before, and its `path.line` is a frozen copy. Transit
offers the same for a vehicle (`getSelectedVehicle`, `getCockpitSubject`, offers the same for a vehicle (`getSelectedVehicle`, `getCockpitSubject`,
`setCockpitSubject`, `focusVehicle`): a held vehicle keeps playing when it is `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 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 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/cockpitSubjectProviders.test.mjs`, `src/cockpitProgress.test.mjs`,
`src/layers/rail/cockpitSubject.test.mjs` and the Transit lifecycle tests `src/layers/rail/cockpitSubject.test.mjs` and the Transit lifecycle tests
cover them. cover them.
+11 -4
View File
@@ -337,7 +337,9 @@ function candidateOf(raw) {
* A `heightM` that is not finite or lies outside * A `heightM` that is not finite or lies outside
* COCKPIT_SUBJECT_MIN_HEIGHT_M..COCKPIT_SUBJECT_MAX_HEIGHT_M reads as null: * 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 * 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 {unknown} raw What `read()` returned.
* @param {string} heldId The subject the view holds. * @param {string} heldId The subject the view holds.
* @returns {object|null} `{id, name, latitude, longitude, heightM, headingDeg, * @returns {object|null} `{id, name, latitude, longitude, heightM, headingDeg,
@@ -354,7 +356,8 @@ export function cockpitSubjectFrame(raw, heldId) {
} }
function frameOf(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 latitude = raw.latitude;
const longitude = raw.longitude; const longitude = raw.longitude;
if (!finite(latitude) || !finite(longitude) || Math.abs(latitude) > 90) if (!finite(latitude) || !finite(longitude) || Math.abs(latitude) > 90)
@@ -364,6 +367,8 @@ function frameOf(raw, heldId) {
const heading = (value) => const heading = (value) =>
finite(value) ? ((value % 360) + 360) % 360 : null; finite(value) ? ((value % 360) + 360) % 360 : null;
const headingDeg = heading(raw.headingDeg); const headingDeg = heading(raw.headingDeg);
const speedMps =
finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null;
return { return {
id: heldId, id: heldId,
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(heldId, 64), name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(heldId, 64),
@@ -377,10 +382,12 @@ function frameOf(raw, heldId) {
: null, : null,
headingDeg, headingDeg,
courseDeg: heading(raw.courseDeg) ?? headingDeg, courseDeg: heading(raw.courseDeg) ?? headingDeg,
speedMps: finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null, speedMps,
stale: raw.stale === true, stale: raw.stale === true,
groundClamp: raw.groundClamp !== false, 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: { eye: {
upM: eye.upM, upM: eye.upM,
forwardM: 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, speedMps: null,
stale: false, stale: false,
groundClamp: true, 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 }, 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 [ for (const bad of [
null, null,
frame({ id: 'other' }), 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', 'a1',
); );
assert.equal(loose.groundClamp, true); 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.courseDeg, 90);
assert.equal(loose.speedMps, null); assert.equal(loose.speedMps, null);
}); });
+15 -4
View File
@@ -63,10 +63,15 @@ function createBinding(shapeIndex, path, via, s, fix) {
path, path,
via, via,
/** /**
* Which way the train runs along the shape: +1 along it, -1 against it, * Which way the drawn train runs along the shape: +1 along it, -1
* 0 until two reports or a playback segment tell. Kept until a rebind. * 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, direction: 0,
/** The direction the last placed playback segment ran, 0 before one. */
drawDirection: 0,
/** Arc and time of the newest report. */ /** Arc and time of the newest report. */
fixS: s, fixS: s,
fixT: fix.t, fixT: fix.t,
@@ -154,6 +159,7 @@ export function followFix(binding, fix) {
pathWindowM(fix.t - binding.fixT), pathWindowM(fix.t - binding.fixT),
); );
if (!hit || hit.offsetM > PATH_OFF_TRACK_M) return false; if (!hit || hit.offsetM > PATH_OFF_TRACK_M) return false;
if (!binding.drawDirection)
binding.direction = directionOf(hit.s - binding.fixS, binding.direction); binding.direction = directionOf(hit.s - binding.fixS, binding.direction);
binding.fixS = hit.s; binding.fixS = hit.s;
binding.fixT = fix.t; binding.fixT = fix.t;
@@ -246,8 +252,13 @@ export function placeOnPath(binding, sample, segment, out) {
cache.sFrom = sFrom; cache.sFrom = sFrom;
cache.sTo = sTo; cache.sTo = sTo;
cache.ok = Number.isFinite(sFrom) && Number.isFinite(sTo); cache.ok = Number.isFinite(sFrom) && Number.isFinite(sTo);
if (cache.ok) if (cache.ok) {
binding.direction = directionOf(sTo - sFrom, binding.direction ?? 0); binding.drawDirection = directionOf(
sTo - sFrom,
binding.drawDirection || binding.direction || 0,
);
binding.direction = binding.drawDirection;
}
} }
if (!cache.ok) return false; if (!cache.ok) return false;
const f = Number.isFinite(sample.fraction) 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); 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); 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. */ /** The vehicle as a frozen snapshot (see the module comment), or null. */
function vehicleView(entry) { function vehicleView(entry) {
if (!entry?.record) return null; if (!entry?.record) return null;
@@ -91,9 +110,7 @@ export function createTransitCockpitSubject({ state, parts, services }) {
const position = draft.position; const position = draft.position;
position.latitude = finiteOrNull(sample?.lat) ?? record.lat; position.latitude = finiteOrNull(sample?.lat) ?? record.lat;
position.longitude = finiteOrNull(sample?.lon) ?? record.lon; position.longitude = finiteOrNull(sample?.lon) ?? record.lon;
position.groundHeightM = Number.isFinite(entry.heightM) position.groundHeightM = drawnFloorOf(entry);
? entry.heightM - liftM
: null;
position.atMs = finiteOrNull(sample?.displayT); position.atMs = finiteOrNull(sample?.displayT);
const motion = draft.motion; 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); 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) => { 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 // 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 // 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'); assert.ok(Math.abs(entry.heightM - 101.5) < 1, 'the destination is uphill');
const height = () => const height = () =>
Cesium.Cartographic.fromCartesian(entry.marker.position).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); app.run(400);
assert.ok( assert.ok(
height() < 30, height() < 30,
`still on the ground it is drawn on, got ${height().toFixed(1)} m`, `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. // Halfway along the segment it is halfway up; at the end, on the high ground.
const firstFixAt = start - AGE_MS; 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, Math.abs(height() - 101.5) < 1.5,
`and arrives on the high ground, got ${height().toFixed(1)} m`, `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) => { test('a lone impossible fix leaves the displayed vehicle and health untouched', async (t) => {
const app = harness(t, { floorAt: () => 5 }); const app = harness(t, { floorAt: () => 5 });
+3
View File
@@ -41,6 +41,9 @@ function subjectWorldPosition(kind) {
} }
if (kind === 'satellite') if (kind === 'satellite')
return this.services.satellitesLayer?.getCockpitPosition?.() ?? null; 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(); const info = this.readVesselInfo();
if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude)) if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude))
return null; 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'); 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) => { test('a provider view with look turns with the arrow keys and words its readout', async (t) => {
const clock = manualClock(t, 10_000); const clock = manualClock(t, 10_000);
const ride = subjectProvider(); const ride = subjectProvider();