mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge origin/i18n-spanish: whole-interface translation meets KartaView
This branch moved twice at once: upstream merged KartaView (#1121, a third keyless street-level provider sharing a new lineCoverage.js), and the remote i18n-spanish gained the whole-interface translation work (1993 catalog entries, translateKnown, data-i18n-runtime, the unmarked- string detector in npm run i18n) built on the Street View merge. Integration keeps the remote's mechanisms and re-adds KartaView on top: - presentMeta: remote's translateHint for coverage hints, plus the KartaView count line as its own literal tn pair (Russian plurals need the noun inside the template; keyed off coverageLabel.noun with the sequences pair as the default). - uiState: upstream's highlightsSequence legend gate (KartaView's lines do not highlight) with t('Selected'). - layer-panels.html: remote's data-i18n/data-i18n-runtime hooks plus the sl-since-note wiring KartaView's upload-date SINCE note needs. - kartaview viewer: the photo-navigation aria-label and img alt go through t(), as the remote's unmarked-string detector requires. - catalogs: remote's 1987-entry base (its refined keys supersede this branch's earlier Street View wording) plus the KartaView entries: coverage hints, the upload-date note, the streets count line (with Russian one/few/many/other forms) and the viewer strings. npm run i18n: complete, no unmarked strings. npm test: 6520/6522 (the one failure is the pre-existing environmental OPENAI_API_KEY leak in devFreshDotenv). Build passes.
This commit is contained in:
commit
32b11e27de
176 files changed
+9713
-2210
No files matched your search
+33
-19
@@ -18,6 +18,7 @@ import {
|
||||
alprCreditMarkup,
|
||||
} from './model.js';
|
||||
import { createAlprPresentation } from './presentation.js';
|
||||
import { t, tn, msg } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Own one layer's requests, records, display and viewer subscriptions.
|
||||
@@ -293,7 +294,8 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
id: LAYER_ID,
|
||||
name: 'ALPR Cameras',
|
||||
icon: '📷',
|
||||
source: source.label || 'Mapped camera locations',
|
||||
// English in layer state; the layer row translates it.
|
||||
source: source.label || msg('Mapped camera locations'),
|
||||
updateInterval: 0,
|
||||
statsRefreshInterval: 1000,
|
||||
init(viewer) {
|
||||
@@ -372,10 +374,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
chips: [
|
||||
{
|
||||
id: 'find-camera',
|
||||
label: 'SHOW NEAREST',
|
||||
label: t('SHOW NEAREST'),
|
||||
title: state.viewer?.trackedEntity
|
||||
? 'Stop following the current object before navigating to a camera'
|
||||
: 'Move to the nearest loaded camera and show its details',
|
||||
? t(
|
||||
'Stop following the current object before navigating to a camera',
|
||||
)
|
||||
: t('Move to the nearest loaded camera and show its details'),
|
||||
disabled:
|
||||
!state.enabled || !count || Boolean(state.viewer?.trackedEntity),
|
||||
onClick: focusNearest,
|
||||
@@ -383,11 +387,12 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
],
|
||||
legend: [
|
||||
{
|
||||
label: 'Camera badges',
|
||||
label: t('Camera badges'),
|
||||
color: ALPR_COLOR,
|
||||
count,
|
||||
blurb:
|
||||
blurb: t(
|
||||
'Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.',
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -428,11 +433,18 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
count: state.dataSource?.entities.values.length || 0,
|
||||
countLabel:
|
||||
state.enabled && !state.noCoverage && state.status !== 'zoom-in'
|
||||
? `${state.dataSource?.entities.values.length || 0} nearby${
|
||||
Number.isInteger(state.onScreen)
|
||||
? ` · ${state.onScreen} on screen`
|
||||
: ''
|
||||
}`
|
||||
? Number.isInteger(state.onScreen)
|
||||
? tn(
|
||||
state.dataSource?.entities.values.length || 0,
|
||||
'{count} nearby · {onScreen} on screen',
|
||||
'{count} nearby · {onScreen} on screen',
|
||||
{ onScreen: state.onScreen },
|
||||
)
|
||||
: tn(
|
||||
state.dataSource?.entities.values.length || 0,
|
||||
'{count} nearby',
|
||||
'{count} nearby',
|
||||
)
|
||||
: '',
|
||||
noCoverage: Boolean(state.noCoverage),
|
||||
lastUpdate: state.lastUpdate,
|
||||
@@ -451,20 +463,22 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
: 0,
|
||||
loadingLabel: state.loading
|
||||
? state.retrying
|
||||
? 'retrying mapped ALPR cameras'
|
||||
: 'loading mapped ALPR cameras'
|
||||
? t('retrying mapped ALPR cameras')
|
||||
: t('loading mapped ALPR cameras')
|
||||
: state.noCoverage
|
||||
? 'No ALPR data for this area — US and Canada only'
|
||||
? t('No ALPR data for this area — US and Canada only')
|
||||
: state.status === 'zoom-in'
|
||||
? 'Zoom in to load mapped cameras'
|
||||
? t('Zoom in to load mapped cameras')
|
||||
: [
|
||||
state.stale ? 'Showing cached locations' : '',
|
||||
state.stale ? t('Showing cached locations') : '',
|
||||
state.saturated || state.renderSaturated
|
||||
? 'Coverage limited — zoom in'
|
||||
? t('Coverage limited — zoom in')
|
||||
: '',
|
||||
state.status === 'empty'
|
||||
? t('No ALPR data for this area')
|
||||
: '',
|
||||
state.status === 'empty' ? 'No ALPR data for this area' : '',
|
||||
state.status === 'ready' && state.onScreen === 0
|
||||
? 'None on screen — nearby cameras are outside the view'
|
||||
? t('None on screen — nearby cameras are outside the view')
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
MARKER_SCALE_BY_DISTANCE,
|
||||
} from './policy.js';
|
||||
import { boxContains } from './model.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { createAlprFloorResolver } from './floors.js';
|
||||
import { createAlprOverlay } from './overlay.js';
|
||||
import {
|
||||
@@ -451,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
layerName: 'ALPR Cameras',
|
||||
source:
|
||||
source.attribution?.description || source.label || 'Camera source',
|
||||
label: 'ALPR camera',
|
||||
label: t('ALPR camera'),
|
||||
latitude: record.latitude,
|
||||
longitude: record.longitude,
|
||||
properties: {
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
} from '../../sources/vectorTiles.js';
|
||||
import { buildOverpassQuery, normalizeAlprNode } from './records.js';
|
||||
import { decodeAlprTile } from './tileRecords.js';
|
||||
// Marks text for i18n extraction; the UI translates it with t(). Kept local
|
||||
// because AI tool services import this module outside the browser.
|
||||
const msg = (text) => text;
|
||||
|
||||
/**
|
||||
* Finest attributed extract zoom whose tiles for `box` fit the per-view cap,
|
||||
@@ -184,7 +187,7 @@ export function createAlprTileSource({
|
||||
for (const source of sources) source.clear();
|
||||
precision.clear();
|
||||
},
|
||||
label: 'OpenStreetMap · community mapped',
|
||||
label: msg('OpenStreetMap · community mapped'),
|
||||
attribution: {
|
||||
name: 'OpenStreetMap',
|
||||
description: '© OpenStreetMap contributors, ODbL',
|
||||
@@ -260,7 +263,7 @@ export function createOverpassAlprSource({
|
||||
}
|
||||
return {
|
||||
fetch: fetchAlprNodes,
|
||||
label: 'OpenStreetMap · community mapped',
|
||||
label: msg('OpenStreetMap · community mapped'),
|
||||
attribution: {
|
||||
name: 'OpenStreetMap',
|
||||
description: 'OpenStreetMap contributors (ODbL 1.0; community mapped)',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
formatAwarenessLabel,
|
||||
formatAwarenessDistance,
|
||||
AWARENESS_RADIUS_M,
|
||||
} from '../../data/militaryAwarenessEngine.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { formatNumber, t, translateKnown } from '../../i18n/index.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
|
||||
|
||||
/** Format a cohort total without presenting a retained cap as exact. */
|
||||
@@ -15,6 +15,33 @@ export function formatAwarenessCount(summary = {}) {
|
||||
: count;
|
||||
}
|
||||
|
||||
/** The count as the panel shows it, in the UI language. */
|
||||
function presentAwarenessCount(summary = {}) {
|
||||
if (!Number.isFinite(summary.count)) return '?';
|
||||
const count = formatNumber(summary.count);
|
||||
return summary.complete === false || summary.truncated === true
|
||||
? t('At least {count}', { count })
|
||||
: count;
|
||||
}
|
||||
|
||||
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
|
||||
function presentAwarenessDistance(meters) {
|
||||
if (!Number.isFinite(meters) || meters < 0) return '—';
|
||||
if (meters < 1000) return formatQuantity(Math.round(meters), 'm');
|
||||
const digits = meters < 10000 ? 1 : 0;
|
||||
return formatQuantity(meters / 1000, 'km', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
/** The cohort's reason, kept English in data, shown in the UI language. */
|
||||
function presentAwarenessReason(summary = {}) {
|
||||
return summary.reasonTemplate
|
||||
? t(summary.reasonTemplate, summary.reasonValues)
|
||||
: translateKnown(summary.reason || '');
|
||||
}
|
||||
|
||||
export function createPanel({ state: layerState, services, parts, source }) {
|
||||
function ensurePanel() {
|
||||
if (layerState.panel) return layerState.panel;
|
||||
@@ -71,7 +98,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
|
||||
function rowHtml(cohort) {
|
||||
const summary = cohort.summary;
|
||||
const count = formatAwarenessCount(summary);
|
||||
const count = presentAwarenessCount(summary);
|
||||
const page = layerState.cohortPages.get(cohort.id) || 0;
|
||||
const nearest = summary.nearest
|
||||
.slice(page, page + AWARENESS_PAGE_SIZE)
|
||||
@@ -79,10 +106,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
const label = formatAwarenessLabel(item);
|
||||
const targetId = item.icao24 || item.mmsi || item.id;
|
||||
if (!targetId) {
|
||||
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></span></li>`;
|
||||
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></span></li>`;
|
||||
}
|
||||
const accessibleLabel = label === '—' ? t('Unavailable') : label;
|
||||
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${formatAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
|
||||
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
|
||||
})
|
||||
.join('');
|
||||
const pageCount = Math.max(
|
||||
@@ -96,7 +123,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
|
||||
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
|
||||
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
|
||||
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)}</small>
|
||||
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(presentAwarenessReason(summary))}</small>
|
||||
${nearest ? `<ul>${nearest}</ul>` : ''}
|
||||
</section>`;
|
||||
}
|
||||
@@ -176,7 +203,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
|
||||
function namedAreasHtml(names) {
|
||||
return names?.length
|
||||
? `<details class="military-awareness-names"><summary>Named areas (${names.length})</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
|
||||
? `<details class="military-awareness-names"><summary>${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
|
||||
: '';
|
||||
}
|
||||
|
||||
@@ -184,7 +211,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
if (!layerState.enabled || !layerState.results) return hidePanel();
|
||||
const panel = ensurePanel();
|
||||
const { subject, cohorts } = layerState.results;
|
||||
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
|
||||
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${escapeHtml(t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) }))}</div>
|
||||
${namedAreasHtml(subject.memberNames)}
|
||||
${navigationControlsHtml()}
|
||||
${cohorts.map(rowHtml).join('')}
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js';
|
||||
|
||||
// The reason stays English for the voice analyst; the Contacts panel shows it
|
||||
// in the UI language from `reasonTemplate`. Kept local so this module stays
|
||||
// free of the i18n import.
|
||||
const msg = (text) => text;
|
||||
const fillTemplate = (template, values = {}) =>
|
||||
template.replace(/\{(\w+)\}/g, (_, name) => String(values[name]));
|
||||
const withReason = (summary, template, values) => ({
|
||||
...summary,
|
||||
reason: fillTemplate(template, values),
|
||||
reasonTemplate: template,
|
||||
reasonValues: values,
|
||||
});
|
||||
import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js';
|
||||
|
||||
export function createQueries({ state: layerState, services, parts, source }) {
|
||||
@@ -119,19 +132,20 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
||||
// A subject window is its own bounded area: name it instead of the viewport.
|
||||
if (coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)) {
|
||||
const km = Math.round(coverage.radiusM / 1000);
|
||||
return {
|
||||
...summary,
|
||||
reason: summary.count
|
||||
? `mapped matches within ${km} km of the subject`
|
||||
: `none mapped within ${km} km; not a complete 250 km survey`,
|
||||
};
|
||||
return withReason(
|
||||
summary,
|
||||
summary.count
|
||||
? msg('mapped matches within {km} km of the subject')
|
||||
: msg('none mapped within {km} km; not a complete 250 km survey'),
|
||||
{ km },
|
||||
);
|
||||
}
|
||||
return {
|
||||
...summary,
|
||||
reason: summary.count
|
||||
? 'mapped matches from the loaded viewport'
|
||||
: 'viewport feed is not a complete 250 km survey',
|
||||
};
|
||||
return withReason(
|
||||
summary,
|
||||
summary.count
|
||||
? msg('mapped matches from the loaded viewport')
|
||||
: msg('viewport feed is not a complete 250 km survey'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
-18
@@ -179,7 +179,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'nyc-midtown-w',
|
||||
cityId: 'nyc',
|
||||
poiIndex: 1,
|
||||
label: 'Midtown West @ 34th',
|
||||
label: 'Midtown West @ 34th', // i18n-ignore: camera name
|
||||
offsetNorthM: 120,
|
||||
offsetEastM: -70,
|
||||
headingDeg: 206,
|
||||
@@ -191,7 +191,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'nyc-wtc-n',
|
||||
cityId: 'nyc',
|
||||
poiIndex: 2,
|
||||
label: 'WTC North Plaza',
|
||||
label: 'WTC North Plaza', // i18n-ignore: camera name
|
||||
offsetNorthM: 95,
|
||||
offsetEastM: 34,
|
||||
headingDeg: 164,
|
||||
@@ -203,7 +203,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'nyc-times-square-ne',
|
||||
cityId: 'nyc',
|
||||
poiIndex: 1,
|
||||
label: 'Times Sq Northeast',
|
||||
label: 'Times Sq Northeast', // i18n-ignore: camera name
|
||||
offsetNorthM: 230,
|
||||
offsetEastM: 120,
|
||||
headingDeg: 218,
|
||||
@@ -216,7 +216,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'sf-market-5th',
|
||||
cityId: 'sf',
|
||||
poiIndex: 2,
|
||||
label: 'Market & 5th',
|
||||
label: 'Market & 5th', // i18n-ignore: camera name
|
||||
offsetNorthM: -160,
|
||||
offsetEastM: 80,
|
||||
headingDeg: 320,
|
||||
@@ -228,7 +228,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'sf-financial-district',
|
||||
cityId: 'sf',
|
||||
poiIndex: 1,
|
||||
label: 'SF Financial Core',
|
||||
label: 'SF Financial Core', // i18n-ignore: camera name
|
||||
offsetNorthM: 110,
|
||||
offsetEastM: 52,
|
||||
headingDeg: 205,
|
||||
@@ -241,7 +241,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'tokyo-shibuya-scramble',
|
||||
cityId: 'tokyo',
|
||||
poiIndex: 4,
|
||||
label: 'Shibuya Crossing',
|
||||
label: 'Shibuya Crossing', // i18n-ignore: camera name
|
||||
offsetNorthM: 180,
|
||||
offsetEastM: 46,
|
||||
headingDeg: 18,
|
||||
@@ -253,7 +253,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'tokyo-ginza-core',
|
||||
cityId: 'tokyo',
|
||||
poiIndex: 0,
|
||||
label: 'Ginza Core',
|
||||
label: 'Ginza Core', // i18n-ignore: camera name
|
||||
offsetNorthM: -180,
|
||||
offsetEastM: 150,
|
||||
headingDeg: 245,
|
||||
@@ -265,7 +265,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'tokyo-asakusa-n',
|
||||
cityId: 'tokyo',
|
||||
poiIndex: 3,
|
||||
label: 'Asakusa North Gate',
|
||||
label: 'Asakusa North Gate', // i18n-ignore: camera name
|
||||
offsetNorthM: 110,
|
||||
offsetEastM: -65,
|
||||
headingDeg: 192,
|
||||
@@ -278,7 +278,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'london-city-a1',
|
||||
cityId: 'london',
|
||||
poiIndex: 4,
|
||||
label: 'City Cluster A1',
|
||||
label: 'City Cluster A1', // i18n-ignore: camera name
|
||||
offsetNorthM: 80,
|
||||
offsetEastM: 65,
|
||||
headingDeg: 220,
|
||||
@@ -290,7 +290,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'london-soho-core',
|
||||
cityId: 'london',
|
||||
poiIndex: 2,
|
||||
label: 'Soho Core',
|
||||
label: 'Soho Core', // i18n-ignore: camera name
|
||||
offsetNorthM: 210,
|
||||
offsetEastM: 120,
|
||||
headingDeg: 206,
|
||||
@@ -303,7 +303,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'paris-rivoli',
|
||||
cityId: 'paris',
|
||||
poiIndex: 4,
|
||||
label: 'Rue de Rivoli',
|
||||
label: 'Rue de Rivoli', // i18n-ignore: camera name
|
||||
offsetNorthM: 55,
|
||||
offsetEastM: 85,
|
||||
headingDeg: 248,
|
||||
@@ -315,7 +315,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'paris-champs-n',
|
||||
cityId: 'paris',
|
||||
poiIndex: 1,
|
||||
label: 'Champs-Élysées North',
|
||||
label: 'Champs-Élysées North', // i18n-ignore: camera name
|
||||
offsetNorthM: 130,
|
||||
offsetEastM: -38,
|
||||
headingDeg: 175,
|
||||
@@ -328,7 +328,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'dc-mall-center',
|
||||
cityId: 'dc',
|
||||
poiIndex: 1,
|
||||
label: 'National Mall Center',
|
||||
label: 'National Mall Center', // i18n-ignore: camera name
|
||||
offsetNorthM: 120,
|
||||
offsetEastM: 20,
|
||||
headingDeg: 258,
|
||||
@@ -340,7 +340,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'dc-pentagon-s',
|
||||
cityId: 'dc',
|
||||
poiIndex: 3,
|
||||
label: 'Pentagon South',
|
||||
label: 'Pentagon South', // i18n-ignore: camera name
|
||||
offsetNorthM: -100,
|
||||
offsetEastM: 92,
|
||||
headingDeg: 14,
|
||||
@@ -353,7 +353,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'dubai-difc-loop',
|
||||
cityId: 'dubai',
|
||||
poiIndex: 4,
|
||||
label: 'DIFC Loop',
|
||||
label: 'DIFC Loop', // i18n-ignore: camera name
|
||||
offsetNorthM: 92,
|
||||
offsetEastM: -45,
|
||||
headingDeg: 196,
|
||||
@@ -365,7 +365,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'dubai-downtown-east',
|
||||
cityId: 'dubai',
|
||||
poiIndex: 0,
|
||||
label: 'Downtown East',
|
||||
label: 'Downtown East', // i18n-ignore: camera name
|
||||
offsetNorthM: -130,
|
||||
offsetEastM: 190,
|
||||
headingDeg: 322,
|
||||
@@ -378,7 +378,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'austin-congress-s',
|
||||
cityId: 'austin',
|
||||
poiIndex: 0,
|
||||
label: 'Congress Southbound',
|
||||
label: 'Congress Southbound', // i18n-ignore: camera name
|
||||
offsetNorthM: -165,
|
||||
offsetEastM: 40,
|
||||
headingDeg: 12,
|
||||
@@ -390,7 +390,7 @@ export const CAMERA_SEEDS = [
|
||||
id: 'austin-downtown-west',
|
||||
cityId: 'austin',
|
||||
poiIndex: 1,
|
||||
label: 'Downtown West',
|
||||
label: 'Downtown West', // i18n-ignore: camera name
|
||||
offsetNorthM: -120,
|
||||
offsetEastM: -160,
|
||||
headingDeg: 120,
|
||||
|
||||
@@ -14,23 +14,32 @@ import {
|
||||
CYCLONE_OVERLAY_SOURCE_ID,
|
||||
cycloneStormIdFromEntryId,
|
||||
} from './labels.js';
|
||||
import { formatUtcDayMonth, t, tn, msg } from '../../i18n/index.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
|
||||
// "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word
|
||||
// joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen).
|
||||
const utc = (value) =>
|
||||
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
|
||||
const COVERAGE =
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
|
||||
value
|
||||
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${value.slice(11, 16)}\u00a0UTC`
|
||||
: t('Unavailable');
|
||||
const COVERAGE = msg(
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
|
||||
);
|
||||
const CLASSIFICATION_NAMES = Object.freeze({
|
||||
PTC: 'Potential tropical cyclone',
|
||||
HU: 'Hurricane',
|
||||
TS: 'Tropical storm',
|
||||
TD: 'Tropical depression',
|
||||
SS: 'Subtropical storm',
|
||||
SD: 'Subtropical depression',
|
||||
PTC: msg('Potential tropical cyclone'),
|
||||
HU: msg('Hurricane'),
|
||||
TS: msg('Tropical storm'),
|
||||
TD: msg('Tropical depression'),
|
||||
SS: msg('Subtropical storm'),
|
||||
SD: msg('Subtropical depression'),
|
||||
});
|
||||
const classificationName = (code) =>
|
||||
Object.hasOwn(CLASSIFICATION_NAMES, code) ? CLASSIFICATION_NAMES[code] : code;
|
||||
Object.hasOwn(CLASSIFICATION_NAMES, code)
|
||||
? t(CLASSIFICATION_NAMES[code])
|
||||
: code;
|
||||
const number = (value, unit) =>
|
||||
value === null ? 'Unavailable' : `${value} ${unit}`;
|
||||
value === null ? t('Unavailable') : formatQuantity(value, unit);
|
||||
|
||||
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
|
||||
export function createCyclonesLayer({
|
||||
@@ -316,41 +325,68 @@ export function createCyclonesLayer({
|
||||
const geometry =
|
||||
storm &&
|
||||
(coherentCycloneGeometry(storm)
|
||||
? 'Track and cone match this advisory'
|
||||
? t('Track and cone match this advisory')
|
||||
: storm.geometryStatus === 'pending'
|
||||
? `Track/cone awaiting advisory ${storm.advisoryNumber}`
|
||||
: 'Track/cone unavailable');
|
||||
? t('Track/cone awaiting advisory {number}', {
|
||||
number: storm.advisoryNumber,
|
||||
})
|
||||
: t('Track/cone unavailable'));
|
||||
const status =
|
||||
error ||
|
||||
(snapshot?.stale
|
||||
? 'Cached advisory · stale source'
|
||||
? t('Cached advisory · stale source')
|
||||
: loading
|
||||
? 'Loading advisories…'
|
||||
? t('Loading advisories…')
|
||||
: storm && !coherentCycloneGeometry(storm)
|
||||
? geometry
|
||||
: null);
|
||||
const stormCount = snapshot?.storms.length || 0;
|
||||
const activeStorms = tn(
|
||||
stormCount,
|
||||
'{count} active storm',
|
||||
'{count} active storms',
|
||||
);
|
||||
const position = storm
|
||||
? t('Position as of {time}', { time: utc(storm.positionAt) })
|
||||
: '';
|
||||
const intensity = storm
|
||||
? t('Maximum sustained wind: {wind} · Pressure: {pressure}', {
|
||||
wind: number(storm.windKt, 'kt'),
|
||||
pressure: number(storm.pressureHpa, 'hPa'),
|
||||
})
|
||||
: '';
|
||||
const detail = storm
|
||||
? `${storm.name} · ${classificationName(storm.classification)} · Advisory ${storm.advisoryNumber} · issued ${utc(storm.issuedAt)}`
|
||||
? t('{name} · {classification} · Advisory {number} · issued {time}', {
|
||||
name: storm.name,
|
||||
classification: classificationName(storm.classification),
|
||||
number: storm.advisoryNumber,
|
||||
time: utc(storm.issuedAt),
|
||||
})
|
||||
: empty
|
||||
? 'No active NHC/CPHC systems'
|
||||
: snapshot?.storms.length
|
||||
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}`
|
||||
? t('No active NHC/CPHC systems')
|
||||
: stormCount
|
||||
? activeStorms
|
||||
: loading
|
||||
? 'Loading advisories…'
|
||||
: 'Advisories unavailable';
|
||||
? t('Loading advisories…')
|
||||
: t('Advisories unavailable');
|
||||
const controls = {
|
||||
readout: true,
|
||||
summary: {
|
||||
label: 'Cyclones · NHC / CPHC',
|
||||
coverage: 'Atlantic · E/C Pacific',
|
||||
compact: snapshot?.storms.length
|
||||
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}${storm ? ` · ${storm.name} selected` : ''}`
|
||||
label: t('Cyclones · NHC / CPHC'),
|
||||
coverage: t('Atlantic · E/C Pacific'),
|
||||
compact: stormCount
|
||||
? storm
|
||||
? t('{storms} · {name} selected', {
|
||||
storms: activeStorms,
|
||||
name: storm.name,
|
||||
})
|
||||
: activeStorms
|
||||
: detail,
|
||||
actions: storm?.advisoryUrl
|
||||
? [
|
||||
{
|
||||
id: 'advisory',
|
||||
label: 'Official advisory ↗',
|
||||
label: t('Official advisory ↗'),
|
||||
href: storm.advisoryUrl,
|
||||
},
|
||||
]
|
||||
@@ -360,12 +396,12 @@ export function createCyclonesLayer({
|
||||
? [
|
||||
{
|
||||
id: 'position',
|
||||
text: `Position as of ${utc(storm.positionAt)}`,
|
||||
text: position,
|
||||
muted: true,
|
||||
},
|
||||
{
|
||||
id: 'intensity',
|
||||
text: `Maximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}`,
|
||||
text: intensity,
|
||||
},
|
||||
{ id: 'geometry', text: geometry, muted: true },
|
||||
]
|
||||
@@ -375,12 +411,12 @@ export function createCyclonesLayer({
|
||||
units: 'kt',
|
||||
},
|
||||
list: {
|
||||
ariaLabel: 'Active NHC and CPHC cyclone advisories',
|
||||
ariaLabel: t('Active NHC and CPHC cyclone advisories'),
|
||||
items: (snapshot?.storms || []).map((item, index) => ({
|
||||
id: item.id,
|
||||
ordinal: index + 1,
|
||||
lead: item.basin,
|
||||
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? 'Wind unavailable' : `${item.windKt} kt`}`,
|
||||
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? t('Wind unavailable') : formatQuantity(item.windKt, 'kt')}`,
|
||||
active: item.id === selectedId,
|
||||
params: { stormId: item.id, focus: true },
|
||||
})),
|
||||
@@ -388,15 +424,22 @@ export function createCyclonesLayer({
|
||||
chips: [],
|
||||
legend: storm
|
||||
? [
|
||||
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
|
||||
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
|
||||
{
|
||||
label: t('Advisory center / forecast track'),
|
||||
color: '#7fe6ed',
|
||||
},
|
||||
{
|
||||
label: t('Center-track uncertainty cone'),
|
||||
color: '#7fe6ed44',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
info: storm
|
||||
? `${detail}\nPosition as of ${utc(storm.positionAt)}\nMaximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
|
||||
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || COVERAGE}`,
|
||||
infoTitle:
|
||||
? `${detail}\n${position}\n${intensity}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
|
||||
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || t(COVERAGE)}`,
|
||||
infoTitle: t(
|
||||
'Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.',
|
||||
),
|
||||
};
|
||||
return controls;
|
||||
},
|
||||
|
||||
@@ -565,7 +565,9 @@ test('advisory selection uses accessible row descriptors and shared camera hando
|
||||
1,
|
||||
);
|
||||
assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10');
|
||||
assert.match(controls.info, /09-16 03:00 UTC/);
|
||||
// No line break inside the date: a word joiner holds the hyphen.
|
||||
assert.match(controls.info, /issued 09-\u206016\u00a003:00\u00a0UTC/);
|
||||
assert.match(controls.info, /: 25\u00a0kt · .*: 1006\u00a0hPa/);
|
||||
assert.match(controls.infoTitle, /not storm size/);
|
||||
assert.match(controls.infoTitle, /follows the surface/);
|
||||
assert.equal(controls.list.items[1].params.focus, true);
|
||||
|
||||
+350
-53
@@ -17,10 +17,9 @@
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
formatRouteDistance,
|
||||
formatRouteDuration,
|
||||
} from '../../data/routeSteps.js';
|
||||
import { roadLabel } from '../../data/routeSteps.js';
|
||||
import { t, msg, formatNumber } from '../../i18n/index.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
@@ -31,9 +30,9 @@ export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
|
||||
/** Travel modes, keyed by the `/api/route` profile name. */
|
||||
export const DIRECTIONS_MODES = Object.freeze({
|
||||
car: Object.freeze({ chip: 'DRIVE', word: 'Drive', icon: '🚗' }),
|
||||
foot: Object.freeze({ chip: 'WALK', word: 'Walk', icon: '🚶' }),
|
||||
bike: Object.freeze({ chip: 'BIKE', word: 'Bike', icon: '🚲' }),
|
||||
car: Object.freeze({ chip: msg('DRIVE'), word: msg('Drive'), icon: '🚗' }),
|
||||
foot: Object.freeze({ chip: msg('WALK'), word: msg('Walk'), icon: '🚶' }),
|
||||
bike: Object.freeze({ chip: msg('BIKE'), word: msg('Bike'), icon: '🚲' }),
|
||||
});
|
||||
export const DEFAULT_DIRECTIONS_MODE = 'car';
|
||||
|
||||
@@ -68,6 +67,270 @@ export const STEP_ANCHOR_FAST_ATTEMPTS = 5;
|
||||
/** How often the row repaints the highlighted step while FLY is running (ms). */
|
||||
export const FLIGHT_PROGRESS_MS = 250;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display text: distances, durations and maneuvers in the page's language.
|
||||
// The proxy's English `instruction` stays the machine value; the row and the
|
||||
// cards phrase each step from its OSRM type/modifier/road instead.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Localized "850 m", "1.2 km", "21 km" (the rounding of `formatRouteDistance`
|
||||
* in src/data/routeSteps.js); number and unit never break apart.
|
||||
* @param {number} meters
|
||||
* @returns {string}
|
||||
*/
|
||||
export function routeDistanceText(meters) {
|
||||
if (!Number.isFinite(meters) || meters < 0) return '';
|
||||
if (meters < 100) return formatQuantity(Math.round(meters), 'm');
|
||||
if (meters < 1000) return formatQuantity(Math.round(meters / 10) * 10, 'm');
|
||||
const km = meters / 1000;
|
||||
return km < 10
|
||||
? formatQuantity(km, 'km', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
: formatQuantity(Math.round(km), 'km');
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized "40 s", "12 min", "1 h 5 min" (the rounding of
|
||||
* `formatRouteDuration` in src/data/routeSteps.js); never broken apart.
|
||||
* @param {number} seconds
|
||||
* @returns {string}
|
||||
*/
|
||||
export function routeDurationText(seconds) {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '';
|
||||
if (seconds < 60) return formatQuantity(Math.round(seconds), 's');
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return formatQuantity(minutes, 'min');
|
||||
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
|
||||
const rest = minutes % 60;
|
||||
return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours;
|
||||
}
|
||||
|
||||
/** "left", "slightly right", … as it reads inside a maneuver sentence. */
|
||||
function directionWord(modifier) {
|
||||
switch (modifier) {
|
||||
case 'left':
|
||||
return t('left', { context: 'direction' });
|
||||
case 'right':
|
||||
return t('right', { context: 'direction' });
|
||||
case 'slight left':
|
||||
return t('slightly left', { context: 'direction' });
|
||||
case 'slight right':
|
||||
return t('slightly right', { context: 'direction' });
|
||||
case 'sharp left':
|
||||
return t('sharply left', { context: 'direction' });
|
||||
case 'sharp right':
|
||||
return t('sharply right', { context: 'direction' });
|
||||
case 'straight':
|
||||
return t('straight', { context: 'direction' });
|
||||
case 'uturn':
|
||||
return t('around', { context: 'direction' });
|
||||
default:
|
||||
return modifier;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The side to keep at a fork. English reuses the turn words; a language may
|
||||
* need another form here ("держитесь левее", not "держитесь налево").
|
||||
*/
|
||||
function forkWord(modifier) {
|
||||
switch (modifier) {
|
||||
case 'left':
|
||||
return t('left', { context: 'fork direction' });
|
||||
case 'right':
|
||||
return t('right', { context: 'fork direction' });
|
||||
case 'slight left':
|
||||
return t('slightly left', { context: 'fork direction' });
|
||||
case 'slight right':
|
||||
return t('slightly right', { context: 'fork direction' });
|
||||
case 'sharp left':
|
||||
return t('sharply left', { context: 'fork direction' });
|
||||
case 'sharp right':
|
||||
return t('sharply right', { context: 'fork direction' });
|
||||
case 'straight':
|
||||
return t('straight', { context: 'fork direction' });
|
||||
default:
|
||||
return directionWord(modifier);
|
||||
}
|
||||
}
|
||||
|
||||
/** The lane to use, as an adjective where a language needs one ("левую полосу"). */
|
||||
function laneWord(modifier) {
|
||||
switch (modifier) {
|
||||
case 'left':
|
||||
return t('left', { context: 'lane direction' });
|
||||
case 'right':
|
||||
return t('right', { context: 'lane direction' });
|
||||
case 'slight left':
|
||||
return t('slightly left', { context: 'lane direction' });
|
||||
case 'slight right':
|
||||
return t('slightly right', { context: 'lane direction' });
|
||||
case 'sharp left':
|
||||
return t('sharply left', { context: 'lane direction' });
|
||||
case 'sharp right':
|
||||
return t('sharply right', { context: 'lane direction' });
|
||||
case 'straight':
|
||||
return t('straight', { context: 'lane direction' });
|
||||
default:
|
||||
return directionWord(modifier);
|
||||
}
|
||||
}
|
||||
|
||||
/** "1st", "2nd", … for a roundabout exit. */
|
||||
function exitOrdinal(exit) {
|
||||
const number = formatNumber(exit);
|
||||
switch (new Intl.PluralRules('en', { type: 'ordinal' }).select(exit)) {
|
||||
case 'one':
|
||||
return t('{number}st', { context: 'ordinal', number });
|
||||
case 'two':
|
||||
return t('{number}nd', { context: 'ordinal', number });
|
||||
case 'few':
|
||||
return t('{number}rd', { context: 'ordinal', number });
|
||||
default:
|
||||
return t('{number}th', { context: 'ordinal', number });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One maneuver in the page's language, from the step's OSRM type, modifier,
|
||||
* exit and road. English matches `instructionFor` in src/data/routeSteps.js
|
||||
* word for word. A step without a type (an older payload) keeps the proxy's
|
||||
* English instruction.
|
||||
* @param {{type?:string, modifier?:string|null, exit?:number|null, name?:string, ref?:string|null, instruction?:string}} step
|
||||
* @returns {string}
|
||||
*/
|
||||
export function directionsInstruction(step) {
|
||||
const type =
|
||||
typeof step?.type === 'string' ? step.type.trim().toLowerCase() : '';
|
||||
if (!type)
|
||||
return typeof step?.instruction === 'string' ? step.instruction : '';
|
||||
const modifier =
|
||||
typeof step.modifier === 'string' ? step.modifier.trim().toLowerCase() : '';
|
||||
const road = roadLabel(step);
|
||||
const direction = directionWord(modifier);
|
||||
const values = { road, direction };
|
||||
switch (type) {
|
||||
case 'depart':
|
||||
return road ? t('Head out on {road}', values) : t('Head out');
|
||||
case 'arrive':
|
||||
if (modifier === 'left')
|
||||
return t('Arrive at your destination, on the left');
|
||||
if (modifier === 'right')
|
||||
return t('Arrive at your destination, on the right');
|
||||
return t('Arrive at your destination');
|
||||
case 'turn':
|
||||
if (modifier === 'uturn')
|
||||
return road
|
||||
? t('Make a U-turn onto {road}', values)
|
||||
: t('Make a U-turn');
|
||||
if (modifier === 'straight')
|
||||
return road
|
||||
? t('Continue straight onto {road}', values)
|
||||
: t('Continue straight');
|
||||
if (direction)
|
||||
return road
|
||||
? t('Turn {direction} onto {road}', values)
|
||||
: t('Turn {direction}', values);
|
||||
return road
|
||||
? t('Turn onto {road}', values)
|
||||
: t('Turn', { context: 'maneuver' });
|
||||
case 'continue':
|
||||
if (modifier === 'uturn')
|
||||
return road
|
||||
? t('Make a U-turn onto {road}', values)
|
||||
: t('Make a U-turn');
|
||||
if (direction && modifier !== 'straight')
|
||||
return road
|
||||
? t('Continue {direction} onto {road}', values)
|
||||
: t('Continue {direction}', values);
|
||||
return road
|
||||
? t('Continue straight onto {road}', values)
|
||||
: t('Continue straight');
|
||||
case 'end of road':
|
||||
if (direction)
|
||||
return road
|
||||
? t('At the end of the road, turn {direction} onto {road}', values)
|
||||
: t('At the end of the road, turn {direction}', values);
|
||||
return road
|
||||
? t('At the end of the road, continue onto {road}', values)
|
||||
: t('At the end of the road, continue');
|
||||
case 'fork':
|
||||
if (direction) {
|
||||
const fork = { road, direction: forkWord(modifier) };
|
||||
return road
|
||||
? t('Keep {direction} at the fork onto {road}', fork)
|
||||
: t('Keep {direction} at the fork', fork);
|
||||
}
|
||||
return road
|
||||
? t('Continue at the fork onto {road}', values)
|
||||
: t('Continue at the fork');
|
||||
case 'merge':
|
||||
if (direction)
|
||||
return road
|
||||
? t('Merge {direction} onto {road}', values)
|
||||
: t('Merge {direction}', values);
|
||||
return road
|
||||
? t('Merge onto {road}', values)
|
||||
: t('Merge', { context: 'maneuver' });
|
||||
case 'on ramp':
|
||||
if (direction)
|
||||
return road
|
||||
? t('Take the ramp on the {direction} onto {road}', values)
|
||||
: t('Take the ramp on the {direction}', values);
|
||||
return road ? t('Take the ramp onto {road}', values) : t('Take the ramp');
|
||||
case 'off ramp':
|
||||
if (direction)
|
||||
return road
|
||||
? t('Take the exit on the {direction} onto {road}', values)
|
||||
: t('Take the exit on the {direction}', values);
|
||||
return road ? t('Take the exit onto {road}', values) : t('Take the exit');
|
||||
case 'roundabout':
|
||||
case 'rotary':
|
||||
if (Number.isInteger(step.exit) && step.exit >= 1) {
|
||||
const exit = exitOrdinal(step.exit);
|
||||
return road
|
||||
? t('At the roundabout, take the {exit} exit onto {road}', {
|
||||
exit,
|
||||
road,
|
||||
})
|
||||
: t('At the roundabout, take the {exit} exit', { exit });
|
||||
}
|
||||
return road
|
||||
? t('Enter the roundabout onto {road}', values)
|
||||
: t('Enter the roundabout');
|
||||
case 'roundabout turn':
|
||||
if (direction)
|
||||
return road
|
||||
? t('At the roundabout, turn {direction} onto {road}', values)
|
||||
: t('At the roundabout, turn {direction}', values);
|
||||
return road
|
||||
? t('At the roundabout, continue onto {road}', values)
|
||||
: t('At the roundabout, continue');
|
||||
case 'exit roundabout':
|
||||
case 'exit rotary':
|
||||
return road
|
||||
? t('Exit the roundabout onto {road}', values)
|
||||
: t('Exit the roundabout');
|
||||
case 'use lane':
|
||||
if (direction) {
|
||||
const lane = { road, direction: laneWord(modifier) };
|
||||
return road
|
||||
? t('Use the {direction} lane onto {road}', lane)
|
||||
: t('Use the {direction} lane', lane);
|
||||
}
|
||||
return road
|
||||
? t('Continue onto {road}', values)
|
||||
: t('Continue', { context: 'maneuver' });
|
||||
default:
|
||||
return road
|
||||
? t('Continue onto {road}', values)
|
||||
: t('Continue', { context: 'maneuver' });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers (exported for tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -107,32 +370,35 @@ export function directionsRowControls(state) {
|
||||
const routing = status === 'routing';
|
||||
const chips = Object.entries(DIRECTIONS_MODES).map(([id, spec]) => ({
|
||||
id: `mode-${id}`,
|
||||
label: spec.chip,
|
||||
label: t(spec.chip),
|
||||
active: mode === id,
|
||||
state: mode === id ? 'active' : 'idle',
|
||||
title: `${spec.word} — reroute for ${spec.word.toLowerCase()}`,
|
||||
title: t('{mode} — reroute for {modeLower}', {
|
||||
mode: t(spec.word),
|
||||
modeLower: t(spec.word).toLowerCase(),
|
||||
}),
|
||||
params: { mode: id },
|
||||
}));
|
||||
chips.push({
|
||||
id: 'set-a',
|
||||
label: armed === 'a' ? 'CLICK MAP' : a ? 'A ✓' : 'SET A',
|
||||
label: armed === 'a' ? t('CLICK MAP') : a ? 'A ✓' : t('SET A'),
|
||||
active: armed === 'a',
|
||||
state: armed === 'a' ? 'active' : 'idle',
|
||||
title:
|
||||
armed === 'a'
|
||||
? 'Click a spot on the globe to place A (click again to cancel)'
|
||||
: 'Then click the globe to place the start',
|
||||
? t('Click a spot on the globe to place A (click again to cancel)')
|
||||
: t('Then click the globe to place the start'),
|
||||
params: { arm: armed === 'a' ? null : 'a' },
|
||||
});
|
||||
chips.push({
|
||||
id: 'set-b',
|
||||
label: armed === 'b' ? 'CLICK MAP' : b ? 'B ✓' : 'SET B',
|
||||
label: armed === 'b' ? t('CLICK MAP') : b ? 'B ✓' : t('SET B'),
|
||||
active: armed === 'b',
|
||||
state: armed === 'b' ? 'active' : 'idle',
|
||||
title:
|
||||
armed === 'b'
|
||||
? 'Click a spot on the globe to place B (click again to cancel)'
|
||||
: 'Then click the globe to place the destination',
|
||||
? t('Click a spot on the globe to place B (click again to cancel)')
|
||||
: t('Then click the globe to place the destination'),
|
||||
params: { arm: armed === 'b' ? null : 'b' },
|
||||
});
|
||||
chips.push({
|
||||
@@ -140,26 +406,28 @@ export function directionsRowControls(state) {
|
||||
label: '⇄',
|
||||
disabled: !(a && b) || routing,
|
||||
state: 'idle',
|
||||
title: 'Swap A and B',
|
||||
title: t('Swap A and B'),
|
||||
params: { swap: true },
|
||||
});
|
||||
const flying = Number.isInteger(flightStep);
|
||||
chips.push({
|
||||
id: 'fly',
|
||||
label: routing ? 'FLY ···' : flying ? 'FLYING' : 'FLY',
|
||||
label: routing ? t('FLY ···') : flying ? t('FLYING') : t('FLY'),
|
||||
disabled: !route || routing,
|
||||
busy: routing || flying,
|
||||
active: flying,
|
||||
state: routing || flying ? 'loading' : 'idle',
|
||||
title: route ? 'Fly the camera along the route' : 'Place A and B first',
|
||||
title: route
|
||||
? t('Fly the camera along the route')
|
||||
: t('Place A and B first'),
|
||||
params: { fly: true },
|
||||
});
|
||||
chips.push({
|
||||
id: 'clear',
|
||||
label: 'CLEAR',
|
||||
label: t('CLEAR', { context: 'action' }),
|
||||
disabled: !a && !b && !route,
|
||||
state: 'idle',
|
||||
title: 'Remove the route and both markers',
|
||||
title: t('Remove the route and both markers'),
|
||||
params: { clear: true },
|
||||
});
|
||||
return {
|
||||
@@ -179,8 +447,8 @@ export function directionsRowControls(state) {
|
||||
*/
|
||||
export const POINTER_TOOL_EXITS = Object.freeze({
|
||||
draw: Object.freeze({
|
||||
name: 'Draw',
|
||||
leave: 'press Escape twice to leave Draw',
|
||||
name: msg('Draw'),
|
||||
leave: msg('press Escape twice to leave Draw'),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -201,9 +469,16 @@ export function pointerBlockedMessage(owner, which) {
|
||||
if (!id || id === DIRECTIONS_POINTER_OWNER) return null;
|
||||
const endpoint = which === 'b' ? 'B' : 'A';
|
||||
const known = POINTER_TOOL_EXITS[id];
|
||||
const name = known?.name || id;
|
||||
const leave = known?.leave || `turn ${name} off`;
|
||||
return `${name} is active — ${leave}, then set ${endpoint}`;
|
||||
if (!known)
|
||||
return t('{name} is active — turn {name} off, then set {endpoint}', {
|
||||
name: id,
|
||||
endpoint,
|
||||
});
|
||||
return t('{name} is active — {leave}, then set {endpoint}', {
|
||||
name: t(known.name),
|
||||
leave: t(known.leave),
|
||||
endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,8 +547,8 @@ export function directionsStepList({
|
||||
const items = steps.map((step, index) => ({
|
||||
id: `step-${index}`,
|
||||
ordinal: index + 1,
|
||||
lead: formatRouteDistance(step.distanceM) || '—',
|
||||
text: step.instruction,
|
||||
lead: routeDistanceText(step.distanceM) || '—',
|
||||
text: directionsInstruction(step),
|
||||
active: index === active,
|
||||
current: Number.isInteger(flightStep) && index === flightStep,
|
||||
params: { step: index },
|
||||
@@ -285,13 +560,15 @@ export function directionsStepList({
|
||||
id: 'step-truncated',
|
||||
ordinal: items.length + 1,
|
||||
lead: '',
|
||||
text: `Only the first ${steps.length} turns of this route are shown`,
|
||||
text: t('Only the first {count} turns of this route are shown', {
|
||||
count: steps.length,
|
||||
}),
|
||||
active: false,
|
||||
current: false,
|
||||
disabled: true,
|
||||
});
|
||||
}
|
||||
return { ariaLabel: 'Turn-by-turn directions', items };
|
||||
return { ariaLabel: t('Turn-by-turn directions'), items };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,12 +608,13 @@ export function directionsStats(state) {
|
||||
armed,
|
||||
pointerBlocked,
|
||||
} = state;
|
||||
const source = 'OSM routing';
|
||||
// English in layer state; the layer row translates it.
|
||||
const source = 'OSM routing'; // i18n-ignore: source name, translated by layerPanel
|
||||
if (pointerBlocked) {
|
||||
return {
|
||||
count: route?.steps.length || 0,
|
||||
lastUpdate,
|
||||
error: 'Another map tool is using clicks — close it, then SET A again',
|
||||
error: t('Another map tool is using clicks — close it, then SET A again'),
|
||||
status: 'empty',
|
||||
source,
|
||||
};
|
||||
@@ -347,7 +625,7 @@ export function directionsStats(state) {
|
||||
lastUpdate,
|
||||
error: null,
|
||||
loading: true,
|
||||
loadingLabel: 'Routing…',
|
||||
loadingLabel: t('Routing…'),
|
||||
source,
|
||||
};
|
||||
}
|
||||
@@ -355,7 +633,7 @@ export function directionsStats(state) {
|
||||
return {
|
||||
count: 0,
|
||||
lastUpdate,
|
||||
error: error || 'No route found',
|
||||
error: error || t('No route found'),
|
||||
status: 'empty',
|
||||
source,
|
||||
};
|
||||
@@ -364,10 +642,17 @@ export function directionsStats(state) {
|
||||
// set (not only while loading), so the route summary and the placement
|
||||
// guidance ride on it; `coverage` carries the same text for stats readers.
|
||||
if (route) {
|
||||
const word = DIRECTIONS_MODES[mode]?.word || mode;
|
||||
const summary =
|
||||
`${formatRouteDistance(route.distanceM)} · ${formatRouteDuration(route.durationS)} · ${word}` +
|
||||
(route.stepsTruncated ? ` · first ${route.steps.length} turns` : '');
|
||||
const spec = DIRECTIONS_MODES[mode];
|
||||
const word = spec ? t(spec.word) : mode;
|
||||
const values = {
|
||||
distance: routeDistanceText(route.distanceM),
|
||||
duration: routeDurationText(route.durationS),
|
||||
mode: word,
|
||||
count: route.steps.length,
|
||||
};
|
||||
const summary = route.stepsTruncated
|
||||
? t('{distance} · {duration} · {mode} · first {count} turns', values)
|
||||
: t('{distance} · {duration} · {mode}', values);
|
||||
return {
|
||||
count: route.steps.length,
|
||||
lastUpdate,
|
||||
@@ -378,10 +663,13 @@ export function directionsStats(state) {
|
||||
};
|
||||
}
|
||||
let coverage;
|
||||
if (armed) coverage = `Click the globe to place ${armed.toUpperCase()}`;
|
||||
else if (a && !b) coverage = 'SET B, then click the globe';
|
||||
else if (!a && b) coverage = 'SET A, then click the globe';
|
||||
else coverage = 'SET A, then click the globe';
|
||||
if (armed)
|
||||
coverage = t('Click the globe to place {endpoint}', {
|
||||
endpoint: armed.toUpperCase(),
|
||||
});
|
||||
else if (a && !b) coverage = t('SET B, then click the globe');
|
||||
else if (!a && b) coverage = t('SET A, then click the globe');
|
||||
else coverage = t('SET A, then click the globe');
|
||||
return {
|
||||
count: 0,
|
||||
lastUpdate,
|
||||
@@ -403,14 +691,23 @@ export function directionsStepCopy(steps, index) {
|
||||
const step = steps[index];
|
||||
const details = [];
|
||||
const leg = [];
|
||||
if (step.distanceM > 0) leg.push(formatRouteDistance(step.distanceM));
|
||||
if (step.durationS > 0) leg.push(formatRouteDuration(step.durationS));
|
||||
if (step.distanceM > 0) leg.push(routeDistanceText(step.distanceM));
|
||||
if (step.durationS > 0) leg.push(routeDurationText(step.durationS));
|
||||
const position = { step: index + 1, total: steps.length };
|
||||
details.push(
|
||||
`Step ${index + 1} of ${steps.length}${leg.length ? ` · then ${leg.join(' · ')}` : ''}`,
|
||||
leg.length
|
||||
? t('Step {step} of {total} · then {leg}', {
|
||||
...position,
|
||||
leg: leg.join(' · '),
|
||||
})
|
||||
: t('Step {step} of {total}', position),
|
||||
);
|
||||
const next = steps[index + 1];
|
||||
if (next) details.push(`Then: ${next.instruction}`);
|
||||
return { title: step.instruction, details };
|
||||
if (next)
|
||||
details.push(
|
||||
t('Then: {instruction}', { instruction: directionsInstruction(next) }),
|
||||
);
|
||||
return { title: directionsInstruction(step), details };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -819,10 +1116,10 @@ export function createDirectionsLayer({ services, source }) {
|
||||
_status = 'error';
|
||||
_error =
|
||||
payload?.error === 'no route found'
|
||||
? 'No route found between A and B'
|
||||
? t('No route found between A and B')
|
||||
: payload?.error
|
||||
? `Routing failed: ${payload.error}`
|
||||
: 'No route found between A and B';
|
||||
? t('Routing failed: {error}', { error: payload.error })
|
||||
: t('No route found between A and B');
|
||||
return;
|
||||
}
|
||||
_route = route;
|
||||
@@ -839,10 +1136,10 @@ export function createDirectionsLayer({ services, source }) {
|
||||
// happened instead.
|
||||
_error =
|
||||
error?.name === 'AbortError'
|
||||
? 'Routing timed out'
|
||||
? t('Routing timed out')
|
||||
: error?.name === 'TypeError'
|
||||
? 'Routing unavailable — no response from the routing service'
|
||||
: error?.message || 'Routing unavailable';
|
||||
? t('Routing unavailable — no response from the routing service')
|
||||
: error?.message || t('Routing unavailable');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (_routeAbort === controller) _routeAbort = null;
|
||||
@@ -1037,7 +1334,7 @@ export function createDirectionsLayer({ services, source }) {
|
||||
[
|
||||
{
|
||||
type: 'route',
|
||||
label: 'Directions',
|
||||
label: t('Directions'),
|
||||
path: _route.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||||
},
|
||||
],
|
||||
|
||||
+67
-10
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
import {
|
||||
satelliteShortName,
|
||||
fireDetectionKey,
|
||||
@@ -332,11 +332,11 @@ export function createModel({
|
||||
function buildSelectedFireCard(fire, nowMs) {
|
||||
const meta = [
|
||||
t('{value} conf', {
|
||||
value: confidenceBucket(fire.confidence),
|
||||
value: confidenceLabel(confidenceBucket(fire.confidence)),
|
||||
}),
|
||||
];
|
||||
if (fire.acqMs > 0) {
|
||||
const age = formatAge(nowMs - fire.acqMs);
|
||||
const age = formatAgeLabel(nowMs - fire.acqMs);
|
||||
if (age) meta.push(t('{value} ago', { value: age }));
|
||||
}
|
||||
const sat = satelliteShortName(fire.satellite);
|
||||
@@ -351,10 +351,10 @@ export function createModel({
|
||||
cullPosition: fireCullPosition(fire),
|
||||
gapPx: frpPixelSize(fire.frp),
|
||||
accent: accentForSeverity(detectionColorStop(fire).name),
|
||||
title: t('FIRE · {frp} MW', { frp: formatFrp(fire.frp) }),
|
||||
title: t('FIRE · {frp} MW', { frp: formatFrpLabel(fire.frp) }),
|
||||
details: [
|
||||
meta.join(' · '),
|
||||
formatLatLon(fire.lat, fire.lon) +
|
||||
formatLatLonLabel(fire.lat, fire.lon) +
|
||||
(fire.night ? ` · ${t('NIGHT')}` : ''),
|
||||
],
|
||||
selected: true,
|
||||
@@ -374,9 +374,9 @@ export function createModel({
|
||||
|
||||
function buildFireCard(candidate, nowMs) {
|
||||
const fire = candidate.fire;
|
||||
const meta = [confidenceBucket(fire.confidence)];
|
||||
const meta = [confidenceLabel(confidenceBucket(fire.confidence))];
|
||||
if (fire.acqMs > 0) {
|
||||
const age = formatAge(nowMs - fire.acqMs);
|
||||
const age = formatAgeLabel(nowMs - fire.acqMs);
|
||||
if (age) meta.push(age);
|
||||
}
|
||||
const sat = satelliteShortName(fire.satellite) || fire.sensor;
|
||||
@@ -388,7 +388,7 @@ export function createModel({
|
||||
cullPosition: candidate.cullPosition || candidate.position,
|
||||
gapPx: frpPixelSize(fire.frp),
|
||||
accent: accentForSeverity(detectionColorStop(fire).name),
|
||||
title: t('▲ {frp} MW', { frp: formatFrp(fire.frp) }),
|
||||
title: t('▲ {frp} MW', { frp: formatFrpLabel(fire.frp) }),
|
||||
details: [meta.join(' · ')],
|
||||
selected: false,
|
||||
priority: Number(fire.frp) || 0,
|
||||
@@ -407,9 +407,9 @@ export function createModel({
|
||||
function buildCellCard(candidate, nowMs) {
|
||||
const cell = candidate.cell;
|
||||
const noun = cell.count === 1 ? t('FIRE') : t('FIRES');
|
||||
const parts = [t('max {value} MW', { value: formatFrp(cell.maxFrp) })];
|
||||
const parts = [t('max {value} MW', { value: formatFrpLabel(cell.maxFrp) })];
|
||||
if (cell.newestAcqMs > 0) {
|
||||
const age = formatAge(nowMs - cell.newestAcqMs);
|
||||
const age = formatAgeLabel(nowMs - cell.newestAcqMs);
|
||||
if (age) parts.push(t('new {value}', { value: age }));
|
||||
}
|
||||
return {
|
||||
@@ -483,6 +483,37 @@ export function createModel({
|
||||
return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1);
|
||||
}
|
||||
|
||||
/** formatFrp for a card, with the locale's decimal separator ("4,9"). */
|
||||
|
||||
function formatFrpLabel(frp) {
|
||||
const digits = frp >= 10 ? 0 : 1;
|
||||
return formatNumber(frp, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
useGrouping: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** formatLatLon for a card: "8,389° ю. ш. 44,162° з. д." in Russian. */
|
||||
|
||||
function formatLatLonLabel(lat, lon) {
|
||||
const amount = (value) =>
|
||||
formatNumber(Math.abs(value), {
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3,
|
||||
useGrouping: false,
|
||||
});
|
||||
const latPart =
|
||||
lat >= 0
|
||||
? t('{value}°N', { value: amount(lat) })
|
||||
: t('{value}°S', { value: amount(lat) });
|
||||
const lonPart =
|
||||
lon >= 0
|
||||
? t('{value}°E', { value: amount(lon) })
|
||||
: t('{value}°W', { value: amount(lon) });
|
||||
return `${latPart} ${lonPart}`;
|
||||
}
|
||||
|
||||
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
|
||||
|
||||
function formatAge(deltaMs) {
|
||||
@@ -503,6 +534,32 @@ export function createModel({
|
||||
return t('{count}h ago', { count: Math.round(minutes / 60) });
|
||||
}
|
||||
|
||||
/** A confidence bucket as a card shows it, in the UI language. */
|
||||
|
||||
function confidenceLabel(bucket) {
|
||||
switch (bucket) {
|
||||
case 'high':
|
||||
return t('high', { context: 'fire confidence' });
|
||||
case 'nominal':
|
||||
return t('nominal', { context: 'fire confidence' });
|
||||
case 'low':
|
||||
return t('low', { context: 'fire confidence' });
|
||||
default:
|
||||
return bucket;
|
||||
}
|
||||
}
|
||||
|
||||
/** formatAge for a card: "<1h", "24h", "3d" in the UI language ("24 ч"). */
|
||||
|
||||
function formatAgeLabel(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return '';
|
||||
const hours = deltaMs / 3600000;
|
||||
if (hours < 1) return t('<1h', { context: 'age' });
|
||||
if (hours < 48)
|
||||
return t('{count}h', { context: 'age', count: Math.round(hours) });
|
||||
return t('{count}d', { context: 'age', count: Math.round(hours / 24) });
|
||||
}
|
||||
|
||||
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
|
||||
|
||||
function confidenceBucket(confidence) {
|
||||
|
||||
@@ -18,7 +18,9 @@ export function createQueries({
|
||||
if (!fire) return null;
|
||||
return {
|
||||
id: fireDetectionKey(fire),
|
||||
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
|
||||
label: t('Fire · FRP {frp} MW', {
|
||||
frp: components.model.formatFrp(fire.frp),
|
||||
}),
|
||||
latitude: fire.lat,
|
||||
longitude: fire.lon,
|
||||
frp: fire.frp,
|
||||
@@ -115,7 +117,9 @@ export function createQueries({
|
||||
latitude: strongest.lat,
|
||||
longitude: strongest.lon,
|
||||
frp: strongest.frp,
|
||||
label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`,
|
||||
label: t('Fire · FRP {frp} MW', {
|
||||
frp: components.model.formatFrp(strongest.frp),
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import { CONTEXT_TOP_N } from './policy.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export function createSelection({
|
||||
layerState,
|
||||
@@ -80,7 +81,7 @@ export function createSelection({
|
||||
requestWorldFocus({
|
||||
kind: 'fire',
|
||||
id: fireDetectionKey(fire),
|
||||
label: 'FIRE',
|
||||
label: t('FIRE'),
|
||||
position: components.model.firePosition(fire),
|
||||
});
|
||||
}
|
||||
@@ -192,7 +193,9 @@ export function createSelection({
|
||||
layerName: name,
|
||||
source: 'NASA FIRMS',
|
||||
dataSource: layerState._dataSource,
|
||||
label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`,
|
||||
label: t('Fire · FRP {frp} MW', {
|
||||
frp: components.model.formatFrp(fire.frp),
|
||||
}),
|
||||
latitude: fire.lat,
|
||||
longitude: fire.lon,
|
||||
properties: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
import { unitLabel } from '../../ui/unitLabels.js';
|
||||
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
||||
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
|
||||
import {
|
||||
@@ -752,8 +753,15 @@ export function createTracking({
|
||||
// the tail number rather than raw hex.
|
||||
const cs = parts.queries._contactLabel(icao24, info);
|
||||
const altFt = Math.round((info.altitude || 0) * 3.28084);
|
||||
const fl = altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : `${altFt} ft`;
|
||||
const spd = info.velocity ? `${Math.round(info.velocity * 1.944)} kts` : '';
|
||||
// Flight levels are an aviation code; feet and knots read in the active
|
||||
// language ("0 фт · 1 уз").
|
||||
const quantity = (value, unit) =>
|
||||
`${formatNumber(value, { useGrouping: false })} ${unitLabel(unit)}`;
|
||||
const fl =
|
||||
altFt >= 18000 ? `FL${Math.round(altFt / 100)}` : quantity(altFt, 'ft');
|
||||
const spd = info.velocity
|
||||
? quantity(Math.round(info.velocity * 1.944), 'kts')
|
||||
: '';
|
||||
const stale =
|
||||
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
|
||||
? t('STALE')
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* An installation class as a map card shows it: AIRFIELD, NAVAL BASE…, in
|
||||
* the UI language. The class itself stays an English identifier in data.
|
||||
* @param {string} klass
|
||||
* @returns {string}
|
||||
*/
|
||||
export function installationClassLabel(klass) {
|
||||
const key = String(klass || 'installation');
|
||||
switch (key) {
|
||||
case 'airfield':
|
||||
return t('AIRFIELD', { context: 'installation class' });
|
||||
case 'naval_base':
|
||||
return t('NAVAL BASE', { context: 'installation class' });
|
||||
case 'range':
|
||||
return t('RANGE', { context: 'installation class' });
|
||||
case 'military_land':
|
||||
return t('MILITARY LAND', { context: 'installation class' });
|
||||
case 'base':
|
||||
return t('BASE', { context: 'installation class' });
|
||||
case 'places_candidate':
|
||||
return t('PLACES CANDIDATE', { context: 'installation class' });
|
||||
case 'installation':
|
||||
return t('INSTALLATION', { context: 'installation class' });
|
||||
default:
|
||||
return key.replaceAll('_', ' ').toUpperCase();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { installationClassLabel } from './classLabel.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
MILITARY_LABEL_CAP,
|
||||
@@ -177,9 +178,7 @@ export function createNamedMarkers({ state, services, parts, overlayHost }) {
|
||||
marker.entry.selected = record.id === state.selectedId;
|
||||
marker.entry.details = marker.entry.selected
|
||||
? [
|
||||
String(record.class || 'installation')
|
||||
.replaceAll('_', ' ')
|
||||
.toUpperCase(),
|
||||
installationClassLabel(record.class),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { installationClassLabel } from './classLabel.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { LAYER_ID, MAX_RENDERED, COLOR_BY_CLASS } from './policy.js';
|
||||
@@ -273,9 +274,7 @@ export function createRendering({
|
||||
entity.gevLabelModel = {
|
||||
title: record.name || t('MAPPED INSTALLATION'),
|
||||
details: [
|
||||
String(record.class || 'installation')
|
||||
.replaceAll('_', ' ')
|
||||
.toUpperCase(),
|
||||
installationClassLabel(record.class),
|
||||
...(record.memberNames || []).slice(0, 3),
|
||||
],
|
||||
accent: COLOR_BY_CLASS[record.class] || '#9ca6b0',
|
||||
|
||||
@@ -470,7 +470,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
layerState._missionPanel.className = 'context-space-mission-detail';
|
||||
layerState._missionPanel.setAttribute(
|
||||
'aria-label',
|
||||
t('SELECTED SPACE MISSION'),
|
||||
t('Selected Space Mission'),
|
||||
);
|
||||
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('SELECTED SPACE MISSION')}</span><button type="button" data-mission-close title="${t('Show all missions')}" aria-label="${t('Deselect mission')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('MISSION')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('STATUS · ')}<b data-mission-status></b></span><span data-mission-field>${t('LAUNCH SITE · ')}<b data-mission-site></b></span><span data-mission-field>${t('LAUNCH TIME · ')}<b data-mission-time></b></span><span data-mission-field>${t('ORBIT · ')}<b data-mission-orbit></b></span><span>${t('ASCENT PATH · ')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('CURRENT DISTANCE FROM EARTH · ')}<b data-mission-distance></b></span><span data-mission-field>${t('SATELLITE SPEED · ')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('PAYLOAD')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('NAME')}</th><th>${t('TYPE')}</th><th>${t('DESTINATION')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('STAGE / RE-ENTRY / RECOVERY')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('STAGE')}</th><th>${t('STATUS')}</th><th>${t('FINAL POSITION')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('REPLAY SPEED')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('Replay speed multiplier')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('FOCUS')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('REPLAY ASCENT')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('Previous mission')}"><span aria-hidden="true">‹</span> ${t('PREV')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('Next mission')}">${t('NEXT')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('SHOW ALL / DESELECT')}</button>`;
|
||||
layerState._missionPanel
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
inciwebNodeId,
|
||||
isCurrentPublication,
|
||||
} from './inciweb.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
export { normalizeFirePerimeterSnapshot } from './records.js';
|
||||
export { createWfigsPerimeterSource } from './source.js';
|
||||
export * from './cards.js';
|
||||
@@ -414,10 +415,10 @@ export function createFirePerimetersLayer({
|
||||
|
||||
getRowControls() {
|
||||
const bands = [
|
||||
{ label: 'Not contained or unknown', color: containmentAccent(0) },
|
||||
{ label: 'Under 50% contained', color: containmentAccent(1) },
|
||||
{ label: '50–99% contained', color: containmentAccent(50) },
|
||||
{ label: 'Fully contained', color: containmentAccent(100) },
|
||||
{ label: t('Not contained or unknown'), color: containmentAccent(0) },
|
||||
{ label: t('Under 50% contained'), color: containmentAccent(1) },
|
||||
{ label: t('50–99% contained'), color: containmentAccent(50) },
|
||||
{ label: t('Fully contained'), color: containmentAccent(100) },
|
||||
];
|
||||
return {
|
||||
chips: [],
|
||||
@@ -428,8 +429,9 @@ export function createFirePerimetersLayer({
|
||||
).length,
|
||||
...(index === 0
|
||||
? {
|
||||
blurb:
|
||||
blurb: t(
|
||||
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
|
||||
@@ -70,7 +70,7 @@ export function createCategories({
|
||||
function buildRadioCategories(stations) {
|
||||
const rows = Array.isArray(stations) ? stations : [];
|
||||
const categories = [
|
||||
{ id: 'all', label: t('All') },
|
||||
{ id: 'all', label: t('All', { context: 'radio tag' }) },
|
||||
{ id: 'news', label: t('News') },
|
||||
{ id: 'talk', label: t('Talk') },
|
||||
{ id: 'weather', label: t('Weather / Emergency') },
|
||||
@@ -89,7 +89,7 @@ export function createCategories({
|
||||
for (const [genre, label] of MUSIC_GENRES) {
|
||||
const id = `genre:${genre}`;
|
||||
if (rows.some((station) => stationMatchesRadioCategory(station, id))) {
|
||||
categories.push({ id, label });
|
||||
categories.push({ id, label: t(label) });
|
||||
}
|
||||
}
|
||||
categories.push({ id: 'other', label: t('Other') });
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import {
|
||||
RADIO_CATEGORY_COLORS,
|
||||
RADIO_CLUSTER_LABELS,
|
||||
RADIO_MARKER_CATEGORY_ORDER,
|
||||
} from './policy.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/** Short uppercase category word on a cluster badge, in the page language. */
|
||||
function radioClusterLabel(categoryId) {
|
||||
switch (categoryId) {
|
||||
case 'news':
|
||||
return t('NEWS', { context: 'radio' });
|
||||
case 'talk':
|
||||
return t('TALK', { context: 'radio' });
|
||||
case 'weather':
|
||||
return t('WEATHER', { context: 'radio' });
|
||||
case 'public-safety':
|
||||
return t('SAFETY', { context: 'radio' });
|
||||
case 'aviation-marine':
|
||||
return t('AIR / SEA', { context: 'radio' });
|
||||
case 'traffic-transit':
|
||||
return t('TRANSIT', { context: 'radio' });
|
||||
case 'music':
|
||||
return t('MUSIC', { context: 'radio' });
|
||||
default:
|
||||
return t('OTHER', { context: 'radio' });
|
||||
}
|
||||
}
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
/** Return the shared CSS color for a canonical or detected-genre category. */
|
||||
@@ -18,7 +40,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
function radioClusterBadgeText(categoryId = 'other', count = 0) {
|
||||
const normalized = String(categoryId || 'other');
|
||||
const canonical = normalized.startsWith('genre:') ? 'music' : normalized;
|
||||
const label = RADIO_CLUSTER_LABELS[canonical] || RADIO_CLUSTER_LABELS.other;
|
||||
const label = radioClusterLabel(canonical);
|
||||
const stationCount = Math.max(0, Math.floor(Number(count) || 0));
|
||||
return `${stationCount} ${label}`;
|
||||
}
|
||||
|
||||
+29
-36
@@ -62,32 +62,36 @@ export const DEFAULT_RADIO_FILTER = 'all';
|
||||
|
||||
export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000;
|
||||
|
||||
// Genre labels are marked for i18n extraction; categories.js translates them.
|
||||
// Kept local so this module stays free of the i18n import.
|
||||
const msg = (text) => text;
|
||||
|
||||
export const MUSIC_GENRES = Object.freeze([
|
||||
['alternative', 'Alternative'],
|
||||
['ambient', 'Ambient'],
|
||||
['blues', 'Blues'],
|
||||
['classical', 'Classical'],
|
||||
['country', 'Country'],
|
||||
['dance', 'Dance'],
|
||||
['electronic', 'Electronic'],
|
||||
['folk', 'Folk'],
|
||||
['funk', 'Funk'],
|
||||
['hip hop', 'Hip-Hop'],
|
||||
['house', 'House'],
|
||||
['indie', 'Indie'],
|
||||
['jazz', 'Jazz'],
|
||||
['latin', 'Latin'],
|
||||
['metal', 'Metal'],
|
||||
['oldies', 'Oldies'],
|
||||
['pop', 'Pop'],
|
||||
['punk', 'Punk'],
|
||||
['r&b', 'R&B'],
|
||||
['reggae', 'Reggae'],
|
||||
['rock', 'Rock'],
|
||||
['soul', 'Soul'],
|
||||
['techno', 'Techno'],
|
||||
['trance', 'Trance'],
|
||||
['world', 'World'],
|
||||
['alternative', msg('Alternative')],
|
||||
['ambient', msg('Ambient')],
|
||||
['blues', msg('Blues')],
|
||||
['classical', msg('Classical')],
|
||||
['country', msg('Country')],
|
||||
['dance', msg('Dance')],
|
||||
['electronic', msg('Electronic')],
|
||||
['folk', msg('Folk')],
|
||||
['funk', msg('Funk')],
|
||||
['hip hop', msg('Hip-Hop')],
|
||||
['house', msg('House')],
|
||||
['indie', msg('Indie')],
|
||||
['jazz', msg('Jazz')],
|
||||
['latin', msg('Latin')],
|
||||
['metal', msg('Metal')],
|
||||
['oldies', msg('Oldies')],
|
||||
['pop', msg('Pop')],
|
||||
['punk', msg('Punk')],
|
||||
['r&b', msg('R&B')],
|
||||
['reggae', msg('Reggae')],
|
||||
['rock', msg('Rock')],
|
||||
['soul', msg('Soul')],
|
||||
['techno', msg('Techno')],
|
||||
['trance', msg('Trance')],
|
||||
['world', msg('World')],
|
||||
]);
|
||||
|
||||
export const CATEGORY_MATCHERS = Object.freeze({
|
||||
@@ -127,17 +131,6 @@ export const RADIO_CATEGORY_COLORS = Object.freeze({
|
||||
other: '#9aa7b3',
|
||||
});
|
||||
|
||||
export const RADIO_CLUSTER_LABELS = Object.freeze({
|
||||
news: 'NEWS',
|
||||
talk: 'TALK',
|
||||
weather: 'WEATHER',
|
||||
'public-safety': 'SAFETY',
|
||||
'aviation-marine': 'AIR / SEA',
|
||||
'traffic-transit': 'TRANSIT',
|
||||
music: 'MUSIC',
|
||||
other: 'OTHER',
|
||||
});
|
||||
|
||||
export const RADIO_MARKER_CATEGORY_ORDER = Object.freeze([
|
||||
'news',
|
||||
'public-safety',
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Recent Imagery text in the page's language: days, metres and the focused
|
||||
* day's readout. The model (`model.js`) stays English and i18n-free because AI
|
||||
* tool services import it outside the browser; this module is page-only.
|
||||
*/
|
||||
import { formatDate, formatRelativeTime, t } from '../../i18n/index.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
import { dayTime } from './model.js';
|
||||
|
||||
const SHORT_DAY = Object.freeze({
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
const LONG_DAY = Object.freeze({ ...SHORT_DAY, year: 'numeric' });
|
||||
|
||||
/** `Sep 18` / `18 сент.` for a `YYYY-MM-DD` day, in the page's language. */
|
||||
export function displayDay(day, options = SHORT_DAY) {
|
||||
const time = dayTime(day);
|
||||
return time === null ? '' : formatDate(time, options);
|
||||
}
|
||||
|
||||
/** `30 m` / `30 м`, number and unit kept together. */
|
||||
export const displayMetres = (value) => formatQuantity(value, 'm');
|
||||
|
||||
/** The readout's words in the page's language; the model keeps them English. */
|
||||
export const READOUT_WORDS = Object.freeze({
|
||||
date: (day) => displayDay(day, LONG_DAY),
|
||||
daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'),
|
||||
// "Sentinel-2" never breaks at its hyphen: a word joiner follows it
|
||||
// (JetBrains Mono has no U+2011 non-breaking hyphen).
|
||||
sensor: (sensor) =>
|
||||
t(sensor).replace(/(?<=[\p{L}\p{N}])-(?=[\p{L}\p{N}])/gu, '-\u2060'),
|
||||
metres: displayMetres,
|
||||
overview: () => t('overview'),
|
||||
cloud: (cloud) => {
|
||||
if (!cloud) return t('cloud unknown');
|
||||
const min = Math.round(cloud.min);
|
||||
const max = Math.round(cloud.max);
|
||||
return min === max
|
||||
? t('{percent}% scene cloud', { percent: min })
|
||||
: t('{min}–{max}% scene cloud', { min, max });
|
||||
},
|
||||
});
|
||||
@@ -28,11 +28,12 @@ import {
|
||||
parseCandidateKey,
|
||||
quantizeBox,
|
||||
rankLatest,
|
||||
shortDay,
|
||||
utcDay,
|
||||
validateBox,
|
||||
viewTooLargeMessage,
|
||||
} from './model.js';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import { READOUT_WORDS, displayDay, displayMetres } from './display.js';
|
||||
|
||||
/**
|
||||
* Quiet time after the last arrow key before the focused day previews, so a
|
||||
@@ -46,10 +47,11 @@ export const SPLIT_PUBLISH_MS = 400;
|
||||
/** The three modes, in share-link order (`m` = index). */
|
||||
export const MODES = Object.freeze(['image', 'basemap', 'ab']);
|
||||
|
||||
const COMPARISON_IN_USE = 'Comparison in use by another scene';
|
||||
const COMPARISON_UNAVAILABLE = 'Esri map unavailable · no swipe';
|
||||
const COMPARISON_NEEDS_GLOBE = 'Swipe needs a globe map';
|
||||
const ESRI_KEPT = 'Imagery stays on Esri · CLEAR to use Google 3D';
|
||||
const COMPARISON_IN_USE = msg('Comparison in use by another scene');
|
||||
// Kept English in the snapshot (AI tools read it); the card translates them.
|
||||
const COMPARISON_UNAVAILABLE = msg('Esri map unavailable · no swipe');
|
||||
const COMPARISON_NEEDS_GLOBE = msg('Swipe needs a globe map');
|
||||
const ESRI_KEPT = msg('Imagery stays on Esri · CLEAR to use Google 3D');
|
||||
/** ZOOM IN's flight time, in seconds. */
|
||||
const ZOOM_FIT_SECONDS = 1.2;
|
||||
const DEFAULT_SPLIT = 0.5;
|
||||
@@ -748,15 +750,21 @@ export function createRecentImageryLayer({
|
||||
|
||||
function notes() {
|
||||
const out = [];
|
||||
if (_truncated) out.push('Catalog truncated · a newer clear day may exist');
|
||||
if (_truncated)
|
||||
out.push(t('Catalog truncated · a newer clear day may exist'));
|
||||
for (const error of _catalogErrors) {
|
||||
const label = PRODUCTS[error.product]?.label || error.product;
|
||||
out.push(`${label} catalog unavailable · ${error.message}`);
|
||||
out.push(
|
||||
t('{label} catalog unavailable · {detail}', {
|
||||
label,
|
||||
detail: error.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const today = utcDay(now());
|
||||
const viirsToday = candidateFor(`VIIRS:${today}`);
|
||||
if (viirsToday?.availability === 'unknown' && _sources.viirs)
|
||||
out.push('Daily overview for today may still be empty until the pass');
|
||||
out.push(t('Daily overview for today may still be empty until the pass'));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -767,7 +775,7 @@ export function createRecentImageryLayer({
|
||||
key: key || null,
|
||||
candidate: candidateFor(key),
|
||||
label: parsed
|
||||
? `${shortDay(parsed.day)} · ${PRODUCTS[parsed.product].name} · ${PRODUCTS[parsed.product].resolutionM} m`
|
||||
? `${displayDay(parsed.day)} · ${t(PRODUCTS[parsed.product].name)} · ${displayMetres(PRODUCTS[parsed.product].resolutionM)}`
|
||||
: null,
|
||||
sourceOff,
|
||||
drapable: drapable(candidateFor(key)),
|
||||
@@ -1227,7 +1235,7 @@ export function createRecentImageryLayer({
|
||||
_alpha = 1;
|
||||
for (const slotId of SLOT_IDS) renderer.setAlpha(slotId, 1);
|
||||
}
|
||||
_notice = 'Box and images cleared';
|
||||
_notice = msg('Box and images cleared');
|
||||
syncRender();
|
||||
publish([...BOX_EDGES, 'a', 'b', 'split']);
|
||||
notify();
|
||||
@@ -1276,7 +1284,9 @@ export function createRecentImageryLayer({
|
||||
candidates,
|
||||
focusIndex,
|
||||
focus,
|
||||
readout: focus ? formatCandidateReadout(focus, now()) : '',
|
||||
readout: focus
|
||||
? formatCandidateReadout(focus, now(), READOUT_WORDS)
|
||||
: '',
|
||||
recommended: ranked.candidate
|
||||
? { key: ranked.candidate.key, reason: ranked.reason }
|
||||
: null,
|
||||
@@ -1329,17 +1339,18 @@ export function createRecentImageryLayer({
|
||||
chips: [
|
||||
{
|
||||
id: 'hls',
|
||||
label: 'MORE DETAIL · 30 m',
|
||||
label: t('MORE DETAIL · 30 m'),
|
||||
active: _sources.hls,
|
||||
title: 'Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days',
|
||||
title: t('Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days'),
|
||||
params: { hls: !_sources.hls },
|
||||
},
|
||||
{
|
||||
id: 'viirs',
|
||||
label: 'DAILY OVERVIEW · 250 m',
|
||||
label: t('DAILY OVERVIEW · 250 m'),
|
||||
active: _sources.viirs,
|
||||
title:
|
||||
title: t(
|
||||
'VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km',
|
||||
),
|
||||
params: { viirs: !_sources.viirs },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -228,7 +228,7 @@ test('a valid box searches and previews the START HERE day alone in IMAGE mode',
|
||||
assert.equal(snapshot.focus.key, S18, 'focus starts on it');
|
||||
assert.equal(snapshot.preview.key, S18);
|
||||
assert.equal(snapshot.preview.slot, 'a');
|
||||
assert.equal(snapshot.preview.label, 'Sep 18 · Sentinel-2 · 30 m');
|
||||
assert.equal(snapshot.preview.label, 'Sep 18 · Sentinel-2 · 30\u00a0m');
|
||||
assert.equal(snapshot.candidates[1].preview, true);
|
||||
const none = {
|
||||
key: null,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Marks text for i18n extraction; the UI translates it with t(). Kept local
|
||||
// because AI tool services import this module outside the browser.
|
||||
const msg = (text) => text;
|
||||
|
||||
/**
|
||||
* Pure domain model for the Recent Imagery layer: products, the selection box,
|
||||
* per-day candidates, ranking and the keyless URL builders. No DOM, no Cesium,
|
||||
@@ -29,10 +33,10 @@ const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ');
|
||||
*/
|
||||
export const PRODUCTS = Object.freeze({
|
||||
S30: Object.freeze({
|
||||
label: 'HLS S30',
|
||||
name: 'Sentinel-2',
|
||||
label: 'HLS S30', // i18n-ignore: product code
|
||||
name: msg('Sentinel-2'),
|
||||
source: 'hls',
|
||||
sensor: 'Sentinel-2 via HLS',
|
||||
sensor: msg('Sentinel-2 via HLS'),
|
||||
resolutionM: 30,
|
||||
gibsLayer: 'HLS_S30_Nadir_BRDF_Adjusted_Reflectance',
|
||||
maxLevel: 12,
|
||||
@@ -41,10 +45,10 @@ export const PRODUCTS = Object.freeze({
|
||||
overview: false,
|
||||
}),
|
||||
L30: Object.freeze({
|
||||
label: 'HLS L30',
|
||||
name: 'Landsat 8/9',
|
||||
label: 'HLS L30', // i18n-ignore: product code
|
||||
name: msg('Landsat 8/9'),
|
||||
source: 'hls',
|
||||
sensor: 'Landsat 8/9 via HLS',
|
||||
sensor: msg('Landsat 8/9 via HLS'),
|
||||
resolutionM: 30,
|
||||
gibsLayer: 'HLS_L30_Nadir_BRDF_Adjusted_Reflectance',
|
||||
maxLevel: 12,
|
||||
@@ -53,8 +57,8 @@ export const PRODUCTS = Object.freeze({
|
||||
overview: false,
|
||||
}),
|
||||
VIIRS: Object.freeze({
|
||||
label: 'VIIRS',
|
||||
name: 'Daily overview',
|
||||
label: 'VIIRS', // i18n-ignore: product code
|
||||
name: msg('Daily overview'),
|
||||
source: 'viirs',
|
||||
sensor: 'VIIRS NOAA-21',
|
||||
resolutionM: 250,
|
||||
@@ -262,6 +266,11 @@ export function utcDay(value) {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** UTC midnight of a `YYYY-MM-DD` day in epoch ms, for locale formatting; null otherwise. */
|
||||
export function dayTime(day) {
|
||||
return isValidDay(day) ? Date.parse(`${day}T00:00:00Z`) : null;
|
||||
}
|
||||
|
||||
/** `Sep 18` for a `YYYY-MM-DD` day; '' otherwise. */
|
||||
export function shortDay(day) {
|
||||
const match = /^\d{4}-(\d{2})-(\d{2})$/.exec(String(day || ''));
|
||||
@@ -479,15 +488,14 @@ export function rankLatest(
|
||||
return { candidate: null, reason: null, certain };
|
||||
}
|
||||
|
||||
function daysAgoLabel(day, now) {
|
||||
/** Whole UTC days from `day` to the day of `now`; null when either is invalid. */
|
||||
function daysBetween(day, now) {
|
||||
const today = utcDay(now);
|
||||
if (!today) return null;
|
||||
const delta = Math.round(
|
||||
return Math.round(
|
||||
(Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) /
|
||||
DAY_MS,
|
||||
);
|
||||
if (delta <= 0) return 'today';
|
||||
return delta === 1 ? 'yesterday' : `${delta} days ago`;
|
||||
}
|
||||
|
||||
function cloudLabel(cloud) {
|
||||
@@ -497,18 +505,40 @@ function cloudLabel(cloud) {
|
||||
return min === max ? `${min}% scene cloud` : `${min}–${max}% scene cloud`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The words of the readout. The page passes its own (translated, locale
|
||||
* formatted) set; this module stays free of i18n because AI tool services
|
||||
* import it outside the browser.
|
||||
*/
|
||||
const READOUT_WORDS = Object.freeze({
|
||||
/** `Sep 18, 2026` for a valid `YYYY-MM-DD` day. */
|
||||
date: (day) => {
|
||||
const [y, m, d] = day.split('-').map(Number);
|
||||
return `${MONTHS[m - 1]} ${d}, ${y}`;
|
||||
},
|
||||
/** Age in whole UTC days, 0 or less for today. */
|
||||
daysAgo: (delta) =>
|
||||
delta <= 0 ? 'today' : delta === 1 ? 'yesterday' : `${delta} days ago`,
|
||||
sensor: (sensor) => sensor,
|
||||
metres: (value) => `${value} m`,
|
||||
overview: () => 'overview',
|
||||
/** `{ min, max }` scene cloud in percent, or null. */
|
||||
cloud: cloudLabel,
|
||||
});
|
||||
|
||||
/**
|
||||
* One-line UTC readout, e.g.
|
||||
* `Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud`.
|
||||
* @param {object} candidate
|
||||
* @param {string | number | Date} [now]
|
||||
* @param {Partial<typeof READOUT_WORDS>} [words] the page's localized words
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatCandidateReadout(candidate, now = new Date()) {
|
||||
export function formatCandidateReadout(candidate, now = new Date(), words) {
|
||||
const product = PRODUCTS[candidate?.product];
|
||||
if (!product || !candidate?.day) return '';
|
||||
const [y, m, d] = candidate.day.split('-').map(Number);
|
||||
let when = `${MONTHS[m - 1]} ${d}, ${y}`;
|
||||
if (!product || !isValidDay(candidate?.day)) return '';
|
||||
const say = words ? { ...READOUT_WORDS, ...words } : READOUT_WORDS;
|
||||
let when = say.date(candidate.day);
|
||||
if (candidate.timeRange?.start) {
|
||||
const start = hhmm(candidate.timeRange.start);
|
||||
const end = candidate.timeRange.end ? hhmm(candidate.timeRange.end) : start;
|
||||
@@ -518,11 +548,11 @@ export function formatCandidateReadout(candidate, now = new Date()) {
|
||||
: ` ${start}Z`;
|
||||
}
|
||||
const parts = [when];
|
||||
const ago = daysAgoLabel(candidate.day, now);
|
||||
if (ago) parts.push(ago);
|
||||
parts.push(product.sensor, `${product.resolutionM} m`);
|
||||
if (product.overview) parts.push('overview');
|
||||
parts.push(cloudLabel(candidate.cloud));
|
||||
const delta = daysBetween(candidate.day, now);
|
||||
if (delta !== null) parts.push(say.daysAgo(delta));
|
||||
parts.push(say.sensor(product.sensor), say.metres(product.resolutionM));
|
||||
if (product.overview) parts.push(say.overview());
|
||||
parts.push(say.cloud(candidate.cloud));
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export function createLabels({ state: layerState, services, parts, source }) {
|
||||
id: String(ISS_NORAD),
|
||||
position,
|
||||
variant: 'label',
|
||||
title: 'ISS',
|
||||
title: 'ISS', // i18n-ignore: satellite name, as in the satellite catalog
|
||||
accent: '#ff4444',
|
||||
priority: 1000,
|
||||
collisionGroup: 'ambient-label',
|
||||
|
||||
@@ -37,7 +37,9 @@ export function mapAnalystRecord(raw) {
|
||||
// Empty records stay class-less; the class table's fallback is VISUAL and
|
||||
// must not leak onto a row that has no satellite identity.
|
||||
satelliteClass:
|
||||
noradId || group ? satelliteClassLabel(group, { isIss }) : null,
|
||||
noradId || group
|
||||
? satelliteClassLabel(group, { isIss, localize: false })
|
||||
: null,
|
||||
group,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -239,7 +239,10 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
name,
|
||||
operator: '',
|
||||
noradId: String(noradId),
|
||||
class: satelliteClassLabel(sat.group, { isIss: noradId === ISS_NORAD }),
|
||||
class: satelliteClassLabel(sat.group, {
|
||||
isIss: noradId === ISS_NORAD,
|
||||
localize: false,
|
||||
}),
|
||||
altitude:
|
||||
altitudeKm === null ? '' : `${altitudeKm.toLocaleString('en-US')} km`,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
|
||||
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
|
||||
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
|
||||
import { createMeshSampler } from './meshSampler.js';
|
||||
import { t, translateKnown } from '../../i18n/index.js';
|
||||
|
||||
/** Above this camera height the ground under the camera is not worth fetching. */
|
||||
const SURFACE_WARM_BELOW_M = 6000;
|
||||
@@ -335,7 +336,9 @@ export function createStreetLevelLayer({
|
||||
}
|
||||
|
||||
function unavailableReason(entry) {
|
||||
return `${entry.def.name} needs the Google 3D map: choose it under MAP SOURCE`;
|
||||
return t('{name} needs the Google 3D map: choose it under MAP SOURCE', {
|
||||
name: translateKnown(entry.def.name),
|
||||
});
|
||||
}
|
||||
|
||||
/** One imagery source at a time: lighting a provider darkens the others. */
|
||||
@@ -410,7 +413,9 @@ export function createStreetLevelLayer({
|
||||
/** The hint a ground-click provider shows, or the provider's own. */
|
||||
function hintFor(entry, stats) {
|
||||
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
|
||||
return `zoom in to a street to open ${entry.def.name}`;
|
||||
return t('zoom in to a street to open {name}', {
|
||||
name: translateKnown(entry.def.name),
|
||||
});
|
||||
return stats.hint || '';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
|
||||
import { createMapsLoader } from './mapsLoader.js';
|
||||
import { getLocale } from '../../../../i18n/index.js';
|
||||
import { createGoogleViewer } from './viewer.js';
|
||||
import {
|
||||
COLORS,
|
||||
@@ -48,7 +49,7 @@ function settleWithin(promise, { ms, signal, loader }) {
|
||||
*/
|
||||
export function createGoogleProvider({
|
||||
getApiKey,
|
||||
loader = createMapsLoader({ getApiKey }),
|
||||
loader = createMapsLoader({ getApiKey, getLanguage: getLocale }),
|
||||
}) {
|
||||
if (typeof getApiKey !== 'function')
|
||||
throw new TypeError('Google Street View needs getApiKey');
|
||||
|
||||
@@ -9,10 +9,13 @@ const READY_CALLBACK = '__gevGoogleMapsReady';
|
||||
const PAGE_LOAD = Symbol.for('gev.googleMapsLoad');
|
||||
|
||||
/**
|
||||
* @param {{getApiKey: () => string|null|undefined, document?: Document, global?: object}} options
|
||||
* @param {{getApiKey: () => string|null|undefined, getLanguage?: () => string|null|undefined, document?: Document, global?: object}} options
|
||||
* `getLanguage` names the UI language; Google's own controls (Terms,
|
||||
* Report a problem) follow it. English adds nothing, as before.
|
||||
*/
|
||||
export function createMapsLoader({
|
||||
getApiKey,
|
||||
getLanguage,
|
||||
document = globalThis.document,
|
||||
global = globalThis,
|
||||
}) {
|
||||
@@ -53,12 +56,15 @@ export function createMapsLoader({
|
||||
};
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = `${SCRIPT_ORIGIN}/maps/api/js?${new URLSearchParams({
|
||||
const params = new URLSearchParams({
|
||||
key,
|
||||
v: 'weekly',
|
||||
loading: 'async',
|
||||
callback: READY_CALLBACK,
|
||||
})}`;
|
||||
});
|
||||
const language = getLanguage?.();
|
||||
if (language && language !== 'en') params.set('language', language);
|
||||
script.src = `${SCRIPT_ORIGIN}/maps/api/js?${params}`;
|
||||
script.onerror = () => {
|
||||
if (global[PAGE_LOAD] === loading) delete global[PAGE_LOAD];
|
||||
script.remove();
|
||||
|
||||
@@ -142,3 +142,19 @@ test('a retried load still reports a refusal once, and the page handler once', a
|
||||
assert.deepEqual(heard, ['a']);
|
||||
assert.equal(before, 1);
|
||||
});
|
||||
|
||||
test('Google controls follow the UI language; English adds no parameter', async () => {
|
||||
const language = (locale) => {
|
||||
const page = fakePage();
|
||||
const loader = createMapsLoader({
|
||||
getApiKey: () => 'k-1',
|
||||
getLanguage: () => locale,
|
||||
...page,
|
||||
});
|
||||
void loader.importLibrary('streetView').catch(() => {});
|
||||
return new URL(page.scripts[0].src).searchParams.get('language');
|
||||
};
|
||||
assert.equal(language('es'), 'es');
|
||||
assert.equal(language('ru'), 'ru');
|
||||
assert.equal(language('en'), null);
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
|
||||
import { msg } from '../../../../i18n/index.js';
|
||||
|
||||
export { NEAREST_RADIUS_M } from '../../policy.js';
|
||||
|
||||
export const GOOGLE_PROVIDER_ID = 'google';
|
||||
export const GOOGLE_NAME = 'Google Street View';
|
||||
export const GOOGLE_LABEL = 'GOOGLE STREET VIEW';
|
||||
export const GOOGLE_NAME = msg('Google Street View');
|
||||
export const GOOGLE_LABEL = msg('GOOGLE STREET VIEW');
|
||||
/** The browser Maps key; Street View also needs the Maps JavaScript API on it. */
|
||||
export const GOOGLE_KEY_ID = 'google-maps';
|
||||
|
||||
@@ -19,8 +20,9 @@ export const GOOGLE_CREDIT_HTML =
|
||||
'Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>';
|
||||
|
||||
/** Shown when Google refuses the key (gm_authFailure). */
|
||||
export const KEY_REJECTED_MESSAGE =
|
||||
'Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer';
|
||||
export const KEY_REJECTED_MESSAGE = msg(
|
||||
'Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer',
|
||||
);
|
||||
|
||||
/** Past these, a nearest lookup, an open or a details lookup has failed. */
|
||||
export const NEAREST_TIMEOUT_MS = 10_000;
|
||||
@@ -29,10 +31,11 @@ export const DETAILS_TIMEOUT_MS = 10_000;
|
||||
/** Pause after a failed details lookup before a view event can retry. */
|
||||
export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
|
||||
|
||||
export const NO_ANSWER_MESSAGE =
|
||||
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY';
|
||||
export const NO_ANSWER_MESSAGE = msg(
|
||||
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY',
|
||||
);
|
||||
|
||||
export const GROUND_CLICK_HINT = 'click a street to open Street View';
|
||||
export const GROUND_CLICK_HINT = msg('click a street to open Street View');
|
||||
|
||||
/** Deep link to a panorama on Google Maps (Maps URLs). */
|
||||
export function streetViewUrl(panoId, { heading = 0, pitch = 0 } = {}) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
PANO_FOV_DEFAULT_DEG,
|
||||
wrapDegrees,
|
||||
} from './panorama.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import {
|
||||
FLAT_FULL_SIZE,
|
||||
FLAT_VIEW_SIZE,
|
||||
@@ -436,11 +437,11 @@ export function createKartaViewViewer({
|
||||
root.setAttribute('role', 'group');
|
||||
root.setAttribute(
|
||||
'aria-label',
|
||||
'KartaView photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom',
|
||||
t('KartaView photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom'),
|
||||
);
|
||||
picture = doc.createElement('img');
|
||||
picture.className = 'sl-kv-photo';
|
||||
picture.alt = 'KartaView street-level photo';
|
||||
picture.alt = t('KartaView street-level photo');
|
||||
picture.draggable = false;
|
||||
picture.hidden = true;
|
||||
canvas = makeCanvas(doc);
|
||||
|
||||
@@ -119,7 +119,11 @@ export function composeUIState({
|
||||
provider.groundClick !== true && provider.highlightsSequence !== false,
|
||||
)
|
||||
)
|
||||
legend.push({ key: 'selected', label: t('Selected'), color: COLORS.selected });
|
||||
legend.push({
|
||||
key: 'selected',
|
||||
label: t('Selected'),
|
||||
color: COLORS.selected,
|
||||
});
|
||||
return {
|
||||
enabled,
|
||||
groundClickReady: groundClickReady === true,
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createBundledCableSource({
|
||||
return json;
|
||||
}
|
||||
return {
|
||||
label: 'TeleGeography',
|
||||
label: 'TeleGeography', // i18n-ignore: data provider name
|
||||
async fetch(signal) {
|
||||
const [cables, landingPoints] = await Promise.all([
|
||||
read(cableUrl(), signal),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
accentForVesselType,
|
||||
normalizeVesselType,
|
||||
displayVesselType,
|
||||
} from '../../data/vesselLabels.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
|
||||
export function createCards({
|
||||
vesselState,
|
||||
@@ -130,7 +130,7 @@ export function createCards({
|
||||
/** Uppercased, card-width-bounded AIS type (empty string when unknown). */
|
||||
|
||||
function vesselTypeShort(record) {
|
||||
return normalizeVesselType(record.type).toUpperCase().slice(0, 14);
|
||||
return displayVesselType(record.type).toUpperCase().slice(0, 14);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,11 +160,22 @@ export function createCards({
|
||||
}
|
||||
|
||||
function formatSpeed(speed) {
|
||||
return speed === null ? '--KT' : `${speed.toFixed(1)}KT`;
|
||||
return t('{speed}KT', {
|
||||
speed:
|
||||
speed === null
|
||||
? '--'
|
||||
: formatNumber(speed, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
useGrouping: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function formatHeading(heading) {
|
||||
return Number.isFinite(heading) ? `${Math.round(heading)}DEG` : '--DEG';
|
||||
return t('{heading}DEG', {
|
||||
heading: Number.isFinite(heading) ? Math.round(heading) : '--',
|
||||
});
|
||||
}
|
||||
|
||||
function formatPositionTime(record) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { msg } from '../../i18n/index.js';
|
||||
|
||||
/** Resolve the visible surface that can own weather imagery without scene mutation. */
|
||||
export function resolveImageryHost({ viewer, tileset }) {
|
||||
if (viewer?.scene?.globe?.show === true)
|
||||
@@ -7,7 +9,9 @@ export function resolveImageryHost({ viewer, tileset }) {
|
||||
return { collection: null, kind: 'none' };
|
||||
}
|
||||
|
||||
export const NO_IMAGERY_HOST = 'Hidden by this map source · choose a globe map';
|
||||
export const NO_IMAGERY_HOST = msg(
|
||||
'Hidden by this map source · choose a globe map',
|
||||
);
|
||||
|
||||
/** Report imagery suspension without changing or detaching the resolved host. */
|
||||
export function imageryHostStatus(host) {
|
||||
|
||||
+132
-53
@@ -7,6 +7,8 @@ import {
|
||||
GLOBAL_INFRARED_MAX_GAP_MS,
|
||||
LIGHTNING_MAX_GAP_MS,
|
||||
} from './clock.js';
|
||||
import { formatUtcDayMonth, t, msg } from '../../i18n/index.js';
|
||||
import { formatScaleLabel } from '../../ui/unitLabels.js';
|
||||
|
||||
const STOPS = [
|
||||
[10, '#00ecec'],
|
||||
@@ -29,7 +31,9 @@ const LIGHTNING_STOPS = [
|
||||
['300+', '#00FF00'],
|
||||
];
|
||||
const utc = (value) =>
|
||||
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
|
||||
value
|
||||
? `${formatUtcDayMonth(value)} ${value.slice(11, 16)} UTC`
|
||||
: t('Unavailable');
|
||||
|
||||
/** A per-application observation layer, using the existing layer lifecycle and
|
||||
* row controls. History is transient: shared links always open latest imagery. */
|
||||
@@ -227,12 +231,14 @@ export function createWeatherLayer({
|
||||
rendering.setHidden?.(false);
|
||||
void warmNext(time);
|
||||
}
|
||||
error = ok ? null : 'Frame unavailable; previous observation retained';
|
||||
error = ok
|
||||
? null
|
||||
: msg('Frame unavailable; previous observation retained');
|
||||
if (!ok) stop();
|
||||
return ok;
|
||||
} catch {
|
||||
if (owner === generation && !controller.signal.aborted) {
|
||||
error = 'Weather imagery unavailable';
|
||||
error = msg('Weather imagery unavailable');
|
||||
stop();
|
||||
}
|
||||
return false;
|
||||
@@ -338,7 +344,9 @@ export function createWeatherLayer({
|
||||
if (!enabled || controller.signal.aborted || request !== controller)
|
||||
return false;
|
||||
if (snapshot.unavailable) {
|
||||
error = 'Weather source unavailable; previous observation retained';
|
||||
error = msg(
|
||||
'Weather source unavailable; previous observation retained',
|
||||
);
|
||||
stop();
|
||||
return true;
|
||||
}
|
||||
@@ -368,7 +376,7 @@ export function createWeatherLayer({
|
||||
return !controller.signal.aborted && request === controller && enabled;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
error = cause?.message || 'Weather unavailable';
|
||||
error = cause?.message || msg('Weather unavailable');
|
||||
stop();
|
||||
return true;
|
||||
} finally {
|
||||
@@ -487,15 +495,23 @@ export function createWeatherLayer({
|
||||
noFrame &&
|
||||
shared?.mode === 'history' &&
|
||||
shared.products.find((entry) => entry.id === id)?.selected === null
|
||||
? `No frame within ${maxGap() / 60_000 < 60 ? `${maxGap() / 60_000} min` : `${maxGap() / 3600_000} h`} of ${utc(shared.target)}`
|
||||
? maxGap() / 60_000 < 60
|
||||
? t('No frame within {gap} min of {time}', {
|
||||
gap: maxGap() / 60_000,
|
||||
time: utc(shared.target),
|
||||
})
|
||||
: t('No frame within {gap} h of {time}', {
|
||||
gap: maxGap() / 3600_000,
|
||||
time: utc(shared.target),
|
||||
})
|
||||
: null;
|
||||
const time = shownTime();
|
||||
const relation =
|
||||
shared?.mode === 'history' && time
|
||||
? Date.parse(time) === Date.parse(shared.target)
|
||||
? ' · synced'
|
||||
? 'synced'
|
||||
: Date.parse(time) < Date.parse(shared.target)
|
||||
? ' · nearest'
|
||||
? 'nearest'
|
||||
: ''
|
||||
: '';
|
||||
const times = manifest?.times || [];
|
||||
@@ -508,9 +524,16 @@ export function createWeatherLayer({
|
||||
age === null
|
||||
? ''
|
||||
: age >= 60
|
||||
? `${Math.floor(age / 60)}h ${age % 60}m ago`
|
||||
: `${age}m ago`;
|
||||
? t('{hours}h {minutes}m ago', {
|
||||
hours: Math.floor(age / 60),
|
||||
minutes: age % 60,
|
||||
})
|
||||
: t('{minutes}m ago', { minutes: age });
|
||||
const diagnostic = rendering?.getDiagnostics();
|
||||
// Errors are stored in English (stats, diagnostics); shown translated.
|
||||
const problem = error || diagnostic?.error;
|
||||
const shownProblem = problem ? t(problem) : null;
|
||||
const shownHost = hostStatus ? t(hostStatus) : null;
|
||||
const b = manifest?.bounds;
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
const point =
|
||||
@@ -535,49 +558,94 @@ export function createWeatherLayer({
|
||||
lat < b.south ||
|
||||
lat > b.north ||
|
||||
(lightning && lon > 0 && lon < 110));
|
||||
let detail = time
|
||||
? t('{state} · {time} · {lag}', {
|
||||
state: followLatest ? t('Observed') : t('History'),
|
||||
time: utc(time),
|
||||
lag,
|
||||
})
|
||||
: missing
|
||||
? t('Observation unavailable')
|
||||
: t('Waiting for observation');
|
||||
if (relation === 'synced') detail = t('{detail} · synced', { detail });
|
||||
else if (relation === 'nearest')
|
||||
detail = t('{detail} · nearest', { detail });
|
||||
const infoHeading = radar
|
||||
? t('RADAR REFLECTIVITY · dBZ')
|
||||
: lightning
|
||||
? t('LIGHTNING DENSITY · 15 min accumulation')
|
||||
: product === 'clouds'
|
||||
? t('GLOBAL INFRARED · hourly')
|
||||
: t('GOES INFRARED · ~5 min');
|
||||
const infoTime = time
|
||||
? `${t('{state}: {time}', {
|
||||
state: followLatest ? t('Latest observation') : t('History'),
|
||||
time: utc(time),
|
||||
})}\n${[
|
||||
lag,
|
||||
current && !followLatest
|
||||
? t('frame {index}/{total}', {
|
||||
index: index + 1,
|
||||
total: times.length,
|
||||
})
|
||||
: '',
|
||||
loading ? t('loading') : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}`
|
||||
: [t('Observation: unavailable'), loading ? t('loading') : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const infoCoverage = radar
|
||||
? t('Contiguous US · gaps ≠ no rain')
|
||||
: lightning
|
||||
? `${t('Americas + Pacific · not individual strikes')}\n${t('Color: strikes/km²/min ×10³')}`
|
||||
: product === 'clouds'
|
||||
? t('60°S–60°N · typically 2–3 h delayed')
|
||||
: t('North America · infrared imagery');
|
||||
const infoMotion = motion?.matches
|
||||
? clock
|
||||
? t('Reduced motion · history playback unavailable')
|
||||
: t('Reduced motion · manual history available')
|
||||
: '';
|
||||
const controls = {
|
||||
readout: true,
|
||||
summary: {
|
||||
label: radar
|
||||
? 'Rain radar · US'
|
||||
? t('Rain radar · US')
|
||||
: lightning
|
||||
? 'Lightning density · 15 min'
|
||||
: 'Satellite clouds',
|
||||
? t('Lightning density · 15 min')
|
||||
: t('Satellite clouds'),
|
||||
coverage: radar
|
||||
? 'CONUS'
|
||||
: lightning
|
||||
? 'Americas + Pacific'
|
||||
? t('Americas + Pacific')
|
||||
: product === 'clouds'
|
||||
? 'Global · 60°S–60°N'
|
||||
: 'North America',
|
||||
? t('Global · 60°S–60°N')
|
||||
: t('North America'),
|
||||
shownTime: time,
|
||||
maxGapMinutes: maxGap() / 60_000,
|
||||
detail: time
|
||||
? `${followLatest ? 'Observed' : 'History'} · ${utc(time)} · ${lag}${relation}`
|
||||
: missing
|
||||
? 'Observation unavailable'
|
||||
: 'Waiting for observation',
|
||||
detail,
|
||||
status:
|
||||
missing ||
|
||||
hostStatus ||
|
||||
error ||
|
||||
diagnostic?.error ||
|
||||
shownHost ||
|
||||
shownProblem ||
|
||||
(observationDelayed()
|
||||
? 'Source observations delayed'
|
||||
? t('Source observations delayed')
|
||||
: manifest?.stale
|
||||
? 'Stale source'
|
||||
? t('Stale source')
|
||||
: loading
|
||||
? 'Loading next frame…'
|
||||
? t('Loading next frame…')
|
||||
: outside
|
||||
? 'Map center outside coverage'
|
||||
? t('Map center outside coverage')
|
||||
: null),
|
||||
units: radar ? 'dBZ' : lightning ? 'strikes/km²/min ×10³' : '',
|
||||
units: radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : '',
|
||||
},
|
||||
chips: [
|
||||
...(satellite
|
||||
? [
|
||||
['clouds-regional', 'N. America'],
|
||||
['clouds', 'Global'],
|
||||
['clouds-regional', t('N. America')],
|
||||
['clouds', t('Global')],
|
||||
].map(([value, label]) => ({
|
||||
id: value,
|
||||
label,
|
||||
@@ -585,43 +653,44 @@ export function createWeatherLayer({
|
||||
params: { product: value },
|
||||
title:
|
||||
value === 'clouds'
|
||||
? 'Hourly global mosaic; usually 2–3 hours delayed'
|
||||
: 'GOES regional clouds; approximately 5-minute updates',
|
||||
? t('Hourly global mosaic; usually 2–3 hours delayed')
|
||||
: t('GOES regional clouds; approximately 5-minute updates'),
|
||||
}))
|
||||
: []),
|
||||
...(satellite
|
||||
? [
|
||||
{
|
||||
id: 'filtered',
|
||||
label: 'Clouds only',
|
||||
label: t('Clouds only'),
|
||||
active: infrared === 'filtered',
|
||||
params: { infrared: 'filtered' },
|
||||
title:
|
||||
title: t(
|
||||
'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask',
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'full',
|
||||
label: 'Full',
|
||||
label: t('Full'),
|
||||
active: infrared === 'full',
|
||||
params: { infrared: 'full' },
|
||||
title: 'The complete infrared image at the chosen opacity',
|
||||
title: t('The complete infrared image at the chosen opacity'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...['light', 'strong'].map((value) => ({
|
||||
id: `opacity-${value}`,
|
||||
label: value === 'light' ? 'Soft' : 'Vivid',
|
||||
label: value === 'light' ? t('Soft') : t('Vivid'),
|
||||
active: opacity === value,
|
||||
params: { opacity: value },
|
||||
title: 'Image opacity; does not alter the observed values',
|
||||
title: t('Image opacity; does not alter the observed values'),
|
||||
})),
|
||||
{
|
||||
id: 'coverage',
|
||||
label: radar
|
||||
? 'View US radar'
|
||||
? t('View US radar')
|
||||
: lightning
|
||||
? 'View Americas & Pacific'
|
||||
: 'View coverage',
|
||||
? t('View Americas & Pacific')
|
||||
: t('View coverage'),
|
||||
disabled: !manifest || !runNavigation,
|
||||
params: { focus: true },
|
||||
},
|
||||
@@ -632,37 +701,47 @@ export function createWeatherLayer({
|
||||
label: String(label),
|
||||
color,
|
||||
blurb: lightning
|
||||
? `${label} strikes/km²/min ×10³ (15-minute density)`
|
||||
: `${label} dBZ radar reflectivity`,
|
||||
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
|
||||
value: formatScaleLabel(label),
|
||||
})
|
||||
: t('{value} dBZ radar reflectivity', {
|
||||
value: formatScaleLabel(label),
|
||||
}),
|
||||
}))
|
||||
: [],
|
||||
info: hostHidden
|
||||
? hostStatus
|
||||
: `${radar ? 'RADAR REFLECTIVITY · dBZ' : lightning ? 'LIGHTNING DENSITY · 15 min accumulation' : product === 'clouds' ? 'GLOBAL INFRARED · hourly' : 'GOES INFRARED · ~5 min'}\n${time ? `${followLatest ? 'Latest observation' : 'History'}: ${utc(time)}\n${lag}${current && !followLatest ? ` · frame ${index + 1}/${times.length}` : ''}${loading ? ' · loading' : ''}` : `Observation: unavailable${loading ? ' · loading' : ''}`}${missing ? `\n${missing}` : ''}${manifest?.stale ? '\nSTALE · cached source metadata' : ''}${error || diagnostic?.error ? '\n' + (error || diagnostic.error) : ''}\n${radar ? 'Contiguous US · gaps ≠ no rain' : lightning ? 'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³' : product === 'clouds' ? '60°S–60°N · typically 2–3 h delayed' : 'North America · infrared imagery'}${outside ? '\nMap center is outside source coverage' : ''}${motion?.matches ? (clock ? '\nReduced motion · history playback unavailable' : '\nReduced motion · manual history available') : ''}`,
|
||||
? shownHost
|
||||
: `${infoHeading}\n${infoTime}${missing ? `\n${missing}` : ''}${manifest?.stale ? `\n${t('STALE · cached source metadata')}` : ''}${shownProblem ? `\n${shownProblem}` : ''}\n${infoCoverage}${outside ? `\n${t('Map center is outside source coverage')}` : ''}${infoMotion ? `\n${infoMotion}` : ''}`,
|
||||
infoTitle: lightning
|
||||
? 'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.'
|
||||
? t(
|
||||
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
|
||||
)
|
||||
: radar
|
||||
? 'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.'
|
||||
: 'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
|
||||
? t(
|
||||
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
|
||||
)
|
||||
: t(
|
||||
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
|
||||
),
|
||||
};
|
||||
controls.summary.settings = [
|
||||
...(satellite
|
||||
? [
|
||||
{
|
||||
id: 'region',
|
||||
label: 'REGION',
|
||||
label: t('REGION'),
|
||||
chips: controls.chips.filter(({ params }) => params.product),
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
label: 'IMAGE',
|
||||
label: t('IMAGE'),
|
||||
chips: controls.chips.filter(({ params }) => params.infrared),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'opacity',
|
||||
label: 'OPACITY',
|
||||
label: t('OPACITY'),
|
||||
chips: controls.chips.filter(({ params }) => params.opacity),
|
||||
},
|
||||
];
|
||||
@@ -677,7 +756,7 @@ export function createWeatherLayer({
|
||||
getStats() {
|
||||
return {
|
||||
count: shownTime() ? 1 : 0,
|
||||
countLabel: isLatest() ? 'Observed' : 'History',
|
||||
countLabel: isLatest() ? t('Observed') : t('History'),
|
||||
lastUpdate: shownTime() ? Date.parse(shownTime()) : null,
|
||||
loading,
|
||||
error: error || rendering?.getDiagnostics().error || null,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { orderWeatherImagery } from './imageryOrder.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import { createRasterTileProvider } from './rasterTiles.js';
|
||||
import { createWeatherShell } from './shellRendering.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { readResponseBytesCapped } from '../../sources/httpBody.js';
|
||||
import {
|
||||
acquireInfraredMosaic,
|
||||
@@ -294,7 +295,9 @@ function createGlobeRendering({
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lastError = 'Weather tiles unavailable · previous frame retained';
|
||||
lastError = msg(
|
||||
'Weather tiles unavailable · previous frame retained',
|
||||
);
|
||||
remove(frame);
|
||||
}
|
||||
frame.resolve(ok);
|
||||
@@ -413,7 +416,7 @@ function createGlobeRendering({
|
||||
return;
|
||||
}
|
||||
frame.failed = true;
|
||||
lastError = 'Some weather tiles unavailable';
|
||||
lastError = msg('Some weather tiles unavailable');
|
||||
onChange();
|
||||
});
|
||||
// A shown, transparent layer lets Cesium request staging tiles while the last
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { acquireWeatherImage } from './infraredImage.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import { WEATHER_DETAIL_SIZE, WEATHER_IMAGE_SIZES } from './source.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
|
||||
/** Metres above the ellipsoid. Lower shells draw first, so lightning draws last. */
|
||||
export const WEATHER_SHELL_HEIGHTS = Object.freeze({
|
||||
@@ -630,7 +631,7 @@ export function createWeatherShell({
|
||||
if (incoming !== frame) return;
|
||||
incoming = null;
|
||||
close(frame);
|
||||
lastError = 'Weather tiles unavailable · previous frame retained';
|
||||
lastError = msg('Weather tiles unavailable · previous frame retained');
|
||||
frame.resolve(false);
|
||||
scene.requestRender();
|
||||
onChange();
|
||||
|
||||
@@ -3,14 +3,14 @@ import { normalizeLongitude, sampleWind } from './model.js';
|
||||
/** Fixed physical scales shared by imagery and the field legend. */
|
||||
export const WIND_FIELDS = Object.freeze({
|
||||
speed: Object.freeze({
|
||||
label: 'Wind speed',
|
||||
label: 'Wind speed', // i18n-ignore: internal field name; the shown label comes from index.js
|
||||
units: 'm/s',
|
||||
min: 0,
|
||||
max: 30,
|
||||
stops: ['#133a67', '#2876a3', '#66c9d1', '#c8dfac', '#f1cf79', '#ee9761'],
|
||||
}),
|
||||
temperature: Object.freeze({
|
||||
label: '2 m air temperature',
|
||||
label: '2 m air temperature', // i18n-ignore: internal field name; the shown label comes from index.js
|
||||
units: '°C',
|
||||
min: -40,
|
||||
max: 50,
|
||||
@@ -30,7 +30,7 @@ export const WIND_FIELDS = Object.freeze({
|
||||
],
|
||||
}),
|
||||
pressure: Object.freeze({
|
||||
label: 'Mean sea-level pressure',
|
||||
label: 'Mean sea-level pressure', // i18n-ignore: internal field name; the shown label comes from index.js
|
||||
units: 'hPa',
|
||||
min: 960,
|
||||
max: 1050,
|
||||
|
||||
+56
-41
@@ -11,6 +11,8 @@ import {
|
||||
createWindInspectionMarker,
|
||||
WIND_UNITS,
|
||||
} from './inspection.js';
|
||||
import { t, msg } from '../../i18n/index.js';
|
||||
import { unitLabel } from '../../ui/unitLabels.js';
|
||||
|
||||
/** Format a forecast timestamp explicitly in UTC. */
|
||||
export function formatWindValidTime(value) {
|
||||
@@ -172,7 +174,7 @@ export function createWindLayer({
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
error = cause?.message || 'Wind source unavailable';
|
||||
error = cause?.message || msg('Wind source unavailable');
|
||||
return true;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
@@ -242,7 +244,7 @@ export function createWindLayer({
|
||||
getRowControls() {
|
||||
const historyStatus =
|
||||
clock?.getState().mode === 'history'
|
||||
? 'Forecast · does not follow history'
|
||||
? t('Forecast · does not follow history')
|
||||
: null;
|
||||
const valid = formatWindValidTime(manifest?.cycle?.validIso);
|
||||
const run = formatWindValidTime(manifest?.cycle?.runIso);
|
||||
@@ -258,13 +260,16 @@ export function createWindLayer({
|
||||
requestedScalar() !== 'none' &&
|
||||
manifest &&
|
||||
(!manifest.scalar || manifest.scalar.kind !== overlay);
|
||||
// Stored in English for diagnostics; shown translated.
|
||||
const imageryError =
|
||||
enabled && !loading ? diagnostic?.imageryError : null;
|
||||
enabled && !loading && diagnostic?.imageryError
|
||||
? t(diagnostic.imageryError)
|
||||
: null;
|
||||
const label = {
|
||||
none: 'Wind motion',
|
||||
speed: 'Wind speed',
|
||||
pressure: 'Sea-level pressure',
|
||||
temperature: 'Air temperature · 2 m',
|
||||
none: t('Wind motion'),
|
||||
speed: t('Wind speed'),
|
||||
pressure: t('Sea-level pressure'),
|
||||
temperature: t('Air temperature · 2 m'),
|
||||
}[overlay];
|
||||
const kind = ['temperature', 'pressure'].includes(overlay)
|
||||
? overlay
|
||||
@@ -290,60 +295,64 @@ export function createWindLayer({
|
||||
readout: true,
|
||||
summary: {
|
||||
label,
|
||||
coverage: 'Global · 1° grid',
|
||||
coverage: t('Global · 1° grid'),
|
||||
validTime: manifest?.cycle?.validIso,
|
||||
issuedTime: manifest?.cycle?.runIso,
|
||||
detail: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${valid || 'Unavailable'}`,
|
||||
detail: t('{model} forecast · {time}', {
|
||||
model: model === 'ifs' ? 'ECMWF IFS' : 'GFS',
|
||||
time: valid || t('Unavailable'),
|
||||
}),
|
||||
status: loading
|
||||
? 'Loading forecast'
|
||||
? t('Loading forecast')
|
||||
: preparing
|
||||
? 'Preparing flow'
|
||||
: error ||
|
||||
? t('Preparing flow')
|
||||
: (error && t(error)) ||
|
||||
manifest?.reason ||
|
||||
imageryError ||
|
||||
(scalarMissing ? 'Selected field unavailable' : null) ||
|
||||
(manifest?.stale ? 'Cached forecast · stale' : null),
|
||||
(scalarMissing ? t('Selected field unavailable') : null) ||
|
||||
(manifest?.stale ? t('Cached forecast · stale') : null),
|
||||
units: legendUnit,
|
||||
},
|
||||
chips: [
|
||||
...['gfs', 'ifs'].map((value) => ({
|
||||
id: `model-${value}`,
|
||||
label: value === 'ifs' ? 'ECMWF' : 'GFS',
|
||||
label: value === 'ifs' ? 'ECMWF' : 'GFS', // i18n-ignore: forecast model names
|
||||
active: model === value,
|
||||
params: { model: value },
|
||||
title:
|
||||
value === 'ifs'
|
||||
? 'ECMWF IFS surface forecast'
|
||||
: 'NOAA GFS surface forecast',
|
||||
? t('ECMWF IFS surface forecast')
|
||||
: t('NOAA GFS surface forecast'),
|
||||
})),
|
||||
{
|
||||
id: 'motion',
|
||||
label: diagnostic?.reducedMotion
|
||||
? 'Reduced motion'
|
||||
? t('Reduced motion')
|
||||
: paused
|
||||
? 'Resume'
|
||||
: 'Pause',
|
||||
? t('Resume')
|
||||
: t('Pause'),
|
||||
active: motionPaused,
|
||||
disabled: Boolean(diagnostic?.reducedMotion),
|
||||
params: { paused: !paused },
|
||||
title:
|
||||
title: t(
|
||||
'Pause visual flow; forecast time does not advance with animation',
|
||||
),
|
||||
},
|
||||
...['none', 'speed', 'pressure', 'temperature'].map((value) => ({
|
||||
id: `overlay-${value}`,
|
||||
label: {
|
||||
none: 'None',
|
||||
speed: 'Speed',
|
||||
pressure: 'Pressure',
|
||||
temperature: 'Temperature',
|
||||
none: t('None'),
|
||||
speed: t('Speed'),
|
||||
pressure: t('Pressure'),
|
||||
temperature: t('Temperature'),
|
||||
}[value],
|
||||
active: overlay === value,
|
||||
params: { overlay: value },
|
||||
title: {
|
||||
none: 'Wind trails with no field shading',
|
||||
speed: 'How hard the surface wind is blowing',
|
||||
pressure: 'Sea-level air pressure: broad highs and lows',
|
||||
temperature: 'Air temperature two meters above the surface',
|
||||
none: t('Wind trails with no field shading'),
|
||||
speed: t('How hard the surface wind is blowing'),
|
||||
pressure: t('Sea-level air pressure: broad highs and lows'),
|
||||
temperature: t('Air temperature two meters above the surface'),
|
||||
}[value],
|
||||
})),
|
||||
...(speedLegendVisible ||
|
||||
@@ -353,18 +362,19 @@ export function createWindLayer({
|
||||
: []
|
||||
).map((value) => ({
|
||||
id: `units-${value}`,
|
||||
label: value,
|
||||
label: unitLabel(value),
|
||||
active: units === value,
|
||||
params: { units: value },
|
||||
title: 'Wind speed units',
|
||||
title: t('Wind speed units'),
|
||||
})),
|
||||
{
|
||||
id: 'read-wind',
|
||||
label: 'Read wind at map center',
|
||||
label: t('Read wind at map center'),
|
||||
params: { inspect: true },
|
||||
disabled: loading || !manifest?.u || manifest?.unavailable,
|
||||
title:
|
||||
title: t(
|
||||
'Read the forecast at the center of the map without changing selection',
|
||||
),
|
||||
},
|
||||
],
|
||||
legend:
|
||||
@@ -373,9 +383,14 @@ export function createWindLayer({
|
||||
(overlay === 'none' && !speedLegendVisible)
|
||||
? []
|
||||
: legend,
|
||||
info: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${label} (${legendUnit})${historyStatus ? `\n${historyStatus}` : ''}\nValid: ${valid || 'Unavailable'}${loading ? ' · loading' : preparing ? ' · preparing' : ''}\nIssued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}${error ? '\n' + error : ''}${scalarMissing ? '\nSelected field unavailable · wind remains visible' : ''}${imageryError && !scalarMissing ? '\n' + imageryError + ' · wind remains visible' : ''}`,
|
||||
infoTitle:
|
||||
info: `${t('{model} forecast · {field} ({unit})', {
|
||||
model: model === 'ifs' ? 'ECMWF IFS' : 'GFS',
|
||||
field: label,
|
||||
unit: unitLabel(legendUnit),
|
||||
})}${historyStatus ? `\n${historyStatus}` : ''}\n${t('Valid: {time}', { time: valid || t('Unavailable') })}${loading ? ` · ${t('loading')}` : preparing ? ` · ${t('preparing')}` : ''}\n${t('Issued: {time}', { time: run || t('Unavailable') })}${manifest?.stale ? ' · STALE' : ''}${error ? `\n${t(error)}` : ''}${scalarMissing ? `\n${t('Selected field unavailable · wind remains visible')}` : ''}${imageryError && !scalarMissing ? `\n${t('{error} · wind remains visible', { error: imageryError })}` : ''}`,
|
||||
infoTitle: t(
|
||||
'Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.',
|
||||
),
|
||||
};
|
||||
controls.summary.reading = reading;
|
||||
controls.summary.result = reading ? windReadingResult(reading) : null;
|
||||
@@ -385,12 +400,12 @@ export function createWindLayer({
|
||||
chips: controls.chips.filter((chip) => chip.id.startsWith(prefix)),
|
||||
});
|
||||
controls.summary.settings = [
|
||||
setting('model', 'MODEL', 'model-'),
|
||||
setting('field', 'FIELD', 'overlay-'),
|
||||
setting('model', t('MODEL'), 'model-'),
|
||||
setting('field', t('FIELD'), 'overlay-'),
|
||||
...(controls.chips.some(({ id }) => id.startsWith('units-'))
|
||||
? [{ id: 'units', label: 'UNITS', chips: windUnitChips(units) }]
|
||||
? [{ id: 'units', label: t('UNITS'), chips: windUnitChips(units) }]
|
||||
: []),
|
||||
setting('motion', 'MOTION', 'motion'),
|
||||
setting('motion', t('MOTION'), 'motion'),
|
||||
];
|
||||
controls.summary.actions = controls.chips.filter(
|
||||
({ id }) => id === 'read-wind',
|
||||
@@ -425,7 +440,7 @@ export function createWindLayer({
|
||||
: null;
|
||||
return {
|
||||
...windStats(manifest),
|
||||
countLabel: 'Forecast',
|
||||
countLabel: t('Forecast'),
|
||||
loading: loading || Boolean(preparing),
|
||||
model: model.toUpperCase(),
|
||||
overlay,
|
||||
@@ -433,7 +448,7 @@ export function createWindLayer({
|
||||
stale: Boolean(manifest?.stale),
|
||||
source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS',
|
||||
validTime:
|
||||
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
|
||||
formatWindValidTime(manifest?.cycle?.validIso) || t('Unavailable'),
|
||||
error:
|
||||
error ||
|
||||
windStats(manifest).error ||
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { sampleWind, windSpeed } from './model.js';
|
||||
import { sampleScalar } from './fields.js';
|
||||
|
||||
// The reading stays English: AI tools read it too. Text is marked for i18n
|
||||
// extraction and translated where the WEATHER card shows it (presentation.js).
|
||||
const msg = (text) => text;
|
||||
|
||||
const DIRECTIONS = [
|
||||
'N',
|
||||
'NNE',
|
||||
'NE',
|
||||
'ENE',
|
||||
'E',
|
||||
'ESE',
|
||||
'SE',
|
||||
'SSE',
|
||||
'S',
|
||||
'SSW',
|
||||
'SW',
|
||||
'WSW',
|
||||
'W',
|
||||
'WNW',
|
||||
'NW',
|
||||
'NNW',
|
||||
msg('N'),
|
||||
msg('NNE'),
|
||||
msg('NE'),
|
||||
msg('ENE'),
|
||||
msg('E'),
|
||||
msg('ESE'),
|
||||
msg('SE'),
|
||||
msg('SSE'),
|
||||
msg('S'),
|
||||
msg('SSW'),
|
||||
msg('SW'),
|
||||
msg('WSW'),
|
||||
msg('W'),
|
||||
msg('WNW'),
|
||||
msg('NW'),
|
||||
msg('NNW'),
|
||||
];
|
||||
export const WIND_UNITS = Object.freeze({
|
||||
'km/h': 3.6,
|
||||
@@ -56,13 +60,14 @@ export function inspectWindAtCenter(
|
||||
));
|
||||
if (!point || !snapshot?.u || !snapshot?.v)
|
||||
return {
|
||||
coordinates: 'No surface reading',
|
||||
wind: 'Aim the map center at Earth',
|
||||
coordinates: msg('No surface reading'),
|
||||
wind: msg('Aim the map center at Earth'),
|
||||
model,
|
||||
validTime,
|
||||
status,
|
||||
explanation:
|
||||
explanation: msg(
|
||||
'A location reading needs a loaded forecast and the Earth at the center of the view.',
|
||||
),
|
||||
};
|
||||
const location = cesium.Cartographic.fromCartesian(point, ellipsoid);
|
||||
const lon = cesium.Math.toDegrees(location.longitude);
|
||||
@@ -87,11 +92,11 @@ export function inspectWindAtCenter(
|
||||
scalarKind: overlay,
|
||||
scalarLabel:
|
||||
overlay === 'speed'
|
||||
? 'Wind speed'
|
||||
? msg('Wind speed')
|
||||
: overlay === 'temperature'
|
||||
? 'Air temperature · 2 m'
|
||||
? msg('Air temperature · 2 m')
|
||||
: overlay === 'pressure'
|
||||
? 'Sea-level pressure'
|
||||
? msg('Sea-level pressure')
|
||||
: null,
|
||||
scalarValue:
|
||||
overlay === 'speed'
|
||||
@@ -102,8 +107,9 @@ export function inspectWindAtCenter(
|
||||
model,
|
||||
validTime,
|
||||
status,
|
||||
explanation:
|
||||
explanation: msg(
|
||||
'Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { formatWindSpeed, WIND_UNITS } from './inspection.js';
|
||||
import { formatNumber, formatUtcDayMonth, t } from '../../i18n/index.js';
|
||||
import { formatQuantity, unitLabel } from '../../ui/unitLabels.js';
|
||||
|
||||
/** Reformat a captured sample; changing units never samples the map again. */
|
||||
export function formatWindReading(reading, units) {
|
||||
@@ -19,36 +21,111 @@ export function formatWindReading(reading, units) {
|
||||
export function windUnitChips(units) {
|
||||
return Object.keys(WIND_UNITS).map((value) => ({
|
||||
id: `units-${value}`,
|
||||
label: value,
|
||||
label: unitLabel(value),
|
||||
active: units === value,
|
||||
params: { units: value },
|
||||
title: 'Wind speed units',
|
||||
title: t('Wind speed units'),
|
||||
}));
|
||||
}
|
||||
|
||||
/** formatWindSpeed's value in the active language: "22,0 км/ч". */
|
||||
function displaySpeed(speed, units) {
|
||||
const unit = Object.hasOwn(WIND_UNITS, units) ? units : 'km/h';
|
||||
return formatQuantity(speed * WIND_UNITS[unit], unit, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/** "1012.4 hPa" / "-3.0 °C" from the English reading, in the active language. */
|
||||
function displayScalar(reading) {
|
||||
if (reading.scalarKind === 'speed' && Number.isFinite(reading.speed))
|
||||
return displaySpeed(reading.speed, reading.units);
|
||||
const match = /^(-?\d+(?:\.\d+)?) (°C|hPa)$/.exec(reading.scalarValue);
|
||||
if (!match) return reading.scalarValue;
|
||||
const decimals = match[1].split('.')[1]?.length || 0;
|
||||
return formatQuantity(Number(match[1]), match[2], {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
}
|
||||
|
||||
/** The wind line in the active language; the reading itself stays English. */
|
||||
function windText(reading) {
|
||||
if (!Number.isFinite(reading.speed))
|
||||
return reading.wind === 'Aim the map center at Earth'
|
||||
? t('Aim the map center at Earth')
|
||||
: reading.wind;
|
||||
const speed = displaySpeed(reading.speed, reading.units);
|
||||
return reading.from === 'Calm'
|
||||
? `${speed}${t(' · calm')}`
|
||||
: `${speed}${t(' from {direction}', { direction: t(reading.from) })}`;
|
||||
}
|
||||
|
||||
/** "29.93°N · 97.74°W" from the English reading, in the active language. */
|
||||
function displayCoordinates(coordinates) {
|
||||
const match = /^(\d+(?:\.\d+)?)°([NS]) · (\d+(?:\.\d+)?)°([EW])$/.exec(
|
||||
coordinates,
|
||||
);
|
||||
if (!match) return coordinates.replace(' · ', ' ');
|
||||
const part = (value, hemisphere) => {
|
||||
const decimals = value.split('.')[1]?.length || 0;
|
||||
const amount = formatNumber(Number(value), {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
useGrouping: false,
|
||||
});
|
||||
switch (hemisphere) {
|
||||
case 'N':
|
||||
return t('{value}°N', { value: amount });
|
||||
case 'S':
|
||||
return t('{value}°S', { value: amount });
|
||||
case 'E':
|
||||
return t('{value}°E', { value: amount });
|
||||
default:
|
||||
return t('{value}°W', { value: amount });
|
||||
}
|
||||
};
|
||||
return `${part(match[1], match[2])} ${part(match[3], match[4])}`;
|
||||
}
|
||||
|
||||
/** "2026-10-14 14:00 UTC" from the reading, as "10-14 14:00 UTC" / "14.10 …". */
|
||||
function displayValidTime(validTime) {
|
||||
const match = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}) UTC$/.exec(validTime || '');
|
||||
if (!match) return validTime?.replace(/^\d{4}-/, '');
|
||||
return `${formatUtcDayMonth(match[1])} ${match[2]} UTC`;
|
||||
}
|
||||
|
||||
/** Portable result block consumed by the WEATHER card. */
|
||||
export function windReadingResult(reading) {
|
||||
const coordinates =
|
||||
reading.coordinates === 'No surface reading'
|
||||
? t('No surface reading')
|
||||
: displayCoordinates(reading.coordinates);
|
||||
return {
|
||||
id: 'reading',
|
||||
label: `WIND AT ${reading.coordinates.replace(' · ', ' ')}`,
|
||||
label: t('WIND AT {coordinates}', { coordinates }),
|
||||
lines: [
|
||||
{
|
||||
id: 'wind',
|
||||
text: reading.wind,
|
||||
text: windText(reading),
|
||||
},
|
||||
{
|
||||
id: 'meta',
|
||||
text: `${reading.model} · valid ${reading.validTime?.replace(/^\d{4}-/, '')}`,
|
||||
text: t('{model} · valid {time}', {
|
||||
model: reading.model,
|
||||
time: displayValidTime(reading.validTime),
|
||||
}),
|
||||
},
|
||||
...(reading.scalarValue
|
||||
? [
|
||||
{
|
||||
id: 'scalar',
|
||||
text: `${reading.scalarLabel} · ${reading.scalarValue}`,
|
||||
text: `${t(reading.scalarLabel)} · ${displayScalar(reading)}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ id: 'explanation', text: reading.explanation },
|
||||
{ id: 'explanation', text: t(reading.explanation) },
|
||||
],
|
||||
clear: { params: { inspect: false } },
|
||||
};
|
||||
|
||||
@@ -2,7 +2,16 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatWindReading, windReadingResult } from './presentation.js';
|
||||
|
||||
const sample = { speed: 5, from: 'SW', coordinates: '41.9°N · 87.6°W', model: 'NOAA GFS', validTime: '2026-09-21 12:00 UTC', scalarLabel: 'Air temperature · 2 m', scalarValue: '12 °C', explanation: 'Interpolated model forecast.' };
|
||||
const sample = {
|
||||
speed: 5,
|
||||
from: 'SW',
|
||||
coordinates: '41.9°N · 87.6°W',
|
||||
model: 'NOAA GFS',
|
||||
validTime: '2026-09-21 12:00 UTC',
|
||||
scalarLabel: 'Air temperature · 2 m',
|
||||
scalarValue: '12 °C',
|
||||
explanation: 'Interpolated model forecast.',
|
||||
};
|
||||
test('captured reading reformats without changing its sample and produces a portable result block', () => {
|
||||
const reading = formatWindReading(sample, 'mph');
|
||||
assert.equal(reading.wind, '11.2 mph from SW');
|
||||
@@ -11,12 +20,30 @@ test('captured reading reformats without changing its sample and produces a port
|
||||
assert.equal(sample.units, undefined);
|
||||
const section = windReadingResult(reading);
|
||||
assert.equal(section.id, 'reading');
|
||||
assert.equal(section.lines.find(({ id }) => id === 'scalar').text, 'Air temperature · 2 m · 12 °C');
|
||||
assert.equal(section.lines.find(({ id }) => id === 'wind').text, '11.2 mph from SW');
|
||||
assert.equal(section.lines.find(({ id }) => id === 'meta').text, 'NOAA GFS · valid 09-21 12:00 UTC');
|
||||
assert.equal(
|
||||
section.lines.find(({ id }) => id === 'scalar').text,
|
||||
'Air temperature · 2 m · 12\u00a0°C',
|
||||
);
|
||||
assert.equal(
|
||||
section.lines.find(({ id }) => id === 'wind').text,
|
||||
'11.2\u00a0mph from SW',
|
||||
);
|
||||
assert.equal(
|
||||
section.lines.find(({ id }) => id === 'meta').text,
|
||||
'NOAA GFS · valid 09-21 12:00 UTC',
|
||||
);
|
||||
assert.equal(section.label, 'WIND AT 41.9°N 87.6°W');
|
||||
assert.deepEqual(section.clear.params, { inspect: false });
|
||||
assert.equal(formatWindReading({ ...sample, from: 'Calm' }, 'm/s').wind, '5.0 m/s · calm');
|
||||
assert.equal(
|
||||
formatWindReading({ ...sample, from: 'Calm' }, 'm/s').wind,
|
||||
'5.0 m/s · calm',
|
||||
);
|
||||
assert.equal(formatWindReading(null, 'km/h'), null);
|
||||
assert.equal(windReadingResult({ coordinates: 'No surface reading', wind: 'Unavailable' }).lines.some(({ id }) => id === 'scalar'), false);
|
||||
assert.equal(
|
||||
windReadingResult({
|
||||
coordinates: 'No surface reading',
|
||||
wind: 'Unavailable',
|
||||
}).lines.some(({ id }) => id === 'scalar'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { createWindRelief } from './relief.js';
|
||||
import { orderWeatherImagery } from '../weather/imageryOrder.js';
|
||||
import { NO_IMAGERY_HOST } from '../weather/imageryHost.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { createWindGpuRendering } from './gpuRendering.js';
|
||||
import { advectParticle, sampleWind } from './model.js';
|
||||
import {
|
||||
@@ -344,7 +345,7 @@ export function createWindRendering({
|
||||
? !cesium.Primitive
|
||||
: !collection || !cesium.SingleTileImageryProvider
|
||||
) {
|
||||
imageryError = 'Globe imagery unavailable';
|
||||
imageryError = msg('Globe imagery unavailable');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -384,12 +385,12 @@ export function createWindRendering({
|
||||
imagery.alpha = overlay === 'temperature' ? 1 : 0.85;
|
||||
if (gpuActive) updateImageryFade(viewer.scene.camera, true);
|
||||
imageryErrorRemove = provider.errorEvent?.addEventListener(() => {
|
||||
imageryError = 'Globe field image unavailable';
|
||||
imageryError = msg('Globe field image unavailable');
|
||||
});
|
||||
viewer.scene?.requestRender?.();
|
||||
} catch {
|
||||
removeImagery();
|
||||
imageryError = 'Globe field image unavailable';
|
||||
imageryError = msg('Globe field image unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user