mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +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
+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;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user