mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
The transit, ALPR, vessel, CCTV and flight/military context fixes each wrapped 359.5° to north with an inline Math.round(x) % 360. They now call roundHeadingDegrees in src/data/headingDegrees.js, which also wraps negative and multi-turn bearings. normalizeHeading moves there with it, and cockpitMath re-exports it so existing imports keep working. It now adds a turn only to a negative remainder, so a bearing just under half a degree keeps its precision. The awareness, Local ADS-B, cockpit wind, cockpit heading and Street Level readouts use the same helper. The Cockpit's TRACK ACQUIRED signal rounded without wrapping and could read COURSE 360°; it now uses formatHeadingReadout, which shows --- for a track that is not finite.
379 lines
13 KiB
JavaScript
379 lines
13 KiB
JavaScript
import { getLocale, keepCodesWithValues, msg, t, tn } from '../i18n/index.js';
|
|
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
|
|
|
|
import { createCctvVideoSurface } from './cctvVideo.js';
|
|
import { presentCctvCalibration } from './cctvCalibration.js';
|
|
import { roundHeadingDegrees } from '../data/headingDegrees.js';
|
|
export function _calBadgeLabel(badge) {
|
|
switch (badge) {
|
|
case 'calibrated':
|
|
return t('CALIBRATED');
|
|
case 'curated':
|
|
return t('CURATED');
|
|
case 'raw-prior':
|
|
return t('RAW PRIOR');
|
|
default:
|
|
return '--';
|
|
}
|
|
}
|
|
|
|
/** A picker option for a camera. */
|
|
function pickerOption(camera, id = camera.id) {
|
|
return { value: id, label: `${camera.city} · ${camera.name}` };
|
|
}
|
|
|
|
/** The picker's closing line for the cameras the view leaves out. */
|
|
function outsideNoteText(count) {
|
|
return tn(
|
|
count,
|
|
'{count} more camera outside this view · pan or zoom out',
|
|
'{count} more cameras outside this view · pan or zoom out',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The panel's buttons, CAL chip and meta line for a camera state, as the CCTV
|
|
* panel view renders them. Pure: the picker, frame, source badge, calibration
|
|
* readout and summary stay with the controls.
|
|
* @param {object|null} state Camera layer UI state.
|
|
* @param {boolean} enabled Whether the layer is on.
|
|
*/
|
|
export function presentCctvPanel(state, enabled) {
|
|
const cameras = state?.cameraIndex || state?.cameras || [];
|
|
const catalogCount = Number.isFinite(state?.count)
|
|
? state.count
|
|
: cameras.length;
|
|
const activeId = state?.activeCameraId || '';
|
|
const activeCamera = state?.activeCamera || null;
|
|
// Tri-state (viewshed design §3b): off → on (wireframes) → viewshed
|
|
// (color-coded volumes). The click handler cycles; this renders.
|
|
const coverage = state?.coverageMode || (state?.showCoverage ? 'on' : 'off');
|
|
const autoHop = !!state?.autoHop;
|
|
const showProjection = state?.showProjection !== false;
|
|
// CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only, no
|
|
// in-world tint): three states from cctv.js's deriveCalBadge. Unsaved live
|
|
// edits show EDITED over the persisted state (viewshed design §3e): SAVE
|
|
// CAL promotes to CALIBRATED, RESET CAL clears.
|
|
const badge = activeCamera?.calBadge || null;
|
|
const dirty = !!activeCamera?.calDirty;
|
|
return {
|
|
enabled,
|
|
navDisabled: !enabled || catalogCount === 0,
|
|
focusDisabled: !enabled || catalogCount === 0 || !activeId,
|
|
toggleDisabled: !enabled,
|
|
coverage: {
|
|
active: coverage !== 'off',
|
|
text:
|
|
coverage === 'viewshed'
|
|
? t('VIEWSHED ON')
|
|
: coverage === 'on'
|
|
? t('COVERAGE ON')
|
|
: t('COVERAGE OFF'),
|
|
},
|
|
autoHop: {
|
|
active: autoHop,
|
|
text: autoHop ? t('AUTO HOP ON') : t('AUTO HOP OFF'),
|
|
},
|
|
projection: {
|
|
active: showProjection,
|
|
text: showProjection ? t('PROJECTION ON') : t('PROJECTION OFF'),
|
|
},
|
|
quality: {
|
|
label: dirty
|
|
? t('CAL · EDITED (UNSAVED)')
|
|
: t('CAL · {badge}', { badge: _calBadgeLabel(badge) }),
|
|
calBadge: dirty ? 'edited' : badge || '',
|
|
},
|
|
meta: metaText(state, enabled, activeCamera, catalogCount),
|
|
calibration: presentCctvCalibration(state, enabled),
|
|
};
|
|
}
|
|
|
|
function metaText(state, enabled, activeCamera, catalogCount) {
|
|
if (activeCamera) {
|
|
const provider = cctvSourceLabelText(
|
|
activeCamera.sourceLabel || activeCamera.provider || 'Configured Source',
|
|
);
|
|
const statusMsg = activeCamera.sourceMessage
|
|
? ` · ${cctvSourceMessageText(activeCamera.sourceMessage)}`
|
|
: '';
|
|
// A partner-supplied feed inside a pack names its owner here.
|
|
const credit = activeCamera.credit ? ` · ${activeCamera.credit}` : '';
|
|
const calBadge = activeCamera.calBadge
|
|
? _calBadgeLabel(activeCamera.calBadge)
|
|
: '';
|
|
const projLabel = state?.showProjection !== false ? t('MONITOR') : t('OFF');
|
|
return keepCodesWithValues(
|
|
t(
|
|
'{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
|
|
{
|
|
city: activeCamera.city,
|
|
heading: `${roundHeadingDegrees(Number(activeCamera.headingDeg)) ?? 0}°`,
|
|
fov: `${Math.round(activeCamera.fovDeg)}°`,
|
|
range: Math.round(activeCamera.rangeM),
|
|
projection: projLabel,
|
|
calBadge: calBadge ? ` · ${calBadge}` : '',
|
|
provider,
|
|
status: `${credit}${statusMsg}`,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
if (catalogCount > 0)
|
|
return enabled
|
|
? tn(
|
|
catalogCount,
|
|
'{count} camera loaded · click a camera to activate',
|
|
'{count} cameras loaded · click a camera to activate',
|
|
)
|
|
: tn(
|
|
catalogCount,
|
|
'{count} camera loaded · enable CCTV to activate',
|
|
'{count} cameras loaded · enable CCTV to activate',
|
|
);
|
|
return t('Enable CCTV to load camera intersections');
|
|
}
|
|
|
|
export function _renderCctvState(state) {
|
|
if (this.destroyed) return;
|
|
if (
|
|
!state?.enabled ||
|
|
!this.actions.isEnabled() ||
|
|
state?.activeCameraId !== this._cctvState?.activeCameraId
|
|
) {
|
|
this._calibrationEdit?.(false);
|
|
}
|
|
this._cctvState = state || null;
|
|
// The light index, not `state.cameras`: reading that builds every camera's
|
|
// full state, which a large catalog cannot afford on each notification.
|
|
const cameras = state?.cameraIndex || state?.cameras || [];
|
|
// The picker lists what is in view; the catalog is every camera.
|
|
const catalogCount = Number.isFinite(state?.count)
|
|
? state.count
|
|
: cameras.length;
|
|
const enabled = !!state?.enabled && !!this.actions.isEnabled();
|
|
const activeId = state?.activeCameraId || '';
|
|
const activeCamera = state?.activeCamera || null;
|
|
|
|
// Auto-expand the panel when the active camera CHANGES to a new non-null
|
|
// id while the layer is enabled. Covers click-on-globe, panel controls,
|
|
// and voice (selectCamera/cycleCamera/focusNearest all notify through
|
|
// this subscription). The last-seen guard keeps routine notifications
|
|
// from re-expanding a panel the user deliberately collapsed, and timed
|
|
// auto-hop transitions only expand on the first activation so the panel
|
|
// does not pop open on every hop.
|
|
const effectiveActiveId = enabled ? activeId || null : null;
|
|
const isFirstActivation = this._lastSeenCctvActiveId === null;
|
|
if (
|
|
effectiveActiveId &&
|
|
effectiveActiveId !== this._lastSeenCctvActiveId &&
|
|
(!state?.autoHop || isFirstActivation)
|
|
) {
|
|
this.actions.setPanelCollapsed('cctv-panel', false, {
|
|
explicit: Boolean(state?.explicitSelection),
|
|
});
|
|
}
|
|
this._lastSeenCctvActiveId = effectiveActiveId;
|
|
|
|
this._updateCctvSyncChip(state?.loading, enabled);
|
|
|
|
if (this._cctvSelect) {
|
|
// A focused picker keeps the options it shows; blur re-renders it. One
|
|
// about to be disabled cannot hold focus, so it does not count.
|
|
const disabled = !enabled || cameras.length === 0;
|
|
const picking = document.activeElement === this._cctvSelect && !disabled;
|
|
// Cameras the view leaves out get one closing line, so a short list
|
|
// does not read as the whole catalog. A disabled layer has no view.
|
|
const outside = enabled ? Math.max(0, catalogCount - cameras.length) : 0;
|
|
let picker = this._picker;
|
|
// The index keeps its identity until the view or the active camera
|
|
// changes, so an unchanged list keeps its options; a new locale words
|
|
// the closing line again.
|
|
if (
|
|
!picking &&
|
|
(picker?.source !== cameras ||
|
|
picker.outside !== outside ||
|
|
picker.locale !== getLocale())
|
|
)
|
|
picker = {
|
|
source: cameras,
|
|
outside,
|
|
locale: getLocale(),
|
|
options: cameras.map((camera) => pickerOption(camera)),
|
|
note: outside ? outsideNoteText(outside) : '',
|
|
};
|
|
else if (
|
|
picking &&
|
|
activeId &&
|
|
activeCamera &&
|
|
!picker?.options.some((option) => option.value === activeId)
|
|
) {
|
|
// A deferred rebuild can miss a camera activated meanwhile (auto-hop,
|
|
// voice); add it so the picker does not go blank. It is one fewer
|
|
// outside the view.
|
|
const options = [
|
|
pickerOption(activeCamera, activeId),
|
|
...(picker?.options || []),
|
|
];
|
|
const left = Math.max(0, catalogCount - options.length);
|
|
picker = {
|
|
...picker,
|
|
options,
|
|
note: picker?.note && left ? outsideNoteText(left) : '',
|
|
};
|
|
}
|
|
this._picker = picker;
|
|
this.store.setPicker({
|
|
options: picker?.options || [],
|
|
note: picker?.note || '',
|
|
disabled,
|
|
// The active camera is always one of the listed records. With nothing
|
|
// active the closed picker shows the note when nothing is in view; a
|
|
// focused picker keeps its old options, so it only clears.
|
|
selection:
|
|
activeId && activeCamera
|
|
? { value: activeId }
|
|
: !activeId
|
|
? { index: !picking && cameras.length === 0 && outside ? 0 : -1 }
|
|
: null,
|
|
});
|
|
}
|
|
|
|
this.store.publish(presentCctvPanel(state, enabled));
|
|
|
|
const liveIntent = enabled && !!activeCamera?.isVideo;
|
|
this.store.setLive(liveIntent);
|
|
if (this._cctvVideo) {
|
|
const visible =
|
|
liveIntent &&
|
|
!document.hidden &&
|
|
!this._cctvPanel?.classList.contains('collapsed');
|
|
if (!visible || this._cctvVideoCameraId !== activeId) {
|
|
this._cctvVideoSurface?.stop();
|
|
this._cctvVideoSurface = null;
|
|
}
|
|
this._cctvVideoCameraId = activeId;
|
|
if (visible && !this._cctvVideoSurface) {
|
|
this._cctvVideoSurface = createCctvVideoSurface(this._cctvVideo, () =>
|
|
this.cctv.getActiveVideoElement?.(),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (this._cctvFrame && !liveIntent) {
|
|
const nextSrc = enabled ? activeCamera?.frameUrl : null;
|
|
const nextCameraId = enabled ? activeCamera?.id || '' : '';
|
|
const cameraChanged = this._frame.cameraId !== nextCameraId;
|
|
const frameLoading = this._frame.loading;
|
|
// A same-camera refresh waits for the current image to settle. Replacing
|
|
// src every 10 seconds can cancel a slow but healthy decode forever and
|
|
// leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are
|
|
// immediate so navigation never waits on the prior camera's request.
|
|
if (
|
|
nextSrc &&
|
|
(cameraChanged || (!frameLoading && this._frame.currentSrc !== nextSrc))
|
|
) {
|
|
this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged);
|
|
}
|
|
if (!nextSrc) {
|
|
this._clearCctvFrame();
|
|
}
|
|
} else if (liveIntent) {
|
|
this._clearCctvFrame();
|
|
}
|
|
|
|
this._syncCctvSourceBadge(activeCamera, enabled);
|
|
this._typeCctvSummary(
|
|
state?.summary ||
|
|
t('Enable CCTV to start camera-linked intelligence summaries.'),
|
|
);
|
|
}
|
|
|
|
export function _typeCctvSummary(text) {
|
|
if (this.destroyed || !this._cctvSummary) return;
|
|
const nextText = String(text || '').trim() || t('No summary available.');
|
|
if (nextText === this._lastCctvSummaryText) return;
|
|
this._lastCctvSummaryText = nextText;
|
|
|
|
clearInterval(this._cctvSummaryTypingTimer);
|
|
this.store.setSummary('');
|
|
let idx = 0;
|
|
this._cctvSummaryTypingTimer = setInterval(() => {
|
|
if (this.destroyed) return;
|
|
idx += 3;
|
|
if (idx >= nextText.length) {
|
|
this.store.setSummary(nextText);
|
|
clearInterval(this._cctvSummaryTypingTimer);
|
|
this._cctvSummaryTypingTimer = null;
|
|
return;
|
|
}
|
|
this.store.setSummary(nextText.slice(0, idx));
|
|
}, 20);
|
|
}
|
|
|
|
/** Publish a change to the camera grid chip; its label stays while hidden. */
|
|
export function _publishCctvChip(patch) {
|
|
const current = this._cctvChip ?? {
|
|
visible: false,
|
|
labelKey: msg('loading frames'),
|
|
immediate: false,
|
|
progress: '0/0',
|
|
stopped: false,
|
|
};
|
|
// The chip keeps its English key, so a new locale shows it again; the
|
|
// view gets the label alone.
|
|
const next = { ...current, ...patch };
|
|
next.label = t(next.labelKey);
|
|
if (
|
|
this._cctvChip &&
|
|
Object.keys(next).every((key) => next[key] === current[key])
|
|
)
|
|
return;
|
|
this._cctvChip = next;
|
|
const { labelKey, ...chip } = next;
|
|
this.statusStore.publishCctv(chip);
|
|
}
|
|
|
|
export function _updateCctvSyncChip(loading, enabled) {
|
|
if (this.destroyed) return;
|
|
const total = Number(loading?.total) || 0;
|
|
const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total));
|
|
const busy = !!enabled && !!loading?.active && total > 0;
|
|
|
|
if (busy) {
|
|
clearTimeout(this._cctvChipHideTimer);
|
|
this._cctvChipHideTimer = null;
|
|
this._cctvChipWasBusy = true;
|
|
// The counter is left plain on purpose: it ticks every few frames
|
|
// during a grid load, and flapping it would read as a slot machine.
|
|
this._publishCctvChip({
|
|
visible: true,
|
|
labelKey: msg('loading frames'),
|
|
progress: `${loaded}/${total}`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (this._cctvChipWasBusy && enabled && total > 0) {
|
|
// Load just completed — flash the final count, then auto-hide.
|
|
this._cctvChipWasBusy = false;
|
|
this._publishCctvChip({
|
|
visible: true,
|
|
labelKey: msg('camera grid ready'),
|
|
progress: `${total}/${total}`,
|
|
});
|
|
clearTimeout(this._cctvChipHideTimer);
|
|
this._cctvChipHideTimer = window.setTimeout(() => {
|
|
if (this.destroyed) return;
|
|
this._cctvChipHideTimer = null;
|
|
this._publishCctvChip({ visible: false });
|
|
}, 1500);
|
|
return;
|
|
}
|
|
|
|
if (!this._cctvChipHideTimer) {
|
|
this._cctvChipWasBusy = false;
|
|
this._publishCctvChip({ visible: false });
|
|
}
|
|
}
|