mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Recent community wildlife observations, as a new Wildlife group in the layer panel. - Server: keyless iNaturalist proxy (v2 observations with only the projected fields; undocumented v1 UTFGrid density tiles, decoded on the server) sharing one paced queue (burst 4, ~1/s, 60/min, 8,000/day budget, up to four in flight), in-memory caches and upstream-only cooldowns; Acartia Salish Sea marine-mammal feed rounded to 0.01 degrees with comments dropped. - Layer: grid counts drawn as a soft view-ranked density glow; real sampled sightings as taxon badges at their true positions with ambient photo thumbnails and labels; obscured records stay privacy cells; a clicked sighting stays pinned through camera moves and refreshes. - Anchored photo card per selection with the single iNaturalist link; attribution in the layer row and the Data attribution popover. - Filters (CONFIRMED, taxa presets) round-trip in share links with token 9. Co-Authored-By: Claude Opus 5.5 <[email protected]>
182 lines
6.1 KiB
JavaScript
182 lines
6.1 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'),
|
|
sighting: msg('Exit cockpit to fly to a sighting'),
|
|
'sighting-area': msg('Exit cockpit to fly to a sighting area'),
|
|
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;
|
|
}
|