mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 07:12:21 +00:00
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:
1 parent
974023510f
commit
4bb7b411e9
10 files changed
+161
-21
No files matched your search
+9
-3
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user