mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(i18n): fix Russian layouts and the gaps the screenshot review found
A review of the Russian UI screenshots found ~90 places where longer words
broke the layout or text was still English. Fixed so they hold for any
language, then shortened where a control is short by design.
- Layout: label columns that size to their text (weather and wind settings,
Recent Imagery slots and opacity, style sliders, display panel), layer
list names and icons, buttons that wrap instead of overflowing (CCTV,
scenes, Bhote Koshi), voice header that always shows the cost, styled
EN | ES | RU chips, voice error tray above the dock.
- t() context: t('CLEAR', { context: 'action' }) is «СБРОСИТЬ» while the sky
stays «ЯСНО»; also used for radio categories, voice states and directions.
- Units and dates follow the locale through src/ui/unitLabels.js
(formatQuantity: localized number, translated unit, non-breaking space):
wind, pressure, cyclones, directions, CCTV, SDR gain, scene timing, flight
tooltip, Recent Imagery dates and resolution.
- Newly translated: directions turn-by-turn steps (built from the maneuver;
English matches the server word for word), HUD mode and summary, voice
card states, radio categories on the globe, CCTV status, layer source
lines, scene dialogs (Cancel, file pickers), Bhote Koshi statuses, titles,
comparison dates and caveat, Recent Imagery notices. Stored data and text
for AI tools stay English.
- es and ru: 1,885 messages each.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3129e531e6
commit
2b6ccad732
71 files changed
+2435
-370
No files matched your search
@@ -15,9 +15,13 @@ import {
|
||||
cycloneStormIdFromEntryId,
|
||||
} from './labels.js';
|
||||
import { t, tn, msg } from '../../i18n/index.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
|
||||
// "10-12 15:00 UTC" never breaks across lines.
|
||||
const utc = (value) =>
|
||||
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : t('Unavailable');
|
||||
value
|
||||
? `${value.slice(5, 16).replace('T', '\u00a0')}\u00a0UTC`
|
||||
: t('Unavailable');
|
||||
const COVERAGE = msg(
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
|
||||
);
|
||||
@@ -34,7 +38,7 @@ const classificationName = (code) =>
|
||||
? t(CLASSIFICATION_NAMES[code])
|
||||
: code;
|
||||
const number = (value, unit) =>
|
||||
value === null ? t('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({
|
||||
@@ -411,7 +415,7 @@ export function createCyclonesLayer({
|
||||
id: item.id,
|
||||
ordinal: index + 1,
|
||||
lead: item.basin,
|
||||
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? t('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 },
|
||||
})),
|
||||
|
||||
@@ -565,7 +565,8 @@ 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/);
|
||||
assert.match(controls.info, /09-16\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);
|
||||
|
||||
+227
-14
@@ -17,11 +17,9 @@
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
formatRouteDistance,
|
||||
formatRouteDuration,
|
||||
} from '../../data/routeSteps.js';
|
||||
import { t, msg } from '../../i18n/index.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({
|
||||
@@ -69,6 +67,219 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** "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)
|
||||
return road
|
||||
? t('Keep {direction} at the fork onto {road}', values)
|
||||
: t('Keep {direction} at the fork', values);
|
||||
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)
|
||||
return road
|
||||
? t('Use the {direction} lane onto {road}', values)
|
||||
: t('Use the {direction} lane', values);
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -162,7 +373,7 @@ export function directionsRowControls(state) {
|
||||
});
|
||||
chips.push({
|
||||
id: 'clear',
|
||||
label: t('CLEAR'),
|
||||
label: t('CLEAR', { context: 'action' }),
|
||||
disabled: !a && !b && !route,
|
||||
state: 'idle',
|
||||
title: t('Remove the route and both markers'),
|
||||
@@ -285,8 +496,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 },
|
||||
@@ -382,8 +593,8 @@ export function directionsStats(state) {
|
||||
const spec = DIRECTIONS_MODES[mode];
|
||||
const word = spec ? t(spec.word) : mode;
|
||||
const values = {
|
||||
distance: formatRouteDistance(route.distanceM),
|
||||
duration: formatRouteDuration(route.durationS),
|
||||
distance: routeDistanceText(route.distanceM),
|
||||
duration: routeDurationText(route.durationS),
|
||||
mode: word,
|
||||
count: route.steps.length,
|
||||
};
|
||||
@@ -428,8 +639,8 @@ 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(
|
||||
leg.length
|
||||
@@ -441,8 +652,10 @@ export function directionsStepCopy(steps, index) {
|
||||
);
|
||||
const next = steps[index + 1];
|
||||
if (next)
|
||||
details.push(t('Then: {instruction}', { instruction: next.instruction }));
|
||||
return { title: step.instruction, details };
|
||||
details.push(
|
||||
t('Then: {instruction}', { instruction: directionsInstruction(next) }),
|
||||
);
|
||||
return { title: directionsInstruction(step), details };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -127,17 +127,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,41 @@
|
||||
/**
|
||||
* 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'),
|
||||
sensor: (sensor) => t(sensor),
|
||||
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,12 +28,12 @@ import {
|
||||
parseCandidateKey,
|
||||
quantizeBox,
|
||||
rankLatest,
|
||||
shortDay,
|
||||
utcDay,
|
||||
validateBox,
|
||||
viewTooLargeMessage,
|
||||
} from './model.js';
|
||||
import { t } from '../../i18n/index.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
|
||||
@@ -48,9 +48,10 @@ export const SPLIT_PUBLISH_MS = 400;
|
||||
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';
|
||||
// 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;
|
||||
@@ -774,7 +775,7 @@ export function createRecentImageryLayer({
|
||||
key: key || null,
|
||||
candidate: candidateFor(key),
|
||||
label: parsed
|
||||
? `${shortDay(parsed.day)} · ${t(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)),
|
||||
@@ -1234,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();
|
||||
@@ -1283,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -36,7 +36,7 @@ export const PRODUCTS = Object.freeze({
|
||||
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,
|
||||
@@ -48,7 +48,7 @@ export const PRODUCTS = Object.freeze({
|
||||
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,
|
||||
@@ -266,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 || ''));
|
||||
@@ -483,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) {
|
||||
@@ -501,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;
|
||||
@@ -522,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(' · ');
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
LIGHTNING_MAX_GAP_MS,
|
||||
} from './clock.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { formatScaleLabel } from '../../ui/unitLabels.js';
|
||||
|
||||
const STOPS = [
|
||||
[10, '#00ecec'],
|
||||
@@ -692,9 +693,11 @@ export function createWeatherLayer({
|
||||
color,
|
||||
blurb: lightning
|
||||
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
|
||||
value: label,
|
||||
value: formatScaleLabel(label),
|
||||
})
|
||||
: t('{value} dBZ radar reflectivity', { value: label }),
|
||||
: t('{value} dBZ radar reflectivity', {
|
||||
value: formatScaleLabel(label),
|
||||
}),
|
||||
}))
|
||||
: [],
|
||||
info: hostHidden
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
WIND_UNITS,
|
||||
} from './inspection.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { unitLabel } from '../../ui/unitLabels.js';
|
||||
|
||||
/** Format a forecast timestamp explicitly in UTC. */
|
||||
export function formatWindValidTime(value) {
|
||||
@@ -358,7 +359,7 @@ export function createWindLayer({
|
||||
: []
|
||||
).map((value) => ({
|
||||
id: `units-${value}`,
|
||||
label: value,
|
||||
label: unitLabel(value),
|
||||
active: units === value,
|
||||
params: { units: value },
|
||||
title: t('Wind speed units'),
|
||||
@@ -382,7 +383,7 @@ export function createWindLayer({
|
||||
info: `${t('{model} forecast · {field} ({unit})', {
|
||||
model: model === 'ifs' ? 'ECMWF IFS' : 'GFS',
|
||||
field: label,
|
||||
unit: legendUnit,
|
||||
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' + 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.',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { formatWindSpeed, WIND_UNITS } from './inspection.js';
|
||||
import { 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) {
|
||||
@@ -20,20 +21,42 @@ 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: 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 = formatWindSpeed(reading.speed, reading.units);
|
||||
const speed = displaySpeed(reading.speed, reading.units);
|
||||
return reading.from === 'Calm'
|
||||
? `${speed}${t(' · calm')}`
|
||||
: `${speed}${t(' from {direction}', { direction: t(reading.from) })}`;
|
||||
@@ -64,7 +87,7 @@ export function windReadingResult(reading) {
|
||||
? [
|
||||
{
|
||||
id: 'scalar',
|
||||
text: `${t(reading.scalarLabel)} · ${reading.scalarValue}`,
|
||||
text: `${t(reading.scalarLabel)} · ${displayScalar(reading)}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user