Files
gods-eye-view/src/navigationPolicy.js
T
Said Turksever 4ebb287d36 Merge main into the Utilities panel, rebuilt on the Vue panel rail
main moved the panels to Vue views and a one-open-panel rail, and removed
the two templates this branch edited. This merge carries the branch onto
that architecture; it adds no behaviour of its own beyond the port.

- The Utilities panel is a RailPanel in LayerPanelsView with its own rail
  item (handyman). LayerPanel renders the Directions rows into its list with
  LayerRows, and falls back to Data Layers without it.
- Draw leaves DisplayPanel for DrawControls, DrawMarkList and DrawSwatches,
  which render what the draw tool publishes to the display store and call
  its actions, as GeoImport does. drawMarks.js words the rows; the DOM-built
  drawMarkList.js is gone. A row's fly-to is an immediate navigation like
  Import's ('drawing' gets its Cockpit refusal), the name field keeps a
  draft through v-field-value, and the colours are one radio group with a
  single tab stop.
- UiSegmented draws an option's icon, named by its label, for the shape
  buttons.
- The catalogs take main's languages with this branch's strings, Arabic
  included, and the tests follow the views to their new homes.
2026-11-02 16:43:29 +00:00

180 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'),
drawing: msg('Exit cockpit to fly to a drawing'),
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;
}