mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
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]>
270 lines
11 KiB
JavaScript
270 lines
11 KiB
JavaScript
/**
|
||
* @module detectionDraw
|
||
* @description Pure, renderer-agnostic helpers for the detection overlay.
|
||
*
|
||
* Deliberately free of Cesium and DOM dependencies so the label composition,
|
||
* metric formatting, glyph-width estimation, fade ramp, and corner-bracket
|
||
* geometry can be unit-tested in isolation — and reused verbatim by a future
|
||
* GPU/MSDF renderer. The Canvas2D overlay (detection.js) imports these and
|
||
* supplies the actual drawing surface.
|
||
*/
|
||
import { formatNumber, t } from '../i18n/index.js';
|
||
|
||
/** @constant {number} Max characters shown for a label's primary (id) line. */
|
||
const MAX_PRIMARY = 18;
|
||
/** @constant {number} Max characters shown for a label's secondary (class · metric) line. */
|
||
const MAX_SECONDARY = 26;
|
||
/** @constant {number} Metres-to-feet conversion factor. */
|
||
const M_TO_FT = 3.28084;
|
||
|
||
/**
|
||
* Formats an altitude in metres as an aviation flight level (hundreds of feet),
|
||
* zero-padded to three digits. Returns '' for missing/zero/negative altitude so
|
||
* the label gracefully degrades to id-only.
|
||
* @param {number} altitudeMeters - Altitude above sea level in metres.
|
||
* @returns {string} e.g. 'FL340', or '' when not applicable.
|
||
*/
|
||
export function formatFlightLevel(altitudeMeters) {
|
||
if (!Number.isFinite(altitudeMeters) || altitudeMeters <= 0) return '';
|
||
const fl = Math.round((altitudeMeters * M_TO_FT) / 100);
|
||
return 'FL' + String(fl).padStart(3, '0');
|
||
}
|
||
|
||
/**
|
||
* Formats a speed in knots as a short rounded label in the UI language.
|
||
* Returns '' for non-positive or non-finite speeds (declutters moored/unknown
|
||
* vessels).
|
||
* @param {number} knots - Speed over ground in knots.
|
||
* @returns {string} e.g. '14 kn' (ru '14 уз'), or '' when not applicable.
|
||
*/
|
||
export function formatKnots(knots) {
|
||
if (!Number.isFinite(knots) || knots <= 0) return '';
|
||
return t('{speed} kn', { speed: formatNumber(Math.round(knots)) });
|
||
}
|
||
|
||
/**
|
||
* Estimates the pixel width of a monospace string without touching the canvas.
|
||
* For a fixed-advance font, width is exactly length × advance — this avoids a
|
||
* per-label measureText() call (the old per-frame hot-path stall).
|
||
* @param {string} text - The string to measure.
|
||
* @param {number} charWidth - Per-glyph advance width in pixels.
|
||
* @returns {number} Estimated width in pixels (0 for empty/null).
|
||
*/
|
||
export function monoTextWidth(text, charWidth) {
|
||
if (!text) return 0;
|
||
return text.length * charWidth;
|
||
}
|
||
|
||
/**
|
||
* Composes a detection label into a two-tier structure: a bright primary line
|
||
* (the id/callsign) and a dimmer secondary line ("class · metric"). Missing
|
||
* class/metric collapse the secondary to '' so the label degrades to id-only.
|
||
* @param {{id?: string, klass?: string, metric?: string}} obj - Label source fields.
|
||
* @returns {{primary: string, secondary: string}} Truncated label lines.
|
||
*/
|
||
export function composeLabel(obj) {
|
||
const src = obj || {};
|
||
const primary = String(src.id || '').slice(0, MAX_PRIMARY);
|
||
const parts = [src.klass, src.metric]
|
||
.map((s) => (s == null ? '' : String(s).trim()))
|
||
.filter((s) => s.length > 0);
|
||
const secondary = parts.join(' · ').slice(0, MAX_SECONDARY);
|
||
return { primary, secondary };
|
||
}
|
||
|
||
/**
|
||
* Computes a 0→1 fade-in alpha for a subtle "acquire" animation, ramping over
|
||
* fadeMs from the first-seen timestamp. Defaults to fully visible (1) when the
|
||
* timestamp is unknown or the fade window is non-positive.
|
||
* @param {number} firstSeenMs - Timestamp the target first appeared.
|
||
* @param {number} nowMs - Current timestamp.
|
||
* @param {number} fadeMs - Fade-in duration in milliseconds.
|
||
* @returns {number} Alpha in [0, 1].
|
||
*/
|
||
export function acquireAlpha(firstSeenMs, nowMs, fadeMs) {
|
||
if (!Number.isFinite(firstSeenMs) || !(fadeMs > 0)) return 1;
|
||
const elapsed = nowMs - firstSeenMs;
|
||
if (elapsed <= 0) return 0;
|
||
if (elapsed >= fadeMs) return 1;
|
||
return elapsed / fadeMs;
|
||
}
|
||
|
||
/**
|
||
* Appends one bracket-style bounding box (four L-shaped corner segments only)
|
||
* to a path-like sink exposing moveTo(x, y) / lineTo(x, y). Designed so many
|
||
* boxes accumulate into a single Path2D for one batched stroke() — replacing
|
||
* the old per-box beginPath/stroke. Geometry matches the prior reticle exactly.
|
||
* @param {{moveTo: Function, lineTo: Function}} sink - Path2D or compatible recorder.
|
||
* @param {number} sx - Screen X centre of the box.
|
||
* @param {number} sy - Screen Y centre of the box.
|
||
* @param {number} halfW - Half-width of the box.
|
||
* @param {number} halfH - Half-height of the box.
|
||
*/
|
||
export function appendCornerBracket(sink, sx, sy, halfW, halfH) {
|
||
const x0 = sx - halfW;
|
||
const y0 = sy - halfH;
|
||
const x1 = sx + halfW;
|
||
const y1 = sy + halfH;
|
||
const seg = Math.max(4, Math.floor(Math.min(halfW, halfH) * 0.55));
|
||
|
||
// top-left
|
||
sink.moveTo(x0, y0 + seg);
|
||
sink.lineTo(x0, y0);
|
||
sink.lineTo(x0 + seg, y0);
|
||
// top-right
|
||
sink.moveTo(x1 - seg, y0);
|
||
sink.lineTo(x1, y0);
|
||
sink.lineTo(x1, y0 + seg);
|
||
// bottom-right
|
||
sink.moveTo(x1, y1 - seg);
|
||
sink.lineTo(x1, y1);
|
||
sink.lineTo(x1 - seg, y1);
|
||
// bottom-left
|
||
sink.moveTo(x0 + seg, y1);
|
||
sink.lineTo(x0, y1);
|
||
sink.lineTo(x0, y1 - seg);
|
||
}
|
||
|
||
/**
|
||
* Resolves a detectable object's threat tier — the key into a theme's color
|
||
* ramp (civil/military/sea/space/vehicle). A layer-supplied `tier` wins;
|
||
* otherwise it derives from the coarse `type`. Drives per-tier box/card color.
|
||
* @param {{type?: string, tier?: string}} obj - Detectable object.
|
||
* @returns {string} Tier key.
|
||
*/
|
||
export function resolveTier(obj) {
|
||
if (obj && obj.tier) return obj.tier;
|
||
const t = obj && obj.type;
|
||
if (t === 'SEA') return 'sea';
|
||
if (t === 'SAT') return 'space';
|
||
if (t === 'VEH') return 'vehicle';
|
||
return 'civil';
|
||
}
|
||
|
||
/**
|
||
* Computes the geometry of a two-tier label "card": overall width/height plus
|
||
* the text baselines, sized so the second line and its descenders always fit
|
||
* (the clipping bug in the throwaway spike). Pure layout — the renderer draws
|
||
* the rounded rect, accent bar, and text from these numbers.
|
||
* @param {string} primary - Bright first line (id/callsign).
|
||
* @param {string} secondary - Dim second line (class · metric); '' collapses to one line.
|
||
* @param {number} charWidth - Monospace glyph advance, px.
|
||
* @returns {{w: number, h: number, idBase: number, subBase: number, textX: number, hasSec: boolean}}
|
||
*/
|
||
export function measureLabelCard(primary, secondary, charWidth) {
|
||
const padX = 6;
|
||
const padY = 4;
|
||
const lineH = 11;
|
||
const ASC = 8; // cap height above the first baseline
|
||
const DESC = 3; // descender depth below the last baseline
|
||
const accentW = 3; // left accent bar width
|
||
const accentGap = 4; // gap between accent bar and text (doubles as left pad)
|
||
const hasSec = !!(secondary && secondary.length);
|
||
const wId = monoTextWidth(primary, charWidth);
|
||
const wSub = hasSec ? monoTextWidth(secondary, charWidth) : 0;
|
||
const textW = Math.max(wId, wSub);
|
||
const w = Math.ceil(accentW + accentGap + textW + padX);
|
||
const idBase = padY + ASC;
|
||
const subBase = hasSec ? idBase + lineH : 0;
|
||
const lastBase = hasSec ? subBase : idBase;
|
||
const h = lastBase + DESC + padY;
|
||
const textX = accentW + accentGap;
|
||
return { w, h, idBase, subBase, textX, hasSec };
|
||
}
|
||
|
||
/**
|
||
* Computes the geometry of a SINGLE-LINE track label: callsign (bright) followed
|
||
* on the same baseline by a dim altitude/metric micro-field. This is the
|
||
* persistent label — type is carried by the platform icon, not text — replacing
|
||
* the two-line card for the ambient majority of tracks.
|
||
* @param {string} primary - Callsign / id (bright).
|
||
* @param {string} micro - Short metric, e.g. flight level digits or '14kn' ('' = none).
|
||
* @param {number} charWidth - Monospace glyph advance for the primary text, px.
|
||
* @returns {{w: number, h: number, baseline: number, primaryX: number, microX: number, hasMicro: boolean}}
|
||
*/
|
||
export function measureTrackLabel(primary, micro, charWidth) {
|
||
const padX = 6;
|
||
const padY = 4;
|
||
const ASC = 8;
|
||
const DESC = 3;
|
||
const tickW = 3; // affiliation/tier tick at the left
|
||
const tickGap = 4;
|
||
const microGap = 6; // space between callsign and the micro-field
|
||
const MICRO_SCALE = 0.9; // micro renders slightly smaller than the callsign
|
||
const hasMicro = !!(micro && micro.length);
|
||
const wPrimary = monoTextWidth(primary, charWidth);
|
||
const wMicro = hasMicro ? monoTextWidth(micro, charWidth * MICRO_SCALE) : 0;
|
||
const textW = wPrimary + (hasMicro ? microGap + wMicro : 0);
|
||
const w = Math.ceil(tickW + tickGap + textW + padX);
|
||
const baseline = padY + ASC;
|
||
const h = baseline + DESC + padY;
|
||
const primaryX = tickW + tickGap;
|
||
const microX = primaryX + wPrimary + microGap;
|
||
return { w, h, baseline, primaryX, microX, hasMicro };
|
||
}
|
||
|
||
/**
|
||
* Returns whether a screen-space card intersects any reserved UI rectangle.
|
||
* Touching edges are allowed; positive padding expands the exclusion gap.
|
||
* @param {{x:number,y:number,w:number,h:number}} rect - Candidate card rectangle.
|
||
* @param {Array<{x:number,y:number,w:number,h:number}>} obstacles - HUD rectangles.
|
||
* @param {number} [padding=0] - Additional separation in CSS pixels.
|
||
* @returns {boolean} True when the card would be covered by a HUD rectangle.
|
||
*/
|
||
export function rectIntersectsAny(rect, obstacles, padding = 0) {
|
||
if (!rect || !Array.isArray(obstacles)) return false;
|
||
return obstacles.some(
|
||
(obstacle) =>
|
||
obstacle &&
|
||
rect.x < obstacle.x + obstacle.w + padding &&
|
||
rect.x + rect.w + padding > obstacle.x &&
|
||
rect.y < obstacle.y + obstacle.h + padding &&
|
||
rect.y + rect.h + padding > obstacle.y,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Linear near/far interpolation matching Cesium's NearFarScalar — maps a camera
|
||
* distance to a scale, clamped to the near value when closer than `near` and the
|
||
* far value when beyond `far`. Lets the detection reticle track a billboard's
|
||
* on-screen size (which uses the same scaleByDistance curve).
|
||
* @param {number} distance - Camera-to-object distance, metres.
|
||
* @param {number} near - Near distance, metres.
|
||
* @param {number} nearValue - Scale at/under the near distance.
|
||
* @param {number} far - Far distance, metres.
|
||
* @param {number} farValue - Scale at/over the far distance.
|
||
* @returns {number} Interpolated scale.
|
||
*/
|
||
export function nearFarScale(distance, near, nearValue, far, farValue) {
|
||
if (!(far > near)) return nearValue;
|
||
if (distance <= near) return nearValue;
|
||
if (distance >= far) return farValue;
|
||
const t = (distance - near) / (far - near);
|
||
return nearValue + t * (farValue - nearValue);
|
||
}
|
||
|
||
/** Transit keeps literal mode colour on the normal-composite surface. */
|
||
export function paintTransitBracket(ctx, path, color, alpha = 1) {
|
||
ctx.save?.();
|
||
ctx.globalCompositeOperation = 'source-over';
|
||
ctx.globalAlpha = alpha;
|
||
ctx.strokeStyle = '#05080C';
|
||
ctx.lineWidth = 3.25;
|
||
ctx.stroke(path);
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 1.25;
|
||
ctx.stroke(path);
|
||
ctx.restore?.();
|
||
}
|
||
|
||
/** Centre 1.25 px strokes on pixels, so their cores are fully covered. */
|
||
export function appendTransitBracket(sink, sx, sy, halfW, halfH) {
|
||
appendCornerBracket(
|
||
sink,
|
||
Math.floor(sx) + 0.5,
|
||
Math.floor(sy) + 0.5,
|
||
Math.round(halfW),
|
||
Math.round(halfH),
|
||
);
|
||
}
|