Files
gods-eye-view/src/navigationPolicy.js
T
German KhokhlovandClaude Opus 5.5 3157888780 i18n: fix the repeat-review findings
- Status pieces translate in one place (src/ui/statusText.js): a Local
  ADS-B error with a single piece now translates, and the analyst card
  splits empty-layer notes into pieces (Transit, Local ADS-B).
- Translate the ALPR retry chip text, the ECCC error on the Air quality
  card, "Upstream response too large" and the "entry {n}" feed label.
- The unmarked-text check also sees error/loadingLabel/statusMessage/
  status assignments, `status:` properties and showToast?.( /
  this._showToast( calls; the scene import preview marks its pack
  statuses, data and model-facing text carry i18n-ignore with a reason.
- Tests check the real code: the HUD locality formatter moves to
  src/hudLocalityFormat.js, the unsaved-keys test pins the teardown.
- One copy each: city names come from src/cityPois.js, the GNSS HTTP
  template and wind "field unavailable" texts; dead fallbacks removed.
- Leftovers of the reverted vessel-label change and the reformatting of
  detectionHost.test.mjs / keySetup.test.mjs are back to main.

Catalogs: es/ru 2617 messages each, complete.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:28:59 +04:00

179 lines
6.0 KiB
JavaScript

/**
* Camera-ownership policy for explicit and deferred navigation.
*
* Immediate destinations refuse Cockpit before mutation, then stamp, release,
* and fly. Deferred destinations stamp without releasing; after resolution
* they must recheck the stamp and Cockpit immediately before releasing and
* flying.
*/
import { t, msg } from './i18n/index.js';
export const NAVIGATION_AUTHORITY_EVENT = 'gev:navigation-authority-taken';
// The Cockpit refusal per destination noun. Nouns are English identifiers
// from the callers ('camera', 'location', 'photo'…);
// navigationPolicyFrame.test.mjs collects every noun the callers pass and
// fails when one has no sentence here.
const COCKPIT_REFUSALS = Object.freeze({
camera: msg('Exit cockpit to fly to a camera'),
location: msg('Exit cockpit to fly to a location'),
photo: msg('Exit cockpit to fly to a photo'),
'shared view': msg('Exit cockpit to fly to a shared view'),
target: msg('Exit cockpit to fly to a target'),
vessel: msg('Exit cockpit to fly to a vessel'),
fire: msg('Exit cockpit to fly to a fire'),
route: msg('Exit cockpit to fly to a route'),
file: msg('Exit cockpit to fly to a file'),
scene: msg('Exit cockpit to fly to a scene'),
// Radar, satellite and lightning fly to the layer's coverage area (their
// «View coverage» button); the cyclone layer flies to the selected storm.
weather: msg('Exit cockpit to view weather coverage'),
cyclone: msg('Exit cockpit to fly to a cyclone'),
// Voice track_entity (TRACKABLE_FAMILIES kinds in gevActions.js) and
// frame_overhead, which frames the contacts around the current view.
aircraft: msg('Exit cockpit to fly to an aircraft'),
satellite: msg('Exit cockpit to fly to a satellite'),
frame: msg('Exit cockpit to frame contacts'),
});
/** The destination nouns with their own Cockpit refusal (for tests). */
export const COCKPIT_REFUSAL_NOUNS = Object.freeze(
Object.keys(COCKPIT_REFUSALS),
);
function cockpitRefusal(noun) {
return t(COCKPIT_REFUSALS[noun]);
}
/**
* Announce that a layer-owned camera flight is taking navigation authority.
*
* Aircraft focus reaches the stamp for free: it assigns `viewer.trackedEntity`,
* and the UI stamps on `trackedEntityChanged`. Vessel and installation focus
* fly the camera WITHOUT ever setting a tracked entity, so they have no such
* seam — an earlier deferred geocode would still match the generation it
* captured and could resolve on top of the new Context focus. This is that
* missing seam, kept explicit so the flight and the stamp cannot drift apart.
* @param {string} reason Diagnostic label for the taking path.
* @param {object} [options] Authority options.
* @param {EventTarget} [options.eventTarget=globalThis.window] Dispatch target.
* @param {boolean} [options.cancelPendingSelection=true] Whether this is newer
* direct intent that may supersede a passive selected-entity restore.
* @returns {boolean} Whether the announcement was dispatched.
*/
export function announceNavigationAuthority(
reason,
{ eventTarget = globalThis.window, cancelPendingSelection = true } = {},
) {
if (typeof eventTarget?.dispatchEvent !== 'function') return false;
eventTarget.dispatchEvent(
new CustomEvent(NAVIGATION_AUTHORITY_EVENT, {
detail: {
reason: String(reason || 'layer-focus'),
cancelPendingSelection: Boolean(cancelPendingSelection),
},
}),
);
return true;
}
/**
* Register one authority listener and return an idempotent disposer.
* @param {EventTarget} eventTarget Listener host.
* @param {Function} listener Authority handler.
* @returns {() => void} Idempotent disposer.
*/
export function registerNavigationAuthorityListener(eventTarget, listener) {
if (
!eventTarget?.addEventListener ||
!eventTarget?.removeEventListener ||
typeof listener !== 'function'
)
return () => {};
eventTarget.addEventListener(NAVIGATION_AUTHORITY_EVENT, listener);
let disposed = false;
return () => {
if (disposed) return;
disposed = true;
eventTarget.removeEventListener(NAVIGATION_AUTHORITY_EVENT, listener);
};
}
/**
* Let a physical globe gesture supersede the delayed shared camera and Follow.
* Ordinary layer visibility/options remain authoritative, but camera intent
* from the recipient always wins over a passive selected-subject restore.
* @param {Function} stamp Navigation stamp callback.
* @returns {*} The callback result, when present.
*/
export function stampInitialShareGesture(stamp) {
return stamp?.({ cancelPendingSelection: true });
}
/**
* Run an immediate explicit camera navigation.
* @param {Object} options
* @returns {*} Navigation result, or false when disposed or Cockpit refuses.
*/
export function runExplicitNavigation({
disposed = false,
cockpitActive = false,
noun = 'target',
showToast,
stamp,
release,
navigate,
} = {}) {
if (disposed) return false;
if (cockpitActive) {
showToast?.(cockpitRefusal(noun));
return false;
}
const generation = stamp?.();
release?.();
return navigate?.(generation);
}
/**
* Accept a deferred navigation intent without releasing the current owner.
* @param {Object} options
* @returns {number|false} Generation stamp, or false when disposed or Cockpit refuses.
*/
export function beginDeferredNavigation({
disposed = false,
cockpitActive = false,
noun = 'location',
showToast,
stamp,
} = {}) {
if (disposed) return false;
if (cockpitActive) {
showToast?.(cockpitRefusal(noun));
return false;
}
return stamp?.();
}
/**
* Re-assert authority immediately before a deferred flight.
* @param {Object} options
* @returns {boolean} Whether the deferred flight still owns the camera.
*/
export function reassertNavigationHandoff({
generation,
currentGeneration,
cockpitActive = false,
disposed = false,
showToast,
release,
} = {}) {
if (disposed || generation !== currentGeneration) return false;
if (cockpitActive) {
showToast?.(cockpitRefusal('location'));
return false;
}
release?.();
return true;
}