diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index d0f8ad579..3aa85f72c 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 54fdedbe0..9627e41d7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/src/cockpitSubjectProviders.js b/src/cockpitSubjectProviders.js index e7a8153b3..88b91e62c 100644 --- a/src/cockpitSubjectProviders.js +++ b/src/cockpitSubjectProviders.js @@ -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: diff --git a/src/cockpitSubjectProviders.test.mjs b/src/cockpitSubjectProviders.test.mjs index fb97b93b6..2063cca41 100644 --- a/src/cockpitSubjectProviders.test.mjs +++ b/src/cockpitSubjectProviders.test.mjs @@ -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); }); diff --git a/src/layers/rail/pathBinding.js b/src/layers/rail/pathBinding.js index eb407d1d3..9eba452d3 100644 --- a/src/layers/rail/pathBinding.js +++ b/src/layers/rail/pathBinding.js @@ -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) diff --git a/src/layers/rail/pathBinding.test.mjs b/src/layers/rail/pathBinding.test.mjs index 79aee4bce..7b9e11c0c 100644 --- a/src/layers/rail/pathBinding.test.mjs +++ b/src/layers/rail/pathBinding.test.mjs @@ -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); +}); diff --git a/src/layers/transit/cockpitSubject.js b/src/layers/transit/cockpitSubject.js index b0e8a64c0..556c50ab8 100644 --- a/src/layers/transit/cockpitSubject.js +++ b/src/layers/transit/cockpitSubject.js @@ -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; diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 69b752b99..9f0bae557 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -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 }); diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index c3a982f92..9574068ce 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -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; diff --git a/src/ui/cockpitSubjectView.test.mjs b/src/ui/cockpitSubjectView.test.mjs index fd0290c4f..e8549442f 100644 --- a/src/ui/cockpitSubjectView.test.mjs +++ b/src/ui/cockpitSubjectView.test.mjs @@ -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();