Files
gods-eye-view/src/layers/installations/controls.js
T
German KhokhlovandClaude Opus 5.5 13efd411b6 refactor(i18n): key messages by English text; add Russian
Replace the semantic keys with the English source text, so code reads as the
UI does and English needs no catalog: t('Save keys'), tn(count, one, other)
for plurals, msg() for text stored in data, and bare data-i18n* markup that
translates the element's own English. Other languages are one flat JSON each.

- Simplify src/i18n: drop namespaces, the configurable locale pair
  (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE) and its build defines. Locale
  comes from ?lang=, the stored choice, then browser languages.
- Add scripts/i18n.mjs (npm run i18n): extracts every message from t/tn/msg
  and markup, and fails on missing, unused, placeholder-mismatched or
  wrongly-shaped plural entries. npm test runs it via catalog.test.mjs.
- Add Russian (src/i18n/locales/ru.json, 833 messages), started from the
  community catalog in #312 and reviewed against the current English.
- Fix data-i18n placed on parents that would wipe their children (cockpit
  LEVEL and local weather cells, first-run EXPLORE MANUALLY tile), and a
  doubled degree sign in the TRACK ACQUIRED signal.
- Rewrite docs/TRANSLATORS.md for the new workflow; drop sections the earlier
  merge resurrected in docs/CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:48:56 +04:00

297 lines
10 KiB
JavaScript

