Files
gods-eye-view/src/ui/imageryBoxTool.js
T
German KhokhlovandClaude Opus 5.5 00704ddc89 i18n: fix review findings and polish ru/es strings
Fixes the 51 P0–P2 findings from the full review of the i18n branch and
the issues found while checking every new string on screen in ru and es.

- Translate errors and statuses that still reached ru/es in English:
  feed and tile errors, provider statuses, the ALPR chip, the traffic
  mode chip, voice plan step labels, fire feed values, receiver bands,
  scene save reasons and the Cesium attribution link.
- Show preset city names in the UI language (src/ui/placeNames.js);
  data and everything sent to the model stay English.
- Share layer names between the layer panel, HUD and voice card
  (src/ui/layerNames.js).
- Keep long ru/es strings whole: voice card error lines, analyst
  titles, the voice tier status, the Nepal evidence button, the ALPR
  retry chip. English layout is unchanged.
- Ask before a language switch drops unsaved work.
- Catalog fixes: glossary terms (пуск, пресет), gender agreement and
  consistent "peticiones" in es, fewer calques in both languages.
- Fix English text that was itself wrong (e.g. "Exit cockpit to fly to
  a weather", raw tool ids in voice plan steps).

Catalogs: es/ru 2535 messages each, complete.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 23:49:52 +04:00

296 lines
9.1 KiB
JavaScript

/**
* Box selection for the Recent Imagery layer: press on the world, drag, and
* release to hand the layer a degrees box. Follows the draw tool's ownership
* rules — the pointer is claimed for the session, the viewer's own click
* actions are borrowed and given back, and `destroy()` returns everything —
* and freezes the camera only while a drag is in progress.
*/
import * as Cesium from 'cesium';
import { pickWorldFromScreen } from '../annotations/annotationResolver.js';
import {
claimPointer,
pointerOwner,
releasePointer,
} from '../data/inputOwnership.js';
import { validateBox } from '../layers/recentImagery/model.js';
import { msg } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
export const IMAGERY_BOX_POINTER_OWNER = 'recent-imagery-box';
/**
* Wire the box tool.
* @param {{ viewer: object, onBox: (box: object) => void, onCancel?: (reason: string, message?: string, box?: object) => void, onActive?: (active: boolean) => void, onEscape?: () => boolean, cesium?: object, pickWorld?: Function, documentRef?: object }} deps
* `onEscape` runs before the tool cancels on Escape; returning `true` claims
* the key (it is still consumed) and leaves the tool armed.
* @returns {{ start: () => boolean, cancel: (reason?: string) => void, isActive: () => boolean, destroy: () => Promise<void> } | null}
*/
export function initImageryBoxTool({
viewer,
onBox,
onCancel,
onActive,
onEscape,
cesium = Cesium,
pickWorld = pickWorldFromScreen,
documentRef = globalThis.document,
} = {}) {
if (!viewer?.scene || typeof onBox !== 'function') return null;
let active = false;
let destroyed = false;
/** Drag corners in degrees; `anchor` is null while no drag is live. */
let anchor = null;
let corner = null;
let handler = null;
let lease = null;
let savedSingleClick = null;
let savedDoubleClick = null;
let savedCameraInputs = null;
const dragBox = () =>
anchor && {
west: Math.min(anchor.lon, corner.lon),
south: Math.min(anchor.lat, corner.lat),
east: Math.max(anchor.lon, corner.lon),
north: Math.max(anchor.lat, corner.lat),
};
const dataSource = new cesium.CustomDataSource(
'gev-recent-imagery-box-preview',
);
let attaching = Promise.resolve(viewer.dataSources.add(dataSource)).catch(
() => null,
);
const stroke = cesium.Color.fromCssColorString('#8be9ff');
const previewRectangle = () => {
const box = dragBox();
return box
? cesium.Rectangle.fromDegrees(box.west, box.south, box.east, box.north)
: undefined;
};
const previewOutline = () => {
const box = dragBox();
if (!box) return [];
return cesium.Cartesian3.fromDegreesArray([
box.west,
box.south,
box.east,
box.south,
box.east,
box.north,
box.west,
box.north,
box.west,
box.south,
]);
};
const fill = dataSource.entities.add({
show: false,
rectangle: {
coordinates: new cesium.CallbackProperty(previewRectangle, false),
material: stroke.withAlpha(0.12),
classificationType: cesium.ClassificationType.BOTH,
},
});
// A rectangle graphic cannot draw a dashed edge on the ground, so the
// outline is its own draped polyline traced round the same corners.
const outline = dataSource.entities.add({
show: false,
polyline: {
positions: new cesium.CallbackProperty(previewOutline, false),
width: 2,
clampToGround: true,
classificationType: cesium.ClassificationType.BOTH,
material: new cesium.PolylineDashMaterialProperty({
color: stroke.withAlpha(0.95),
dashLength: 12,
}),
},
});
const requestRender = () => viewer.scene.requestRender?.();
const setPreview = (visible) => {
fill.show = visible;
outline.show = visible;
requestRender();
};
const worldAt = (position) => {
if (!position) return null;
const canvas = viewer.scene.canvas;
const w = canvas?.clientWidth || canvas?.width || 1;
const h = canvas?.clientHeight || canvas?.height || 1;
const point = pickWorld(viewer, position.x / w, position.y / h);
const lon = Number(point?.lon);
const lat = Number(point?.lat);
return Number.isFinite(lon) && Number.isFinite(lat) ? { lon, lat } : null;
};
const cameraController = () => viewer.scene.screenSpaceCameraController;
function freezeCamera() {
const controller = cameraController();
if (!controller || savedCameraInputs !== null) return;
savedCameraInputs = controller.enableInputs;
controller.enableInputs = false;
}
function thawCamera() {
const controller = cameraController();
if (controller && savedCameraInputs !== null)
controller.enableInputs = savedCameraInputs;
savedCameraInputs = null;
}
function endDrag() {
anchor = null;
corner = null;
thawCamera();
setPreview(false);
}
const onDown = (event) => {
if (!active || destroyed || anchor) return;
const point = worldAt(event.position);
if (!point) {
onCancel?.('sky', msg('Press on the ground, not the sky'));
return;
}
anchor = point;
corner = point;
freezeCamera();
setPreview(true);
};
const onMove = (event) => {
const point = anchor && !destroyed && worldAt(event.endPosition);
if (!point) return;
corner = point;
requestRender();
};
const onUp = (event) => {
if (!anchor || destroyed) return;
corner = worldAt(event.position) || corner;
const box = dragBox();
const result = validateBox(box);
if (result.reason === 'degenerate')
result.message = msg('Drag to size the box');
endDrag();
setActive(false);
if (result.ok) onBox(result.box);
else onCancel?.(result.reason, result.message, box);
};
// Escape is taken from a document capture listener, ahead of every other
// handler, so the interceptor runs first: when it claims the key (the
// panel exits a live comparison), the tool stays armed and the key still
// goes no further; otherwise Escape cancels the session.
const onKey = (event) => {
if (!active || destroyed || event.key !== 'Escape') return;
event.preventDefault?.();
event.stopImmediatePropagation?.();
if (onEscape?.() === true) return;
cancel('escape');
};
function bindScene() {
if (handler) return;
handler = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction(onDown, cesium.ScreenSpaceEventType.LEFT_DOWN);
handler.setInputAction(onMove, cesium.ScreenSpaceEventType.MOUSE_MOVE);
handler.setInputAction(onUp, cesium.ScreenSpaceEventType.LEFT_UP);
const stock = viewer.screenSpaceEventHandler;
if (stock) {
savedSingleClick =
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
savedDoubleClick =
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null;
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK);
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
}
documentRef?.addEventListener?.('keydown', onKey, true);
documentRef?.body?.classList?.add?.('gev-imagery-box');
}
function releaseScene() {
if (handler) {
handler.destroy();
handler = null;
}
const stock = viewer.screenSpaceEventHandler;
if (stock && savedSingleClick) {
stock.setInputAction(
savedSingleClick,
cesium.ScreenSpaceEventType.LEFT_CLICK,
);
}
if (stock && savedDoubleClick) {
stock.setInputAction(
savedDoubleClick,
cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
);
}
savedSingleClick = null;
savedDoubleClick = null;
documentRef?.removeEventListener?.('keydown', onKey, true);
documentRef?.body?.classList?.remove?.('gev-imagery-box');
}
function setActive(next) {
if (next === active) return active;
if (next) {
lease = claimPointer(IMAGERY_BOX_POINTER_OWNER);
if (!lease) {
onCancel?.(
'pointer-busy',
`${pointerOwner()} is using the pointer — close it first`,
);
return active;
}
active = true;
bindScene();
} else {
if (anchor) endDrag();
releaseScene();
releasePointer(lease);
lease = null;
active = false;
}
onActive?.(active);
return active;
}
function cancel(reason = 'cancel') {
if (!active || destroyed) return;
setActive(false);
onCancel?.(reason);
}
return {
/** Begin a box session. False when the pointer is held elsewhere. */
start() {
return !destroyed && setActive(true);
},
cancel,
isActive() {
return active;
},
/** Returns everything; resolves once the preview is detached. */
destroy() {
if (destroyed) return attaching;
if (active) setActive(false);
destroyed = true;
releaseScene();
releasePointer(lease);
lease = null;
thawCamera();
dataSource.entities.removeAll();
attaching = attaching.then(() => {
try {
viewer.dataSources.remove(dataSource, true);
} catch {
/* viewer already disposed */
}
});
return attaching;
},
};
}