mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
With no aircraft tracked, Cockpit can ride the selected AIS vessel. Entry needs only Live AIS Vessels and a selected vessel; C, double-click and the entry buttons (reading BRIDGE with a ship icon) enter it, while voice stays aircraft-only. The eye sits on the AIS position at a representative bridge height for the vessel's type family, looks along true heading and moves along course over ground. The last fix is dead-reckoned for up to 180 s between the layer's refreshes, and on rivers and lakes the waterline is read from the rendered surface around the ship rather than the geoid. The bridge HUD shows speed and course over ground, a straight heading tape, window mullions, the AIS destination as a reported voyage and a heading-up radar of nearby ships (3 to 24 NM). The arrow keys look around, Home or 0 looks ahead again. The vessel layer hides the ridden ship's sprite and card, ignores scene clicks while held, and returns the camera to the vessel's map view on exit.
488 lines
18 KiB
JavaScript
488 lines
18 KiB
JavaScript
import { msg } from './i18n/marker.js';
|
|
|
|
const CONTEXT_DEPENDENCIES = Object.freeze({
|
|
flights: new Set([
|
|
'military-awareness',
|
|
'flights',
|
|
'military',
|
|
'ais-live-vessels',
|
|
'military-installations',
|
|
]),
|
|
'space-missions': new Set(['rocket-launches', 'satellites']),
|
|
});
|
|
const CONTEXT_COMPANIONS = new Set(['radio']);
|
|
/** The Space Missions refusal, with the blocked layer's name as {layer}. */
|
|
export const SPACE_MISSIONS_LAYER_BLOCK_REASON = msg(
|
|
'Space Missions isolates replay data. Exit the mode to enable {layer}.',
|
|
);
|
|
const [BLOCK_REASON_PREFIX, BLOCK_REASON_SUFFIX] =
|
|
SPACE_MISSIONS_LAYER_BLOCK_REASON.split('{layer}');
|
|
/** Return whether an origin represents a direct user choice on this route. */
|
|
export function isExplicitUserIntentOrigin(origin, layerId = null) {
|
|
return origin === 'user' || origin === 'voice';
|
|
}
|
|
|
|
/** Preserve explicit layer intent that completes while a stale restore is queued. */
|
|
export function recordContextRestoreExplicitChange({ restoreState, change }) {
|
|
if (
|
|
!restoreState?.enabledLayerIds ||
|
|
change?.type !== 'visibility' ||
|
|
!isExplicitUserIntentOrigin(change.origin, change.layerId)
|
|
)
|
|
return false;
|
|
if (!restoreState.explicitLayerStates)
|
|
restoreState.explicitLayerStates = new Map();
|
|
restoreState.explicitLayerStates.set(change.layerId, Boolean(change.enabled));
|
|
if (change.enabled) restoreState.enabledLayerIds.add(change.layerId);
|
|
else restoreState.enabledLayerIds.delete(change.layerId);
|
|
return true;
|
|
}
|
|
|
|
/** Settle every explicit Context-restore replay without losing semantic failures. */
|
|
export async function settleContextIntentReplay({
|
|
restoreState,
|
|
setEnabled,
|
|
notificationToken = null,
|
|
}) {
|
|
if (restoreState?.cancelled) return null;
|
|
const entries = [...(restoreState?.explicitLayerStates || [])];
|
|
const results = await Promise.allSettled(
|
|
entries.map(([layerId, enabled]) =>
|
|
setEnabled(layerId, enabled, {
|
|
origin: 'context-intent-replay',
|
|
...(notificationToken ? { notificationToken } : {}),
|
|
}),
|
|
),
|
|
);
|
|
const failedIndexes = results.flatMap((result, index) =>
|
|
result.status === 'rejected' || result.value === false ? [index] : [],
|
|
);
|
|
if (failedIndexes.length === 0) return null;
|
|
const failedLayerIds = failedIndexes.map((index) => entries[index][0]);
|
|
const rejected = failedIndexes
|
|
.map((index) => results[index])
|
|
.find((result) => result.status === 'rejected');
|
|
const error =
|
|
rejected?.reason instanceof Error
|
|
? rejected.reason
|
|
: new Error(
|
|
`Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`,
|
|
);
|
|
error.failedLayerIds = [
|
|
...new Set([
|
|
...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []),
|
|
...failedLayerIds,
|
|
]),
|
|
];
|
|
return error;
|
|
}
|
|
|
|
/** Preserve the primary Context failure while aggregating every failed layer. */
|
|
export function mergeContextTransitionErrors(primaryError, secondaryError) {
|
|
if (!primaryError) return secondaryError || null;
|
|
if (!secondaryError) return primaryError;
|
|
primaryError.failedLayerIds = [
|
|
...new Set([
|
|
...(Array.isArray(primaryError.failedLayerIds)
|
|
? primaryError.failedLayerIds
|
|
: []),
|
|
...(Array.isArray(secondaryError.failedLayerIds)
|
|
? secondaryError.failedLayerIds
|
|
: []),
|
|
]),
|
|
];
|
|
return primaryError;
|
|
}
|
|
|
|
/** Settle a user-facing Context action and convert every failure form to false. */
|
|
export async function settleUserFacingContextAction({
|
|
operation,
|
|
onFailure,
|
|
falseIsFailure = true,
|
|
}) {
|
|
try {
|
|
const result = await operation();
|
|
if (falseIsFailure && result === false)
|
|
throw new Error('Context transition did not complete');
|
|
return result;
|
|
} catch (error) {
|
|
try {
|
|
onFailure?.(error);
|
|
} catch {
|
|
// A broken error surface must not recreate the unhandled rejection.
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Layers whose user-owned enable IS a Context entry. They own the session,
|
|
* so they are never bookkept as layers the user added *during* it — filing
|
|
* one into `userAdded` makes exit restoration re-enable the mode layer the
|
|
* user just turned off.
|
|
*/
|
|
export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze([
|
|
'military-awareness',
|
|
'rocket-launches',
|
|
]);
|
|
|
|
/**
|
|
* Leave a Context transaction: publish the settled coordination flag, then
|
|
* re-run the owner's context funnel so every SETTLE-GATED consumer sees the
|
|
* final state.
|
|
*
|
|
* That second half is load-bearing. Consumers gated on `!_contextModeChanging`
|
|
* — the Contacts detection override among them — deliberately no-op while a
|
|
* transaction is in flight, so a path that mutates `_contextMode` mid-flight and
|
|
* then merely drops the flag leaves them holding the pre-transaction state
|
|
* forever. (Field case: destroying a Contacts dependency layer exited the
|
|
* session but left detection forced on.)
|
|
*
|
|
* A nested scope handing control back to a still-running outer transaction has
|
|
* settled nothing, so the funnel only runs on a real settle.
|
|
*
|
|
* @param {{_contextModeChanging?: boolean, _syncContextModeButtons?: () => void}} owner
|
|
* @param {boolean} [changing=false] Flag state to publish.
|
|
* @returns {void}
|
|
*/
|
|
export function settleContextModeChange(owner, changing = false) {
|
|
if (!owner) return;
|
|
const wasChanging = Boolean(owner._contextModeChanging);
|
|
owner._contextModeChanging = Boolean(changing);
|
|
if (wasChanging && !owner._contextModeChanging)
|
|
owner._syncContextModeButtons?.();
|
|
}
|
|
|
|
/**
|
|
* Run an asynchronous Context transition while preserving the caller's prior
|
|
* coordination state. This matters during teardown: restoration notifications
|
|
* must remain suppressed only for the duration of the restore, not forever.
|
|
*
|
|
* @param {{_contextModeChanging?: boolean}} owner Context coordinator.
|
|
* @param {() => Promise<*>|*} operation Transition work.
|
|
* @returns {Promise<*>} The operation result.
|
|
*/
|
|
export async function runWithContextModeChanging(owner, operation) {
|
|
const wasChanging = Boolean(owner?._contextModeChanging);
|
|
owner._contextModeChanging = true;
|
|
try {
|
|
return await operation();
|
|
} finally {
|
|
settleContextModeChange(owner, wasChanging);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decide whether a manual Data Layer change should hand control away from the
|
|
* active Context mode. User-enabled layers are additive and keep the active
|
|
* mode running. Manually disabling one of the mode's dependencies requests
|
|
* exit; programmatic dependency changes remain allowed.
|
|
*
|
|
* @param {object} input Context and layer-change state.
|
|
* @param {string|null} input.contextMode Active Context mode.
|
|
* @param {boolean} input.globalContextEnabled Whether the neutral/Contacts shell is enabled.
|
|
* @param {object|null} input.change DataLayerManager visibility notification.
|
|
* @returns {boolean} Whether the active Context bundle should be disabled.
|
|
*/
|
|
export function shouldExitContextForLayerChange({
|
|
contextMode,
|
|
globalContextEnabled,
|
|
change,
|
|
}) {
|
|
if (
|
|
change?.type !== 'visibility' ||
|
|
!isExplicitUserIntentOrigin(change.origin, change.layerId) ||
|
|
change.layerId === 'military-awareness' ||
|
|
CONTEXT_COMPANIONS.has(change.layerId)
|
|
) {
|
|
return false;
|
|
}
|
|
const active = Boolean(globalContextEnabled || contextMode);
|
|
if (!active) return false;
|
|
|
|
const dependencies =
|
|
CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']);
|
|
if (change.enabled) return false;
|
|
return Boolean(contextMode && dependencies.has(change.layerId));
|
|
}
|
|
|
|
/**
|
|
* Return the only layers allowed while a Context mode is being activated.
|
|
* @param {string|null} contextMode Context mode or neutral shell.
|
|
* @returns {Set<string>} Allowed layer identifiers.
|
|
*/
|
|
export function contextAllowedLayerIds(contextMode) {
|
|
return new Set([
|
|
...(CONTEXT_DEPENDENCIES[contextMode] || ['military-awareness']),
|
|
...CONTEXT_COMPANIONS,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Decide whether an accepted intent or pre-transition notification represents a user-owned
|
|
* Context entry whose pre-entry state must be captured. Internal programmatic,
|
|
* dependency, and restoration enables must never create or replace a user
|
|
* restoration snapshot.
|
|
* @param {object|null} change DataLayerManager visibility notification.
|
|
* @returns {boolean} Whether the Context session should capture now.
|
|
*/
|
|
export function shouldCaptureContextSession(change) {
|
|
return Boolean(
|
|
['visibility-requested', 'visibility-will-change'].includes(change?.type) &&
|
|
isExplicitUserIntentOrigin(change.origin, change.layerId) &&
|
|
change.enabled &&
|
|
CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Return whether one explicit mission intent superseded an in-flight Clear All
|
|
* reservation and therefore needs deferred Context adoption.
|
|
*/
|
|
export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) {
|
|
return Boolean(
|
|
clearInFlight &&
|
|
change?.type === 'visibility-requested' &&
|
|
change.layerId === 'rocket-launches' &&
|
|
change.enabled === true &&
|
|
isExplicitUserIntentOrigin(change.origin, change.layerId) &&
|
|
Number.isInteger(change.intentEpoch),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Bookkeep a user-owned layer change against the active Context session so
|
|
* exit restoration can honor layers the user added mid-session. Two rules
|
|
* keep restoration exact:
|
|
* - Context ENTRY layers are never recorded — they own the session and must
|
|
* not survive their own exit.
|
|
* - The allowed set is judged against the EFFECTIVE mode (a mode still being
|
|
* entered counts), because the entry layer's own enable event lands before
|
|
* the mode is committed.
|
|
* @param {object} input Session and change state.
|
|
* @param {{userAdded: Set<string>, userRemoved?: Set<string>}|null} input.snapshot Active session snapshot.
|
|
* @param {object|null} input.change DataLayerManager visibility notification.
|
|
* @param {string|null} [input.effectiveContextMode] Committed or entering mode.
|
|
* @returns {boolean} Whether the snapshot's userAdded set was modified.
|
|
*/
|
|
export function recordContextSessionUserChange({
|
|
snapshot,
|
|
change,
|
|
effectiveContextMode = null,
|
|
}) {
|
|
if (
|
|
!snapshot?.userAdded ||
|
|
change?.type !== 'visibility' ||
|
|
!isExplicitUserIntentOrigin(change.origin, change.layerId)
|
|
) {
|
|
return false;
|
|
}
|
|
const isCompanion = CONTEXT_COMPANIONS.has(change.layerId);
|
|
if (change.enabled) {
|
|
if (CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId)) return false;
|
|
if (change.adoptedFromSelection) {
|
|
snapshot.userAdded.add(change.layerId);
|
|
return true;
|
|
}
|
|
if (
|
|
contextAllowedLayerIds(effectiveContextMode).has(change.layerId) &&
|
|
!isCompanion
|
|
)
|
|
return false;
|
|
snapshot.userAdded.add(change.layerId);
|
|
if (isCompanion) snapshot.userRemoved?.delete(change.layerId);
|
|
return true;
|
|
}
|
|
const removedAddition = snapshot.userAdded.delete(change.layerId);
|
|
if (isCompanion && snapshot.userRemoved) {
|
|
const wasRemoved = snapshot.userRemoved.has(change.layerId);
|
|
snapshot.userRemoved.add(change.layerId);
|
|
return removedAddition || !wasRemoved;
|
|
}
|
|
return removedAddition;
|
|
}
|
|
|
|
/**
|
|
* Return the authoritative layer set for a new Context snapshot. When a prior
|
|
* session is still restoring, the settled restore target is authoritative;
|
|
* reading the manager mid-restore would capture a partial, order-dependent set.
|
|
* @param {Iterable<string>} currentEnabledLayerIds Manager state at capture time.
|
|
* @param {Iterable<string>|null} restoringLayerIds Pending restore target, if any.
|
|
* @param {Iterable<string>} excludeLayerIds Entry-layer intents that were
|
|
* already published synchronously but did not exist before this session.
|
|
* @returns {Set<string>} Detached pre-entry layer set.
|
|
*/
|
|
export function contextSnapshotLayerIds(
|
|
currentEnabledLayerIds,
|
|
restoringLayerIds = null,
|
|
excludeLayerIds = [],
|
|
) {
|
|
const snapshot = new Set(restoringLayerIds || currentEnabledLayerIds || []);
|
|
for (const layerId of excludeLayerIds || []) snapshot.delete(layerId);
|
|
return snapshot;
|
|
}
|
|
|
|
/**
|
|
* Block layers that would mix unrelated live/current data into Space Missions.
|
|
* The manager calls this before lifecycle work so a refused layer never starts.
|
|
* @param {object} input Current mode and requested visibility transition.
|
|
* @param {string|null} input.contextMode Active Context mode.
|
|
* @param {object|null} input.change Requested visibility transition.
|
|
* @param {string|null} [input.layerName] User-facing layer name.
|
|
* @returns {string|null} Honest user-facing refusal reason, or null when allowed.
|
|
*/
|
|
export function contextLayerEnableBlockReason({
|
|
contextMode,
|
|
change,
|
|
layerName = null,
|
|
}) {
|
|
if (
|
|
contextMode !== 'space-missions' ||
|
|
change?.enabled !== true ||
|
|
contextAllowedLayerIds('space-missions').has(change.layerId)
|
|
) {
|
|
return null;
|
|
}
|
|
const label = String(layerName || change.layerId || 'that layer');
|
|
return SPACE_MISSIONS_LAYER_BLOCK_REASON.replace('{layer}', label);
|
|
}
|
|
|
|
/**
|
|
* The layer name from a Space Missions refusal, so the UI can rebuild the
|
|
* sentence in its language.
|
|
* @param {string} reason
|
|
* @returns {{layer: string}|null} The blocked layer's name, or null when the
|
|
* reason is not a Space Missions refusal.
|
|
*/
|
|
export function parseContextLayerBlockReason(reason) {
|
|
if (
|
|
typeof reason !== 'string' ||
|
|
!reason.startsWith(BLOCK_REASON_PREFIX) ||
|
|
!reason.endsWith(BLOCK_REASON_SUFFIX) ||
|
|
reason.length <= BLOCK_REASON_PREFIX.length + BLOCK_REASON_SUFFIX.length
|
|
)
|
|
return null;
|
|
return {
|
|
layer: reason.slice(
|
|
BLOCK_REASON_PREFIX.length,
|
|
reason.length - BLOCK_REASON_SUFFIX.length,
|
|
),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve who owns a cancelled direct Space Missions entry. A newer ON keeps
|
|
* the entry shell reserved for its replacement transaction; every other
|
|
* cancellation must roll back the isolated pre-entry session.
|
|
*
|
|
* @param {object} input Cancellation and current manager intent state.
|
|
* @param {object|null} input.change Manager visibility notification.
|
|
* @returns {'ignore'|'replacement'|'restore'} Cancellation disposition.
|
|
*/
|
|
export function spaceMissionEntryCancellationDisposition({ change }) {
|
|
if (
|
|
change?.type !== 'visibility-cancelled' ||
|
|
change.layerId !== 'rocket-launches' ||
|
|
change.enabled !== true
|
|
)
|
|
return 'ignore';
|
|
if (
|
|
change.cancellationReason === 'superseded' &&
|
|
Number.isInteger(change.successorIntentEpoch) &&
|
|
change.successorIntentEpoch > change.intentEpoch &&
|
|
change.successorEnabled === true
|
|
)
|
|
return 'replacement';
|
|
return 'restore';
|
|
}
|
|
|
|
/**
|
|
* Merge the pre-entry layer snapshot with layers the user enabled during the
|
|
* Context session so both survive restoration.
|
|
* @param {{enabledLayerIds?: Iterable<string>, userAdded?: Iterable<string>, userRemoved?: Iterable<string>}} snapshot Session snapshot.
|
|
* @returns {Set<string>} Layer identifiers to restore.
|
|
*/
|
|
export function contextRestoreLayerIds(snapshot = {}) {
|
|
const restored = new Set([
|
|
...(snapshot.enabledLayerIds || []),
|
|
...(snapshot.userAdded || []),
|
|
]);
|
|
for (const layerId of snapshot.userRemoved || []) restored.delete(layerId);
|
|
return restored;
|
|
}
|
|
|
|
/** The layers an aircraft Cockpit subject can come from. */
|
|
export const COCKPIT_AIRCRAFT_LAYER_IDS = Object.freeze([
|
|
'flights',
|
|
'military',
|
|
]);
|
|
|
|
/**
|
|
* Cockpit entry needs only the aircraft it rides: that aircraft's own layer
|
|
* (Live Flights for a civilian, Military Flights for a military aircraft) must
|
|
* be on. Contacts and the other feeds are not required. When a source the
|
|
* surrounding picture uses is off, the Cockpit's Contact card and Live
|
|
* Signals say the picture is partial instead of blocking entry, so a partial
|
|
* picture is never presented as complete. A Context mode change in flight
|
|
* still owns the camera, so entry waits it out.
|
|
*
|
|
* @param {object} input Current subject and Context state.
|
|
* @param {string|null} input.subjectLayerId Layer of the tracked aircraft
|
|
* ('flights' or 'military'), or null with none tracked.
|
|
* @param {boolean} input.subjectLayerEnabled Whether that layer is enabled.
|
|
* @param {boolean} input.contextModeChanging Whether a Context transaction is unsettled.
|
|
* @returns {boolean} Whether cockpit entry may be offered or activated.
|
|
*/
|
|
export function cockpitEntryAllowed({
|
|
subjectLayerId,
|
|
subjectLayerEnabled,
|
|
contextModeChanging,
|
|
}) {
|
|
return (
|
|
COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) &&
|
|
Boolean(subjectLayerEnabled) &&
|
|
!contextModeChanging
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether the selected vessel's bridge view can be entered. Like aircraft
|
|
* Cockpit it needs only its own layer, Live AIS Vessels, with a vessel
|
|
* selected. A Context mode change in flight still owns the camera, so entry
|
|
* waits it out.
|
|
* @param {{vesselsEnabled: boolean, contextModeChanging: boolean}} state
|
|
* @returns {boolean}
|
|
*/
|
|
export function bridgeEntryAllowed({ vesselsEnabled, contextModeChanging }) {
|
|
return Boolean(vesselsEnabled) && !contextModeChanging;
|
|
}
|
|
|
|
/**
|
|
* Internal context-mode id → the word the voice tools accept for it.
|
|
*
|
|
* `set_context_mode` takes 'contacts'; the mode's internal id is 'flights'.
|
|
* The mapping lives here, beside the rest of the context-mode policy, so the
|
|
* UI's operator-facing text and the voice payload translator cannot drift
|
|
* apart — one saying "flights" while the other says "contacts" is the bug
|
|
* this whole seam exists to prevent.
|
|
*/
|
|
export const CONTEXT_MODE_VOICE_NAMES = Object.freeze({
|
|
flights: 'contacts',
|
|
'space-missions': 'space-missions',
|
|
});
|
|
|
|
/**
|
|
* Name a context mode in the vocabulary the tools and the operator share.
|
|
* @param {string|null|undefined} internalMode Internal mode id.
|
|
* @param {object} [options]
|
|
* @param {string|null} [options.emptyAs='off'] What "no mode" is called. 'off'
|
|
* for a state field that always names a mode; null where an absent value
|
|
* means "none at all" (nothing is entering, there was no prior mode) and
|
|
* calling it 'off' would assert something untrue.
|
|
* @returns {string|null} The shared word for this mode.
|
|
*/
|
|
export function contextModeWord(internalMode, { emptyAs = 'off' } = {}) {
|
|
if (!internalMode) return emptyAs;
|
|
return CONTEXT_MODE_VOICE_NAMES[internalMode] || internalMode;
|
|
}
|