mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 07:12:21 +00:00
fix(cockpit): application subjects follow a change of language, never stand on the ellipsoid, and are guarded on every call; a re-selected subject takes entry again
- Labels, entry face and look may be functions, read again on every use and on a locale change; a failing read keeps the last good value. - A frame with no height keeps the eye's last height, or waits (surfaceAcquiring) until the floor is known; heights outside -1,000..50,000 m read as null. - Provider methods are synchronous: a returned promise is refused like a throw, and a hold that fails is released. - A candidate may carry selectedAt; Rail and Transit stamp selectedAtMs on every selection, so re-selecting moves a subject to the front. - A released Transit vehicle leaves the visible set at once. - docs/CURRENT-STATE.md states the new entry order; docs/APPLICATION.md documents cockpit subjects, ./i18n/locale and ./layers/rail/paths.
This commit is contained in:
1 parent
01a74fceec
commit
57d87d2bbf
18 files changed
+707
-199
No files matched your search
@@ -228,3 +228,81 @@ view name is an error. A host that does not initialize Core's locale passes
|
||||
`localePicker: false` to `createApplicationControls` instead of editing the
|
||||
markup. Node tests that import application modules register the component
|
||||
loader with `node --import=<core package>/build/register-vue`.
|
||||
|
||||
### Cockpit subjects
|
||||
|
||||
Core's Cockpit rides a tracked aircraft, the tracked satellite and the
|
||||
selected AIS vessel. An application adds kinds of its own with
|
||||
`services.cockpitSubjects` on `createApplicationControls`: a list of
|
||||
providers, checked once when the controls are built
|
||||
(`src/cockpitSubjectProviders.js`). A malformed provider is left out with a
|
||||
warning. Entry rides the most recently selected subject of all kinds and needs
|
||||
the provider's `layerId` on; the provider then holds its subject, hands the
|
||||
camera a frame and the generic HUD a view model on every update, and releases
|
||||
it on exit. Provider methods are synchronous; one that throws or returns a
|
||||
promise is treated as having failed.
|
||||
|
||||
```js
|
||||
import { getLocale, onLocaleChange } from '@gods-eye-view/core/i18n/locale';
|
||||
import { pointAtS } from '@gods-eye-view/core/layers/rail/paths';
|
||||
|
||||
const words = {
|
||||
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
|
||||
de: {
|
||||
view: 'Fahrtansicht',
|
||||
exit: 'Fahrtansicht verlassen',
|
||||
exitText: 'VERLASSEN',
|
||||
},
|
||||
};
|
||||
|
||||
/** Ride the Rail layer's selected train; `rail` is its layer instance. */
|
||||
const ride = {
|
||||
kind: 'example-ride',
|
||||
layerId: 'rail',
|
||||
entryFace: { icon: 'train', label: 'RIDE' },
|
||||
// A function is read again when the page's language changes.
|
||||
labels: () => words[getLocale()] ?? words.en,
|
||||
candidate() {
|
||||
const train = rail.getSelectedTrain();
|
||||
return (
|
||||
train && {
|
||||
id: train.key,
|
||||
name: train.label,
|
||||
selectedAt: train.selectedAtMs,
|
||||
}
|
||||
);
|
||||
},
|
||||
hold: (id) => rail.setCockpitSubject(id),
|
||||
read(nowMs, ctx) {
|
||||
const train = rail.getCockpitSubject();
|
||||
if (!train?.path) return null; // ends the view
|
||||
const point = pointAtS(train.path.line, train.path.s);
|
||||
return {
|
||||
id: train.key,
|
||||
latitude: point.lat,
|
||||
longitude: point.lon,
|
||||
heightM: train.position.railHeightM, // null stands it on the shared floor
|
||||
headingDeg:
|
||||
train.path.direction < 0 ? point.bearingDeg + 180 : point.bearingDeg,
|
||||
eye: { upM: 3.5 },
|
||||
};
|
||||
},
|
||||
hud: (frame) => ({ title: frame.name }),
|
||||
release(id, { restore }) {
|
||||
rail.setCockpitSubject(null);
|
||||
if (restore) rail.focusTrain(id);
|
||||
},
|
||||
};
|
||||
|
||||
createApplicationControls({ /* ... */ services: { cockpitSubjects: [ride] } });
|
||||
```
|
||||
|
||||
`@gods-eye-view/core/i18n/locale` 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
|
||||
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
|
||||
and Transit read APIs in `src/layers/rail/cockpitSubject.js` and
|
||||
`src/layers/transit/cockpitSubject.js`.
|
||||
+17
-9
@@ -1696,7 +1696,9 @@ and portal cleanup follows that restoration.
|
||||
|
||||
### Ship bridge view
|
||||
|
||||
With no aircraft tracked, Cockpit can ride the selected AIS vessel instead.
|
||||
Cockpit can also ride the selected AIS vessel; when other subjects are
|
||||
selected too, entry takes the most recently selected one (see Application
|
||||
subjects below).
|
||||
Entry needs only the layer of the vessel it rides, Live AIS Vessels, and a
|
||||
selected vessel; Contacts and every other layer may be off, before or during
|
||||
the bridge (`bridgeEntryAllowed` in `src/contextModePolicy.js`). A Context mode
|
||||
@@ -1782,9 +1784,9 @@ satellite instead. Entry needs only the Satellites layer
|
||||
layer may be off. A Context mode change in flight blocks entry until it
|
||||
settles. Debris (rocket bodies, fragments and dead satellites; see the
|
||||
satellite class toggles) can be tracked but never entered: no ORBIT entry is
|
||||
offered and double-click refuses it. A tracked aircraft still takes
|
||||
entry first, and a tracked satellite
|
||||
takes it before a selected vessel. Double-clicking the tracked satellite
|
||||
offered and double-click refuses it. When an aircraft or a vessel is also
|
||||
tracked or selected, entry takes the most recently selected subject (see
|
||||
Application subjects below). Double-clicking the tracked satellite
|
||||
enters it, and the entry button reads
|
||||
ORBIT with a satellite icon in Contacts (`#cockpit-entry`) and under the Intel
|
||||
HUD readout (`#cockpit-entry-readout`). Voice (`control_cockpit`) stays
|
||||
@@ -2001,13 +2003,15 @@ the reason, while a provider's view is in use.
|
||||
|
||||
Entry rides the most recently selected subject of all kinds: a tracked
|
||||
aircraft, the tracked satellite, the selected vessel or a provider's
|
||||
candidate, by when the Cockpit first saw it; subjects first seen together keep
|
||||
candidate, by when the Cockpit first saw it, or saw it selected again (a
|
||||
provider candidate's `selectedAt` changing); subjects first seen together keep
|
||||
the fixed order (aircraft, satellite, vessel, then providers by priority). The
|
||||
newest decides alone: when its gate is shut there is no entry rather than an
|
||||
older subject's.
|
||||
|
||||
A frame places the eye `eye.upM` above `heightM` (the shared floor where the
|
||||
frame gives none), slews toward `headingDeg` at the provider's rate, and may
|
||||
frame gives none, or where it last stood while that floor is cold; with
|
||||
neither the camera waits where it is), 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
|
||||
@@ -2022,19 +2026,23 @@ 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/locale`:
|
||||
`getLocale`, `onLocaleChange`).
|
||||
`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.
|
||||
|
||||
The Rail layer offers a read API for such a view: `getSelectedTrain()` and
|
||||
`getCockpitSubject()` (a snapshot of the train: where it is along its line,
|
||||
which way it runs (`pathBinding.js` learns it), its rail height, speed as
|
||||
reported or derived, position source, feed credit and stops),
|
||||
reported or derived, position source, feed credit and stops, and
|
||||
`selectedAtMs`, when the user last selected it, even again),
|
||||
`setCockpitSubject(key)` (its marker and card hidden, the selection kept, its
|
||||
feed held, clicks and Escape left to the view) and `focusTrain(key)`; its pure
|
||||
path module is exported as `@gods-eye-view/core/layers/rail/paths`. 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, card, trail and detection box are
|
||||
hidden. `src/ui/cockpitSubjectView.test.mjs`,
|
||||
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`,
|
||||
`src/cockpitSubjectProviders.test.mjs`, `src/cockpitProgress.test.mjs`,
|
||||
`src/layers/rail/cockpitSubject.test.mjs` and the Transit lifecycle tests
|
||||
cover them.
|
||||
|
||||
+44
-122
@@ -47,11 +47,7 @@ test('cockpit anchor correction cannot turn a forward step into a reversal', ()
|
||||
const speedMps = 200;
|
||||
const dtSec = 0.1;
|
||||
const forwardStepM = speedMps * dtSec;
|
||||
const backwardCorrectionM = cockpitAnchorCorrectionStep(
|
||||
1000,
|
||||
speedMps,
|
||||
dtSec,
|
||||
);
|
||||
const backwardCorrectionM = cockpitAnchorCorrectionStep(1000, speedMps, dtSec);
|
||||
assert.ok(backwardCorrectionM > 0);
|
||||
assert.ok(backwardCorrectionM < forwardStepM);
|
||||
});
|
||||
@@ -87,7 +83,10 @@ test('cockpit Context scope distinguishes radius-complete feeds from viewport in
|
||||
});
|
||||
|
||||
test('cockpit Context scope preserves zero and replaces missing values intentionally', () => {
|
||||
assert.equal(formatCockpitContextScope('', 0), '— · 0 KM AIR/SEA WINDOW');
|
||||
assert.equal(
|
||||
formatCockpitContextScope('', 0),
|
||||
'— · 0 KM AIR/SEA WINDOW',
|
||||
);
|
||||
assert.equal(
|
||||
formatCockpitContextScope(undefined, Number.NaN, {}),
|
||||
'— · — KM AIR/SEA WINDOW',
|
||||
@@ -224,29 +223,26 @@ test('relative bearing stays on the shortest signed arc', () => {
|
||||
});
|
||||
|
||||
test('HUD rail layout centers between intersecting upper and lower obstacles', () => {
|
||||
assert.deepEqual(
|
||||
resolveHudRailLayout({
|
||||
viewportHeight: 1000,
|
||||
panelHeight: 200,
|
||||
laneLeft: 50,
|
||||
laneRight: 410,
|
||||
baseTop: 280,
|
||||
baseBottom: 940,
|
||||
gap: 10,
|
||||
obstacles: [
|
||||
{ left: 40, right: 240, top: 320, bottom: 460 },
|
||||
{ left: 300, right: 1000, top: 780, bottom: 930 },
|
||||
{ left: 900, right: 1100, top: 300, bottom: 700 },
|
||||
],
|
||||
}),
|
||||
{
|
||||
top: 520,
|
||||
maxHeight: 300,
|
||||
safeTop: 470,
|
||||
safeBottom: 770,
|
||||
constrained: false,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(resolveHudRailLayout({
|
||||
viewportHeight: 1000,
|
||||
panelHeight: 200,
|
||||
laneLeft: 50,
|
||||
laneRight: 410,
|
||||
baseTop: 280,
|
||||
baseBottom: 940,
|
||||
gap: 10,
|
||||
obstacles: [
|
||||
{ left: 40, right: 240, top: 320, bottom: 460 },
|
||||
{ left: 300, right: 1000, top: 780, bottom: 930 },
|
||||
{ left: 900, right: 1100, top: 300, bottom: 700 },
|
||||
],
|
||||
}), {
|
||||
top: 520,
|
||||
maxHeight: 300,
|
||||
safeTop: 470,
|
||||
safeBottom: 770,
|
||||
constrained: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('HUD rail layout constrains an oversized panel to the safe corridor', () => {
|
||||
@@ -300,10 +296,7 @@ function contextSnapshot(subject, extra = {}) {
|
||||
}
|
||||
|
||||
test('Contact panel hides only when there is no snapshot at all', () => {
|
||||
const readout = resolveCockpitContextReadout({
|
||||
snapshot: null,
|
||||
info: TRACKED_INFO,
|
||||
});
|
||||
const readout = resolveCockpitContextReadout({ snapshot: null, info: TRACKED_INFO });
|
||||
assert.equal(readout.visible, false);
|
||||
assert.equal(readout.mode, 'standby');
|
||||
assert.equal(
|
||||
@@ -319,22 +312,14 @@ test('Contact panel survives NEXT onto a vessel or installation subject', () =>
|
||||
// camera: "click next... whole left panel disappears").
|
||||
for (const subject of [
|
||||
{ layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' },
|
||||
{
|
||||
layerId: 'military-installations',
|
||||
id: 'fort-hood',
|
||||
label: 'FORT CAVAZOS',
|
||||
},
|
||||
{ layerId: 'military-installations', id: 'fort-hood', label: 'FORT CAVAZOS' },
|
||||
{ layerId: 'military', id: 'ae01ce', label: 'RCH451' },
|
||||
]) {
|
||||
const readout = resolveCockpitContextReadout({
|
||||
snapshot: contextSnapshot(subject),
|
||||
info: TRACKED_INFO,
|
||||
});
|
||||
assert.equal(
|
||||
readout.visible,
|
||||
true,
|
||||
`${subject.layerId} subject must keep the panel up`,
|
||||
);
|
||||
assert.equal(readout.visible, true, `${subject.layerId} subject must keep the panel up`);
|
||||
assert.equal(readout.mode, 'foreign');
|
||||
assert.equal(readout.subjectMatchesTracked, false);
|
||||
}
|
||||
@@ -342,11 +327,7 @@ test('Contact panel survives NEXT onto a vessel or installation subject', () =>
|
||||
|
||||
test('a foreign subject dashes the aircraft-relative fields and keeps the rest live', () => {
|
||||
const readout = resolveCockpitContextReadout({
|
||||
snapshot: contextSnapshot({
|
||||
layerId: 'ais-live-vessels',
|
||||
id: '353136000',
|
||||
label: 'MAERSK DETROIT',
|
||||
}),
|
||||
snapshot: contextSnapshot({ layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' }),
|
||||
info: TRACKED_INFO,
|
||||
});
|
||||
assert.equal(readout.visible, true);
|
||||
@@ -355,20 +336,12 @@ test('a foreign subject dashes the aircraft-relative fields and keeps the rest l
|
||||
// frame while every other row value is measured from the subject; rendering
|
||||
// both live would present one mixed-frame reading as a single measurement.
|
||||
assert.equal(readout.aircraftRelative, false);
|
||||
assert.equal(
|
||||
readout.contactLost,
|
||||
false,
|
||||
'subject-frame values still refresh',
|
||||
);
|
||||
assert.equal(readout.contactLost, false, 'subject-frame values still refresh');
|
||||
});
|
||||
|
||||
test('the tracked aircraft as subject keeps every field in its own frame', () => {
|
||||
const readout = resolveCockpitContextReadout({
|
||||
snapshot: contextSnapshot({
|
||||
layerId: 'flights',
|
||||
id: 'aaa077',
|
||||
label: 'SWA1234',
|
||||
}),
|
||||
snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077', label: 'SWA1234' }),
|
||||
info: TRACKED_INFO,
|
||||
});
|
||||
assert.equal(readout.visible, true);
|
||||
@@ -387,11 +360,7 @@ test('a fast-culled subject keeps the panel up as CONTACT LOST instead of collap
|
||||
snapshot: contextSnapshot(subject, { subjectPresent: false }),
|
||||
info: TRACKED_INFO,
|
||||
});
|
||||
assert.equal(
|
||||
readout.visible,
|
||||
true,
|
||||
'a lost contact must not take the panel down',
|
||||
);
|
||||
assert.equal(readout.visible, true, 'a lost contact must not take the panel down');
|
||||
assert.equal(readout.mode, 'lost');
|
||||
assert.equal(readout.contactLost, true, 'last-known values stay on screen');
|
||||
}
|
||||
@@ -399,10 +368,7 @@ test('a fast-culled subject keeps the panel up as CONTACT LOST instead of collap
|
||||
|
||||
test('an explicitly present subject is never reported lost', () => {
|
||||
const present = resolveCockpitContextReadout({
|
||||
snapshot: contextSnapshot(
|
||||
{ layerId: 'flights', id: 'aaa077' },
|
||||
{ subjectPresent: true },
|
||||
),
|
||||
snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077' }, { subjectPresent: true }),
|
||||
info: TRACKED_INFO,
|
||||
});
|
||||
assert.equal(present.contactLost, false);
|
||||
@@ -421,36 +387,20 @@ test('the tracked flight layer is resolved by normalized tracked identity', () =
|
||||
// Both layers describe a tracked aircraft during a cross-layer handoff;
|
||||
// civilian-first precedence would hand the cockpit the wrong aircraft.
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({
|
||||
civilian,
|
||||
military,
|
||||
trackedId: 'military:ae01ce',
|
||||
}).layerId,
|
||||
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).layerId,
|
||||
'military',
|
||||
);
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({
|
||||
civilian,
|
||||
military,
|
||||
trackedId: 'military:ae01ce',
|
||||
}).icao24,
|
||||
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).icao24,
|
||||
'ae01ce',
|
||||
);
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({
|
||||
civilian,
|
||||
military,
|
||||
trackedId: 'flights:aaa077',
|
||||
}).layerId,
|
||||
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'flights:aaa077' }).layerId,
|
||||
'flights',
|
||||
);
|
||||
// Case-insensitive: layers stamp lowercase hex, callers may not.
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({
|
||||
civilian,
|
||||
military,
|
||||
trackedId: 'MILITARY:AE01CE',
|
||||
}).layerId,
|
||||
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'MILITARY:AE01CE' }).layerId,
|
||||
'military',
|
||||
);
|
||||
});
|
||||
@@ -460,56 +410,28 @@ test('tracked-identity resolution falls back to layer precedence', () => {
|
||||
const military = { icao24: 'ae01ce' };
|
||||
// No stamped identity (or one that matches neither layer) keeps the historic
|
||||
// civilian-first order so tracking paths without gevTrackedId still work.
|
||||
assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights');
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({ civilian, military }).layerId,
|
||||
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId,
|
||||
'flights',
|
||||
);
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({
|
||||
civilian,
|
||||
military,
|
||||
trackedId: 'satellites:25544',
|
||||
}).layerId,
|
||||
'flights',
|
||||
);
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({ military, trackedId: '' }).layerId,
|
||||
'military',
|
||||
);
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(resolveTrackedAircraftInfo({ military, trackedId: '' }).layerId, 'military');
|
||||
assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null);
|
||||
assert.equal(resolveTrackedAircraftInfo(), null);
|
||||
});
|
||||
|
||||
test('cockpit emergency wording states the broadcast code, not a conclusion', () => {
|
||||
assert.equal(
|
||||
formatCockpitEmergency({
|
||||
label: 'General emergency',
|
||||
squawk: '7700',
|
||||
source: 'squawk',
|
||||
severity: 'emergency',
|
||||
}),
|
||||
formatCockpitEmergency({ label: 'General emergency', squawk: '7700', source: 'squawk', severity: 'emergency' }),
|
||||
'SQUAWK 7700 · GENERAL EMERGENCY',
|
||||
);
|
||||
assert.equal(
|
||||
formatCockpitEmergency({
|
||||
label: 'Downed aircraft',
|
||||
squawk: '1200',
|
||||
source: 'ads-b',
|
||||
severity: 'emergency',
|
||||
}),
|
||||
formatCockpitEmergency({ label: 'Downed aircraft', squawk: '1200', source: 'ads-b', severity: 'emergency' }),
|
||||
'ADS-B EMERGENCY · DOWNED AIRCRAFT',
|
||||
'an unrelated squawk is not quoted beside an ADS-B status',
|
||||
);
|
||||
assert.equal(
|
||||
formatCockpitEmergency({
|
||||
label: 'Minimum fuel',
|
||||
squawk: null,
|
||||
source: 'ads-b',
|
||||
severity: 'priority',
|
||||
}),
|
||||
formatCockpitEmergency({ label: 'Minimum fuel', squawk: null, source: 'ads-b', severity: 'priority' }),
|
||||
'ADS-B PRIORITY · MINIMUM FUEL',
|
||||
);
|
||||
assert.equal(formatCockpitEmergency(null), null);
|
||||
|
||||
+131
-40
@@ -30,6 +30,14 @@ export const COCKPIT_SUBJECT_TONES = Object.freeze([
|
||||
'alert',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The heights a frame may give (m, ellipsoidal): below the deepest dry
|
||||
* ground, above anything ridden near it; outside, the frame stands on the
|
||||
* floor.
|
||||
*/
|
||||
export const COCKPIT_SUBJECT_MIN_HEIGHT_M = -1_000;
|
||||
export const COCKPIT_SUBJECT_MAX_HEIGHT_M = 50_000;
|
||||
|
||||
/** The most readouts, sources and signals a HUD view shows. */
|
||||
export const COCKPIT_SUBJECT_MAX_READOUTS = 4;
|
||||
export const COCKPIT_SUBJECT_MAX_SOURCES = 4;
|
||||
@@ -77,6 +85,34 @@ function faceOf(raw) {
|
||||
return Object.freeze({ icon: raw.icon, label });
|
||||
}
|
||||
|
||||
/** The labels a provider gives, normalized, or null without the required. */
|
||||
function labelsOf(raw) {
|
||||
if (!isObject(raw)) return null;
|
||||
const labels = {
|
||||
view: cockpitSubjectText(raw.view, 48),
|
||||
exit: cockpitSubjectText(raw.exit, 48),
|
||||
exitText: cockpitSubjectText(raw.exitText, 24),
|
||||
kicker: cockpitSubjectText(raw.kicker, 48),
|
||||
};
|
||||
return labels.view && labels.exit && labels.exitText
|
||||
? Object.freeze(labels)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider's worded member as it reads now: the value, or what the
|
||||
* function it is returns (so it can follow the page's language); undefined
|
||||
* when that throws.
|
||||
*/
|
||||
function wordedNow(value) {
|
||||
if (typeof value !== 'function') return value;
|
||||
try {
|
||||
return value();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Why `provider` cannot be kept, or null. */
|
||||
function refusal(provider, kinds) {
|
||||
if (!isObject(provider)) return 'not an object';
|
||||
@@ -96,19 +132,13 @@ function refusal(provider, kinds) {
|
||||
typeof provider.isEntryAllowed !== 'function'
|
||||
)
|
||||
return 'isEntryAllowed is not a function';
|
||||
if (!faceOf(provider.entryFace)) return 'malformed entry face';
|
||||
const labels = provider.labels;
|
||||
if (
|
||||
!isObject(labels) ||
|
||||
!cockpitSubjectText(labels.view, 48) ||
|
||||
!cockpitSubjectText(labels.exit, 48) ||
|
||||
!cockpitSubjectText(labels.exitText, 24)
|
||||
)
|
||||
if (!faceOf(wordedNow(provider.entryFace))) return 'malformed entry face';
|
||||
if (!labelsOf(wordedNow(provider.labels)))
|
||||
return 'labels need view, exit and exitText';
|
||||
const look = wordedNow(provider.look);
|
||||
if (
|
||||
provider.look != null &&
|
||||
(!isObject(provider.look) ||
|
||||
!cockpitSubjectText(provider.look.aheadLabel, 64))
|
||||
(!isObject(look) || !cockpitSubjectText(look.aheadLabel, 64))
|
||||
)
|
||||
return 'look needs an aheadLabel';
|
||||
return null;
|
||||
@@ -128,12 +158,20 @@ function refusal(provider, kinds) {
|
||||
* - `look` (optional): `{restPitchDeg?, aheadLabel}`, arrow-key look-around as
|
||||
* on a bridge, resting at `restPitchDeg` (default 0, -90..90), its Look
|
||||
* ahead button named `aheadLabel`;
|
||||
* `entryFace`, `labels` and `look` may each be a function returning the
|
||||
* object: it is called again whenever they are shown and when the page's
|
||||
* language changes, so its words can follow it (`./i18n/locale`). A read
|
||||
* that throws or is malformed keeps the last good one; `look` is on or off
|
||||
* and `restPitchDeg` fixed as first read;
|
||||
* - `cameraIntervalMs` (optional): 0..1000, null for the Cockpit's default; 0
|
||||
* updates the camera every rendered frame;
|
||||
* - `headingSlewDps` (optional): how fast the view turns to a new heading,
|
||||
* positive or Infinity, null for the Cockpit's default;
|
||||
* - `candidate()`, `isEntryAllowed(candidate)` (optional), `hold(id)`,
|
||||
* `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`.
|
||||
* `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`, all
|
||||
* synchronous (createCockpitSubjectCaller). `isEntryAllowed` allows entry
|
||||
* only by returning `true`. A `hold` that throws is followed by
|
||||
* `release(id, {restore: false})`.
|
||||
*
|
||||
* A provider that is not so is left out with one warning naming it.
|
||||
* @param {unknown} list
|
||||
@@ -157,28 +195,40 @@ export function cockpitSubjectProviders(
|
||||
return;
|
||||
}
|
||||
kinds.add(provider.kind);
|
||||
const look = isObject(provider.look)
|
||||
? Object.freeze({
|
||||
restPitchDeg: finite(provider.look.restPitchDeg)
|
||||
? clamp(provider.look.restPitchDeg, -90, 90)
|
||||
: 0,
|
||||
aheadLabel: cockpitSubjectText(provider.look.aheadLabel, 64),
|
||||
})
|
||||
: null;
|
||||
// Worded members are read again on each use (see above); the last good
|
||||
// read stands in for a bad one.
|
||||
let face = faceOf(wordedNow(provider.entryFace));
|
||||
let labels = labelsOf(wordedNow(provider.labels));
|
||||
const firstLook = wordedNow(provider.look);
|
||||
const lookOf = (raw) =>
|
||||
Object.freeze({
|
||||
restPitchDeg: finite(firstLook.restPitchDeg)
|
||||
? clamp(firstLook.restPitchDeg, -90, 90)
|
||||
: 0,
|
||||
aheadLabel: cockpitSubjectText(raw.aheadLabel, 64),
|
||||
});
|
||||
let look = isObject(firstLook) ? lookOf(firstLook) : null;
|
||||
kept.push(
|
||||
Object.freeze({
|
||||
kind: provider.kind,
|
||||
layerId: provider.layerId,
|
||||
priority: finite(provider.priority) ? provider.priority : 0,
|
||||
order,
|
||||
entryFace: faceOf(provider.entryFace),
|
||||
labels: Object.freeze({
|
||||
view: cockpitSubjectText(provider.labels.view, 48),
|
||||
exit: cockpitSubjectText(provider.labels.exit, 48),
|
||||
exitText: cockpitSubjectText(provider.labels.exitText, 24),
|
||||
kicker: cockpitSubjectText(provider.labels.kicker, 48),
|
||||
}),
|
||||
look,
|
||||
get entryFace() {
|
||||
face = faceOf(wordedNow(provider.entryFace)) ?? face;
|
||||
return face;
|
||||
},
|
||||
get labels() {
|
||||
labels = labelsOf(wordedNow(provider.labels)) ?? labels;
|
||||
return labels;
|
||||
},
|
||||
get look() {
|
||||
if (!look) return null;
|
||||
const raw = wordedNow(provider.look);
|
||||
if (isObject(raw) && cockpitSubjectText(raw.aheadLabel, 64))
|
||||
look = lookOf(raw);
|
||||
return look;
|
||||
},
|
||||
cameraIntervalMs:
|
||||
finite(provider.cameraIntervalMs) &&
|
||||
provider.cameraIntervalMs >= 0 &&
|
||||
@@ -204,25 +254,38 @@ export function cockpitSubjectProviders(
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider's candidate: `{id, name, face}`, or null without a usable id.
|
||||
* `name` falls back to the id; `face` is null unless well formed.
|
||||
* A provider's candidate: `{id, name, face, selectedAt}`, or null without a
|
||||
* usable id. `name` falls back to the id; `face` is null unless well formed;
|
||||
* `selectedAt` (a number or a short string, else null) is a token the
|
||||
* provider changes each time its subject is selected, even again: entry
|
||||
* takes the most recently selected subject (entryCandidates).
|
||||
* @param {unknown} raw What `candidate()` returned.
|
||||
*/
|
||||
export function cockpitSubjectCandidate(raw) {
|
||||
if (!isObject(raw) || typeof raw.id !== 'string') return null;
|
||||
const id = raw.id.trim();
|
||||
if (!id || id.length > 128) return null;
|
||||
const selectedAt =
|
||||
finite(raw.selectedAt) ||
|
||||
(typeof raw.selectedAt === 'string' && raw.selectedAt.length <= 64)
|
||||
? raw.selectedAt
|
||||
: null;
|
||||
return Object.freeze({
|
||||
id,
|
||||
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(id, 64),
|
||||
face: faceOf(raw.face),
|
||||
selectedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One camera frame from a provider, normalized, or null when it cannot place
|
||||
* the camera (the view then ends): not an object, an id other than `heldId`,
|
||||
* a position that is not finite, or an eye with no valid `upM`.
|
||||
* a position that is not finite, or an eye with no valid `upM` (0..1000).
|
||||
* 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.
|
||||
* @param {unknown} raw What `read()` returned.
|
||||
* @param {string} heldId The subject the view holds.
|
||||
* @returns {object|null} `{id, name, latitude, longitude, heightM, headingDeg,
|
||||
@@ -245,7 +308,12 @@ export function cockpitSubjectFrame(raw, heldId) {
|
||||
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(heldId, 64),
|
||||
latitude,
|
||||
longitude: ((((longitude + 180) % 360) + 360) % 360) - 180,
|
||||
heightM: finite(raw.heightM) ? raw.heightM : null,
|
||||
heightM:
|
||||
finite(raw.heightM) &&
|
||||
raw.heightM >= COCKPIT_SUBJECT_MIN_HEIGHT_M &&
|
||||
raw.heightM <= COCKPIT_SUBJECT_MAX_HEIGHT_M
|
||||
? raw.heightM
|
||||
: null,
|
||||
headingDeg,
|
||||
courseDeg: heading(raw.courseDeg) ?? headingDeg,
|
||||
speedMps: finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null,
|
||||
@@ -429,25 +497,48 @@ export function cockpitSubjectHudView(raw) {
|
||||
* A guarded caller for provider methods: `call(provider, method, args,
|
||||
* fallback)` returns what the method returns, or `fallback` when it throws,
|
||||
* warning once per provider and method.
|
||||
*
|
||||
* Provider methods are synchronous: the Cockpit calls them on its render
|
||||
* cadence and uses the answer at once. One that returns a promise (an async
|
||||
* function, say) breaks that contract: the promise is refused as a throw
|
||||
* would be (its rejection, if any, is swallowed), with one warning.
|
||||
* @param {{warn?: (message: string, error: unknown) => void}} [options]
|
||||
*/
|
||||
export function createCockpitSubjectCaller({
|
||||
warn = (message, error) => console.warn(message, error),
|
||||
} = {}) {
|
||||
const warned = new Set();
|
||||
const warnOnce = (provider, method, message, error) => {
|
||||
const key = `${provider.kind}:${method}`;
|
||||
if (warned.has(key)) return;
|
||||
warned.add(key);
|
||||
warn(
|
||||
`[Cockpit] subject provider ${provider.kind} ${method}() ${message}`,
|
||||
error,
|
||||
);
|
||||
};
|
||||
return function call(provider, method, args = [], fallback = null) {
|
||||
let result;
|
||||
try {
|
||||
return provider[method](...args);
|
||||
result = provider[method](...args);
|
||||
} catch (error) {
|
||||
const key = `${provider.kind}:${method}`;
|
||||
if (!warned.has(key)) {
|
||||
warned.add(key);
|
||||
warn(
|
||||
`[Cockpit] subject provider ${provider.kind} ${method}() failed`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
warnOnce(provider, method, 'failed', error);
|
||||
return fallback;
|
||||
}
|
||||
if (
|
||||
result !== null &&
|
||||
(typeof result === 'object' || typeof result === 'function') &&
|
||||
typeof result.then === 'function'
|
||||
) {
|
||||
Promise.resolve(result).catch(() => {});
|
||||
warnOnce(
|
||||
provider,
|
||||
method,
|
||||
'returned a promise; provider methods are synchronous',
|
||||
null,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -147,7 +147,22 @@ test('a candidate needs an id; its name and face are read with care', () => {
|
||||
id: 'a1',
|
||||
name: 'a1',
|
||||
face: null,
|
||||
selectedAt: null,
|
||||
});
|
||||
// A selection token is a number or a short string.
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate({ id: 'a1', selectedAt: 5 }).selectedAt,
|
||||
5,
|
||||
);
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate({ id: 'a1', selectedAt: 'click-2' }).selectedAt,
|
||||
'click-2',
|
||||
);
|
||||
for (const selectedAt of [Number.NaN, {}, 'x'.repeat(65)])
|
||||
assert.equal(
|
||||
cockpitSubjectCandidate({ id: 'a1', selectedAt }).selectedAt,
|
||||
null,
|
||||
);
|
||||
const read = cockpitSubjectCandidate({
|
||||
id: 'a1',
|
||||
name: ' Example 12 ',
|
||||
@@ -213,6 +228,13 @@ test('a frame is the held subject at a finite place with an eye height, else nul
|
||||
);
|
||||
assert.equal(full.longitude, -170);
|
||||
assert.equal(full.heightM, 412.5);
|
||||
// A height outside any plausible ground reads as none.
|
||||
for (const heightM of [-6_400_000, 1e9, Infinity])
|
||||
assert.equal(cockpitSubjectFrame(frame({ heightM }), 'a1').heightM, null);
|
||||
assert.equal(
|
||||
cockpitSubjectFrame(frame({ heightM: -400 }), 'a1').heightM,
|
||||
-400,
|
||||
);
|
||||
assert.equal(full.headingDeg, 350);
|
||||
assert.equal(full.courseDeg, 5);
|
||||
assert.equal(full.speedMps, 22);
|
||||
@@ -385,3 +407,69 @@ test('a provider method that throws returns the fallback and warns once', () =>
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /example-kind read\(\) failed/);
|
||||
});
|
||||
|
||||
test('a provider method that returns a promise is refused as a throw is, warning once', async () => {
|
||||
const warnings = [];
|
||||
const call = createCockpitSubjectCaller({
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
const asynchronous = {
|
||||
kind: 'example-kind',
|
||||
hold: async () => true,
|
||||
read: () => Promise.reject(new Error('late')),
|
||||
};
|
||||
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
|
||||
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
|
||||
assert.equal(call(asynchronous, 'read', [], null), null);
|
||||
assert.equal(warnings.length, 2, 'once per method');
|
||||
assert.match(warnings[0], /hold\(\) returned a promise/);
|
||||
// The rejected promise is not left unhandled.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
test('labels, entry face and look given as functions are read again on each use', () => {
|
||||
let lang = 'en';
|
||||
const words = {
|
||||
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
|
||||
de: { view: 'Fahrtansicht', exit: 'Verlassen', exitText: 'VERLASSEN' },
|
||||
};
|
||||
const { kept, warnings } = keep([
|
||||
provider({
|
||||
labels: () => words[lang],
|
||||
entryFace: () => ({
|
||||
icon: 'commute',
|
||||
label: { en: 'RIDE', de: 'FAHRT' }[lang],
|
||||
}),
|
||||
look: () => ({
|
||||
restPitchDeg: lang === 'de' ? 40 : -3,
|
||||
aheadLabel: lang === 'de' ? 'Nach vorn' : 'Look ahead',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
assert.deepEqual(warnings, []);
|
||||
const [ride] = kept;
|
||||
assert.equal(ride.labels.exitText, 'EXIT RIDE');
|
||||
assert.equal(ride.entryFace.label, 'RIDE');
|
||||
assert.equal(ride.look.aheadLabel, 'Look ahead');
|
||||
lang = 'de';
|
||||
assert.equal(ride.labels.exitText, 'VERLASSEN');
|
||||
assert.equal(ride.entryFace.label, 'FAHRT');
|
||||
assert.equal(ride.look.aheadLabel, 'Nach vorn');
|
||||
assert.equal(ride.look.restPitchDeg, -3, 'the rest pitch is as first read');
|
||||
// A read that fails or is malformed keeps the last good one.
|
||||
lang = 'xx';
|
||||
assert.equal(ride.labels.exitText, 'VERLASSEN');
|
||||
assert.equal(ride.entryFace.label, 'FAHRT');
|
||||
// Functions that are malformed from the start leave the provider out.
|
||||
const bad = keep([
|
||||
provider({ kind: 'bad-labels', labels: () => null }),
|
||||
provider({
|
||||
kind: 'thrown-face',
|
||||
entryFace: () => {
|
||||
throw new Error('no face');
|
||||
},
|
||||
}),
|
||||
]);
|
||||
assert.deepEqual(bad.kept, []);
|
||||
assert.equal(bad.warnings.length, 2);
|
||||
});
|
||||
@@ -14,8 +14,11 @@ import { displayMotion } from '../transit/movement.js';
|
||||
* selection. The hold ends with `setCockpitSubject(null)`, and on its own
|
||||
* when the train is removed or the layer is turned off. A train removed while
|
||||
* held leaves its feed polled until the next `setCockpitSubject` call, so a
|
||||
* view that waits for it holds it again if the feed lists it again. `focusTrain(key)` selects a train and
|
||||
* hands it the camera, as a click does.
|
||||
* view that waits for it holds it again if the feed lists it again.
|
||||
* `focusTrain(key)` selects a train and hands it the camera, as a click does.
|
||||
* A snapshot's `selectedAtMs` is when the user last selected the selected
|
||||
* train, even again (null for any other), for a view's entry to tell a
|
||||
* re-selection.
|
||||
*
|
||||
* @param {object} context The layer's state, parts, services and timetable.
|
||||
* @returns {{methods: object}}
|
||||
@@ -98,6 +101,8 @@ export function createRailCockpitSubject({
|
||||
return Object.freeze({
|
||||
key: entry.key,
|
||||
feedId: entry.feedId,
|
||||
selectedAtMs:
|
||||
state._selectedKey === entry.key ? state._selectedAtMs : null,
|
||||
id: String(record.id ?? ''),
|
||||
tripId: record.tripId || null,
|
||||
routeId: record.routeId || null,
|
||||
|
||||
@@ -79,6 +79,8 @@ function createState({ services }) {
|
||||
_stopAnchors: new Map(),
|
||||
_categories: { ...DEFAULT_RAIL_CATEGORY_VISIBILITY },
|
||||
_selectedKey: null,
|
||||
/** When the user last selected a train, even the selected one (ms). */
|
||||
_selectedAtMs: null,
|
||||
_selectedCardAt: 0,
|
||||
_selectedCardText: null,
|
||||
/** The camera's follow of the selected train (follow.js), or null. */
|
||||
|
||||
@@ -108,6 +108,8 @@ export function createSelection({ state, services, parts }) {
|
||||
*/
|
||||
function selectAndFocusTrain(key) {
|
||||
if (!state._trains.has(key)) return;
|
||||
// A selection by the user, even of the selected train (cockpitSubject.js).
|
||||
state._selectedAtMs = Date.now();
|
||||
const following = parts.follow.isFollowing(key);
|
||||
// Another train's follow lets go first, so this is never taken for a
|
||||
// move between two copies of one train.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { getRegisteredTransitFeed } from '../../data/transitFeeds.js';
|
||||
import { transitRouteLabel } from '../../data/transitFeeds.js';
|
||||
import {
|
||||
getRegisteredTransitFeed,
|
||||
transitRouteLabel,
|
||||
} from '../../data/transitFeeds.js';
|
||||
import { displayMotion } from './movement.js';
|
||||
|
||||
/**
|
||||
@@ -14,7 +16,9 @@ import { displayMotion } from './movement.js';
|
||||
* camera's view, its feed stays polled, and clicks and Escape no longer
|
||||
* change the selection. The hold ends with `setCockpitSubject(null)`, and on
|
||||
* its own when the vehicle is removed or the layer is turned off.
|
||||
* `focusVehicle(key)` selects a vehicle, as a click does.
|
||||
* `focusVehicle(key)` selects a vehicle, as a click does. A snapshot's
|
||||
* `selectedAtMs` is when the user last selected the selected vehicle, even
|
||||
* again (null for any other).
|
||||
*
|
||||
* @param {object} context The layer's state, parts and services.
|
||||
* @returns {{methods: object}}
|
||||
@@ -26,9 +30,12 @@ export function createTransitCockpitSubject({ state, parts, services }) {
|
||||
const finiteOrNull = (value) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
function feedOf(feedId) {
|
||||
const feed =
|
||||
state._activeFeeds.get(feedId) || getRegisteredTransitFeed(feedId);
|
||||
/** The feed in use, else the registry's entry for it. */
|
||||
function feedEntry(feedId) {
|
||||
return state._activeFeeds.get(feedId) || getRegisteredTransitFeed(feedId);
|
||||
}
|
||||
|
||||
function feedOf(feed) {
|
||||
if (!feed) return null;
|
||||
return Object.freeze({
|
||||
id: feed.id,
|
||||
@@ -46,9 +53,7 @@ export function createTransitCockpitSubject({ state, parts, services }) {
|
||||
if (!entry?.record) return null;
|
||||
const record = entry.record;
|
||||
const sample = entry.sample ?? null;
|
||||
const feed =
|
||||
state._activeFeeds.get(entry.feedId) ||
|
||||
getRegisteredTransitFeed(entry.feedId);
|
||||
const feed = feedEntry(entry.feedId);
|
||||
const reported = finiteOrNull(record.speedMps);
|
||||
const derived = finiteOrNull(sample?.segmentSpeedMps);
|
||||
const status = state._feedStatus.get(entry.feedId);
|
||||
@@ -57,6 +62,8 @@ export function createTransitCockpitSubject({ state, parts, services }) {
|
||||
return Object.freeze({
|
||||
key: entry.key,
|
||||
feedId: entry.feedId,
|
||||
selectedAtMs:
|
||||
state._selectedKey === entry.key ? state._selectedAtMs : null,
|
||||
id: String(record.id ?? ''),
|
||||
routeId: record.routeId || null,
|
||||
routeLabel:
|
||||
@@ -68,7 +75,7 @@ export function createTransitCockpitSubject({ state, parts, services }) {
|
||||
label: record.label || null,
|
||||
mode: entry.mode ?? null,
|
||||
modeInferred: entry.modeInferred === true,
|
||||
feed: feedOf(entry.feedId),
|
||||
feed: feedOf(feed),
|
||||
position: Object.freeze({
|
||||
latitude,
|
||||
longitude,
|
||||
@@ -131,6 +138,12 @@ export function createTransitCockpitSubject({ state, parts, services }) {
|
||||
const previous = state._vehicles.get(state._cockpitSubjectKey);
|
||||
if (key === null || key === undefined) {
|
||||
state._cockpitSubjectKey = null;
|
||||
// The hold kept it in play out of view: it leaves until the next
|
||||
// visibility sweep finds it in view again.
|
||||
if (previous) {
|
||||
state._visible.delete(previous);
|
||||
parts.rendering.requestVisibility();
|
||||
}
|
||||
repaint(previous);
|
||||
parts.selection.refreshSelectedCard(true);
|
||||
return true;
|
||||
|
||||
@@ -4235,12 +4235,15 @@ test('a held vehicle hides, keeps moving out of view, and lets go when it goes',
|
||||
|
||||
assert.equal(app.layer.setCockpitSubject(null), true);
|
||||
assert.equal(app.layer.getCockpitSubject(), null);
|
||||
// Let go, it is out of play until a sweep finds it in view.
|
||||
assert.equal(app.state()._visible.has(entry), false, 'out of play');
|
||||
app.lookAt({
|
||||
west: -71.2,
|
||||
south: 42.2,
|
||||
east: -70.9,
|
||||
north: 42.5,
|
||||
});
|
||||
app.advance(250);
|
||||
app.settle();
|
||||
assert.equal(entry.marker.show, true, 'shown again');
|
||||
assert.equal(app.layer.focusVehicle('mbta:nope'), false);
|
||||
|
||||
@@ -109,6 +109,7 @@ export function createSelection({ state, services, parts }) {
|
||||
const entry = state._vehicles.get(key);
|
||||
if (!entry?.marker) return;
|
||||
state._selectedKey = key;
|
||||
state._selectedAtMs = Date.now();
|
||||
state._detectRevision += 1;
|
||||
parts.rendering.paintSelected(entry, true);
|
||||
parts.trails.select(entry);
|
||||
|
||||
@@ -95,6 +95,8 @@ export function createState({ services }) {
|
||||
|
||||
/** @type {string|null} */
|
||||
state._selectedKey = null;
|
||||
/** When the user last selected a vehicle, even the selected one (ms). */
|
||||
state._selectedAtMs = null;
|
||||
/**
|
||||
* The vehicle a first-person view holds (cockpitSubject.js), or null: its
|
||||
* sprite, card, trail and detection box are hidden, and it keeps moving
|
||||
|
||||
+13
-1
@@ -343,8 +343,20 @@ function updateSubject(nowMs) {
|
||||
frame.latitude,
|
||||
frame.longitude,
|
||||
);
|
||||
heightM = Number.isFinite(floorM) ? floorM : 0;
|
||||
// A frame with no height stands on the shared floor; while that is cold
|
||||
// it keeps the height it last stood at.
|
||||
heightM = Number.isFinite(floorM) ? floorM : this.subjectHeightM;
|
||||
}
|
||||
if (!Number.isFinite(heightM)) {
|
||||
// Nothing to stand on yet, from the first frame: the camera stays where
|
||||
// it is while the floor is acquired, as the built-in views wait for the
|
||||
// surface, rather than standing on the ellipsoid (kilometres underground
|
||||
// on a plateau).
|
||||
this.surfaceAcquiring = true;
|
||||
return;
|
||||
}
|
||||
this.surfaceAcquiring = false;
|
||||
this.subjectHeightM = heightM;
|
||||
const target = Cesium.Cartesian3.fromDegrees(
|
||||
frame.longitude,
|
||||
frame.latitude,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
toggleTrackedTr3b,
|
||||
syncTr3bToggle,
|
||||
syncEntry,
|
||||
refreshSubjectWords,
|
||||
publishPresenceView,
|
||||
navigateContext,
|
||||
_adoptTrackedEntity,
|
||||
@@ -185,6 +186,10 @@ export class CockpitViewController {
|
||||
this.surfaceWaitStartedMs = 0;
|
||||
this.surfaceAcquiring = false;
|
||||
this.surfaceFallback = false;
|
||||
/** The height an application subject's frame last stood at (m). */
|
||||
this.subjectHeightM = null;
|
||||
/** The ground clamp's eased lift under that subject's eye (m). */
|
||||
this.subjectLiftM = null;
|
||||
/**
|
||||
* Where the bridge view is turned to (`lookTarget`) and where the camera
|
||||
* looks on its way there (`lookView`): yaw off the bow, starboard
|
||||
@@ -581,6 +586,9 @@ export class CockpitViewController {
|
||||
syncEntry() {
|
||||
return syncEntry.call(this);
|
||||
}
|
||||
refreshSubjectWords() {
|
||||
return refreshSubjectWords.call(this);
|
||||
}
|
||||
navigateContext(direction, options) {
|
||||
return navigateContext.call(this, direction, options);
|
||||
}
|
||||
@@ -783,6 +791,9 @@ export class CockpitViewController {
|
||||
this.relocalizeBrief();
|
||||
this.publishVision(createVisionState(this.visionMode));
|
||||
this.routeSignature = null;
|
||||
// A provider's words, ridden or on the entry, follow the language.
|
||||
this.refreshSubjectWords();
|
||||
if (!this.active) this.syncEntry();
|
||||
if (this.active && this.lastSubjectInfo) {
|
||||
this.rewordingSignals = true;
|
||||
try {
|
||||
|
||||
@@ -56,8 +56,10 @@ function fixture(
|
||||
dataManager: suppliedManager = null,
|
||||
readContext = null,
|
||||
subjects = [],
|
||||
vessel = null,
|
||||
} = {},
|
||||
) {
|
||||
let selectedVessel = vessel;
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
@@ -102,7 +104,13 @@ function fixture(
|
||||
services: {
|
||||
flightsLayer: { getTrackedInfo: trackedFrom('flights') },
|
||||
militaryFlightsLayer: { getTrackedInfo: trackedFrom('military') },
|
||||
aisLiveVesselsLayer: { getCockpitSubject: () => null },
|
||||
// Like the layer, no subject while Live AIS Vessels is off.
|
||||
aisLiveVesselsLayer: {
|
||||
getCockpitSubject: () =>
|
||||
selectedVessel && layers.has('ais-live-vessels')
|
||||
? { ...selectedVessel }
|
||||
: null,
|
||||
},
|
||||
// Like the layer, no subject while Satellites is off.
|
||||
satellitesLayer: {
|
||||
getCockpitSubject: () =>
|
||||
@@ -174,10 +182,35 @@ function fixture(
|
||||
viewer.trackedEntity = entityFor(subject);
|
||||
entities.add(viewer.trackedEntity);
|
||||
};
|
||||
/** Select a vessel on the map, or none. */
|
||||
const selectVessel = (info) => {
|
||||
selectedVessel = info;
|
||||
};
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
return { $, coordinator, cockpit, store, layers, disable, retrack, pinia };
|
||||
return {
|
||||
$,
|
||||
coordinator,
|
||||
cockpit,
|
||||
store,
|
||||
layers,
|
||||
disable,
|
||||
retrack,
|
||||
selectVessel,
|
||||
pinia,
|
||||
};
|
||||
}
|
||||
|
||||
/** A selected vessel as the AIS layer gives it to the bridge view. */
|
||||
const VESSEL = Object.freeze({
|
||||
id: '211000001',
|
||||
name: 'EXAMPLE',
|
||||
latitude: 54,
|
||||
longitude: 10,
|
||||
headingDeg: 90,
|
||||
courseDeg: 90,
|
||||
speedMps: 5,
|
||||
});
|
||||
|
||||
test('a civilian aircraft enters with Live Flights alone: no Contacts, no Military', (t) => {
|
||||
const { coordinator, cockpit } = fixture(t, {
|
||||
enabled: ['flights'],
|
||||
@@ -570,6 +603,32 @@ test('the readout entry reads BRIDGE for a selected vessel', async (t) => {
|
||||
assert.match(readoutEntry.textContent, /BRIDGE/);
|
||||
});
|
||||
|
||||
test('without providers too, the later selection takes entry: a vessel after an aircraft reads BRIDGE', async (t) => {
|
||||
const { $, cockpit, store, selectVessel } = fixture(t, {
|
||||
enabled: ['flights', 'ais-live-vessels'],
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
});
|
||||
cockpit.syncEntry();
|
||||
assert.equal(store.presence.entryKind, 'aircraft');
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
selectVessel(VESSEL);
|
||||
cockpit.syncEntry();
|
||||
await nextTick();
|
||||
assert.equal(store.presence.entryKind, 'vessel');
|
||||
assert.match($('#cockpit-entry-readout').textContent, /BRIDGE/);
|
||||
// And the other way round: an aircraft tracked after the vessel.
|
||||
const later = fixture(t, {
|
||||
enabled: ['flights', 'ais-live-vessels'],
|
||||
vessel: VESSEL,
|
||||
});
|
||||
later.cockpit.syncEntry();
|
||||
assert.equal(later.store.presence.entryKind, 'vessel');
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
later.retrack({ layerId: 'flights', info: CIVIL });
|
||||
later.cockpit.syncEntry();
|
||||
assert.equal(later.store.presence.entryKind, 'aircraft');
|
||||
});
|
||||
|
||||
test('a tracked satellite needs Satellites on for the ORBIT entry', async (t) => {
|
||||
const { $, cockpit } = fixture(t, { satellite: SATELLITE });
|
||||
cockpit.syncEntry();
|
||||
@@ -730,6 +789,60 @@ test("a provider's view reports its subject, refuses voice entry and ends with i
|
||||
assert.deepEqual(released, [['a1', false]]);
|
||||
});
|
||||
|
||||
test("a provider's guards: a throwing candidate, gate or release, and a gate that is not true", async (t) => {
|
||||
const warned = [];
|
||||
t.mock.method(console, 'warn', (message) => warned.push(String(message)));
|
||||
const entered = (provider) => {
|
||||
const { cockpit } = fixture(t, {
|
||||
enabled: ['example-layer'],
|
||||
subjects: [provider],
|
||||
});
|
||||
cockpit.syncEntry();
|
||||
return { cockpit, entered: cockpit.enter() };
|
||||
};
|
||||
const { provider: base } = exampleSubject();
|
||||
// A candidate() that throws offers nothing.
|
||||
const noCandidate = entered({
|
||||
...base,
|
||||
candidate: () => {
|
||||
throw new Error('candidate failed');
|
||||
},
|
||||
});
|
||||
assert.equal(noCandidate.entered, false);
|
||||
assert.equal(noCandidate.cockpit.resolveEntry(), null);
|
||||
// An isEntryAllowed() that throws, or answers anything but true, shuts
|
||||
// the gate.
|
||||
for (const isEntryAllowed of [
|
||||
() => {
|
||||
throw new Error('gate failed');
|
||||
},
|
||||
() => 1,
|
||||
() => 'yes',
|
||||
() => ({}),
|
||||
]) {
|
||||
const gated = entered({ ...base, isEntryAllowed });
|
||||
assert.equal(gated.entered, false, String(isEntryAllowed));
|
||||
assert.equal(gated.cockpit.active, false);
|
||||
}
|
||||
// A release() that throws still ends the view.
|
||||
const { provider } = exampleSubject();
|
||||
const releasing = entered({
|
||||
...provider,
|
||||
release: () => {
|
||||
throw new Error('release failed');
|
||||
},
|
||||
});
|
||||
assert.equal(releasing.entered, true);
|
||||
assert.equal(releasing.cockpit.exit(), true);
|
||||
assert.equal(releasing.cockpit.active, false);
|
||||
assert.equal(document.body.classList.contains('cockpit-subject'), false);
|
||||
for (const method of ['candidate', 'isEntryAllowed', 'release'])
|
||||
assert.ok(
|
||||
warned.some((line) => line.includes(`${method}() failed`)),
|
||||
method,
|
||||
);
|
||||
});
|
||||
|
||||
test('a malformed provider is left out and the built-in kinds are unchanged', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const { cockpit } = fixture(t, {
|
||||
|
||||
@@ -994,7 +994,7 @@ test('a place that lands after the exit is dropped', async (t) => {
|
||||
assert.equal(store.orbital, null, 'no HUD comes back for a stale answer');
|
||||
});
|
||||
|
||||
test('a tracked aircraft takes entry before a tracked satellite', async (t) => {
|
||||
test('an aircraft and a satellite first seen in one sync: the aircraft takes entry, by the fixed order', async (t) => {
|
||||
const aircraftInfo = {
|
||||
icao24: 'abc123',
|
||||
callsign: 'TEST1',
|
||||
@@ -1007,6 +1007,9 @@ test('a tracked aircraft takes entry before a tracked satellite', async (t) => {
|
||||
};
|
||||
const { cockpit, viewer, aircraft, store } = fixture(t, { aircraftInfo });
|
||||
viewer.trackedEntity = aircraft;
|
||||
// The satellite and the aircraft are both new to this sync; the fixed order
|
||||
// decides between subjects first seen together. Selected one after the
|
||||
// other, the later one takes entry (cockpitEntryGate.test.mjs).
|
||||
cockpit.syncEntry();
|
||||
assert.equal(store.presence.entryKind, 'aircraft');
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useCockpitStore } from './stores/cockpit.js';
|
||||
import CockpitView from './views/CockpitView.vue';
|
||||
import ContextView from './views/ContextView.vue';
|
||||
import { cockpitSubjectProviders } from '../cockpitSubjectProviders.js';
|
||||
import { getLocale, setLocale } from '../i18n/index.js';
|
||||
import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js';
|
||||
|
||||
const LAT = 47;
|
||||
@@ -500,6 +501,115 @@ test('entry fails, releasing nothing it did not hold, when hold or the first fra
|
||||
assert.equal(app.cockpit.enter(), false);
|
||||
assert.deepEqual(lastRelease(blank.calls), ['release', 'a1', false]);
|
||||
assert.equal(app.cockpit.active, false);
|
||||
// A hold that throws may have half happened: it is released.
|
||||
const thrown = subjectProvider({
|
||||
hold: (id) => {
|
||||
thrown.calls.push(['hold', id]);
|
||||
throw new Error('hold failed');
|
||||
},
|
||||
});
|
||||
app = fixture(t, { providers: [thrown] });
|
||||
app.cockpit.syncEntry();
|
||||
assert.equal(app.cockpit.enter(), false);
|
||||
assert.deepEqual(lastRelease(thrown.calls), ['release', 'a1', false]);
|
||||
assert.equal(app.warned.length, 1);
|
||||
// So is one that answers with a promise: provider methods are synchronous.
|
||||
const later = subjectProvider({
|
||||
hold: async () => true,
|
||||
});
|
||||
app = fixture(t, { providers: [later] });
|
||||
app.cockpit.syncEntry();
|
||||
assert.equal(app.cockpit.enter(), false);
|
||||
assert.deepEqual(lastRelease(later.calls), ['release', 'a1', false]);
|
||||
assert.match(app.warned[0], /returned a promise/);
|
||||
});
|
||||
|
||||
test('a frame with no height and a cold floor never stands the eye on the ellipsoid', async (t) => {
|
||||
const clock = manualClock(t, 10_000);
|
||||
const ride = subjectProvider();
|
||||
ride.subject.frame = { ...ride.subject.frame, heightM: null };
|
||||
const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] });
|
||||
// Nothing to stand on from the first frame: the camera waits where it is.
|
||||
setFloor(undefined);
|
||||
cockpit.syncEntry();
|
||||
assert.equal(cockpit.enter(), true);
|
||||
const before = shots.length;
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
}
|
||||
assert.equal(shots.length, before, 'no camera placed');
|
||||
assert.equal(cockpit.active, true, 'the view waits');
|
||||
// The floor arrives: the eye stands on it.
|
||||
setFloor(FLOOR_M);
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01);
|
||||
// It goes cold again: the eye keeps the height it stood at, never 0 m,
|
||||
// with the ground clamp off too.
|
||||
setFloor(undefined);
|
||||
ride.subject.frame = { ...ride.subject.frame, groundClamp: false };
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01);
|
||||
}
|
||||
// A height out of any plausible range reads as none.
|
||||
ride.subject.frame = { ...ride.subject.frame, heightM: -6_400_000 };
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01);
|
||||
});
|
||||
|
||||
test('a provider’s labels, entry face and look follow a change of language', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('en');
|
||||
const clock = manualClock(t, 10_000);
|
||||
const words = {
|
||||
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
|
||||
de: {
|
||||
view: 'Fahrtansicht',
|
||||
exit: 'Fahrtansicht verlassen',
|
||||
exitText: 'VERLASSEN',
|
||||
},
|
||||
};
|
||||
const german = () => getLocale().startsWith('de');
|
||||
const ride = subjectProvider({
|
||||
labels: () => words[german() ? 'de' : 'en'],
|
||||
entryFace: () => ({ icon: 'commute', label: german() ? 'FAHRT' : 'RIDE' }),
|
||||
look: () => ({
|
||||
restPitchDeg: -3,
|
||||
aheadLabel: german() ? 'Nach vorn sehen' : 'Look ahead',
|
||||
}),
|
||||
});
|
||||
const { $, cockpit } = fixture(t, { providers: [ride] });
|
||||
cockpit.syncEntry();
|
||||
await nextTick();
|
||||
assert.match($('#cockpit-entry').textContent, /RIDE/);
|
||||
// On the entry, before the view.
|
||||
setLocale('de');
|
||||
await settle();
|
||||
assert.match($('#cockpit-entry').textContent, /FAHRT/);
|
||||
setLocale('en');
|
||||
await settle();
|
||||
assert.equal(cockpit.enter(), true);
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
await settle();
|
||||
assert.match($('#map-view-switch').textContent, /EXIT RIDE/);
|
||||
// While riding.
|
||||
setLocale('de');
|
||||
await settle();
|
||||
assert.match($('#map-view-switch').textContent, /VERLASSEN/);
|
||||
assert.equal(
|
||||
$('#map-view-switch').getAttribute('aria-label'),
|
||||
'Fahrtansicht verlassen',
|
||||
);
|
||||
assert.equal($('#cockpit-hud').getAttribute('aria-label'), 'Fahrtansicht');
|
||||
assert.equal(
|
||||
$('#cockpit-look-ahead').getAttribute('aria-label'),
|
||||
'Nach vorn sehen',
|
||||
);
|
||||
});
|
||||
|
||||
test('a read that throws ends the view once; a hud that throws keeps the last', async (t) => {
|
||||
@@ -567,6 +677,21 @@ test('the most recently selected subject wins entry', async (t) => {
|
||||
selectVessel({ ...VESSEL, id: '211000099', mmsi: '211000099' });
|
||||
cockpit.syncEntry();
|
||||
assert.equal(store.presence.entryKind, 'vessel');
|
||||
// The same subject selected again comes back to the front.
|
||||
clock.advance(500);
|
||||
ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 2 };
|
||||
cockpit.syncEntry();
|
||||
assert.equal(store.presence.entryKind, 'example-kind', 're-selected');
|
||||
clock.advance(500);
|
||||
selectVessel({ ...VESSEL, id: '211000098', mmsi: '211000098' });
|
||||
cockpit.syncEntry();
|
||||
assert.equal(store.presence.entryKind, 'vessel');
|
||||
clock.advance(500);
|
||||
cockpit.syncEntry();
|
||||
assert.equal(store.presence.entryKind, 'vessel', 'an unchanged token');
|
||||
ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 3 };
|
||||
cockpit.syncEntry();
|
||||
assert.equal(store.presence.entryKind, 'example-kind');
|
||||
// The newest decides alone: its shut gate leaves no entry.
|
||||
clock.advance(500);
|
||||
ride.subject.candidate = { id: 'a3', name: 'Example 3' };
|
||||
|
||||
@@ -104,9 +104,11 @@ const BUILT_IN_ENTRY_ORDER = Object.freeze({
|
||||
* Every subject entry could ride now, the most recently selected first: a
|
||||
* tracked aircraft, the tracked satellite, the selected vessel and each
|
||||
* provider's candidate. A candidate's time is when this controller first saw
|
||||
* it (it looks on its entry cadence and on tracking changes); candidates
|
||||
* first seen together keep the fixed order (aircraft, satellite, vessel, then
|
||||
* providers by priority). Each carries `allowed()`, its kind's entry gate.
|
||||
* it (it looks on its entry cadence and on tracking changes), or saw it
|
||||
* selected again: a provider candidate whose `selectedAt` changes, the same
|
||||
* subject clicked again, moves to the front. Candidates first seen together
|
||||
* keep the fixed order (aircraft, satellite, vessel, then providers by
|
||||
* priority). Each carries `allowed()`, its kind's entry gate.
|
||||
* @param {number} [nowMs]
|
||||
* @returns {Array<{kind: string, id: string, seenAtMs: number, order: number,
|
||||
* allowed: () => boolean, provider?: object, candidate?: object}>}
|
||||
@@ -114,9 +116,10 @@ const BUILT_IN_ENTRY_ORDER = Object.freeze({
|
||||
export function entryCandidates(nowMs = performance.now()) {
|
||||
const seen = (this._entrySeen ??= new Map());
|
||||
const list = [];
|
||||
const note = (kind, id, entry) => {
|
||||
const note = (kind, id, entry, selectedAt = null) => {
|
||||
const last = seen.get(kind);
|
||||
if (!last || last.id !== id) seen.set(kind, { id, atMs: nowMs });
|
||||
if (!last || last.id !== id || last.selectedAt !== selectedAt)
|
||||
seen.set(kind, { id, selectedAt, atMs: nowMs });
|
||||
list.push({ kind, id, seenAtMs: seen.get(kind).atMs, ...entry });
|
||||
};
|
||||
const aircraft = this.readAircraftInfo();
|
||||
@@ -149,12 +152,17 @@ export function entryCandidates(nowMs = performance.now()) {
|
||||
this.subjectProviders.forEach((provider, index) => {
|
||||
const candidate = this.readSubjectCandidate(provider);
|
||||
if (candidate)
|
||||
note(provider.kind, candidate.id, {
|
||||
order: 3 + index,
|
||||
provider,
|
||||
candidate,
|
||||
allowed: () => this.isSubjectEntryAllowed(provider, candidate),
|
||||
});
|
||||
note(
|
||||
provider.kind,
|
||||
candidate.id,
|
||||
{
|
||||
order: 3 + index,
|
||||
provider,
|
||||
candidate,
|
||||
allowed: () => this.isSubjectEntryAllowed(provider, candidate),
|
||||
},
|
||||
candidate.selectedAt,
|
||||
);
|
||||
});
|
||||
// A subject gone and back counts as selected again.
|
||||
for (const kind of [...seen.keys()])
|
||||
@@ -268,6 +276,20 @@ export function syncTr3bToggle(info) {
|
||||
this.publishPresenceView();
|
||||
}
|
||||
|
||||
/**
|
||||
* Word the ridden provider subject's labels and Look ahead button again,
|
||||
* after a change of the page's language: a provider may give them as
|
||||
* functions (cockpitSubjectProviders).
|
||||
*/
|
||||
export function refreshSubjectWords() {
|
||||
const provider = this.subjectProvider;
|
||||
if (!this.active || !provider) return;
|
||||
this.presence.subjectLabels = { ...provider.labels };
|
||||
const look = provider.look;
|
||||
this.presence.subjectLook = look ? { aheadLabel: look.aheadLabel } : null;
|
||||
this.publishPresenceView();
|
||||
}
|
||||
|
||||
export function syncEntry() {
|
||||
if (this.destroyed) return false;
|
||||
if (this.active) return;
|
||||
@@ -376,8 +398,14 @@ function enterSubject(provider) {
|
||||
const candidate = this.readSubjectCandidate(provider);
|
||||
if (!candidate || !this.isSubjectEntryAllowed(provider, candidate))
|
||||
return false;
|
||||
if (this.callSubject(provider, 'hold', [candidate.id], false) !== true)
|
||||
const failed = {};
|
||||
const held = this.callSubject(provider, 'hold', [candidate.id], failed);
|
||||
if (held === failed) {
|
||||
// A hold that threw (or broke the contract) may have half happened.
|
||||
this.callSubject(provider, 'release', [candidate.id, { restore: false }]);
|
||||
return false;
|
||||
}
|
||||
if (held !== true) return false;
|
||||
const frame = cockpitSubjectFrame(
|
||||
this.callSubject(
|
||||
provider,
|
||||
@@ -419,6 +447,7 @@ function enterSubject(provider) {
|
||||
this.surfaceAcquiring = false;
|
||||
this.surfaceFallback = false;
|
||||
this.cockpitAnchorValid = false;
|
||||
this.subjectHeightM = null;
|
||||
this.lookRestPitchDeg = provider.look?.restPitchDeg ?? null;
|
||||
this.resetLook({ immediate: true });
|
||||
this.active = true;
|
||||
|
||||
Reference in new issue
Block a user