mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(i18n): translate the whole interface
Mark every UI string the unmarked-text check reported (534) plus text it cannot see (helpers, joined labels, runtime-built panels), and translate the result to Spanish and Russian: 1,679 messages each. - Kept on purpose, with the reason where the text lives: provider and product names, place and camera names, codes and units, dB values, the HUD terminal readouts, demo shot titles that saved projects look up by title, and text for AI tools and the voice model. - Modules shared with the server or AI tools stay free of the i18n import (local msg); the wind reading and voice session stay English and are translated where the page shows them. - npm test now runs the unmarked-text check. - Fixes on the way: a duplicated radio privacy paragraph, the contact panel toggle saying Expand while open, the CSS filter mistaking & for CSS. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ee72133574
commit
3129e531e6
99 files changed
+4670
-1294
No files matched your search
+32
-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,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
|
||||
id: LAYER_ID,
|
||||
name: 'ALPR Cameras',
|
||||
icon: '📷',
|
||||
source: source.label || 'Mapped camera locations',
|
||||
source: t(source.label || msg('Mapped camera locations')),
|
||||
updateInterval: 0,
|
||||
statsRefreshInterval: 1000,
|
||||
init(viewer) {
|
||||
@@ -372,10 +373,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 +386,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 +432,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 +462,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)',
|
||||
|
||||
+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,27 @@ import {
|
||||
CYCLONE_OVERLAY_SOURCE_ID,
|
||||
cycloneStormIdFromEntryId,
|
||||
} from './labels.js';
|
||||
import { t, tn, msg } from '../../i18n/index.js';
|
||||
|
||||
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 ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 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') : `${value} ${unit}`;
|
||||
|
||||
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
|
||||
export function createCyclonesLayer({
|
||||
@@ -316,41 +320,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 +391,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 +406,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') : `${item.windKt} kt`}`,
|
||||
active: item.id === selectedId,
|
||||
params: { stormId: item.id, focus: true },
|
||||
})),
|
||||
@@ -388,15 +419,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;
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
formatRouteDistance,
|
||||
formatRouteDuration,
|
||||
} from '../../data/routeSteps.js';
|
||||
import { t, msg } from '../../i18n/index.js';
|
||||
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
@@ -31,9 +32,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';
|
||||
|
||||
@@ -107,32 +108,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 +144,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'),
|
||||
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 +185,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 +207,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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,13 +298,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 +346,12 @@ export function directionsStats(state) {
|
||||
armed,
|
||||
pointerBlocked,
|
||||
} = state;
|
||||
const source = 'OSM routing';
|
||||
const source = t('OSM routing');
|
||||
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 +362,7 @@ export function directionsStats(state) {
|
||||
lastUpdate,
|
||||
error: null,
|
||||
loading: true,
|
||||
loadingLabel: 'Routing…',
|
||||
loadingLabel: t('Routing…'),
|
||||
source,
|
||||
};
|
||||
}
|
||||
@@ -355,7 +370,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 +379,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: formatRouteDistance(route.distanceM),
|
||||
duration: formatRouteDuration(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 +400,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,
|
||||
@@ -405,11 +430,18 @@ export function directionsStepCopy(steps, index) {
|
||||
const leg = [];
|
||||
if (step.distanceM > 0) leg.push(formatRouteDistance(step.distanceM));
|
||||
if (step.durationS > 0) leg.push(formatRouteDuration(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}`);
|
||||
if (next)
|
||||
details.push(t('Then: {instruction}', { instruction: next.instruction }));
|
||||
return { title: step.instruction, details };
|
||||
}
|
||||
|
||||
@@ -840,10 +872,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;
|
||||
@@ -860,10 +892,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;
|
||||
@@ -1058,7 +1090,7 @@ export function createDirectionsLayer({ services, source }) {
|
||||
[
|
||||
{
|
||||
type: 'route',
|
||||
label: 'Directions',
|
||||
label: t('Directions'),
|
||||
path: _route.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.',
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
validateBox,
|
||||
viewTooLargeMessage,
|
||||
} from './model.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Quiet time after the last arrow key before the focused day previews, so a
|
||||
@@ -748,15 +749,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 +774,7 @@ export function createRecentImageryLayer({
|
||||
key: key || null,
|
||||
candidate: candidateFor(key),
|
||||
label: parsed
|
||||
? `${shortDay(parsed.day)} · ${PRODUCTS[parsed.product].name} · ${PRODUCTS[parsed.product].resolutionM} m`
|
||||
? `${shortDay(parsed.day)} · ${t(PRODUCTS[parsed.product].name)} · ${PRODUCTS[parsed.product].resolutionM} m`
|
||||
: null,
|
||||
sourceOff,
|
||||
drapable: drapable(candidateFor(key)),
|
||||
@@ -1329,17 +1336,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 },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,8 +33,8 @@ 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',
|
||||
resolutionM: 30,
|
||||
@@ -41,8 +45,8 @@ 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',
|
||||
resolutionM: 30,
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
+112
-45
@@ -7,6 +7,7 @@ import {
|
||||
GLOBAL_INFRARED_MAX_GAP_MS,
|
||||
LIGHTNING_MAX_GAP_MS,
|
||||
} from './clock.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
const STOPS = [
|
||||
[10, '#00ecec'],
|
||||
@@ -29,7 +30,7 @@ const LIGHTNING_STOPS = [
|
||||
['300+', '#00FF00'],
|
||||
];
|
||||
const utc = (value) =>
|
||||
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
|
||||
value ? `${value.slice(5, 16).replace('T', ' ')} 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. */
|
||||
@@ -487,15 +488,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,8 +517,11 @@ 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();
|
||||
const b = manifest?.bounds;
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
@@ -535,49 +547,95 @@ 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 ||
|
||||
(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 +643,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 +691,45 @@ 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: label,
|
||||
})
|
||||
: t('{value} dBZ radar reflectivity', { value: 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') : ''}`,
|
||||
: `${infoHeading}\n${infoTime}${missing ? `\n${missing}` : ''}${manifest?.stale ? `\n${t('STALE · cached source metadata')}` : ''}${error || diagnostic?.error ? '\n' + (error || diagnostic.error) : ''}\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 +744,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,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,
|
||||
|
||||
+48
-37
@@ -11,6 +11,7 @@ import {
|
||||
createWindInspectionMarker,
|
||||
WIND_UNITS,
|
||||
} from './inspection.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/** Format a forecast timestamp explicitly in UTC. */
|
||||
export function formatWindValidTime(value) {
|
||||
@@ -242,7 +243,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);
|
||||
@@ -261,10 +262,10 @@ export function createWindLayer({
|
||||
const imageryError =
|
||||
enabled && !loading ? 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 +291,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'
|
||||
? t('Preparing flow')
|
||||
: 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 ||
|
||||
@@ -356,15 +361,16 @@ export function createWindLayer({
|
||||
label: 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 +379,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: 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' + 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 +396,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 +436,7 @@ export function createWindLayer({
|
||||
: null;
|
||||
return {
|
||||
...windStats(manifest),
|
||||
countLabel: 'Forecast',
|
||||
countLabel: t('Forecast'),
|
||||
loading: loading || Boolean(preparing),
|
||||
model: model.toUpperCase(),
|
||||
overlay,
|
||||
@@ -433,7 +444,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,8 +60,8 @@ 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,
|
||||
@@ -87,11 +91,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 +106,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,5 @@
|
||||
import { formatWindSpeed, WIND_UNITS } from './inspection.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/** Reformat a captured sample; changing units never samples the map again. */
|
||||
export function formatWindReading(reading, units) {
|
||||
@@ -22,33 +23,52 @@ export function windUnitChips(units) {
|
||||
label: value,
|
||||
active: units === value,
|
||||
params: { units: value },
|
||||
title: 'Wind speed units',
|
||||
title: t('Wind speed units'),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 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 = formatWindSpeed(reading.speed, reading.units);
|
||||
return reading.from === 'Calm'
|
||||
? `${speed}${t(' · calm')}`
|
||||
: `${speed}${t(' from {direction}', { direction: t(reading.from) })}`;
|
||||
}
|
||||
|
||||
/** Portable result block consumed by the WEATHER card. */
|
||||
export function windReadingResult(reading) {
|
||||
const coordinates =
|
||||
reading.coordinates === 'No surface reading'
|
||||
? t('No surface reading')
|
||||
: reading.coordinates.replace(' · ', ' ');
|
||||
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: reading.validTime?.replace(/^\d{4}-/, ''),
|
||||
}),
|
||||
},
|
||||
...(reading.scalarValue
|
||||
? [
|
||||
{
|
||||
id: 'scalar',
|
||||
text: `${reading.scalarLabel} · ${reading.scalarValue}`,
|
||||
text: `${t(reading.scalarLabel)} · ${reading.scalarValue}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ id: 'explanation', text: reading.explanation },
|
||||
{ id: 'explanation', text: t(reading.explanation) },
|
||||
],
|
||||
clear: { params: { inspect: false } },
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user