import * as Cesium from 'cesium';
import { installationFeedback } from '../../data/installationFeedback.js';
import {
LAYER_ID,
DISTANCE_PREFILTER_MARGIN_M,
ANCHOR_REFRESH_M,
} from './policy.js';
import { t, msg } from '../../i18n/index.js';
// installationFeedback.js composes its guidance inside the portable
// node/browser graph (the server-side HUD summary reads the same module), so
// its copy stays English there. getStats is the browser presentation
// boundary: map the closed message vocabulary to the catalog, passing any
// live provider error through verbatim (docs/TRANSLATORS.md).
const INSTALLATION_FEEDBACK_KEYS = Object.freeze({
'Retrying mapped sites…': msg('Retrying mapped sites…'),
'Fetching mapped sites…': msg('Fetching mapped sites…'),
'Zoom in to search mapped installations': msg(
'Zoom in to search mapped installations',
),
'Showing cached mapped sites': msg('Showing cached mapped sites'),
'Mapped sites not loaded': msg('Mapped sites not loaded'),
'Mapped sites loaded': msg('Mapped sites loaded'),
'Overpass rate-limited': msg('Overpass rate-limited'),
'Overpass timed out': msg('Overpass timed out'),
'Overpass could not complete the query': msg(
'Overpass could not complete the query',
),
'Overpass temporarily unavailable': msg('Overpass temporarily unavailable'),
});
const INSTALLATION_RETRY_IN_RE = /^(.*) — retrying in (\d+)s$/;
const INSTALLATION_RETRY_PENDING_RE = /^(.*) — retry pending$/;
function localizeInstallationFeedback(message) {
if (typeof message !== 'string' || !message) return message;
const key = INSTALLATION_FEEDBACK_KEYS[message];
if (key) return t(key);
const retryIn = message.match(INSTALLATION_RETRY_IN_RE);
if (retryIn) {
const reasonKey = INSTALLATION_FEEDBACK_KEYS[retryIn[1]];
return t('{reason} — retrying in {seconds}s', {
reason: reasonKey ? t(reasonKey) : retryIn[1],
seconds: retryIn[2],
});
}
const pending = message.match(INSTALLATION_RETRY_PENDING_RE);
if (pending) {
const reasonKey = INSTALLATION_FEEDBACK_KEYS[pending[1]];
return t('{reason} — retry pending', {
reason: reasonKey ? t(reasonKey) : pending[1],
});
}
return message;
}
export function createControls({ state: layerState, services, parts, source }) {
const methods = {
id: LAYER_ID,
name: 'Mapped Installations',
icon: '⌖',
source: 'OpenStreetMap + optional Google Maps Places',
updateInterval: 0,
statsRefreshInterval: 1000,
visitNamedMarkers(visitor) {
parts.namedMarkers.visit(visitor);
},
/** Request a one-shot Google Maps Places search around the current map view. */
searchNearby() {
layerState.googleSearchRequested = true;
return parts.ingestion.loadInstallations();
},
/**
* Load sites around a Contacts subject instead of the camera viewport.
*
* A follow or Cockpit camera moves every frame, so `moveEnd` never fires and
* its view rectangle often reaches the horizon; a subject window is the
* only area that answers "what is near this contact". The window moves
* only after the subject travels ANCHOR_REFRESH_M, and cached tiles make
* that cheap. Passing null returns the layer to viewport loading.
* @param {Cesium.Cartesian3|{latitude:number, longitude:number}|null} position
* Subject position.
* @returns {boolean} Whether a new load was scheduled.
*/
setContextAnchor(position) {
let next = null;
if (position) {
if (
Number.isFinite(position.latitude) &&
Number.isFinite(position.longitude)
)
next = { latitude: position.latitude, longitude: position.longitude };
else {
const carto = Cesium.Cartographic.fromCartesian(position);
if (carto)
next = {
latitude: Cesium.Math.toDegrees(carto.latitude),
longitude: Cesium.Math.toDegrees(carto.longitude),
};
}
}
// Keep the live centre even when the coarse fetch anchor has not moved.
layerState.contextPosition = next
? Cesium.Cartesian3.fromDegrees(next.longitude, next.latitude)
: null;
const previous = layerState.contextAnchor;
if (!next) {
if (!previous) return false;
layerState.contextAnchor = null;
parts.viewport.scheduleLoad();
return true;
}
if (
previous &&
parts.model.approximateSurfaceDistanceM(
Cesium.Math.toRadians(previous.latitude),
Cesium.Math.toRadians(previous.longitude),
next.latitude,
next.longitude,
) < ANCHOR_REFRESH_M
)
return false;
layerState.contextAnchor = next;
parts.viewport.scheduleLoad();
return true;
},
getNearby(center, rangeM, maxCount = 50) {
if (!center) return [];
const range = Number.isFinite(rangeM) ? rangeM : Infinity;
const centerCartographic = Cesium.Cartographic.fromCartesian(center);
if (!centerCartographic) return [];
const nearby = [];
const approximateLimit = Number.isFinite(range)
? range * 1.03 + DISTANCE_PREFILTER_MARGIN_M
: Infinity;
for (const record of layerState.records) {
if (record.kind !== 'installation') continue;
if (
parts.model.approximateSurfaceDistanceM(
centerCartographic.latitude,
centerCartographic.longitude,
record.latitude,
record.longitude,
) > approximateLimit
)
continue;
// The awareness disk is projected onto the ground. Confirm candidates
// with an exact ellipsoidal surface distance and reusable scratch state.
layerState.distanceEndpointScratch.longitude = Cesium.Math.toRadians(
record.longitude,
);
layerState.distanceEndpointScratch.latitude = Cesium.Math.toRadians(
record.latitude,
);
layerState.distanceEndpointScratch.height = 0;
layerState.distanceGeodesicScratch.setEndPoints(
centerCartographic,
layerState.distanceEndpointScratch,
);
const distanceM = layerState.distanceGeodesicScratch.surfaceDistance;
if (!Number.isFinite(distanceM) || distanceM > range) continue;
nearby.push({
...record,
position: Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
parts.rendering.installationSurfaceHeightM(record),
),
distanceM,
});
}
nearby.sort((a, b) => a.distanceM - b.distanceM);
return nearby.slice(
0,
Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 50,
);
},
/**
* Select and frame a mapped installation from another contextual UI.
* @param {string} id Source-backed installation id.
* @returns {boolean} True when an available installation was focused.
*/
focusById(id) {
const record = layerState.recordById.get(String(id));
if (!record || !layerState.viewer) return false;
// No camera flight without a real selection: a flight plus a stale subject
// reads as success to Context navigation and strands NEXT on this item.
if (!parts.selection.selectRecord(record.id)) return false;
layerState.viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
parts.rendering.installationSurfaceHeightM(record),
),
18000,
),
{ duration: 1.4 },
);
return true;
},
/**
* Snapshot loaded mapped installations as plain JSON-safe records for the analyst
* query engine. On demand only (once per spoken query); [] while the
* layer is off.
* @param {number} [maxCount=Infinity] Most records to return.
* @returns {Array<Object>} {id, name, class, kind, lat, lon}.
*/
getAnalystRecords(maxCount = Infinity) {
if (!layerState.enabled) return [];
const result = [];
for (const record of layerState.records || []) {
if (result.length >= maxCount) break;
if (
!Number.isFinite(record.latitude) ||
!Number.isFinite(record.longitude)
)
continue;
result.push({
id: record.name || record.id,
name: record.name || null,
class: record.class || null,
kind: record.kind || null,
lat: record.latitude,
lon: record.longitude,
});
}
return result;
},
getStats() {
const namedMarkers = parts.namedMarkers?.stats();
const subjectWindow =
layerState.coverage?.kind === 'subject' &&
layerState.contextPosition &&
Number.isFinite(layerState.coverage.radiusM);
const mappedCount = subjectWindow
? methods.getNearby(
layerState.contextPosition,
layerState.coverage.radiusM,
layerState.records.length || 1,
).length
: layerState.wide && namedMarkers
? namedMarkers.pointsOnScreen
: layerState.records.filter(
(record) => record.kind === 'installation',
).length;
return {
count:
subjectWindow || layerState.wide
? mappedCount
: layerState.records.length,
namedInView: layerState.namedInView || 0,
wide: Boolean(layerState.wide),
...namedMarkers,
lastUpdate: layerState.lastUpdate,
stale: layerState.stale,
saturated: layerState.saturated,
error: layerState.error,
status: layerState.status,
loading: layerState.loading,
retryAt: layerState.retryAt,
retrying: layerState.loading && Boolean(layerState.failureReason),
failureReason: layerState.failureReason,
statusMessage: localizeInstallationFeedback(
installationFeedback({
...layerState,
count: layerState.lastUpdate ? mappedCount : undefined,
retrying: layerState.loading && Boolean(layerState.failureReason),
}),
),
loadingLabel: layerState.loading
? t('loading mapped installation context')
: '',
coverage: layerState.coverage,
coverageLabel:
layerState.coverage?.kind === 'subject'
? `WITHIN ${Math.round(layerState.coverage.radiusM / 1000)} KM`
: 'CURRENT VIEWPORT ONLY',
};
},
};
return { methods };
}