Merge branch 'main' into fix/tracked-info-vertical-rate

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-17 00:06:57 +01:00
commit 694c51d0d1
476 files changed
+34589 -4748

No files matched your search

+13 -11
View File
@@ -7,6 +7,8 @@
* integer category (states index 17) and ADS-B emitter strings ("A1".."B1").
*/
import { msg } from '../i18n/marker.js';
const HELI = new Set([
'EC20',
'EC25',
@@ -438,18 +440,18 @@ export function classifyAircraft({ typeCode, category } = {}) {
return 'airliner';
}
/** Readable name of each class, for cards and readouts. */
/** Readable name of each class, for cards and readouts (English data). */
export const AIRCRAFT_CLASS_LABELS = Object.freeze({
light: 'Light aircraft',
glider: 'Glider',
turboprop: 'Turboprop',
airliner: 'Airliner',
widebody: 'Widebody',
quadjet: 'Four-engine jet',
helicopter: 'Helicopter',
fastjet: 'Fast jet',
bizjet: 'Business jet',
uav: 'Drone',
light: msg('Light aircraft'),
glider: msg('Glider'),
turboprop: msg('Turboprop'),
airliner: msg('Airliner'),
widebody: msg('Widebody'),
quadjet: msg('Four-engine jet'),
helicopter: msg('Helicopter'),
fastjet: msg('Fast jet'),
bizjet: msg('Business jet'),
uav: msg('Drone'),
});
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
+11 -6
View File
@@ -13,18 +13,23 @@
* broadcast, which does not confirm an incident or its resolution.
*/
import { msg } from '../i18n/marker.js';
/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */
export const EMERGENCY_TINT_CSS = '#FF4444';
const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' };
const KINDS = {
general: { label: 'General emergency', severity: 'emergency' },
unlawful: { label: 'Unlawful interference', severity: 'emergency' },
nordo: { label: 'Radio failure', severity: 'emergency' },
downed: { label: 'Downed aircraft', severity: 'emergency' },
minfuel: { label: 'Minimum fuel', severity: 'priority' },
lifeguard: { label: 'Lifeguard (medical priority)', severity: 'priority' },
general: { label: msg('General emergency'), severity: 'emergency' },
unlawful: { label: msg('Unlawful interference'), severity: 'emergency' },
nordo: { label: msg('Radio failure'), severity: 'emergency' },
downed: { label: msg('Downed aircraft'), severity: 'emergency' },
minfuel: { label: msg('Minimum fuel'), severity: 'priority' },
lifeguard: {
label: msg('Lifeguard (medical priority)'),
severity: 'priority',
},
};
/**
+4
View File
@@ -249,6 +249,7 @@ export function createAisWatchdog(options = {}) {
if (!env.hasKey) {
const actions = terminateOwned('key-absent');
status = 'missing-key';
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
error = 'AISSTREAM_API_KEY is not set';
reconnectAttempt = 0;
keyFingerprint = null;
@@ -257,6 +258,7 @@ export function createAisWatchdog(options = {}) {
if (env.hasTransport === false) {
const actions = terminateOwned('no-transport');
status = 'unsupported';
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
error = 'Node WebSocket transport is unavailable';
return actions;
}
@@ -329,6 +331,7 @@ export function createAisWatchdog(options = {}) {
if (!silenceWatchArmed) return [];
const silentFor = monoNow - silenceSinceMono;
if (silentFor >= recycleAfterMs) {
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
error = `AISStream delivered no data for ${Math.round(silentFor / 1000)}s`;
const actions = terminateOwned('silent');
scheduleRetry('transport');
@@ -401,6 +404,7 @@ export function createAisWatchdog(options = {}) {
function onClose(eventGeneration) {
if (!ownsGeneration(eventGeneration)) return [];
release();
// i18n-ignore: server detail; layers/vessels/queries.js shows a translated reason
if (!error) error = 'AISStream websocket closed';
scheduleRetry('transport');
return [];
+12
View File
@@ -0,0 +1,12 @@
/**
* Local equirectangular distance in km, for viewer-proximity gates. Takes the
* short way round, so points either side of 180° stay close.
*/
export function approxDistanceKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
let dLonDeg = lon2 - lon1;
dLonDeg -= 360 * Math.round(dLonDeg / 360);
const dLon =
dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
+112 -24
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t, msg } from '../i18n/index.js';
const YOUTUBE_HOSTS = new Set([
'youtube.com',
@@ -39,6 +40,55 @@ const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
facebook: Object.freeze(['https://www.facebook.com']),
});
// Known English from public/events/bhote-koshi-2026/event.json, translated at
// display (whole or by " · " parts). Anything else shows as authored.
const EVENT_PACK_TEXT = new Set([
// Field reports: our labels (credits after " · " stay) and their statuses.
msg('Rasuwagadhi witness video'),
msg('Bhote Koshi valley footage'),
msg('Flood footage'),
msg('Compilation and casualty claims'),
msg('SOURCE GEOCONFIRMED · WORLD-PINNED'),
msg('SOURCE CREDITED · LOCATION UNRESOLVED'),
msg('REFERENCE ONLY · CLAIMS UNVERIFIED'),
msg('SOURCE GEOCONFIRMED'),
// Reconstruction caveat.
msg(
'2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.',
),
// Evidence titles that are more than a place name.
msg('Immediate collapse viewpoint'),
msg('Collapse viewpoint'),
msg('Debris-dammed lake'),
msg('Dammed lake'),
msg('Second landslide'),
msg('Gyirong border gate'),
msg('Border gate'),
msg('Timure evidence cluster'),
msg('Mailung / Upper Trishuli bridge'),
msg('Dandagaun viewpoint restaurant'),
msg('Dandagaun viewpoint'),
msg('Bidur - Trishuli bridge'),
msg('Bidur bridge'),
msg('Devighat / Taadi Khola Bridge'),
msg('Devighat bridge'),
]);
/**
* Display text for an authored event-pack string.
* @param {unknown} value
* @returns {string}
*/
export function bhoteEventText(value) {
const text = value == null ? '' : String(value);
if (EVENT_PACK_TEXT.has(text)) return t(text);
if (!text.includes(' · ')) return text;
return text
.split(' · ')
.map((part) => (EVENT_PACK_TEXT.has(part) ? t(part) : part))
.join(' · ');
}
let _xWidgetsPromise = null;
let _facebookSdkPromise = null;
let _facebookEmbedSequence = 0;
@@ -240,7 +290,7 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
prior?.();
} finally {
if (facebookSdkReady(globalRef.FB)) finish();
else finish(new Error('Facebook player API did not initialize.'));
else finish(new Error(msg('Facebook player API did not initialize.')));
}
};
globalRef.fbAsyncInit = ready;
@@ -256,12 +306,12 @@ function loadFacebookSdk(documentRef, globalRef = globalThis) {
}
script.addEventListener?.(
'error',
() => finish(new Error('Facebook player API could not load.')),
() => finish(new Error(msg('Facebook player API could not load.'))),
{ once: true },
);
timer =
globalRef.setTimeout?.(
() => finish(new Error('Facebook player API timed out.')),
() => finish(new Error(msg('Facebook player API timed out.'))),
EMBED_TIMEOUT_MS,
) ?? null;
}).catch((error) => {
@@ -398,7 +448,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
};
const ready = () => {
if (xWidgetsReady(globalRef.twttr)) finish();
else finish(new Error('X embed API did not initialize.'));
else finish(new Error(msg('X embed API did not initialize.')));
};
let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
if (!script) {
@@ -411,7 +461,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
}
script.addEventListener?.(
'error',
() => finish(new Error('X embed could not load.')),
() => finish(new Error(msg('X embed could not load.'))),
{
once: true,
},
@@ -440,7 +490,7 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
},
);
timer = setTimeout(
() => finish(new Error('X embed timed out.')),
() => finish(new Error(msg('X embed timed out.'))),
EMBED_TIMEOUT_MS,
);
}).catch((error) => {
@@ -450,9 +500,17 @@ function loadXWidgets(documentRef, globalRef = globalThis) {
return _xWidgetsPromise;
}
// Outcomes a trimmed YouTube clip reports to the card status.
const YOUTUBE_PHASE_LABELS = Object.freeze({
unavailable: msg('UNAVAILABLE'),
timeout: msg('TIMEOUT'),
blocked: msg('BLOCKED'),
});
function providerLabel(provider) {
return (
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] || 'SOURCE'
{ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' }[provider] ||
t('SOURCE')
);
}
@@ -737,7 +795,9 @@ export function createBhoteKoshiEmbeddedMedia({
globalRef.setTimeout?.(() => {
if (!session.active || session.ready) return;
session.error = new Error(
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
msg(
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
),
);
session.onError?.(session.error);
}, EMBED_TIMEOUT_MS) ?? null;
@@ -857,7 +917,9 @@ export function createBhoteKoshiEmbeddedMedia({
startAtSec,
endAtSec,
});
iframe.title = `Preloading ${providerLabel(source.provider)} media`;
iframe.title = t('Preloading {provider} media', {
provider: providerLabel(source.provider),
});
iframe.tabIndex = -1;
iframe.setAttribute('aria-hidden', 'true');
iframe.loading = 'eager';
@@ -868,7 +930,7 @@ export function createBhoteKoshiEmbeddedMedia({
}
async function mountX(current, token) {
current.status.textContent = 'LOADING X POST…';
current.status.textContent = t('LOADING X POST…');
try {
const api = await xLoader(documentRef, globalRef);
if (token !== generation || record !== current) return;
@@ -885,13 +947,17 @@ export function createBhoteKoshiEmbeddedMedia({
);
if (token !== generation || record !== current) return;
if (!widget)
throw new Error('The X post is unavailable or cannot be embedded.');
current.status.textContent = 'X POST · PROVIDER CONTROLS';
throw new Error(
msg('The X post is unavailable or cannot be embedded.'),
);
current.status.textContent = t('X POST · PROVIDER CONTROLS');
position();
} catch (error) {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = `${String(error?.message || 'X embed unavailable')} OPEN ORIGINAL.`;
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('X embed unavailable'))),
});
position();
}
}
@@ -904,19 +970,23 @@ export function createBhoteKoshiEmbeddedMedia({
session.shouldPlay = autoplay === true;
session.onReady = () => {
if (token !== generation || record !== current) return;
current.status.textContent = 'FACEBOOK · PROVIDER CONTROLS';
current.status.textContent = t('FACEBOOK · PROVIDER CONTROLS');
current.card.classList.remove('is-unavailable');
position();
};
session.onError = (error) => {
if (token !== generation || record !== current) return;
current.card.classList.add('is-unavailable');
current.status.textContent = `${String(error?.message || 'Facebook embed unavailable')} OPEN ORIGINAL.`;
current.status.textContent = t('{reason} OPEN ORIGINAL.', {
reason: t(String(error?.message || msg('Facebook embed unavailable'))),
});
position();
};
session.onPlaybackBlocked = () => {
if (token !== generation || record !== current) return;
current.status.textContent = 'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS';
current.status.textContent = t(
'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS',
);
};
if (session.ready) session.onReady();
else if (session.error) session.onError(session.error);
@@ -968,7 +1038,12 @@ export function createBhoteKoshiEmbeddedMedia({
card.dataset.provider = source.provider;
card.setAttribute(
'aria-label',
`Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`,
t('Embedded {provider} media for {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
}),
);
const leader = documentRef.createElementNS(
'http://www.w3.org/2000/svg',
@@ -982,17 +1057,19 @@ export function createBhoteKoshiEmbeddedMedia({
player.className = 'bhote-embedded-callout-player';
const footer = documentRef.createElement('footer');
const title = documentRef.createElement('strong');
title.textContent = String(
observation?.shortTitle || observation?.title || 'Witness source',
title.textContent = bhoteEventText(
observation?.shortTitle || observation?.title || t('Witness source'),
).toUpperCase();
const status = documentRef.createElement('small');
status.setAttribute('role', 'status');
status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`;
status.textContent = t('{provider} · EMBEDDED SOURCE', {
provider: providerLabel(source.provider),
});
const link = documentRef.createElement('a');
link.href = source.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = 'OPEN ORIGINAL ↗';
link.textContent = t('OPEN ORIGINAL ↗');
footer.append(title, status, link);
const body = documentRef.createElement('div');
body.className = 'bhote-embedded-callout-body';
@@ -1032,7 +1109,12 @@ export function createBhoteKoshiEmbeddedMedia({
url.searchParams.set('origin', globalRef.location.origin);
iframe.src = url.href;
}
iframe.title = `${providerLabel(source.provider)} media: ${observation?.title || 'witness source'}`;
iframe.title = t('{provider} media: {source}', {
provider: providerLabel(source.provider),
source: observation?.title
? bhoteEventText(observation.title)
: t('witness source'),
});
iframe.allow =
'autoplay; encrypted-media; picture-in-picture; fullscreen';
iframe.allowFullscreen = true;
@@ -1049,7 +1131,9 @@ export function createBhoteKoshiEmbeddedMedia({
record.youtubeSession.getState().phase,
)
) {
status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
status.textContent = t('{provider} · PROVIDER CONTROLS', {
provider: providerLabel(source.provider),
});
}
position();
},
@@ -1067,7 +1151,11 @@ export function createBhoteKoshiEmbeddedMedia({
isCurrent: () => token === generation && record === current,
onStatus: (phase) => {
if (phase !== 'completed')
status.textContent = `YOUTUBE ${phase.toUpperCase()} · OPEN ORIGINAL`;
status.textContent = t('YOUTUBE {state} · OPEN ORIGINAL', {
state: YOUTUBE_PHASE_LABELS[phase]
? t(YOUTUBE_PHASE_LABELS[phase])
: phase.toUpperCase(),
});
},
});
}
+262 -73
View File
@@ -19,11 +19,13 @@ import {
SOURCE_PATH_BEAT_IDS,
} from './bhoteKoshiShotPaths.js';
import {
bhoteEventText,
createBhoteKoshiEmbeddedMedia,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
import { acquireImageryComparison } from '../maps/imageryComparison.js';
import { createImagerySplit } from '../ui/imagerySplit.js';
import { formatDate, t, tn, msg } from '../i18n/index.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -384,13 +386,90 @@ export function adjacentEvidenceIndex(progress, timeline, direction) {
return Math.max(0, Math.min(beats.length - 1, current + delta));
}
// Evidence phases from the event pack, shown as the beat meta prefix.
const EVIDENCE_PHASE_LABELS = Object.freeze({
initiation: msg('INITIATION'),
damming: msg('DAMMING'),
'secondary failure': msg('SECONDARY FAILURE'),
'border arrival': msg('BORDER ARRIVAL'),
propagation: msg('PROPAGATION'),
'downstream passage': msg('DOWNSTREAM PASSAGE'),
consequence: msg('CONSEQUENCE'),
'downstream consequence': msg('DOWNSTREAM CONSEQUENCE'),
});
/** Display label for an evidence phase; unknown phases show as authored. */
function evidencePhaseLabel(phase) {
const label = EVIDENCE_PHASE_LABELS[String(phase || '').toLowerCase()];
if (label) return t(label);
return phase ? String(phase).toUpperCase() : t('EVIDENCE');
}
// Imagery endpoint names from the event pack ("2021 HISTORICAL REFERENCE ·
// 16 OCT 2021"); unknown names and unparseable dates show as authored.
const IMAGERY_LABEL_NAMES = Object.freeze([
[/^(\d{4}) HISTORICAL REFERENCE$/, msg('{year} HISTORICAL REFERENCE')],
[/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')],
]);
/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */
function imageryDateText(observedAt) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || ''));
if (!match) return '';
const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3]));
if (Number.isNaN(date.getTime())) return '';
const part = (options) => formatDate(date, { timeZone: 'UTC', ...options });
return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase();
}
/**
* Localized name and date of an imagery endpoint for display.
* @param {{label?: string, observedAt?: string}} entry
* @returns {{name: string, date: string}}
*/
export function imageryEndpointLabel(entry) {
const label = String(entry?.label || '');
const split = label.lastIndexOf(' · ');
const authoredName = split >= 0 ? label.slice(0, split) : label;
const authoredDate = split >= 0 ? label.slice(split + 3) : '';
let name = authoredName;
for (const [pattern, message] of IMAGERY_LABEL_NAMES) {
const match = pattern.exec(authoredName);
if (match) name = t(message, { year: match[1] });
}
return { name, date: imageryDateText(entry?.observedAt) || authoredDate };
}
function imageryEndpointText(entry) {
const { name, date } = imageryEndpointLabel(entry);
return [name, date].filter(Boolean).join(' · ');
}
/** Name, then the date kept on one line so the year never wraps alone. */
function renderImageryEndpoint(element, entry) {
if (!element) return;
const { name, date } = imageryEndpointLabel(entry);
element.textContent = date ? `${name} · ` : name;
if (!date) return;
const dateElement = document.createElement('span');
dateElement.className = 'bhote-event-date';
dateElement.textContent = date;
element.appendChild(dateElement);
}
/** Compact, source-bounded disclosure for multi-source geolocation beats. */
export function evidenceCorroborationLabel(observation) {
const count = Math.max(
0,
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
);
return count > 1 ? `${count} GEOLOCATED SOURCE-MAP PLACEMENTS` : '';
return count > 1
? tn(
count,
'{count} GEOLOCATED SOURCE-MAP PLACEMENT',
'{count} GEOLOCATED SOURCE-MAP PLACEMENTS',
)
: '';
}
/** Interpolate headings across north using the shortest angular path. */
@@ -641,7 +720,7 @@ function drawEvidenceFallback(ctx, observation, width, height) {
}
ctx.fillStyle = WORLD_OVERLAY_STYLE.accent;
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontLabel, 1.4);
ctx.fillText('NO PREVIEW', 20, 30);
ctx.fillText(t('NO PREVIEW'), 20, 30);
}
function ellipsizeEvidenceText(value, limit) {
@@ -650,6 +729,19 @@ function ellipsizeEvidenceText(value, limit) {
return `${text.slice(0, Math.max(1, limit - 3)).trimEnd()}...`;
}
// The pack names the kind of source in `media.publisher` (event.json); the
// frame shows it in the UI language. Any other publisher shows as given.
const EVIDENCE_PUBLISHER_TEXT = new Set([
msg('YouTube source'),
msg('Facebook source'),
]);
/** An evidence item's publisher as its frame shows it. */
function evidencePublisherText(publisher) {
const text = String(publisher);
return EVIDENCE_PUBLISHER_TEXT.has(text) ? t(text) : bhoteEventText(text);
}
/** Repaint one stable frame with a poster or the current source-video frame. */
export function updateEvidenceFrame(
canvas,
@@ -704,25 +796,78 @@ export function updateEvidenceFrame(
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontTitle);
ctx.fillText(
ellipsizeEvidenceText(
observation.shortTitle || observation.title,
bhoteEventText(observation.shortTitle || observation.title),
layout.titleChars,
).toUpperCase(),
16,
infoY + 27,
);
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
ctx.font = scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail);
const publisher = String(
observation.media?.publisher || 'SOURCE LINK',
const publisher = (
observation.media?.publisher
? evidencePublisherText(observation.media.publisher)
: t('SOURCE LINK')
).toUpperCase();
ctx.fillText(
`${ellipsizeEvidenceText(publisher, 24)} / OPEN`,
fitEvidenceSourceLine(
ctx,
publisher,
scaledOverlayFont(WORLD_OVERLAY_STYLE.fontDetail),
canvas.width - 32,
),
16,
infoY + 53,
);
return true;
}
// The smallest the source line's font may shrink to before the publisher is
// shortened instead.
const EVIDENCE_SOURCE_MIN_FONT_SCALE = 0.8;
/**
* Set the font for the «{publisher} / OPEN» line and return a line that fits
* `maxWidth`. Translations run longer: first shrink the font (to 80%), then
* shorten the publisher; «/ OPEN» always stays whole.
* @param {CanvasRenderingContext2D} ctx
* @param {string} publisher Display publisher, already upper-cased.
* @param {string} font CSS font with a px size.
* @param {number} maxWidth
* @returns {string}
*/
function fitEvidenceSourceLine(ctx, publisher, font, maxWidth) {
const line = (limit) =>
t('{publisher} / OPEN', {
publisher: ellipsizeEvidenceText(publisher, limit),
});
let limit = 24;
let text = line(limit);
ctx.font = font;
if (typeof ctx.measureText !== 'function' || !(maxWidth > 0)) return text;
const width = () => Number(ctx.measureText(text)?.width) || 0;
if (width() <= maxWidth) return text;
const size = /(\d+(?:\.\d+)?)px/.exec(font);
if (size) {
const base = Number(size[1]);
const floor = base * EVIDENCE_SOURCE_MIN_FONT_SCALE;
// Width scales with the font size; step down until it fits or bottoms out.
let next = Math.max(
floor,
Math.floor(((base * maxWidth) / width()) * 10) / 10,
);
for (;;) {
ctx.font = font.replace(size[0], `${next}px`);
if (width() <= maxWidth || next <= floor) break;
next = Math.max(floor, next - 0.5);
}
}
while (width() > maxWidth && limit > 4) {
limit = Math.min(limit, publisher.length) - 1;
text = line(limit);
}
return text;
}
/** Compose source imagery and caveats into one stable renderer-owned frame. */
export function createEvidenceFrame(observation, poster = null, options = {}) {
const canvas = evidenceFrameCanvas(observation);
@@ -813,28 +958,40 @@ function collectPanelReferences(panel) {
});
}
/** Escape translated text for the panel markup template. */
function escapeMarkup(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function createPanel(event, handlers) {
const panel = document.createElement('aside');
panel.id = 'bhote-koshi-event-panel';
panel.setAttribute('aria-label', 'Bhote Koshi flood reconstruction controls');
panel.setAttribute(
'aria-label',
t('Bhote Koshi flood reconstruction controls'),
);
panel.innerHTML = `
<div class="bhote-event-header">
<div>
<div class="bhote-event-kicker">EVENT RECONSTRUCTION · 26 AUG 2026</div>
<div class="bhote-event-title">BHOTE KOSHI OUTBURST FLOOD</div>
<div class="bhote-event-kicker">${escapeMarkup(t('EVENT RECONSTRUCTION · 26 AUG 2026'))}</div>
<div class="bhote-event-title">${escapeMarkup(t('BHOTE KOSHI OUTBURST FLOOD'))}</div>
</div>
<button class="bhote-event-icon-btn" type="button" data-action="close" title="Close event layer" aria-label="Close event layer">×</button>
<button class="bhote-event-icon-btn" type="button" data-action="close" title="${escapeMarkup(t('Close event layer'))}" aria-label="${escapeMarkup(t('Close event layer'))}">×</button>
</div>
<div class="bhote-event-status-row">
<span class="bhote-event-status observed">OBSERVED IMAGERY</span>
<span class="bhote-event-status reconstructed">SCHEMATIC CORRIDOR</span>
<span class="bhote-event-status observed">${escapeMarkup(t('OBSERVED IMAGERY'))}</span>
<span class="bhote-event-status reconstructed">${escapeMarkup(t('SCHEMATIC CORRIDOR'))}</span>
</div>
<section class="bhote-event-section" data-role="imagery-comparison">
<div class="bhote-event-section-head">
<span>2021 HISTORICAL REFERENCE / 2026 POST-EVENT</span>
<span>${escapeMarkup(t('2021 HISTORICAL REFERENCE / 2026 POST-EVENT'))}</span>
<span data-role="split-readout">50 / 50</span>
</div>
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="Historical reference and post-event image split" />
<input class="bhote-event-range" data-role="split" type="range" min="0" max="100" value="50" aria-label="${escapeMarkup(t('Historical reference and post-event image split'))}" />
<div class="bhote-event-dates">
<span data-role="before-date"></span>
<span data-role="after-date"></span>
@@ -842,44 +999,44 @@ function createPanel(event, handlers) {
</section>
<section class="bhote-event-section">
<div class="bhote-event-section-head">
<span data-role="timeline-label">RECONSTRUCTION CLOCK</span>
<span data-role="timeline-label">${escapeMarkup(t('RECONSTRUCTION CLOCK'))}</span>
<span data-role="time"></span>
</div>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="Schematic downstream progression" />
<div class="bhote-event-story-nav" aria-label="Story beat navigation">
<button type="button" data-action="previous-beat" title="Previous story beat" aria-label="Previous story beat">‹</button>
<input class="bhote-event-range flood" data-role="progress" type="range" min="0" max="1000" value="0" aria-label="${escapeMarkup(t('Schematic downstream progression'))}" />
<div class="bhote-event-story-nav" aria-label="${escapeMarkup(t('Story beat navigation'))}">
<button type="button" data-action="previous-beat" title="${escapeMarkup(t('Previous story beat'))}" aria-label="${escapeMarkup(t('Previous story beat'))}">‹</button>
<div class="bhote-event-story-current">
<span data-role="beat-index">01 / 06</span>
<strong data-role="beat-title" aria-live="polite">CAUSE</strong>
<small data-role="beat-meta">CAPTURE TIME UNVERIFIED</small>
<strong data-role="beat-title" aria-live="polite">${escapeMarkup(t('CAUSE'))}</strong>
<small data-role="beat-meta">${escapeMarkup(t('CAPTURE TIME UNVERIFIED'))}</small>
</div>
<button type="button" data-action="next-beat" title="Next story beat" aria-label="Next story beat">›</button>
<button type="button" data-action="next-beat" title="${escapeMarkup(t('Next story beat'))}" aria-label="${escapeMarkup(t('Next story beat'))}">›</button>
</div>
<div class="bhote-event-actions">
<button type="button" data-action="play">▶ PLAY</button>
<button type="button" data-action="play-scene" hidden title="Play the next shot and continue through this scene">▶ PLAY SCENE</button>
<button type="button" data-action="cinematic" aria-pressed="false">◉ CINEMATIC</button>
<button type="button" data-action="story-replay">↺ FULL STORY</button>
<button type="button" data-action="open-source">↗ OPEN ORIGINAL</button>
<button type="button" data-action="corridor">⌖ LOWER GORGE</button>
<button type="button" data-action="play">${escapeMarkup(t('▶ PLAY'))}</button>
<button type="button" data-action="play-scene" hidden title="${escapeMarkup(t('Play the next shot and continue through this scene'))}">${escapeMarkup(t('▶ PLAY SCENE'))}</button>
<button type="button" data-action="cinematic" aria-pressed="false">${escapeMarkup(t('◉ CINEMATIC'))}</button>
<button type="button" data-action="story-replay">${escapeMarkup(t('↺ FULL STORY'))}</button>
<button type="button" data-action="open-source">${escapeMarkup(t('↗ OPEN ORIGINAL'))}</button>
<button type="button" data-action="corridor">${escapeMarkup(t('⌖ LOWER GORGE'))}</button>
</div>
</section>
<details class="bhote-event-evidence">
<summary>FIELD REPORTS · ${event.fieldReports.length}</summary>
<summary>${escapeMarkup(t('FIELD REPORTS · {count}', { count: event.fieldReports.length }))}</summary>
<div class="bhote-event-report-list"></div>
</details>
<div class="bhote-event-credit">
<span>GEOLOCATIONS · GEO GEORGE SHADRACH</span>
<button type="button" data-action="geolocation-map">↗ OPEN PUBLIC MAP</button>
<span>${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))}</span>
<button type="button" data-action="geolocation-map">${escapeMarkup(t('↗ OPEN PUBLIC MAP'))}</button>
</div>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> Clouds are preserved from the source imagery.</span></p>
<p class="bhote-event-caveat"><span data-role="caveat"></span><span data-role="imagery-cloud-note"> ${escapeMarkup(t('Clouds are preserved from the source imagery.'))}</span></p>
`;
const refs = collectPanelReferences(panel);
refs.beforeDate.textContent = event.imagery.before.label;
refs.afterDate.textContent = event.imagery.after.label;
renderImageryEndpoint(refs.beforeDate, event.imagery.before);
renderImageryEndpoint(refs.afterDate, event.imagery.after);
refs.time.textContent = `~0:00 / ${elapsedLabel(1, event.reconstruction.elapsedSeconds)}`;
refs.caveat.textContent = event.reconstruction.caveat;
refs.caveat.textContent = bhoteEventText(event.reconstruction.caveat);
for (const report of event.fieldReports) {
const button = document.createElement('button');
@@ -887,8 +1044,8 @@ function createPanel(event, handlers) {
const status = document.createElement('small');
button.type = 'button';
button.className = 'bhote-event-report';
label.textContent = report.label;
status.textContent = report.status;
label.textContent = bhoteEventText(report.label);
status.textContent = bhoteEventText(report.status);
button.append(label, status);
button.addEventListener('click', () => openExternal(report.url));
refs.reportList.appendChild(button);
@@ -1205,7 +1362,7 @@ export function createBhoteKoshiEventLayer({
_embeddedMedia?.show?.({
observation: {
title: witness.title,
shortTitle: 'Rasuwagadhi witness',
shortTitle: t('Rasuwagadhi witness'),
media: { sourceUrl: witness.sourceUrl },
},
anchor,
@@ -2320,7 +2477,10 @@ export function createBhoteKoshiEventLayer({
collisionGroup: 'ambient-card',
zIndex: 60,
interactive: true,
accessibilityLabel: `Open original source for ${observation.title}; capture time unverified`,
accessibilityLabel: t(
'Open original source for {source}; capture time unverified',
{ source: bhoteEventText(observation.title) },
),
activate: () => openExternal(observation.media.sourceUrl),
minDistance: 0,
maxDistance: 220000,
@@ -2353,7 +2513,7 @@ export function createBhoteKoshiEventLayer({
position: evidencePositionGetter(index, true),
cullPosition: anchor,
variant: 'label',
title: `${String(observation.sequence).padStart(2, '0')} · ${observation.shortTitle || observation.title}`,
title: `${String(observation.sequence).padStart(2, '0')} · ${bhoteEventText(observation.shortTitle || observation.title)}`,
details: [],
accent: WORLD_OVERLAY_STYLE.accent,
priority: 80_000 - index,
@@ -2559,11 +2719,14 @@ export function createBhoteKoshiEventLayer({
id: 'bhote-koshi-split-line',
handleClass: 'bhote-koshi-split-handle',
cssProperty: '--bhote-koshi-split',
beforeTitle: event.imagery.before.label,
afterTitle: event.imagery.after.label,
ariaLabel: 'Historical reference and post-event image divider',
beforeTitle: imageryEndpointText(event.imagery.before),
afterTitle: imageryEndpointText(event.imagery.after),
ariaLabel: t('Historical reference and post-event image divider'),
formatValueText: (beforePercent, afterPercent) =>
`A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
t(
'A historical reference {before} percent, B post-event {after} percent',
{ before: beforePercent, after: afterPercent },
),
onChange: setSplit,
});
}
@@ -2595,7 +2758,7 @@ export function createBhoteKoshiEventLayer({
setElementProperty(
button,
'textContent',
_cinematicActive ? '■ RELEASE CAMERA' : '◉ CINEMATIC',
_cinematicActive ? t('■ RELEASE CAMERA') : t('◉ CINEMATIC'),
);
setElementAttribute(button, 'aria-pressed', String(_cinematicActive));
}
@@ -2961,13 +3124,13 @@ export function createBhoteKoshiEventLayer({
_panelRefs.progressInput,
'aria-label',
sceneDirected
? 'Seek Nepal scene clock'
: 'Schematic downstream progression',
? t('Seek Nepal scene clock')
: t('Schematic downstream progression'),
);
setElementProperty(
_panelRefs.timelineLabel,
'textContent',
sceneDirected ? 'NEPAL SCENE CLOCK' : 'RECONSTRUCTION CLOCK',
sceneDirected ? t('NEPAL SCENE CLOCK') : t('RECONSTRUCTION CLOCK'),
);
const sceneDuration = Number(_sceneContext?.sceneDurationSec) || 0;
setElementProperty(
@@ -2980,22 +3143,22 @@ export function createBhoteKoshiEventLayer({
);
const playLabel = sceneDirected
? _sceneActionPending && _sceneActionPending.type !== 'scene'
? '… PLAYING SHOT'
: '▶ PLAY SHOT'
? t('… PLAYING SHOT')
: t('▶ PLAY SHOT')
: _playing
? 'Ⅱ PAUSE'
? t('Ⅱ PAUSE')
: _progress >= 1
? _cinematicReplayArmed
? '↺ REPLAY CINEMATIC'
: '↺ REPLAY'
: '▶ PLAY';
? t('↺ REPLAY CINEMATIC')
: t('↺ REPLAY')
: t('▶ PLAY');
setElementProperty(_panelRefs.playButton, 'textContent', playLabel);
setElementProperty(
_panelRefs.playSceneButton,
'textContent',
_sceneActionPending?.type === 'scene'
? '… PLAYING SCENE'
: '▶ PLAY SCENE',
? t('… PLAYING SCENE')
: t('▶ PLAY SCENE'),
);
setElementProperty(
_panelRefs.playSceneButton,
@@ -3011,19 +3174,24 @@ export function createBhoteKoshiEventLayer({
setElementAttribute(
_panelRefs.playSceneButton,
'aria-label',
`Continue ${_sceneContext?.sceneTitle || 'current scene'} from next shot`,
t('Continue {scene} from next shot', {
scene:
_sceneContext?.sceneDisplayTitle ||
_sceneContext?.sceneTitle ||
t('current scene'),
}),
);
setElementProperty(
_panelRefs.storyReplayButton,
'textContent',
'↺ FULL STORY',
t('↺ FULL STORY'),
);
if (!sceneDirected) {
setElementProperty(_panelRefs.storyReplayButton, 'disabled', false);
setElementAttribute(
_panelRefs.storyReplayButton,
'aria-label',
'Replay the full reconstruction',
t('Replay the full reconstruction'),
);
}
const beatIndex = currentEvidenceIndex();
@@ -3047,18 +3215,26 @@ export function createBhoteKoshiEventLayer({
_panelRefs.beatTitle,
'textContent',
sceneDirected
? _sceneContext?.shotTitle || 'SCENE SHOT'
: observation?.shortTitle || observation?.title || 'EVIDENCE',
? _sceneContext?.shotDisplayTitle ||
_sceneContext?.shotTitle ||
t('SCENE SHOT')
: observation?.shortTitle || observation?.title
? bhoteEventText(observation.shortTitle || observation.title)
: t('EVIDENCE'),
);
const sourceCount = Math.max(
0,
Math.floor(Number(observation?.corroboration?.sourceCount) || 0),
);
const beatMeta = [
String(observation?.phase || 'EVIDENCE').toUpperCase(),
'TIME UNVERIFIED',
sourceCount > 1 ? `${sourceCount} SOURCES` : '',
observation?.imageryCoverage === 'outside' ? 'OUTSIDE IMAGERY SWIPE' : '',
evidencePhaseLabel(observation?.phase),
t('TIME UNVERIFIED'),
sourceCount > 1
? tn(sourceCount, '{count} SOURCE', '{count} SOURCES')
: '',
observation?.imageryCoverage === 'outside'
? t('OUTSIDE IMAGERY SWIPE')
: '',
]
.filter(Boolean)
.join(' / ');
@@ -3066,7 +3242,13 @@ export function createBhoteKoshiEventLayer({
_panelRefs.beatMeta,
'textContent',
sceneDirected
? `${String(_sceneContext?.sceneTitle || 'NEPAL FLOOD INCIDENT').toUpperCase()} / AUTHORED SHOT`
? t('{scene} / AUTHORED SHOT', {
scene: String(
_sceneContext?.sceneDisplayTitle ||
_sceneContext?.sceneTitle ||
t('NEPAL FLOOD INCIDENT'),
).toUpperCase(),
})
: beatMeta,
);
setElementProperty(
@@ -3093,18 +3275,23 @@ export function createBhoteKoshiEventLayer({
_panelRefs.playButton,
'aria-label',
sceneDirected
? `Play ${_sceneContext?.shotTitle || 'current scene shot'}`
: 'Play reconstruction',
? t('Play {shot}', {
shot:
_sceneContext?.shotDisplayTitle ||
_sceneContext?.shotTitle ||
t('current scene shot'),
})
: t('Play reconstruction'),
);
setElementAttribute(
_panelRefs.previousButton,
'aria-label',
sceneDirected ? 'Load previous scene shot' : 'Previous story beat',
sceneDirected ? t('Load previous scene shot') : t('Previous story beat'),
);
setElementAttribute(
_panelRefs.nextButton,
'aria-label',
sceneDirected ? 'Load next scene shot' : 'Next story beat',
sceneDirected ? t('Load next scene shot') : t('Next story beat'),
);
setElementProperty(
_panelRefs.openSourceButton,
@@ -3153,8 +3340,8 @@ export function createBhoteKoshiEventLayer({
if (_afterLayer) _afterLayer.show = imageryAvailable;
if (_panelRefs?.caveat) {
_panelRefs.caveat.textContent = imageryAvailable
? _event?.reconstruction?.caveat || ''
: 'SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.';
? bhoteEventText(_event?.reconstruction?.caveat || '')
: t('SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.');
}
if (_panelRefs?.imageryCloudNote)
_panelRefs.imageryCloudNote.hidden = !imageryAvailable;
@@ -3717,7 +3904,9 @@ export function createBhoteKoshiEventLayer({
count: _enabled ? 1 : 0,
status: _enabled ? 'nominal' : 'idle',
source: 'Vantor + GeoPera',
coverage: _enabled ? 'Rasuwa · 26 Aug 2026' : 'Event reconstruction',
coverage: _enabled
? t('Rasuwa · 26 Aug 2026')
: t('Event reconstruction'),
};
},
getPlaybackState() {
+120
View File
@@ -0,0 +1,120 @@
// The «{publisher} / OPEN» line is drawn on a canvas, so CSS cannot wrap it:
// a long translation shrinks to fit the portrait card, action word whole.
// English is drawn at full size.
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { setLocale } from '../i18n/index.js';
import { evidenceCardLayout, updateEvidenceFrame } from './bhoteKoshiEvent.js';
const eventUrl = new URL(
'../../public/events/bhote-koshi-2026/event.json',
import.meta.url,
);
// JetBrains Mono advances every glyph by 0.6 em.
const fontPx = (font) => Number(/(\d+(?:\.\d+)?)px/.exec(font)[1]);
const measure = (text, font) => text.length * 0.6 * fontPx(font);
function drawSourceLine(observation, publisher) {
const layout = evidenceCardLayout(observation);
const drawn = [];
const context = {
font: '',
beginPath() {},
clearRect() {},
moveTo() {},
lineTo() {},
stroke() {},
fillRect() {},
drawImage() {},
measureText(text) {
return { width: measure(String(text), this.font) };
},
fillText(value, x) {
drawn.push({ text: String(value), font: this.font, x });
},
};
const canvas = {
width: layout.canvasWidth,
height: layout.canvasHeight,
_bhoteEvidenceLayout: layout,
getContext: () => context,
};
const subject = publisher
? { ...observation, media: { ...observation.media, publisher } }
: observation;
assert.equal(updateEvidenceFrame(canvas, subject), true);
const line = drawn.find((entry) => entry.text.includes(' / '));
assert.ok(line, 'source line drawn');
return { ...line, maxWidth: layout.canvasWidth - 32 };
}
async function portraitObservations() {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const portrait = event.evidenceSpine.filter(
(o) => evidenceCardLayout(o).kind === 'portrait',
);
const youtube = portrait.find((o) => o.media?.publisher === 'YouTube source');
const facebook = portrait.find(
(o) => o.media?.publisher === 'Facebook source',
);
assert.ok(youtube, 'a portrait YouTube card exists');
return [youtube, facebook].filter(Boolean);
}
test('English source lines draw unchanged on every card', async () => {
setLocale('en');
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
for (const observation of event.evidenceSpine) {
const line = drawSourceLine(observation);
assert.equal(fontPx(line.font), 21, `${observation.id}: font kept`);
assert.match(
line.text,
/^(YOUTUBE|FACEBOOK) SOURCE \/ OPEN$|^SOURCE LINK \/ OPEN$/,
);
}
});
test('ru/es source lines fit the portrait card with the action word whole', async () => {
const observations = await portraitObservations();
try {
for (const [locale, action] of [
['ru', '/ ОТКРЫТЬ'],
['es', '/ ABRIR'],
]) {
setLocale(locale);
for (const observation of observations) {
const line = drawSourceLine(observation);
assert.ok(line.text.endsWith(action), `${locale}: ${line.text}`);
assert.ok(
measure(line.text, line.font) <= line.maxWidth,
`${locale}: «${line.text}» at ${line.font} overflows`,
);
assert.ok(fontPx(line.font) >= 21 * 0.8, 'font stays readable');
}
}
setLocale('ru');
const ru = drawSourceLine(observations[0]);
assert.equal(ru.text, 'ИСТОЧНИК YOUTUBE / ОТКРЫТЬ', 'publisher kept whole');
} finally {
setLocale('en');
}
});
test('a publisher too long even at the smallest font is shortened, not the action', async () => {
const [observation] = await portraitObservations();
try {
setLocale('ru');
const line = drawSourceLine(
observation,
'A very long regional broadcaster',
);
assert.ok(line.text.endsWith('... / ОТКРЫТЬ'), line.text);
assert.ok(measure(line.text, line.font) <= line.maxWidth, line.text);
assert.equal(fontPx(line.font), 21 * 0.8);
} finally {
setLocale('en');
}
});
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
// The publisher on the evidence frame ("YouTube source") is pack text shown
// in the UI language; English stays "YOUTUBE SOURCE / OPEN".
import { setLocale } from '../i18n/index.js';
import { createEvidenceFrame } from './bhoteKoshiEvent.js';
const eventUrl = new URL(
'../../public/events/bhote-koshi-2026/event.json',
import.meta.url,
);
function captureFrameText(observation) {
const originalDocument = globalThis.document;
const text = [];
globalThis.document = {
createElement() {
const context = {
beginPath() {},
clearRect() {},
moveTo() {},
lineTo() {},
stroke() {},
fillRect() {},
fillText(value) {
text.push(String(value));
},
drawImage() {},
};
return { width: 0, height: 0, getContext: () => context };
},
};
try {
assert.ok(createEvidenceFrame(observation));
} finally {
globalThis.document = originalDocument;
}
return text;
}
test('evidence card source line is fully in the UI language (es, ru)', async () => {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const youtube = event.evidenceSpine.find(
(o) => o.media?.publisher === 'YouTube source',
);
const facebook = event.evidenceSpine.find(
(o) => o.media?.publisher === 'Facebook source',
);
assert.ok(youtube && facebook);
try {
for (const locale of ['es', 'ru']) {
setLocale(locale);
for (const observation of [youtube, facebook]) {
const lines = captureFrameText(observation);
const sourceLine = lines.find((line) => line.includes(' / '));
assert.ok(sourceLine, 'source line drawn');
assert.doesNotMatch(
sourceLine,
/SOURCE/,
`${locale}: English "SOURCE" left in "${sourceLine}"`,
);
}
}
} finally {
setLocale('en');
}
});
test('evidence card source line keeps its English wording', async () => {
const event = JSON.parse(await readFile(eventUrl, 'utf8'));
const youtube = event.evidenceSpine.find(
(o) => o.media?.publisher === 'YouTube source',
);
setLocale('en');
const sourceLine = captureFrameText(youtube).find((line) =>
line.includes(' / '),
);
assert.equal(sourceLine, 'YOUTUBE SOURCE / OPEN');
});
+60 -25
View File
@@ -12,6 +12,7 @@ import {
} from './dataCredits.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import { t, msg, getLocale } from '../i18n/index.js';
export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
@@ -51,23 +52,42 @@ const TRIGGER_ORBIT_DURATION_SEC = 2.8;
const TRIGGER_SOURCE_URL =
'https://earthquake.usgs.gov/earthquakes/eventpage/us7000tbwb/executive';
// Descriptive pin titles translate at display (same entries as the event
// panel); place names stay Latin, anything else shows as authored.
const PLACE_TITLE_TEXT = new Map(
[
msg('Collapse viewpoint'),
msg('Dammed lake'),
msg('Second landslide'),
msg('Border gate'),
msg('Dandagaun viewpoint'),
msg('Bidur bridge'),
msg('Devighat bridge'),
].map((text) => [text.toUpperCase(), text]),
);
function placeTitle(title) {
const text = PLACE_TITLE_TEXT.get(title);
return text ? t(text).toLocaleUpperCase(getLocale()) : title;
}
const INCIDENT = Object.freeze({
lat: 28.3632333,
lon: 85.4423639,
heightM: 5200,
title: 'BHOTE KOSHI FLOOD INCIDENT',
subtitle: 'NEPAL · 26 AUG 2026',
title: msg('BHOTE KOSHI FLOOD INCIDENT'),
subtitle: msg('NEPAL · 26 AUG 2026'),
});
const TRIGGER_RECORD = Object.freeze({
lat: 28.271,
lon: 85.515,
heightM: 4200,
title: 'USGS INITIAL REPORT',
title: msg('USGS INITIAL REPORT'),
details: Object.freeze([
'26 AUG 2026 · 08:37 NPT',
'28.2710° N · 85.5150° E',
'INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE',
'USGS EVENT us7000tbwb · OPEN SOURCE',
msg('26 AUG 2026 · 08:37 NPT'),
msg('28.2710° N · 85.5150° E'),
msg('INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE'),
msg('USGS EVENT us7000tbwb · OPEN SOURCE'),
]),
});
const TRIGGER_APPROACH_CAMERA = Object.freeze({
@@ -90,11 +110,13 @@ const NEPAL = Object.freeze({
lat: 28.3949,
lon: 84.124,
heightM: 6800,
// i18n-ignore: country name, shown as is
title: 'NEPAL',
});
const NEARBY_CITIES = Object.freeze([
Object.freeze({
id: 'kathmandu',
// i18n-ignore: city name
title: 'KATHMANDU',
lat: 27.7017,
lon: 85.3206,
@@ -103,6 +125,7 @@ const NEARBY_CITIES = Object.freeze([
}),
Object.freeze({
id: 'bhaktapur',
// i18n-ignore: city name
title: 'BHAKTAPUR',
lat: 27.673,
lon: 85.43,
@@ -111,6 +134,7 @@ const NEARBY_CITIES = Object.freeze([
}),
Object.freeze({
id: 'dhulikhel',
// i18n-ignore: city name
title: 'DHULIKHEL',
lat: 27.6221,
lon: 85.5428,
@@ -394,8 +418,8 @@ export function createBhoteKoshiLocatorLayer({
position,
variant: 'card',
paintLane: 'thumbnail',
title: subject.title,
details: [subject.subtitle],
title: t(subject.title),
details: [t(subject.subtitle)],
accent: INCIDENT_ACCENT,
sourceAlpha: () => presentation().alpha,
presentationScale: () => presentation().scale,
@@ -536,8 +560,8 @@ export function createBhoteKoshiLocatorLayer({
position,
variant: 'card',
paintLane: 'thumbnail',
title: TRIGGER_RECORD.title,
details: [...TRIGGER_RECORD.details],
title: t(TRIGGER_RECORD.title),
details: TRIGGER_RECORD.details.map((line) => t(line)),
accent: INCIDENT_ACCENT,
sourceAlpha: () => presentation().alpha,
presentationScale: () => presentation().scale,
@@ -549,8 +573,9 @@ export function createBhoteKoshiLocatorLayer({
protected: true,
active: true,
interactive: true,
accessibilityLabel:
accessibilityLabel: t(
'Open the USGS initial report for the 26 August 2026 Nepal landslide',
),
activate: () => {
const opened = window.open(
TRIGGER_SOURCE_URL,
@@ -815,7 +840,7 @@ export function createBhoteKoshiLocatorLayer({
position,
variant: 'label',
paintLane: 'ambient-label',
title: `${String(index + 1).padStart(2, '0')} · ${place.title}`,
title: `${String(index + 1).padStart(2, '0')} · ${placeTitle(place.title)}`,
details: [],
accent: INCIDENT_ACCENT,
sourceAlpha: progress,
@@ -1121,57 +1146,67 @@ export function createBhoteKoshiLocatorLayer({
chips: [
{
id: 'nepal-context',
// i18n-ignore: country name
label: 'NEPAL',
active: _presentation === NEPAL_CONTEXT_PRESENTATION,
title:
title: t(
'Show the Nepal context callout without a border or country fill',
),
params: { presentation: NEPAL_CONTEXT_PRESENTATION },
},
{
id: 'bhote-koshi-regional',
// i18n-ignore: river name (Bhote Koshi)
label: 'BHOTE',
active: _presentation === BHOTE_KOSHI_REGIONAL_PRESENTATION,
title:
title: t(
'Introduce Nepal, draw its border, then reveal the Bhote Koshi incident',
),
params: { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION },
},
{
id: 'bhote-koshi-city-context',
label: 'CITIES',
label: t('CITIES'),
active: _presentation === BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
title:
title: t(
'Keep the regional incident context and introduce nearby city labels',
),
params: { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION },
},
{
id: 'bhote-koshi-incident-places',
label: 'PLACES',
label: t('PLACES'),
active: _presentation === BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION,
title:
title: t(
'Introduce the reconstruction scene key places from trigger to downstream',
),
params: { presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION },
},
{
id: 'bhote-koshi-flood-path',
label: 'PATH',
label: t('PATH'),
active: _presentation === BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
title:
title: t(
'Animate the sourced cyan river-centerline route to Trishuli Bazaar',
),
params: { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION },
},
{
id: 'bhote-koshi-path-overview',
label: 'OVERVIEW',
label: t('OVERVIEW'),
active: _presentation === BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
title: 'Keep the completed flood path and location labels visible',
title: t(
'Keep the completed flood path and location labels visible',
),
params: { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION },
},
{
id: 'bhote-koshi-trigger-record',
label: 'RECORD',
label: t('RECORD'),
active: _presentation === BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
title:
title: t(
'Reveal the trigger record while making a slow camera approach',
),
params: {
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
preview: true,
+408 -121
View File
@@ -1,9 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { INCIDENT_OVERVIEW_HOLD_SEC, INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import {
INCIDENT_OVERVIEW_HOLD_SEC,
INCIDENT_OVERVIEW_PLACES,
} from './bhoteKoshiIncidentPlaces.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import * as Cesium from 'cesium';
import { setLocale } from '../i18n/index.js';
import { SceneDirector } from '../scenes/director.js';
import { createPlaybackClock } from '../director/clock.js';
import { createDefaultScenePacks } from '../scenes/packs/defaults.js';
@@ -29,8 +33,12 @@ function viewerFixture() {
return {
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
dataSources: {
add(source) { added.push(source); },
remove(source) { removed.push(source); },
add(source) {
added.push(source);
},
remove(source) {
removed.push(source);
},
},
_test: { added, removed },
};
@@ -38,7 +46,9 @@ function viewerFixture() {
function nonRunningAnimation() {
return {
scheduleFrame() { return 1; },
scheduleFrame() {
return 1;
},
cancelFrame() {},
};
}
@@ -58,24 +68,68 @@ function overlayHostFixture() {
}
test('Bhote Koshi locator closes valid region rings once', () => {
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [88, 30]]), [
[80, 26], [88, 26], [88, 30], [80, 26],
]);
assert.deepEqual(closeRegionRing([[80, 26], [88, 26], [80, 26]]), [
[80, 26], [88, 26], [80, 26],
]);
assert.deepEqual(closeRegionRing([[80, 26], ['bad', 27]]), []);
assert.deepEqual(
closeRegionRing([
[80, 26],
[88, 26],
[88, 30],
]),
[
[80, 26],
[88, 26],
[88, 30],
[80, 26],
],
);
assert.deepEqual(
closeRegionRing([
[80, 26],
[88, 26],
[80, 26],
]),
[
[80, 26],
[88, 26],
[80, 26],
],
);
assert.deepEqual(
closeRegionRing([
[80, 26],
['bad', 27],
]),
[],
);
});
test('Bhote Koshi locator selects the largest GeoJSON country exterior', () => {
const main = [[80, 26], [88, 26], [88, 30], [80, 30], [80, 26]];
const island = [[82, 25], [83, 25], [82, 25]];
assert.equal(largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }), main);
assert.equal(largestGeoJsonRing({
type: 'MultiPolygon',
coordinates: [[island], [main]],
}), main);
assert.deepEqual(largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }), []);
const main = [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
[80, 26],
];
const island = [
[82, 25],
[83, 25],
[82, 25],
];
assert.equal(
largestGeoJsonRing({ type: 'Polygon', coordinates: [main] }),
main,
);
assert.equal(
largestGeoJsonRing({
type: 'MultiPolygon',
coordinates: [[island], [main]],
}),
main,
);
assert.deepEqual(
largestGeoJsonRing({ type: 'Point', coordinates: [84, 28] }),
[],
);
});
test('Bhote Koshi locator reveals a border progressively', () => {
@@ -84,7 +138,10 @@ test('Bhote Koshi locator reveals a border progressively', () => {
new Cesium.Cartesian3(10, 0, 0),
new Cesium.Cartesian3(20, 0, 0),
];
assert.deepEqual(revealPolylinePositions(positions, 0), [positions[0], positions[0]]);
assert.deepEqual(revealPolylinePositions(positions, 0), [
positions[0],
positions[0],
]);
assert.deepEqual(revealPolylinePositions(positions, 0.25), [
positions[0],
new Cesium.Cartesian3(5, 0, 0),
@@ -115,7 +172,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender: (reason) => renders.push(reason),
overlayHost,
@@ -141,8 +203,12 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
const publication = overlayHost._test.publications.at(-1);
assert.equal(publication.sourceId, BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
assert.equal(publication.entries.length, 2);
const nepal = publication.entries.find(({ id }) => id === 'nepal-context-callout');
const incident = publication.entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
const nepal = publication.entries.find(
({ id }) => id === 'nepal-context-callout',
);
const incident = publication.entries.find(
({ id }) => id === 'bhote-koshi-incident-callout',
);
assert.equal(nepal.variant, 'label');
assert.equal(nepal.title, 'NEPAL');
assert.equal(nepal.anchorDot, true);
@@ -175,12 +241,17 @@ test('Bhote Koshi regional locator sequences Nepal, border, then the incident',
assert.equal(incident.contentAlpha(), 1);
assert.equal(incident.presentationScale(), 1);
assert.equal(layer.getStats().status, 'nominal');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION,
});
assert.ok(renders.includes('bhote-koshi-locator-boundary'));
assert.equal(await layer.disable(), true);
assert.equal(viewer._test.removed.length, 1);
assert.equal(overlayHost._test.clears.at(-1), BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID);
assert.equal(
overlayHost._test.clears.at(-1),
BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID,
);
assert.equal(layer.getStats().status, 'idle');
});
@@ -190,7 +261,12 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender() {},
overlayHost,
@@ -199,37 +275,46 @@ test('Bhote Koshi locator switches between Nepal context and the regional incide
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
await layer.enable(viewer);
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
'nepal-context-halo',
]);
assert.deepEqual(
viewer._test.added[0].entities.values.map((entity) => entity.id),
['nepal-context-halo'],
);
const nepalLabel = overlayHost._test.publications.at(-1).entries[0];
assert.equal(nepalLabel.variant, 'label');
assert.equal(nepalLabel.placement, 'right');
assert.equal(nepalLabel.anchorDot, true);
assert.equal(layer.getStats().coverage, 'Nepal context');
assert.deepEqual(layer.getRowControls().chips.map(({ id, active }) => ({ id, active })), [
{ id: 'nepal-context', active: true },
{ id: 'bhote-koshi-regional', active: false },
{ id: 'bhote-koshi-city-context', active: false },
{ id: 'bhote-koshi-incident-places', active: false },
{ id: 'bhote-koshi-flood-path', active: false },
{ id: 'bhote-koshi-path-overview', active: false },
{ id: 'bhote-koshi-trigger-record', active: false },
]);
assert.deepEqual(
layer.getRowControls().chips.map(({ id, active }) => ({ id, active })),
[
{ id: 'nepal-context', active: true },
{ id: 'bhote-koshi-regional', active: false },
{ id: 'bhote-koshi-city-context', active: false },
{ id: 'bhote-koshi-incident-places', active: false },
{ id: 'bhote-koshi-flood-path', active: false },
{ id: 'bhote-koshi-path-overview', active: false },
{ id: 'bhote-koshi-trigger-record', active: false },
],
);
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
assert.equal(
overlayHost._test.publications.at(-1).entries
.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
undefined,
);
assert.equal(
overlayHost._test.publications
.at(-1)
.entries.find(({ id }) => id === 'bhote-koshi-incident-callout').variant,
'card',
);
layer.setParams({ presentation: NEPAL_CONTEXT_PRESENTATION });
assert.deepEqual(viewer._test.added[0].entities.values.map((entity) => entity.id), [
'nepal-context-halo',
]);
assert.deepEqual(
viewer._test.added[0].entities.values.map((entity) => entity.id),
['nepal-context-halo'],
);
await layer.destroy();
});
@@ -239,16 +324,28 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
let nowMs = 0;
let pulseFrame = null;
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null, requestRender() {},
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
now: () => nowMs,
scheduleFrame(callback) { pulseFrame = callback; return 1; },
scheduleFrame(callback) {
pulseFrame = callback;
return 1;
},
cancelFrame() {},
});
layer.setParams({ presentation: BHOTE_KOSHI_INCIDENT_PLACES_PRESENTATION });
await layer.enable(viewer);
const source = viewer._test.added[0];
const event = JSON.parse(await readFile(new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url), 'utf8'));
const event = JSON.parse(
await readFile(
new URL(
'../../public/events/bhote-koshi-2026/event.json',
import.meta.url,
),
'utf8',
),
);
assert.equal(event.evidenceSpine.length, 16);
assert.equal(event.evidenceSpine.at(-1).id, 'charaudi');
assert.equal(source.entities.values.length, 15);
@@ -259,30 +356,100 @@ test('Incident Corridor reveals all fifteen overview pins and keeps layout activ
const id = `bhote-koshi-place-${point.id}`;
const pin = labels.find((entry) => entry.id === id);
assert.ok(pin, point.id);
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${point.title}`);
assert.equal(
pin.title,
`${String(index + 1).padStart(2, '0')} · ${point.title}`,
);
assert.equal(pin.variant, 'label');
const location = Cesium.Cartographic.fromCartesian(pin.position);
assert.ok(Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6);
assert.ok(
Math.abs(Cesium.Math.toDegrees(location.latitude) - point.lat) < 1e-6,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(location.longitude) - point.lon) < 1e-6,
);
nowMs = 349 + index * 650;
assert.equal(pin.sourceAlpha(), 0);
nowMs += 1;
assert.equal(pin.sourceAlpha(), 0);
assert.equal(source.entities.values.filter(entity => entity.id.endsWith('-halo') && entity.show.getValue()).length, 1);
assert.equal(
source.entities.values.filter(
(entity) => entity.id.endsWith('-halo') && entity.show.getValue(),
).length,
1,
);
nowMs += 520;
assert.equal(pin.sourceAlpha(), 1);
}
assert.ok(nowMs < INCIDENT_OVERVIEW_HOLD_SEC * 1000);
// A repeat halo cycle must not erase already revealed labels.
nowMs = 350 + 15 * 650;
assert.equal(source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo').show.getValue(), true);
assert.equal(labels.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge').sourceAlpha(), 1);
assert.equal(labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'), false);
assert.equal(
source.entities
.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo')
.show.getValue(),
true,
);
assert.equal(
labels
.find(({ id }) => id === 'bhote-koshi-place-devighat-taadi-khola-bridge')
.sourceAlpha(),
1,
);
assert.equal(
labels.some(({ id }) => id === 'bhote-koshi-place-charaudi'),
false,
);
assert.equal(typeof pulseFrame, 'function');
pulseFrame();
assert.equal(overlayHost._test.publications.at(-1).options.moving, false);
layer.setParams({ presentation: BHOTE_KOSHI_REGIONAL_PRESENTATION });
assert.equal(source.entities.values.some(entity => entity.id.startsWith('bhote-koshi-place-')), false);
assert.equal(
source.entities.values.some((entity) =>
entity.id.startsWith('bhote-koshi-place-'),
),
false,
);
await layer.destroy();
});
test('Bhote Koshi overview pins translate their descriptive titles and keep place names', async (t) => {
t.after(() => setLocale('en'));
setLocale('ru');
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
overlayHost,
now: () => 0,
...nonRunningAnimation(),
});
layer.setParams({ presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
await layer.enable(viewer);
const titles = new Map(
overlayHost._test.publications
.at(-1)
.entries.map(({ id, title }) => [id, title]),
);
assert.equal(
titles.get('bhote-koshi-place-immediate-collapse-viewpoint'),
'01 · ВИД НА ОБРУШЕНИЕ',
);
assert.equal(
titles.get('bhote-koshi-place-gyirong-border-gate'),
'04 · ПОГРАНПЕРЕХОД',
);
assert.equal(
titles.get('bhote-koshi-place-dandagaun-viewpoint'),
'11 · СМОТРОВАЯ ПЛОЩАДКА DANDAGAUN',
);
assert.equal(
titles.get('bhote-koshi-place-bidur-trishuli-bridge'),
'14 · МОСТ BIDUR',
);
assert.equal(titles.get('bhote-koshi-place-timure-cluster'), '05 · TIMURE');
await layer.destroy();
});
@@ -303,8 +470,12 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
const source = viewer._test.added[0];
const path = source.entities.getById('bhote-koshi-flood-path');
const labels = overlayHost._test.publications.at(-1).entries;
const trigger = labels.find(({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint');
const bidur = labels.find(({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge');
const trigger = labels.find(
({ id }) => id === 'bhote-koshi-place-immediate-collapse-viewpoint',
);
const bidur = labels.find(
({ id }) => id === 'bhote-koshi-place-bidur-trishuli-bridge',
);
assert.equal(source.entities.values.length, 1);
assert.equal(labels.length, 15);
@@ -315,10 +486,14 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
}
assert.equal(path.polyline.clampToGround.getValue(), true);
assert.ok(path.polyline.material instanceof Cesium.ColorMaterialProperty);
assert.ok(path.polyline.material.color.getValue().equalsEpsilon(
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
Cesium.Math.EPSILON7,
));
assert.ok(
path.polyline.material.color
.getValue()
.equalsEpsilon(
Cesium.Color.fromCssColorString('#20e7f2').withAlpha(0.96),
Cesium.Math.EPSILON7,
),
);
assert.equal(path.show.getValue(), false);
assert.equal(trigger.sourceAlpha(), 1);
assert.equal(bidur.sourceAlpha(), 1);
@@ -330,15 +505,23 @@ test('Bhote Koshi flood path draws a solid cyan sourced route after the place sh
const halfwayCount = path.polyline.positions.getValue().length;
assert.ok(Math.abs(halfwayCount - BHOTE_KOSHI_FLOOD_PATH.length / 2) <= 2);
nowMs = 4250;
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
assert.equal(
path.polyline.positions.getValue().length,
BHOTE_KOSHI_FLOOD_PATH.length,
);
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH[0], [85.48476, 28.33255]);
// More observation pins must not extend or straighten the sourced route.
assert.deepEqual(path.polyline.positions.getValue(), BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => (
Cesium.Cartesian3.fromDegrees(lon, lat)
)));
assert.deepEqual(
path.polyline.positions.getValue(),
BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) =>
Cesium.Cartesian3.fromDegrees(lon, lat),
),
);
assert.deepEqual(BHOTE_KOSHI_FLOOD_PATH.at(-1), [85.14943, 27.92177]);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Flood path');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_FLOOD_PATH_PRESENTATION,
});
await layer.destroy();
});
@@ -349,7 +532,12 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender() {},
overlayHost,
@@ -367,21 +555,35 @@ test('Bhote Koshi path-overview presentation keeps the route settled without Nep
assert.equal(labels.length, 15);
for (const [index, place] of INCIDENT_OVERVIEW_PLACES.entries()) {
const pin = labels.find(({ id }) => id === `bhote-koshi-place-${place.id}`);
assert.equal(pin.title, `${String(index + 1).padStart(2, '0')} · ${place.title}`);
assert.equal(
pin.title,
`${String(index + 1).padStart(2, '0')} · ${place.title}`,
);
assert.equal(pin.sourceAlpha(), 1);
}
assert.equal(path.show.getValue(), true);
assert.equal(path.polyline.positions.getValue().length, BHOTE_KOSHI_FLOOD_PATH.length);
assert.ok(Cesium.Cartesian3.equals(path.polyline.positions.getValue()[0],
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255)));
assert.equal(
path.polyline.positions.getValue().length,
BHOTE_KOSHI_FLOOD_PATH.length,
);
assert.ok(
Cesium.Cartesian3.equals(
path.polyline.positions.getValue()[0],
Cesium.Cartesian3.fromDegrees(85.48476, 28.33255),
),
);
assert.equal(source.entities.getById('nepal-border-fill'), undefined);
assert.equal(source.entities.getById('nepal-border-highlight'), undefined);
assert.equal(
source.entities.getById('bhote-koshi-place-immediate-collapse-viewpoint-halo'),
source.entities.getById(
'bhote-koshi-place-immediate-collapse-viewpoint-halo',
),
undefined,
);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Path overview');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_PATH_OVERVIEW_PRESENTATION,
});
await layer.destroy();
});
@@ -406,10 +608,10 @@ test('Bhote Koshi trigger-record presentation reveals sourced incident details',
const publication = overlayHost._test.publications.at(-1);
const record = publication.entries[0];
assert.deepEqual(source.entities.values.map(({ id }) => id), [
'bhote-koshi-flood-path',
'bhote-koshi-trigger-record-halo',
]);
assert.deepEqual(
source.entities.values.map(({ id }) => id),
['bhote-koshi-flood-path', 'bhote-koshi-trigger-record-halo'],
);
assert.equal(record.title, 'USGS INITIAL REPORT');
assert.deepEqual(record.details, [
'26 AUG 2026 · 08:37 NPT',
@@ -440,7 +642,9 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
viewer.camera = {
moveStart,
moveEnd,
flyTo(options) { flights.push(options); },
flyTo(options) {
flights.push(options);
},
cancelFlight() {},
};
const layer = createBhoteKoshiLocatorLayer({
@@ -465,8 +669,12 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
assert.equal(flights.length, 1);
assert.equal(flights[0].duration, 4.6);
const destination = Cesium.Cartographic.fromCartesian(flights[0].destination);
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6);
assert.ok(
Math.abs(Cesium.Math.toDegrees(destination.latitude) - 28.368) < 1e-6,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(destination.longitude) - 85.473) < 1e-6,
);
assert.ok(Math.abs(destination.height - 10526) < 1e-4);
flights[0].complete();
assert.equal(flights.length, 2);
@@ -488,8 +696,11 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
const moveStart = new Cesium.Event();
const moveEnd = new Cesium.Event();
viewer.camera = {
moveStart, moveEnd,
flyTo(options) { flights.push(options); },
moveStart,
moveEnd,
flyTo(options) {
flights.push(options);
},
cancelFlight() {},
};
const layer = createBhoteKoshiLocatorLayer({
@@ -501,21 +712,30 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
timers.set(++nextTimer, callback);
return nextTimer;
},
cancelDelay(id) { timers.delete(id); },
cancelDelay(id) {
timers.delete(id);
},
...nonRunningAnimation(),
});
const director = {
_clock: createPlaybackClock({ isRunning: () => false, timingForShot: () => null, onProgress() {} }),
_clock: createPlaybackClock({
isRunning: () => false,
timingForShot: () => null,
onProgress() {},
}),
_scenePacks: createDefaultScenePacks(),
dataManager: { layers: new Map([[layer.id, { module: layer }]]) },
viewer,
_sceneSeekGeneration: 0,
_loadGeneration: 0,
_cancelActiveSceneTravel: SceneDirector.prototype._cancelActiveSceneTravel,
_cancelActiveSceneTravel:
SceneDirector.prototype._cancelActiveSceneTravel,
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
};
try {
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
layer.setParams({
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
});
await layer.enable(viewer);
if (phase === 'move-end') moveStart.raiseEvent();
if (phase === 'approach' || phase === 'orbit') callbacks[0]();
@@ -528,19 +748,38 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
callbacks[0]();
moveEnd.raiseEvent();
for (const flight of oldFlights) flight.complete();
assert.equal(flights.length, oldFlights.length, 'no post-Stop approach or orbit');
assert.equal(layer.getStats().count, 1, 'Stop preserves the visible locator');
assert.equal(
flights.length,
oldFlights.length,
'no post-Stop approach or orbit',
);
assert.equal(
layer.getStats().count,
1,
'Stop preserves the visible locator',
);
layer.setParams({ presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION });
layer.setParams({
presentation: BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION,
});
callbacks.at(-1)();
const successor = flights.at(-1);
const count = flights.length;
callbacks[0]();
for (const flight of oldFlights) { flight.cancel(); flight.complete(); }
for (const flight of oldFlights) {
flight.cancel();
flight.complete();
}
assert.equal(flights.length, count, 'old callbacks cannot add a flight');
successor.complete();
assert.equal(flights.length, count + 1, 'successor retains its own orbit');
} finally { await layer.destroy(); }
assert.equal(
flights.length,
count + 1,
'successor retains its own orbit',
);
} finally {
await layer.destroy();
}
});
}
@@ -551,7 +790,12 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => ({
name: 'Nepal',
ring: [[80, 26], [88, 26], [88, 30], [80, 30]],
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
}),
requestRender() {},
overlayHost,
@@ -564,19 +808,29 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
const source = viewer._test.added[0];
const borderFill = source.entities.getById('nepal-border-fill');
const entries = overlayHost._test.publications.at(-1).entries;
const incident = entries.find(({ id }) => id === 'bhote-koshi-incident-callout');
const kathmandu = entries.find(({ id }) => id === 'bhote-koshi-city-kathmandu');
const bhaktapur = entries.find(({ id }) => id === 'bhote-koshi-city-bhaktapur');
const dhulikhel = entries.find(({ id }) => id === 'bhote-koshi-city-dhulikhel');
const incident = entries.find(
({ id }) => id === 'bhote-koshi-incident-callout',
);
const kathmandu = entries.find(
({ id }) => id === 'bhote-koshi-city-kathmandu',
);
const bhaktapur = entries.find(
({ id }) => id === 'bhote-koshi-city-bhaktapur',
);
const dhulikhel = entries.find(
({ id }) => id === 'bhote-koshi-city-dhulikhel',
);
assert.equal(borderFill.show.getValue(), true);
assert.equal(incident.contentAlpha(), 1);
assert.equal(kathmandu.title, 'KATHMANDU');
assert.equal(bhaktapur.title, 'BHAKTAPUR');
assert.equal(dhulikhel.title, 'DHULIKHEL');
assert.ok([kathmandu, bhaktapur, dhulikhel].every(({ variant, anchorDot }) => (
variant === 'label' && anchorDot === true
)));
assert.ok(
[kathmandu, bhaktapur, dhulikhel].every(
({ variant, anchorDot }) => variant === 'label' && anchorDot === true,
),
);
assert.equal(kathmandu.sourceAlpha(), 0);
assert.equal(bhaktapur.sourceAlpha(), 0);
assert.equal(dhulikhel.sourceAlpha(), 0);
@@ -591,7 +845,9 @@ test('Bhote Koshi locator keeps the regional context settled while nearby cities
nowMs = 1300;
assert.equal(dhulikhel.sourceAlpha(), 1);
assert.equal(layer.getStats().coverage, 'Bhote Koshi · Nearby cities');
assert.deepEqual(layer.getParams(), { presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION });
assert.deepEqual(layer.getParams(), {
presentation: BHOTE_KOSHI_CITY_CONTEXT_PRESENTATION,
});
await layer.destroy();
});
@@ -608,15 +864,17 @@ test('Bhote Koshi locator degrades to labels when the regional boundary is unava
});
try {
await layer.enable(viewer);
const ids = viewer._test.added[0].entities.values.map((entity) => entity.id);
const ids = viewer._test.added[0].entities.values.map(
(entity) => entity.id,
);
assert.ok(ids.includes('nepal-context-halo'));
assert.ok(ids.includes('bhote-koshi-incident-halo'));
assert.equal(ids.includes('bhote-koshi-city-kathmandu'), false);
assert.equal(ids.includes('nepal-border-highlight'), false);
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
'nepal-context-callout',
'bhote-koshi-incident-callout',
]);
assert.deepEqual(
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
['nepal-context-callout', 'bhote-koshi-incident-callout'],
);
assert.equal(layer.getStats().status, 'degraded');
} finally {
console.warn = originalWarn;
@@ -628,7 +886,9 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
const viewer = viewerFixture();
const overlayHost = overlayHostFixture();
let releaseBoundary;
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
const boundary = new Promise((resolve) => {
releaseBoundary = resolve;
});
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => boundary,
requestRender() {},
@@ -638,23 +898,42 @@ test('Bhote Koshi regional locator waits for its boundary before starting the fu
const enabling = layer.enable(viewer);
assert.ok(viewer._test.added[0].entities.getById('nepal-context-halo'));
assert.equal(viewer._test.added[0].entities.getById('nepal-border-highlight'), undefined);
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'), undefined);
assert.equal(
viewer._test.added[0].entities.getById('nepal-border-highlight'),
undefined,
);
assert.equal(
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
undefined,
);
assert.equal(overlayHost._test.publications.length, 1);
assert.deepEqual(overlayHost._test.publications[0].entries.map(({ id }) => id), [
'nepal-context-callout',
]);
assert.deepEqual(
overlayHost._test.publications[0].entries.map(({ id }) => id),
['nepal-context-callout'],
);
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30], [80, 30]] });
releaseBoundary({
ring: [
[80, 26],
[88, 26],
[88, 30],
[80, 30],
],
});
await enabling;
assert.ok(viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'));
assert.ok(
viewer._test.added[0].entities.getById('bhote-koshi-incident-halo'),
);
assert.ok(viewer._test.added[0].entities.getById('nepal-border-highlight'));
assert.equal(viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'), undefined);
assert.equal(
viewer._test.added[0].entities.getById('bhote-koshi-city-kathmandu'),
undefined,
);
assert.equal(overlayHost._test.publications.length, 2);
assert.deepEqual(overlayHost._test.publications.at(-1).entries.map(({ id }) => id), [
'nepal-context-callout',
'bhote-koshi-incident-callout',
]);
assert.deepEqual(
overlayHost._test.publications.at(-1).entries.map(({ id }) => id),
['nepal-context-callout', 'bhote-koshi-incident-callout'],
);
await layer.destroy();
});
@@ -662,7 +941,9 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
const viewer = viewerFixture();
const controller = new AbortController();
let releaseBoundary;
const boundary = new Promise((resolve) => { releaseBoundary = resolve; });
const boundary = new Promise((resolve) => {
releaseBoundary = resolve;
});
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => boundary,
requestRender() {},
@@ -670,7 +951,13 @@ test('Bhote Koshi locator cancels a stale boundary resolution and removes its su
});
const enabling = layer.enable(viewer, { signal: controller.signal });
controller.abort();
releaseBoundary({ ring: [[80, 26], [88, 26], [88, 30]] });
releaseBoundary({
ring: [
[80, 26],
[88, 26],
[88, 30],
],
});
await assert.rejects(enabling, (error) => error?.name === 'AbortError');
assert.equal(viewer._test.removed.length, 1);
});
+252
View File
@@ -0,0 +1,252 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
fallbackHeadingFromId,
isLikelyCataloniaCoordinate,
toFiniteNumber,
} from '../../server/providers/cctv/normalize.js';
import {
loadCaltransSourcesFromOpenData,
loadTallinnSourcesFromCatalog,
loadTflSourcesFromOpenData,
parseCataloniaXml,
} from '../../server/providers/cctv/sources.js';
import { cctvProxy } from '../../server/providers/cctv.js';
import { createCatalog } from '../layers/cctv/catalog.js';
import { createModel } from '../layers/cctv/model.js';
/** Values `Number` reads as 0 (or 1) although they carry no number. */
const ABSENT = [null, undefined, '', ' ', false, true];
/** Set env vars for one test and restore them afterwards. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** The production browser model; only the catalog's pose pass is stubbed. */
function browserModel() {
const parts = {};
parts.model = createModel({
state: {},
services: { focus: {} },
parts,
source: {},
});
return parts.model;
}
/** The browser catalog over the production model. */
function browserCatalog(model, cityPois = {}) {
return createCatalog({
state: {},
services: { locations: { CITY_POIS: cityPois } },
parts: { model: { ...model, ensureCameraPose: () => {} } },
source: {},
});
}
/** GET /api/cctv/sources through the real proxy middleware. */
async function requestSources(plugin) {
let handler;
plugin.configureServer({
middlewares: {
use(_route, fn) {
handler = fn;
},
},
});
const res = {
writeHead(status, headers) {
Object.assign(this, { status, headers });
},
end(body) {
this.body = body;
},
};
await handler({ url: '/sources', method: 'GET' }, res);
assert.equal(res.status, 200);
return JSON.parse(res.body);
}
test('absent, blank and boolean values take the fallback on both sides of the source list', () => {
const { safeNumber } = browserModel();
for (const value of ABSENT) {
assert.ok(Number.isNaN(toFiniteNumber(value)), `server ${String(value)}`);
assert.equal(toFiniteNumber(value, 15), 15, `server ${String(value)}`);
assert.ok(Number.isNaN(safeNumber(value)), `browser ${String(value)}`);
assert.equal(safeNumber(value, 74), 74, `browser ${String(value)}`);
}
for (const [value, expected] of [
[0, 0],
['0', 0],
[-17, -17],
[' 12.5 ', 12.5],
]) {
assert.equal(toFiniteNumber(value, 99), expected);
assert.equal(safeNumber(value, 99), expected);
}
});
test('a source-pack camera that omits or nulls its pose gets the browser defaults, not the clamp floors', async (t) => {
quiet(t);
const pose = {
headingDeg: null,
pitchDeg: null,
fovDeg: null,
rangeM: null,
mountHeightM: null,
groundElevationM: null,
};
const at = { cityId: 'austin', lat: 30.2672, lon: -97.7431 };
withEnv(t, {
CCTV_SOURCES_FILE: path.join(tmpdir(), 'gev-no-such-pack.json'),
CCTV_SOURCES_JSON: JSON.stringify([
{ id: 'pack-pose-omitted', ...at, feedType: 'image' },
{ id: 'pack-pose-null', ...at, ...pose, feedType: 'image' },
{ id: 'pack-coords-null', lat: null, lon: null, feedType: 'image' },
{ id: 'pack-coords-blank', lat: '', lon: ' ', feedType: 'image' },
]),
});
const body = await requestSources(cctvProxy({ sourceRoot: tmpdir() }));
const model = browserModel();
const cameras = browserCatalog(model, {
austin: { name: 'Austin', groundElevation: 150 },
}).buildCatalogFromSources(body.sources);
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
for (const id of ['pack-pose-omitted', 'pack-pose-null']) {
const camera = byId.get(id);
assert.ok(camera, id);
assert.equal(camera.headingDeg, model.headingFromId(id), id);
assert.equal(camera.pitchDeg, -17, id);
assert.equal(camera.fovDeg, 74, id);
assert.equal(camera.rangeM, 700, id);
assert.equal(camera.mountHeightM, 24, id);
assert.equal(camera.groundElevationM, 150, id);
}
// No position is not a camera in the Gulf of Guinea.
assert.equal(byId.has('pack-coords-null'), false);
assert.equal(byId.has('pack-coords-blank'), false);
});
test('Tallinn rows with a null heading or ground keep the low-confidence prior', (t) => {
quiet(t);
const root = mkdtempSync(path.join(tmpdir(), 'gev-tallinn-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const file = path.join(root, 'tallinn.json');
writeFileSync(
file,
JSON.stringify([
{
id: 'tln-null-heading',
lat: 59.437,
lon: 24.753,
headingDeg: null,
groundElevationM: null,
url: 'https://ristmikud.tallinn.ee/last/cam001.jpg',
},
]),
);
withEnv(t, { CCTV_TALLINN_SOURCES_FILE: file });
const [camera] = loadTallinnSourcesFromCatalog({ sourceRoot: root });
assert.equal(camera.headingConfidence, 'low');
assert.equal(camera.headingDeg, fallbackHeadingFromId('tln-null-heading'));
assert.equal(camera.groundElevationM, 15);
});
test('TfL JamCams with null coordinates are dropped instead of placed at 0,0', async (t) => {
quiet(t);
const jamCam = (id, lat, lon) => ({
id: `JamCams_${id}`,
commonName: `JamCam ${id}`,
lat,
lon,
additionalProperties: [
{ key: 'available', value: 'true' },
{
key: 'imageUrl',
value: `https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/${id}.jpg`,
},
],
});
t.mock.method(globalThis, 'fetch', async () =>
Response.json([
jamCam('00001.00001', 51.5072, -0.1276),
jamCam('00001.00002', null, null),
jamCam('00001.00003', 51.5072, ''),
]),
);
const cameras = await loadTflSourcesFromOpenData();
assert.deepEqual(
cameras.map((camera) => camera.id),
['tfl-00001.00001'],
);
});
test('a Catalonia feature with a blank longitude is not read as the zero meridian', () => {
const feature = (coordinates) => `<gml:featureMember>
<cite:cameres fid="cameres.1">
<gml:coordinates>${coordinates}</gml:coordinates>
<cite:link>https://example.org/cam.jpg</cite:link>
</cite:cameres>
</gml:featureMember>`;
const [blank, valid] = parseCataloniaXml(
feature(',41.5') + feature('2.17,41.39'),
);
// 0 °E at 41.5 °N is inside the Catalonia box, so a zero would pass it.
assert.ok(Number.isNaN(blank.lon));
assert.equal(isLikelyCataloniaCoordinate(blank.lat, blank.lon), false);
assert.equal(isLikelyCataloniaCoordinate(valid.lat, valid.lon), true);
});
test('a Caltrans camera with a blank elevation keeps the 150 m ground prior', async (t) => {
quiet(t);
withEnv(t, { CCTV_CALTRANS_DISTRICTS: '4' });
t.mock.method(globalThis, 'fetch', async () =>
Response.json({
data: [
{
cctv: {
inService: 'true',
location: {
latitude: '37.8044',
longitude: '-122.2712',
elevation: '',
locationName: 'TV101 -- I-880 : Test Ave',
},
imageData: {
static: {
currentImageURL:
'https://cwwp2.dot.ca.gov/data/d4/cctv/image/tv101/tv101.jpg',
},
},
},
},
],
}),
);
const [camera] = await loadCaltransSourcesFromOpenData();
assert.ok(camera);
assert.equal(camera.groundElevationM, 150);
});
+2 -1
View File
@@ -304,7 +304,8 @@ export const DATA_CREDITS = [
key: 'gbfs',
html:
'Bikeshare availability: GBFS operator feeds ' +
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú, operated by Tembici)',
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú and Tembici Bogotá, operated by Tembici; ' +
'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike)',
},
{
key: 'urbansharing-gbfs',
+6 -4
View File
@@ -8,6 +8,7 @@
* GPU/MSDF renderer. The Canvas2D overlay (detection.js) imports these and
* supplies the actual drawing surface.
*/
import { formatNumber, t } from '../i18n/index.js';
/** @constant {number} Max characters shown for a label's primary (id) line. */
const MAX_PRIMARY = 18;
@@ -30,14 +31,15 @@ export function formatFlightLevel(altitudeMeters) {
}
/**
* Formats a speed in knots as a short rounded label. Returns '' for
* non-positive or non-finite speeds (declutters moored/unknown vessels).
* Formats a speed in knots as a short rounded label in the UI language.
* Returns '' for non-positive or non-finite speeds (declutters moored/unknown
* vessels).
* @param {number} knots - Speed over ground in knots.
* @returns {string} e.g. '14 kn', or '' when not applicable.
* @returns {string} e.g. '14 kn' (ru '14 уз'), or '' when not applicable.
*/
export function formatKnots(knots) {
if (!Number.isFinite(knots) || knots <= 0) return '';
return Math.round(knots) + ' kn';
return t('{speed} kn', { speed: formatNumber(Math.round(knots)) });
}
/**
+11
View File
@@ -192,3 +192,14 @@ test('transit bracket stroke centres have full pixel coverage at the failing fra
for(const [x,y] of points){assert.equal(x%1,0.5);assert.equal(y%1,0.5);}
}
});
test('formatKnots writes the unit in the UI language', async () => {
const { setLocale } = await import('../i18n/index.js');
setLocale('ru');
try {
assert.equal(formatKnots(9.5), '10 уз');
} finally {
setLocale('en');
}
assert.equal(formatKnots(9.5), '10 kn');
});
+103 -4
View File
@@ -27,6 +27,13 @@ import {
createApplicationDirectionsLayer,
} from './directions.js';
import { GROUND_FLOOR_LIFT_M } from './groundFloor.js';
import { instructionFor } from './routeSteps.js';
import { setLocale } from '../i18n/index.js';
import {
directionsInstruction,
routeDistanceText,
routeDurationText,
} from '../layers/directions/index.js';
import {
claimPointer,
isPointerFree,
@@ -222,10 +229,10 @@ test('stats guide the user, show progress, and summarize a route honestly', () =
lastUpdate: 5,
});
assert.equal(ready.count, 3);
assert.equal(ready.coverage, '21 km · 25 min · Drive');
assert.equal(ready.coverage, '21\u00a0km · 25\u00a0min · Drive');
assert.equal(
ready.loadingLabel,
'21 km · 25 min · Drive',
'21\u00a0km · 25\u00a0min · Drive',
'the summary is the row detail line',
);
assert.equal(ready.loading, undefined);
@@ -249,7 +256,7 @@ test('step cards name the maneuver, its leg, and what comes next', () => {
const copy = directionsStepCopy(route.steps, 1);
assert.equal(copy.title, 'Turn right onto Kansakoulukatu');
assert.deepEqual(copy.details, [
'Step 2 of 3 · then 240 m · 39 s',
'Step 2 of 3 · then 240\u00a0m · 39\u00a0s',
'Then: Arrive at your destination',
]);
const last = directionsStepCopy(route.steps, 2);
@@ -354,7 +361,7 @@ test('the turn-by-turn list is ordered, labelled, and highlights the flown step'
);
assert.deepEqual(
list.items.map((item) => item.lead),
['8 m', '240 m', '0 m'],
['8\u00a0m', '240\u00a0m', '0\u00a0m'],
);
assert.deepEqual(
list.items.map((item) => item.params),
@@ -733,6 +740,11 @@ test('a refused arming names the tool holding the pointer and how to leave it',
pointerBlockedMessage('measure', 'a'),
'measure is active — turn measure off, then set A',
);
// A registered owner without an exit is named by its tool, not its id.
assert.equal(
pointerBlockedMessage('recent-imagery-box', 'a'),
'Recent imagery is active — turn Recent imagery off, then set A',
);
// Nothing to say when nothing is in the way, or when we are the holder.
assert.equal(pointerBlockedMessage(null, 'a'), null);
assert.equal(pointerBlockedMessage('', 'a'), null);
@@ -773,3 +785,90 @@ test('the refusal reaches the app toast, and a successful arming says nothing',
assert.equal(toasts.length, 1, 'a successful arming is silent');
assert.equal(pointerOwner(), DIRECTIONS_POINTER_OWNER);
});
test('maneuvers are phrased on the page, in English word for word as the proxy does', () => {
const types = [
'depart',
'arrive',
'turn',
'new name',
'continue',
'end of road',
'fork',
'merge',
'on ramp',
'off ramp',
'roundabout',
'rotary',
'roundabout turn',
'exit roundabout',
'exit rotary',
'use lane',
'notification',
'something new',
];
const modifiers = [
null,
'left',
'right',
'slight left',
'slight right',
'sharp left',
'sharp right',
'straight',
'uturn',
];
const roads = [
{ name: '', ref: null },
{ name: 'Congress Avenue', ref: null },
{ name: 'Mannerheimintie', ref: 'E12' },
{ name: '', ref: 'I 35' },
];
for (const type of types)
for (const modifier of modifiers)
for (const road of roads)
for (const exit of [null, 1, 2, 3, 4, 11, 12, 13, 21, 22]) {
const step = { type, modifier, exit, ...road };
assert.equal(
directionsInstruction(step),
instructionFor(step),
JSON.stringify(step),
);
}
// An older payload without a maneuver type keeps the proxy's sentence.
assert.equal(
directionsInstruction({ instruction: 'Head out on Kaivokatu' }),
'Head out on Kaivokatu',
);
});
test('route numbers follow the page language and never break from their unit', (t) => {
t.after(() => setLocale('en'));
assert.equal(routeDistanceText(8.4), '8\u00a0m');
assert.equal(routeDistanceText(236), '240\u00a0m');
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
assert.equal(routeDistanceText(2_345_000), '2345\u00a0km');
assert.equal(routeDurationText(39), '39\u00a0s');
assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min');
assert.equal(routeDurationText(7200), '2\u00a0h');
assert.equal(routeDistanceText(-1), '');
assert.equal(routeDurationText(Number.NaN), '');
setLocale('ru');
assert.equal(routeDistanceText(1500), '1,5\u00a0км');
assert.equal(routeDurationText(240), '4\u00a0мин');
assert.equal(
directionsInstruction({
type: 'turn',
modifier: 'right',
name: 'East 7th Street',
}),
'Поверните направо на East 7th Street',
);
assert.equal(
directionsInstruction({ type: 'roundabout', exit: 2, name: '' }),
'На кольце сверните на 2-й съезд',
);
const list = directionsStepList({ route });
assert.equal(list.items[1].lead, '240\u00a0м');
});
+152
View File
@@ -0,0 +1,152 @@
// GBFS PROXY — the frequency the allowlist never bounded.
//
// `/api/gbfs` already says WHERE it may go: an https allowlisted host, a
// station_information/station_status path, a 12 s timeout, a 5 MB body cap and
// no redirects. Nothing said HOW OFTEN, so the dev server would relay an
// unbounded stream of requests at a third-party bikeshare operator on any
// caller's behalf — one outbound fetch and one timeout timer each.
//
// These cases drive the real mounted middleware. `fetch` is stubbed per test
// and restored afterwards, so a case that reaches the network fails loudly,
// and the stub's call count is what proves a refused request costs nothing.
//
// Run with: npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { gbfsProxy, GBFS_RATE_PER_MIN } from '../../server/providers/gbfs.js';
const STATUS_URL = 'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_status.json';
const STATION_BODY = '{"data":{"stations":[]}}';
/** Mount the proxy the way the dev server does; returns its route handler. */
function mount(options) {
const routes = new Map();
gbfsProxy(options).configureServer({
middlewares: {
use(route, handler) {
routes.set(route, handler);
},
},
});
return routes.get('/api/gbfs');
}
function request(handler, { target = STATUS_URL, remoteAddress, method } = {}) {
return new Promise((resolve, reject) => {
const req = {
method: method || 'GET',
// connect strips the mount prefix before the handler sees the URL.
url: '/' + encodeURIComponent(target),
headers: {},
socket: remoteAddress ? { remoteAddress } : undefined,
};
let status = 200;
let headers = {};
const res = {
writeHead(code, sent) {
status = code;
headers = sent || {};
return res;
},
end(body = '') {
let parsed = null;
try {
parsed = body ? JSON.parse(String(body)) : null;
} catch {
parsed = String(body);
}
resolve({ status, headers, body: parsed });
},
};
Promise.resolve(handler(req, res)).catch(reject);
});
}
/** Answer every allowlisted upstream, counting the calls that got through. */
function stubUpstream(t) {
const asked = [];
const original = globalThis.fetch;
globalThis.fetch = async (url) => {
asked.push(String(url));
return new Response(STATION_BODY, {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
t.after(() => {
globalThis.fetch = original;
});
return asked;
}
test('a maximal real session is never throttled', async (t) => {
const asked = stubUpstream(t);
const handler = mount();
// The bikeshare layer catalogues 15 systems and refreshes each one's status
// once every 60 s, so a minute of the busiest possible session is ~15
// requests. Run eight times that and expect every one of them served.
let served = 0;
for (let i = 0; i < 120; i += 1) {
const res = await request(handler);
if (res.status === 200) served += 1;
}
assert.equal(served, 120, 'the shipped ceiling leaves a real session alone');
assert.equal(asked.length, 120);
assert.equal(GBFS_RATE_PER_MIN, 120, 'the ceiling this case relies on');
});
test('an unbounded relay is refused, and refusal costs no outbound fetch', async (t) => {
const asked = stubUpstream(t);
const handler = mount({ ratePerMinute: 5 });
const responses = [];
for (let i = 0; i < 12; i += 1) responses.push(await request(handler));
const served = responses.filter((r) => r.status === 200);
const refused = responses.filter((r) => r.status === 429);
assert.equal(served.length, 5, 'exactly the admitted budget is served');
assert.equal(refused.length, 7);
assert.equal(refused[0].body.error, 'Rate limit exceeded');
assert.equal(refused[0].headers['Retry-After'], '10');
assert.equal(refused[0].headers['Cache-Control'], 'no-store');
assert.equal(
asked.length,
5,
'a refused request never reaches the GBFS operator',
);
});
test('a different client keeps its own budget', async (t) => {
stubUpstream(t);
const handler = mount({ ratePerMinute: 3 });
for (let i = 0; i < 6; i += 1)
await request(handler, { remoteAddress: '10.0.0.1' });
const noisy = await request(handler, { remoteAddress: '10.0.0.1' });
assert.equal(noisy.status, 429, 'the noisy client spent its own budget');
const quiet = await request(handler, { remoteAddress: '10.0.0.2' });
assert.equal(quiet.status, 200, 'a quiet client is not collateral');
});
test('the limiter does not displace the existing guards', async (t) => {
const asked = stubUpstream(t);
const handler = mount({ ratePerMinute: 10 });
const wrongMethod = await request(handler, { method: 'POST' });
assert.equal(wrongMethod.status, 405);
const offAllowlist = await request(handler, {
target: 'https://example.com/gbfs/station_status.json',
});
assert.equal(offAllowlist.status, 403);
assert.equal(offAllowlist.body.error, 'GBFS host not allowed');
const wrongPath = await request(handler, {
target: 'https://gbfs.lyft.com/gbfs/2.3/bay/en/system_regions.json',
});
assert.equal(wrongPath.status, 400);
assert.equal(asked.length, 0, 'none of those reached upstream');
});
+1
View File
@@ -8,6 +8,7 @@ const GBFS_ALLOWED_HOSTS = new Set([
'austin.publicbikesystem.net',
'hon.publicbikesystem.net',
'chat.publicbikesystem.net',
'gbfs.nextbike.net',
'gbfs.urbansharing.com',
'api.entur.io',
'api.cyclocity.fr',
+94 -36
View File
@@ -2,6 +2,7 @@
import * as Cesium from 'cesium';
import { createImportImageBudget } from './geoImportImages.js';
import { createImportedPins } from './geoImportPins.js';
import { msg } from '../i18n/marker.js';
/** Largest file accepted, in bytes. */
export const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
@@ -61,11 +62,17 @@ const KML_REMOVED_ELEMENTS = new Set([
'httpQuery',
]);
/** An error whose message is meant for the person who chose the file. */
/**
* An error whose message is meant for the person who chose the file. The
* message stays English; `template` and `values`, when given, let the UI show
* it in the active language.
*/
export class GeoImportError extends Error {
constructor(message) {
constructor(message, template = message, values = {}) {
super(message);
this.name = 'GeoImportError';
this.template = template;
this.values = values;
}
}
@@ -124,12 +131,14 @@ export function parseGeoJsonText(text) {
try {
value = JSON.parse(String(text).replace(/^/, ''));
} catch {
throw new GeoImportError('That file is not valid JSON.');
throw new GeoImportError(msg('That file is not valid JSON.'));
}
if (!value || typeof value !== 'object' || !GEOJSON_TYPES.has(value.type))
throw new GeoImportError('That JSON file is not GeoJSON.');
throw new GeoImportError(msg('That JSON file is not GeoJSON.'));
if (value.type === 'FeatureCollection' && !Array.isArray(value.features))
throw new GeoImportError('That FeatureCollection has no features list.');
throw new GeoImportError(
msg('That FeatureCollection has no features list.'),
);
return value;
}
@@ -164,7 +173,7 @@ export function countGeoJsonGeometry(
for (let i = 0; i < list.length; i++) {
if (list[i]?.type !== 'Feature')
throw new GeoImportError(
'Every GeoJSON feature must have type Feature.',
msg('Every GeoJSON feature must have type Feature.'),
);
stack.push(list[i]);
}
@@ -183,11 +192,11 @@ export function countGeoJsonGeometry(
if (node.type === 'GeometryCollection') {
const list = node.geometries;
if (!Array.isArray(list))
throw new GeoImportError('That GeoJSON has invalid geometries.');
throw new GeoImportError(msg('That GeoJSON has invalid geometries.'));
for (let i = 0; i < list.length; i++) {
if (!list[i] || ['Feature', 'FeatureCollection'].includes(list[i].type))
throw new GeoImportError(
'That GeoJSON contains an unsupported geometry.',
msg('That GeoJSON contains an unsupported geometry.'),
);
stack.push(list[i]);
}
@@ -216,7 +225,9 @@ export function countGeoJsonGeometry(
while (coords.length) {
const [c, remaining] = coords.pop();
if (!Array.isArray(c))
throw new GeoImportError('That GeoJSON has invalid coordinates.');
throw new GeoImportError(
msg('That GeoJSON has invalid coordinates.'),
);
if (remaining === 0) {
if (
c.length < 2 ||
@@ -226,7 +237,9 @@ export function countGeoJsonGeometry(
Math.abs(c[1]) > 90 ||
(c.length === 3 && Math.abs(c[2]) > 1_000_000)
)
throw new GeoImportError('That GeoJSON has invalid coordinates.');
throw new GeoImportError(
msg('That GeoJSON has invalid coordinates.'),
);
positions += 1;
if (positions > maxPositions) return result('positions');
} else
@@ -234,7 +247,7 @@ export function countGeoJsonGeometry(
}
} else
throw new GeoImportError(
'That GeoJSON contains an unsupported geometry.',
msg('That GeoJSON contains an unsupported geometry.'),
);
}
return result();
@@ -245,17 +258,36 @@ function overLimitError({ over, features }) {
if (over === 'entities')
return new GeoImportError(
`That file creates more than ${MAX_IMPORT_ENTITIES.toLocaleString()} map objects (the limit).`,
msg('That file creates more than {limit} map objects (the limit).'),
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
);
if (over === 'links')
return new GeoImportError(
`That file has more than ${MAX_KML_LINKS.toLocaleString()} style definitions and image links after cleanup (the limit).`,
msg(
'That file has more than {limit} style definitions and image links after cleanup (the limit).',
),
{ limit: MAX_KML_LINKS.toLocaleString() },
);
if (over === 'features')
return new GeoImportError(
`That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`,
msg('That file has more than {limit} features (the limit).'),
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
);
const limit = MAX_IMPORT_POSITIONS.toLocaleString();
if (features)
return new GeoImportError(
`That file has more than ${limit} points (the limit) across ${features.toLocaleString()} features.`,
msg(
'That file has more than {limit} points (the limit) across {features} features.',
),
{ limit, features: features.toLocaleString() },
);
return new GeoImportError(
`That file has more than ${MAX_IMPORT_POSITIONS.toLocaleString()} points (the limit)${features ? ` across ${features.toLocaleString()} features` : ''}.`,
`That file has more than ${limit} points (the limit).`,
msg('That file has more than {limit} points (the limit).'),
{ limit },
);
}
@@ -302,14 +334,16 @@ async function inflateRaw(bytes, maxBytes) {
total += value.length;
if (total > maxBytes)
throw new GeoImportError(
'That KMZ unpacks to more than the import limit.',
msg('That KMZ unpacks to more than the import limit.'),
);
chunks.push(value);
}
} catch (error) {
await reader.cancel().catch(() => {});
if (error instanceof GeoImportError) throw error;
throw new GeoImportError('That KMZ is damaged and could not be unpacked.');
throw new GeoImportError(
msg('That KMZ is damaged and could not be unpacked.'),
);
}
const out = new Uint8Array(total);
let at = 0;
@@ -332,7 +366,7 @@ export async function readZipEntries(
{ maxInflatedBytes = MAX_KMZ_INFLATED_BYTES } = {},
) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const fail = (message = 'That KMZ is not a readable zip archive.') => {
const fail = (message = msg('That KMZ is not a readable zip archive.')) => {
throw new GeoImportError(message);
};
// End of central directory: 22 bytes plus a comment of up to 64 KiB.
@@ -352,7 +386,7 @@ export async function readZipEntries(
const dirSize = view.getUint32(eocd + 12, true);
let at = view.getUint32(eocd + 16, true);
if (count === 0xffff || at === 0xffffffff || at + dirSize > eocd)
fail('That KMZ uses a zip format this import does not read.');
fail(msg('That KMZ uses a zip format this import does not read.'));
const entries = new Map();
const names = new TextDecoder();
let inflated = 0;
@@ -370,9 +404,9 @@ export async function readZipEntries(
const name = names.decode(bytes.subarray(at + 46, at + 46 + nameLength));
at += 46 + nameLength + extraLength + commentLength;
if (name.endsWith('/')) continue;
if (flags & 0x1) fail('That KMZ is encrypted.');
if (flags & 0x1) fail(msg('That KMZ is encrypted.'));
if (compressed === 0xffffffff || size === 0xffffffff)
fail('That KMZ uses a zip format this import does not read.');
fail(msg('That KMZ uses a zip format this import does not read.'));
if (local + 30 > bytes.length || view.getUint32(local, true) !== 0x04034b50)
fail();
const start =
@@ -386,10 +420,13 @@ export async function readZipEntries(
if (method === 0) contents = data;
else if (method === 8)
contents = await inflateRaw(data, maxInflatedBytes - inflated);
else fail('That KMZ uses a compression method this import does not read.');
else
fail(
msg('That KMZ uses a compression method this import does not read.'),
);
inflated += contents.length;
if (inflated > maxInflatedBytes)
fail('That KMZ unpacks to more than the import limit.');
fail(msg('That KMZ unpacks to more than the import limit.'));
entries.set(name, contents);
}
return entries;
@@ -765,7 +802,7 @@ export function countKmlGeometry(
!Number.isFinite(value) ||
Math.abs(value) > limit
)
throw new GeoImportError('That KML has invalid geometry bounds.');
throw new GeoImportError(msg('That KML has invalid geometry bounds.'));
}
// Cesium creates entities for folders, a parent for every placemark, and
// a child for each geometry in a MultiGeometry, including nested containers.
@@ -822,7 +859,7 @@ function checkKmlPosition(tuple) {
Math.abs(numbers[1]) > 90 ||
(numbers.length === 3 && Math.abs(numbers[2]) > 1_000_000)
)
throw new GeoImportError('That KML has invalid coordinates.');
throw new GeoImportError(msg('That KML has invalid coordinates.'));
}
/**
@@ -843,7 +880,7 @@ export function gpxToGeoJson(
) {
const root = doc.documentElement;
if (root?.localName !== 'gpx')
throw new GeoImportError('That GPX file has no <gpx> document.');
throw new GeoImportError(msg('That GPX file has no <gpx> document.'));
const features = [];
let positions = 0;
const point = (el) => {
@@ -1028,7 +1065,11 @@ function parseXml(text, what) {
'application/xml',
);
if (doc.getElementsByTagName('parsererror').length)
throw new GeoImportError(`That ${what} file is not valid XML.`);
throw new GeoImportError(
`That ${what} file is not valid XML.`,
msg('That {format} file is not valid XML.'),
{ format: what },
);
return doc;
}
@@ -1066,7 +1107,9 @@ function checkLocalImages(doc, entries = new Map(), main = '') {
}
} catch {
throw new GeoImportError(
'That file has an unreadable image or exceeds the 16-megapixel image limit.',
msg(
'That file has an unreadable image or exceeds the 16-megapixel image limit.',
),
);
}
}
@@ -1086,7 +1129,7 @@ async function loadKml(doc, viewer, signal) {
}),
new Promise((_, reject) => {
const failed = () => {
reject(new GeoImportError('That KML could not be read.'));
reject(new GeoImportError(msg('That KML could not be read.')));
};
globalThis.addEventListener?.('error', failed);
removeError = () => globalThis.removeEventListener?.('error', failed);
@@ -1116,7 +1159,9 @@ async function settleGroundPins(dataSource, viewer, signal) {
if (!position) continue;
const cartographic = Cesium.Cartographic.fromCartesian(position);
if (!cartographic)
throw new GeoImportError('That file has invalid point coordinates.');
throw new GeoImportError(
msg('That file has invalid point coordinates.'),
);
pins.push({
entity,
original: position,
@@ -1192,7 +1237,9 @@ async function settleGroundPins(dataSource, viewer, signal) {
const c = points[i] && Cesium.Cartographic.fromCartesian(points[i]);
if (!c)
throw new GeoImportError(
'Ground heights are not available for that file. Try again when the map has loaded.',
msg(
'Ground heights are not available for that file. Try again when the map has loaded.',
),
);
item.groundHeight = c.height;
c.height += item.offset;
@@ -1216,7 +1263,8 @@ async function boundedImport(promise, signal) {
abort = () => reject(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
timer = setTimeout(
() => reject(new GeoImportError('That file took too long to read.')),
() =>
reject(new GeoImportError(msg('That file took too long to read.'))),
60_000,
);
}),
@@ -1244,16 +1292,18 @@ export async function loadGeoFile(
: AbortSignal.timeout(60_000);
signal?.throwIfAborted();
if (!file || !Number.isFinite(file.size))
throw new GeoImportError('Nothing to import.');
throw new GeoImportError(msg('Nothing to import.'));
if (file.size > MAX_IMPORT_BYTES)
throw new GeoImportError(
`That file is larger than ${MAX_IMPORT_BYTES / 1024 / 1024} MiB.`,
msg('That file is larger than {size} MiB.'),
{ size: MAX_IMPORT_BYTES / 1024 / 1024 },
);
let bytes = new Uint8Array(await boundedImport(file.arrayBuffer(), signal));
const format = detectGeoFileFormat(file.name, bytes.subarray(0, 1024));
if (!format)
throw new GeoImportError(
'Only GeoJSON, KML, KMZ and GPX files can be imported.',
msg('Only GeoJSON, KML, KMZ and GPX files can be imported.'),
);
const stroke = Cesium.Color.fromCssColorString(color);
const urls = [];
@@ -1276,7 +1326,7 @@ export async function loadGeoFile(
const size = countGeoJsonGeometry(geojson);
if (size.over) throw overLimitError(size);
if (!size.positions)
throw new GeoImportError('That file has nothing to draw.');
throw new GeoImportError(msg('That file has nothing to draw.'));
prepareGeoJson(geojson);
bytes = null;
dataSource = await boundedImport(
@@ -1299,7 +1349,8 @@ export async function loadGeoFile(
if (format === 'kmz') {
entries = await readZipEntries(bytes);
main = kmzMainDocument(entries);
if (!main) throw new GeoImportError('That KMZ holds no KML document.');
if (!main)
throw new GeoImportError(msg('That KMZ holds no KML document.'));
text = decodeXml(entries.get(main));
resolveLocal = kmzImageResolver(entries, main, (url) => urls.push(url));
} else {
@@ -1325,6 +1376,8 @@ export async function loadGeoFile(
if (error instanceof GeoImportError) throw error;
throw new GeoImportError(
`That ${format.toUpperCase()} file could not be read.`,
msg('That {format} file could not be read.'),
{ format: format.toUpperCase() },
);
}
const entityCount = dataSource.entities.values.length;
@@ -1332,6 +1385,11 @@ export async function loadGeoFile(
revoke();
throw new GeoImportError(
`That file has ${entityCount.toLocaleString()} map objects; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`,
msg('That file has {count} map objects; the limit is {limit}.'),
{
count: entityCount.toLocaleString(),
limit: MAX_IMPORT_ENTITIES.toLocaleString(),
},
);
}
const now = Cesium.JulianDate.now();
@@ -1346,7 +1404,7 @@ export async function loadGeoFile(
);
if (!drawable) {
revoke();
throw new GeoImportError('That file has nothing to draw.');
throw new GeoImportError(msg('That file has nothing to draw.'));
}
scrubImportedEntities(dataSource);
try {
@@ -1356,7 +1414,7 @@ export async function loadGeoFile(
throw error instanceof GeoImportError
? error
: new GeoImportError(
'Ground heights could not be read. Try that file again.',
msg('Ground heights could not be read. Try that file again.'),
);
}
// Keep the clock invariant explicit even though unused KML dates have
@@ -1366,7 +1424,7 @@ export async function loadGeoFile(
pins = createImportedPins(dataSource, viewer);
} catch {
revoke();
throw new GeoImportError('That file has invalid point graphics.');
throw new GeoImportError(msg('That file has invalid point graphics.'));
}
// The display name travels beside the data source (the file name, not
// anything the document says about itself).
+26
View File
@@ -257,6 +257,32 @@ test('stickyFloorCell does not stick to a far-away stale cell', () => {
assert.deepEqual(stickyFloorCell(30.2004, -97.6604, prev), { lat: 30.2, lon: -97.66 });
});
// --- Antimeridian ------------------------------------------------------------
// Raw longitude arithmetic walks a segment across 180° the long way round and
// reads a contact on the seam as half a world from its previous cell.
test('corridorFloorCells walks across the antimeridian, not the long way round', () => {
for (const [from, to, across, behind] of [
[179.999, -179.998, -179.999, 179.998],
[-179.999, 179.998, 179.999, -179.998],
]) {
const cells = corridorFloorCells([{ lat: -16.69, lon: from }, { lat: -16.69, lon: to }]);
const lons = cells.map((c) => c.lon);
assert.equal(lons[0], from);
assert.equal(lons.at(-1), to);
assert.ok(lons.includes(across), `crosses through ${across}: ${lons}`);
assert.ok(!lons.includes(behind), `never walks back through ${behind}: ${lons}`);
assert.ok(lons.every((lon) => Math.abs(lon) <= 180), `normalised: ${lons}`);
}
});
test('stickyFloorCell holds across the antimeridian', () => {
for (const [prevLon, lon] of [[180, -179.9998], [-180, 179.9998]]) {
const prev = { lat: -16.69, lon: prevLon };
assert.deepEqual(stickyFloorCell(-16.69, lon, prev), prev);
}
});
// --- F2: corridor budget fairness ------------------------------------------
// The first cut spent the budget in Map insertion order and decremented it
// BEFORE deduping, so parked contacts (whose corridor is the fix cell the poll
+51 -21
View File
@@ -1,25 +1,43 @@
/** Explain mapped-site availability without claiming an unobserved overload. */
export function installationFeedback(stats = {}, now = Date.now()) {
const reasons = {
rate_limited: 'Overpass rate-limited',
timeout: 'Overpass timed out',
query_failed: 'Overpass could not complete the query',
tiles_unavailable: 'Map tiles temporarily unavailable',
names_unavailable: 'Mapped names temporarily unavailable',
};
const reason =
reasons[stats.failureReason] || 'Overpass temporarily unavailable';
import { msg } from '../i18n/marker.js';
// layers/installations/controls.js translates describeInstallationFeedback().
const REASONS = Object.freeze({
rate_limited: msg('Overpass rate-limited'),
timeout: msg('Overpass timed out'),
query_failed: msg('Overpass could not complete the query'),
tiles_unavailable: msg('Map tiles temporarily unavailable'),
names_unavailable: msg('Mapped names temporarily unavailable'),
});
const FALLBACK_REASON = msg('Overpass temporarily unavailable');
/**
* Mapped-site availability as a message to render, without claiming an
* unobserved overload. Either `{ text, reason?, seconds? }`, where `text` is
* English (`{reason}` / `{seconds}` placeholders, `reason` English too), or
* `{ count, km }` for a found-sites count (`km` is null for the view).
* @param {object} [stats]
* @param {number} [now]
* @returns {{text: string, reason?: string, seconds?: number}|{count: number, km: ?number}}
*/
export function describeInstallationFeedback(stats = {}, now = Date.now()) {
const reason = REASONS[stats.failureReason] || FALLBACK_REASON;
if (stats.loading)
return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…';
return {
text: stats.retrying
? msg('Retrying mapped sites…')
: msg('Fetching mapped sites…'),
};
if (stats.retryAt > 0) {
const seconds = Math.max(0, Math.ceil((stats.retryAt - now) / 1000));
return `${reason} — ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`;
return seconds
? { text: msg('{reason} — retrying in {seconds}s'), reason, seconds }
: { text: msg('{reason} — retry pending'), reason };
}
if (stats.status === 'unavailable') return reason;
if (stats.status === 'unavailable') return { text: reason };
if (stats.status === 'zoom-in')
return 'Zoom in to search mapped installations';
if (stats.stale) return 'Showing cached mapped sites';
if (stats.status === 'idle') return 'Mapped sites not loaded';
return { text: msg('Zoom in to search mapped installations') };
if (stats.stale) return { text: msg('Showing cached mapped sites') };
if (stats.status === 'idle') return { text: msg('Mapped sites not loaded') };
// With a count, say what was found and where; an empty area is not "loaded".
if (Number.isFinite(stats.count)) {
const km =
@@ -27,9 +45,21 @@ export function installationFeedback(stats = {}, now = Date.now()) {
Number.isFinite(stats.coverage.radiusM)
? Math.round(stats.coverage.radiusM / 1000)
: null;
const where = km ? ` within ${km} km of the contact` : ' in view';
if (stats.count === 0) return `No mapped sites${where}`;
return `${stats.count} mapped site${stats.count === 1 ? '' : 's'}${where}`;
return { count: stats.count, km: km || null };
}
return 'Mapped sites loaded';
return { text: msg('Mapped sites loaded') };
}
/** Explain mapped-site availability in English. */
export function installationFeedback(stats = {}, now = Date.now()) {
const feedback = describeInstallationFeedback(stats, now);
if (feedback.text !== undefined)
return feedback.text
.replace('{reason}', feedback.reason)
.replace('{seconds}', feedback.seconds);
const where = feedback.km
? ` within ${feedback.km} km of the contact`
: ' in view';
if (feedback.count === 0) return `No mapped sites${where}`;
return `${feedback.count} mapped site${feedback.count === 1 ? '' : 's'}${where}`;
}
+28
View File
@@ -0,0 +1,28 @@
import { msg } from '../i18n/marker.js';
/**
* English names for installations without a mapped name. Data keeps them
* English (server, AI tools, voice context); classLabel.js and
* militaryAwarenessEngine.js translate them.
*/
/** Name of an unnamed feature, by installation class. */
export const UNNAMED_INSTALLATION_LABEL_BY_CLASS = Object.freeze({
airfield: msg('Military airfield'),
naval_base: msg('Naval base'),
range: msg('Firing range'),
military_land: msg('Military land'),
});
/** Name of an unnamed feature without a usable class. */
export const UNNAMED_INSTALLATION_FALLBACK = msg('Mapped installation');
/** Name of a military polygon the names pack has not named. */
export const UNNAMED_MILITARY_AREA = msg('Military area');
/** Every name above, for the display to translate. */
export const UNNAMED_PLACE_NAMES = Object.freeze([
...Object.values(UNNAMED_INSTALLATION_LABEL_BY_CLASS),
UNNAMED_INSTALLATION_FALLBACK,
UNNAMED_MILITARY_AREA,
]);
+6 -3
View File
@@ -1,3 +1,6 @@
// Restore labels are marked with msg(); ui/shareRestoration.js translates them.
// Local, not i18n/marker.js: the token-ledger tests run this file in isolation.
const msg = (text) => text;
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
@@ -473,19 +476,19 @@ export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
optionOwner: 'flights',
optionKey: 'selectedFlightsTrackingId',
expiryWindowMs: 90_000,
label: 'flight',
label: msg('flight'),
}),
military: Object.freeze({
optionOwner: 'flights',
optionKey: 'selectedMilitaryTrackingId',
expiryWindowMs: 45_000,
label: 'military flight',
label: msg('military flight'),
}),
satellites: Object.freeze({
optionOwner: 'satellites',
optionKey: 'selectedSatTrackingId',
expiryWindowMs: 300_000,
label: 'satellite',
label: msg('satellite'),
}),
});
+9
View File
@@ -128,3 +128,12 @@ test('sampler: outside the google-3d regime nothing is sampled at all', () => {
setMeshFloorPreferred(true);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null);
});
test('sampler: a cell just across the antimeridian is still near the viewer', () => {
reset();
let calls = 0;
const scene = fakeScene(() => { calls += 1; return undefined; });
sampleMeshFloorCells(scene, [{ lat: -16.8, lon: -179.999 }], { viewerLat: -16.8, viewerLon: 179.999 });
sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 });
assert.equal(calls, 2, 'about 200 m apart, so both cells are probed');
});
File diff suppressed because it is too large. Load diff
+19 -6
View File
@@ -1,5 +1,10 @@
/** Pure proximity helpers for Global Context. No capability modeling. */
import { t } from '../i18n/index.js';
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
export const AWARENESS_RADIUS_M = 250000;
export const AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M = 16000000;
export const AWARENESS_RELATIONSHIP = Object.freeze({
@@ -47,7 +52,11 @@ export function formatAwarenessDistance(meters) {
: `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`;
}
/** Return a stable visible label without leaking missing values into UI text. */
/**
* Return a stable visible label without leaking missing values into UI text.
* The English name the data gives an unnamed installation ("Military area")
* is shown in the UI language; mapped names and callsigns show as they are.
*/
export function formatAwarenessLabel(value) {
if (value && typeof value === 'object') {
for (const candidate of [
@@ -63,7 +72,11 @@ export function formatAwarenessLabel(value) {
}
return '—';
}
if (typeof value === 'string') return value.trim() || '—';
if (typeof value === 'string') {
const text = value.trim();
if (!text) return '—';
return UNNAMED_NAMES.has(text) ? t(text) : text;
}
if (Number.isFinite(value)) return String(value);
return '—';
}
@@ -82,14 +95,14 @@ export function summarizeAwarenessCohort(
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed unavailable',
reason: t('feed unavailable'),
};
if (stale)
return {
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
count: null,
nearest: [],
reason: 'feed stale',
reason: t('feed stale'),
};
const normalized = (Array.isArray(items) ? items : [])
.filter(
@@ -106,8 +119,8 @@ export function summarizeAwarenessCohort(
count: normalized.length,
nearest: normalized.slice(0, limit),
reason: normalized.length
? 'observed or mapped nearby context'
: 'no observed or mapped objects in current feeds',
? t('observed or mapped nearby context')
: t('no observed or mapped objects in current feeds'),
};
}
+10 -7
View File
@@ -1,3 +1,8 @@
import {
UNNAMED_INSTALLATION_FALLBACK,
UNNAMED_INSTALLATION_LABEL_BY_CLASS,
} from './installationNames.js';
/**
* Normalize the deliberately small, allow-listed installation context returned
* by `/api/military-installations`. These are mapped features, not assertions
@@ -22,12 +27,10 @@ const CLASS_BY_MILITARY_TAG = {
* nouns. The OSM id is not lost — it stays on `id` and on `sources[].id`, where
* attribution and the details panel read it.
*/
const LABEL_BY_CLASS = {
airfield: 'Military airfield',
naval_base: 'Naval base',
range: 'Firing range',
military_land: 'Military land',
};
// The labels stay English in data (the server provider and AI tools read the
// records too); the map card and Contacts translate them (installationNames.js).
const LABEL_BY_CLASS = UNNAMED_INSTALLATION_LABEL_BY_CLASS;
const UNNAMED_FALLBACK = UNNAMED_INSTALLATION_FALLBACK;
const MAX_FOOTPRINT_POINTS = 400;
@@ -44,7 +47,7 @@ export function humanizeInstallationClass(klass) {
const words = key.replaceAll('_', ' ').trim();
return words
? `${words[0].toUpperCase()}${words.slice(1)}`
: 'Mapped installation';
: UNNAMED_FALLBACK;
}
function finiteLatitude(value) {
+24
View File
@@ -0,0 +1,24 @@
import { msg } from '../i18n/marker.js';
/**
* Tool name for a pointer owner. Owner ids (inputOwnership.js) never go on
* screen; callers translate the English name with translateKnown().
*/
const POINTER_OWNER_NAMES = Object.freeze({
draw: msg('Draw'),
directions: msg('Directions'),
'recent-imagery-box': msg('Recent imagery'),
});
/**
* English display name of a pointer owner. An owner without a registered name
* falls back to its id.
* @param {string|null} owner Pointer-owner id, as `pointerOwner()` returns it.
* @returns {string}
*/
export function pointerOwnerName(owner) {
const id = typeof owner === 'string' ? owner.trim() : '';
if (!id) return '';
return Object.hasOwn(POINTER_OWNER_NAMES, id) ? POINTER_OWNER_NAMES[id] : id;
}
+121
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { gstime } from 'satellite.js';
import { createModel } from '../layers/launches/model.js';
import rocketLaunchesLayer, {
_setRocketMissionOverlayHostForTest,
_setSelectedRocketMissionForTest,
@@ -728,6 +729,126 @@ test('normalizes detailed payload and stage recovery records', () => {
assert.equal(launches[0].recoveryStages[0].lon, -76.2);
});
test('finiteCoordinate normalizes finite numbers and rejects non-numeric or non-finite inputs', () => {
const model = createModel({});
assert.equal(model.finiteCoordinate(42.5), 42.5);
assert.equal(model.finiteCoordinate('28.5619'), 28.5619);
assert.equal(model.finiteCoordinate('-80.5772'), -80.5772);
assert.equal(model.finiteCoordinate(0), 0);
assert.equal(model.finiteCoordinate('0'), 0);
assert.equal(model.finiteCoordinate(undefined), null);
assert.equal(model.finiteCoordinate('invalid'), null);
assert.equal(model.finiteCoordinate('TBD'), null);
assert.equal(model.finiteCoordinate(Number.NaN), null);
assert.equal(model.finiteCoordinate(Number.POSITIVE_INFINITY), null);
assert.equal(model.finiteCoordinate(Number.NEGATIVE_INFINITY), null);
});
test('normalizes recovery stage coordinates, downrange distance, and flight numbers with non-finite fallbacks', () => {
const launches = normalizeRocketLaunches(
{
results: [
{
id: 'recovery-fallbacks',
name: 'Falcon 9 | Edge Case Stage Normalization',
net: '2026-07-20T10:00:00Z',
pad: { latitude: '28.608', longitude: '-80.604' },
rocket: {
launcher_stage: [
{
id: 88,
type: { name: 'Booster' },
launcher_flight_number: 'invalid-flight',
landing: {
attempt: true,
success: false,
downrange_distance: 'TBD',
landing_location: {
name: 'Ocean Impact',
latitude: 'N/A',
longitude: undefined,
},
},
},
{
id: 89,
type: { name: 'Expended Stage' },
landing: {
attempt: false,
type: { name: 'Expendable' },
},
},
],
},
},
],
},
NOW,
);
assert.equal(launches[0].recoveryStages.length, 2);
const booster = launches[0].recoveryStages[0];
assert.equal(booster.status, 'LOST');
assert.equal(booster.success, false);
assert.equal(booster.attempted, true);
assert.equal(booster.flightNumber, null);
assert.equal(booster.downrangeKm, null);
assert.equal(booster.lat, null);
assert.equal(booster.lon, null);
const expendable = launches[0].recoveryStages[1];
assert.equal(expendable.status, 'EXPENDABLE');
assert.equal(expendable.attempted, false);
assert.equal(expendable.success, null);
});
test('normalizes payload amounts and fractional string masses', () => {
const launches = normalizeRocketLaunches(
{
results: [
{
id: 'payload-edge-cases',
name: 'Electron | Mixed Payloads',
net: '2026-07-20T10:00:00Z',
pad: { latitude: '-39.26', longitude: '177.86' },
rocket: {
payloads: [
{
id: 601,
amount: 'not-a-number',
payload: { id: 11, name: 'Unknown Amount Payload' },
},
{
id: 602,
amount: 3,
payload: { id: 12, name: 'Triple Payload' },
},
{
id: 603,
payload: {
id: 13,
name: 'Valid Fractional Mass Payload',
mass: '12.75',
},
},
],
},
},
],
},
NOW,
);
// Blank, negative and non-numeric masses are pinned by "missing payload
// details stay unknown without inventing mass or classification".
const payloads = launches[0].payloads;
assert.equal(payloads.length, 3);
assert.equal(payloads[0].amount, 1);
assert.equal(payloads[1].amount, 3);
assert.equal(payloads[2].massKg, 12.75);
});
test('keeps payload and recovery collections empty when LL2 does not disclose them', () => {
const launches = normalizeRocketLaunches({ results: [{
id: 'undisclosed',
+42 -18
View File
@@ -17,54 +17,46 @@
* for), then the constellations users can actually name, then the grab-bag.
*/
import { t, msg } from '../i18n/index.js';
/**
* Class registry. `label` is the card/legend token, `color` the point color,
* `blurb` the plain-language gloss for the legend tooltip.
* @type {Readonly<Record<string, { label: string, color: string, blurb: string }>>}
* Class registry: the point color of each class. The card and legend text is
* in CLASS_DISPLAY_KEYS.
* @type {Readonly<Record<string, { color: string }>>}
*/
export const SATELLITE_CLASSES = Object.freeze({
station: Object.freeze({
label: 'STATION',
// Warm white — "crewed", and the brightest class in the sky. Deliberately
// NOT amber: 40-48deg amber is the app's known-military convention
// (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not
// leak into another domain. Slightly warm so it also separates from the
// pure white the civilian air fleet draws in.
color: '#fff6e5',
blurb: 'Crewed stations and their visiting vehicles',
}),
nav: Object.freeze({
label: 'NAV',
// Cyan — the strongest accent goes to the class users actually name
// ("how many GPS satellites are up?"). Takes over the slot the catch-all
// VISUAL group used to occupy, so this adds no new hue to the app.
color: '#4fd8ff',
blurb: 'GNSS navigation — GPS, GLONASS, Galileo',
}),
geo: Object.freeze({
label: 'GEO',
// Violet, unchanged. Already the app's "space" semantic (the detection
// overlay paints the SAT tier #bda4ff), and the geostationary belt draws
// as one clean equatorial ring at globe scale.
color: '#c89bff',
blurb: 'Geostationary belt — comms and weather, fixed over the equator',
}),
visual: Object.freeze({
label: 'VISUAL',
// Muted blue-gray. This is the catch-all bucket, so it gives up the bright
// cyan it used to hold and recedes behind the three classes that mean
// something. Still well clear of the dense shell by luminance.
color: '#9fb3c4',
blurb: 'Brightest naked-eye objects — CelesTrak visual group',
}),
comms: Object.freeze({
label: 'COMMS',
// Dim slate — thousands of these appear in DENSE mode. They must read as
// texture behind the core catalog, never compete with it. Rec.601 luma
// ~0.40 against VISUAL's ~0.69 keeps the two separable even when the
// NVG/FLIR shaders collapse the scene to a single channel.
color: '#54697f',
blurb: 'Broadband constellation shell — shown only in DENSE mode',
}),
});
@@ -77,6 +69,32 @@ export const SATELLITE_CLASS_ORDER = Object.freeze([
'comms',
]);
// What the card and legend show for each class.
const CLASS_DISPLAY_KEYS = Object.freeze({
station: Object.freeze({
label: msg('STATION'),
blurb: msg('Crewed stations and their visiting vehicles'),
}),
nav: Object.freeze({
label: msg('NAV'),
blurb: msg('GNSS navigation — GPS, GLONASS, Galileo'),
}),
geo: Object.freeze({
label: msg('GEO'),
blurb: msg(
'Geostationary belt — comms and weather, fixed over the equator',
),
}),
visual: Object.freeze({
label: msg('VISUAL'),
blurb: msg('Brightest naked-eye objects — CelesTrak visual group'),
}),
comms: Object.freeze({
label: msg('COMMS'),
blurb: msg('Broadband constellation shell — shown only in DENSE mode'),
}),
});
/**
* CelesTrak group tag → { class, subtype }. Subtype names the specific
* constellation so the card can read "NAV · GPS" rather than a bare "NAV".
@@ -134,12 +152,17 @@ export function satelliteClassColor(group) {
* The ISS is always a STATION regardless of which group ingested it — see
* satelliteClassOf, which owns that rule for every consumer.
* @param {string|undefined|null} group CelesTrak group tag.
* @param {{ isIss?: boolean }} [options] Whether this is the ISS.
* @param {{ isIss?: boolean, localize?: boolean }} [options] isIss: the ISS;
* localize: false keeps the English label (data for the AI tools).
* @returns {string} Display label.
*/
export function satelliteClassLabel(group, { isIss = false } = {}) {
export function satelliteClassLabel(
group,
{ isIss = false, localize = true } = {},
) {
const { klass, subtype } = satelliteClassOf(group, { isIss });
const base = SATELLITE_CLASSES[klass].label;
const label = CLASS_DISPLAY_KEYS[klass].label;
const base = localize ? t(label) : label;
return subtype ? `${base} · ${subtype}` : base;
}
@@ -180,11 +203,12 @@ export function satelliteClassLegend(counts) {
const count = counts?.[klass];
if (!(count > 0)) continue;
const spec = SATELLITE_CLASSES[klass];
const display = CLASS_DISPLAY_KEYS[klass];
result.push({
klass,
label: spec.label,
label: t(display.label),
color: spec.color,
blurb: spec.blurb,
blurb: t(display.blurb),
count,
});
}
+101 -39
View File
@@ -19,8 +19,10 @@ import {
import satellitesLayer from './satellites.js';
import { createTrackedOverlayEntry } from './trackedReadout.js';
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
test('partial and dense catalogs do not make an early false missing decision', async () => {
_setSatelliteTrackingRefreshOutcomeForTest({
@@ -32,31 +34,45 @@ test('partial and dense catalogs do not make an early false missing decision', a
assert.match(partial.reason, /partial CelesTrak catalog/);
let releaseDense;
const densePromise = new Promise((resolve) => { releaseDense = resolve; });
const densePromise = new Promise((resolve) => {
releaseDense = resolve;
});
_setSatelliteTrackingRefreshOutcomeForTest({
status: 'accepted',
catalog: 'dense',
densePromise,
});
let settled = false;
const resolution = satellitesLayer.resolveTrackingRestoreTarget(987654)
.then((result) => { settled = true; return result; });
const resolution = satellitesLayer
.resolveTrackingRestoreTarget(987654)
.then((result) => {
settled = true;
return result;
});
await Promise.resolve();
assert.equal(settled, false, 'dense absence must wait for the dense catalog');
releaseDense({ status: 'ready' });
assert.equal((await resolution).status, 'missing');
_setSatelliteTrackingRefreshOutcomeForTest({ status: 'accepted', catalog: 'core' });
_setSatelliteTrackingRefreshOutcomeForTest({
status: 'accepted',
catalog: 'core',
});
});
test('satellite first update aborts every parallel catalog request', async () => {
const realFetch = globalThis.fetch;
const observedSignals = [];
globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => {
observedSignals.push(options.signal);
options.signal?.addEventListener('abort', () => {
reject(new DOMException('aborted', 'AbortError'));
}, { once: true });
});
globalThis.fetch = (_url, options = {}) =>
new Promise((_resolve, reject) => {
observedSignals.push(options.signal);
options.signal?.addEventListener(
'abort',
() => {
reject(new DOMException('aborted', 'AbortError'));
},
{ once: true },
);
});
try {
const controller = new AbortController();
const work = satellitesLayer.update({}, { signal: controller.signal });
@@ -64,7 +80,10 @@ test('satellite first update aborts every parallel catalog request', async () =>
assert.ok(observedSignals.length > 1);
controller.abort();
await assert.rejects(work, { name: 'AbortError' });
assert.equal(observedSignals.every((signal) => signal?.aborted), true);
assert.equal(
observedSignals.every((signal) => signal?.aborted),
true,
);
} finally {
globalThis.fetch = realFetch;
}
@@ -94,7 +113,9 @@ test('selected satellite params survive delayed arrival and yield to newer expli
point,
});
try {
assert.doesNotThrow(() => satellitesLayer.setParams({ selectedSatTrackingId: 25544 }));
assert.doesNotThrow(() =>
satellitesLayer.setParams({ selectedSatTrackingId: 25544 }),
);
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
assert.equal(viewer.trackedEntity?.gevTrackedId, 'satellites:25544');
@@ -120,7 +141,11 @@ test('selected satellite params survive delayed arrival and yield to newer expli
);
assert.equal(_pendingSatelliteTrackingRestoreForTest(), 99999);
assert.equal(satellitesLayer.trackById(25544, { origin: 'user' }), true);
assert.equal(_pendingSatelliteTrackingRestoreForTest(), null, 'new user selection cancels stale restore');
assert.equal(
_pendingSatelliteTrackingRestoreForTest(),
null,
'new user selection cancels stale restore',
);
_applyPendingSatelliteTrackingRestoreForTest();
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
@@ -132,7 +157,11 @@ test('selected satellite params survive delayed arrival and yield to newer expli
);
assert.equal(_pendingSatelliteTrackingRestoreForTest(), 25544);
satellitesLayer.stopTracking({ origin: 'user' });
assert.equal(_pendingSatelliteTrackingRestoreForTest(), null, 'explicit clear cancels stale restore');
assert.equal(
_pendingSatelliteTrackingRestoreForTest(),
null,
'explicit clear cancels stale restore',
);
_setSatelliteLabelLifecycleStateForTest({
viewer,
satrec: twoline2satrec(L1, L2),
@@ -187,8 +216,8 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
viewer,
now: () => nowMs,
neighbours: [
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) }, // berthed
{ noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) }, // berthed
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) }, // berthed
{ noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) }, // berthed
{ noradId: 99999, name: 'UNRELATED SAT', point: near(50_000) }, // 50 km away
],
});
@@ -203,10 +232,17 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
const details = entity.gevLabelModel.details;
// Class leads the detail block; the altitude line follows it, and the
// consolidated companions stay last.
assert.equal(details[0], 'STATION · ISS', 'the class line names what this is');
assert.equal(
details[0],
'STATION · ISS',
'the class line names what this is',
);
assert.match(details[1], /NORAD 25544$/, 'the altitude line is unchanged');
assert.equal(details[2], 'DOCKED · PROGRESS-MS 34 · +1',
'companions are consolidated, named, and counted on the tracked card');
assert.equal(
details[2],
'DOCKED · PROGRESS-MS 34 · +1',
'companions are consolidated, named, and counted on the tracked card',
);
// Scoping is encoded in that string: THREE neighbours were seeded but only two
// are counted (the named one plus "+1"). The satellite 50 km away is not part
@@ -222,10 +258,7 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra
camera: null,
scene: { frameState: { frameNumber: 1 } },
};
const epochs = [
Date.UTC(2008, 8, 20, 12, 30),
Date.UTC(2008, 8, 20, 13, 0),
];
const epochs = [Date.UTC(2008, 8, 20, 12, 30), Date.UTC(2008, 8, 20, 13, 0)];
let epochIndex = 0;
_setTrackedSatelliteRefreshStateForTest({
noradId: 25544,
@@ -240,14 +273,18 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra
_runSatellitePreRenderForTest();
assert.equal(entity.gevLabelModel.title, 'ISS (ZARYA)');
assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS');
assert.equal(entity.gevLabelModel.details[1], '353 km · NORAD 25544');
// formatQuantity keeps the number and its unit together with a no-break space.
assert.equal(entity.gevLabelModel.details[1], '353\u00a0km · NORAD 25544');
epochIndex = 1;
viewer.scene.frameState.frameNumber = 2;
_runSatellitePreRenderForTest();
assert.equal(entity.gevLabelModel.details[1], '366 km · NORAD 25544');
assert.equal(entity.gevLabelModel.details[0], 'STATION · ISS',
'the class line survives an altitude-only republish');
assert.equal(entity.gevLabelModel.details[1], '366\u00a0km · NORAD 25544');
assert.equal(
entity.gevLabelModel.details[0],
'STATION · ISS',
'the class line survives an altitude-only republish',
);
});
test('ISS ambient and tracked lifecycle uses cached host entries with no native labels or duplicate text', () => {
@@ -288,34 +325,59 @@ test('ISS ambient and tracked lifecycle uses cached host entries with no native
assert.deepEqual(ambientPublication[3], ISS_OVERLAY_SOURCE_OPTIONS);
const ambient = ambientPublication[2][0];
assert.equal(ambient.title, 'ISS');
assert.equal(ambient.position(), point.position, 'ISS getter reads the existing point cache');
assert.equal(point.label, undefined, 'ISS point primitive carries no label graphic');
assert.equal(
ambient.position(),
point.position,
'ISS getter reads the existing point cache',
);
assert.equal(
point.label,
undefined,
'ISS point primitive carries no label graphic',
);
const trackedEntity = _trackIssForTest();
assert.ok(trackedEntity);
assert.equal(viewer.entities.values.length, 1, 'runtime guard requires a real tracked entity');
assert.equal(
viewer.entities.values.length,
1,
'runtime guard requires a real tracked entity',
);
assert.equal(trackedEntity.label, undefined);
assert.ok(trackedEntity.point, 'tracked dot remains on the entity for camera framing');
assert.ok(
trackedEntity.point,
'tracked dot remains on the entity for camera framing',
);
const trackedEntry = createTrackedOverlayEntry(trackedEntity);
assert.ok(trackedEntry);
assert.equal(trackedEntry.id, 'satellites:25544');
assert.equal(trackedEntry.protected, true);
assert.equal(trackedEntry.paintLane, 'tracked');
assert.equal(
calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length,
calls.filter(
([type, sourceId]) =>
type === 'entries' && sourceId === 'satellites-iss',
).length,
2,
'tracking may refresh before ownership changes but must not publish a second ISS entry',
);
assert.deepEqual(calls.slice(-2), [
['clear', 'satellites-iss'],
['visible', 'satellites-iss', false],
], 'tracked ISS suppresses ambient text so the tracked card is the only text surface');
assert.deepEqual(
calls.slice(-2),
[
['clear', 'satellites-iss'],
['visible', 'satellites-iss', false],
],
'tracked ISS suppresses ambient text so the tracked card is the only text surface',
);
_clearSatelliteLabelLifecycleForTest();
cleaned = true;
assert.equal(viewer.entities.values.length, 0);
assert.equal(
calls.filter(([type, sourceId]) => type === 'entries' && sourceId === 'satellites-iss').length,
calls.filter(
([type, sourceId]) =>
type === 'entries' && sourceId === 'satellites-iss',
).length,
3,
'untracking republishes exactly one persistent ISS ambient entry before teardown',
);
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
import {
_runSatellitePreRenderForTest,
_setTrackedSatelliteRefreshStateForTest,
_trackedFrameCartesianForTest,
} from './satellites.js';
import { setLocale } from '../i18n/index.js';
const L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
function run(locale) {
setLocale(locale);
const entity = { gevLabelModel: { title: 'OLD', details: ['? km'] } };
const point = { position: new Cesium.Cartesian3() };
const viewer = { camera: null, scene: { frameState: { frameNumber: 1 } } };
let nowMs = Date.UTC(2008, 8, 20, 12, 30);
const near = (metres) => ({
get position() {
const tracked = _trackedFrameCartesianForTest();
return new Cesium.Cartesian3(tracked.x + metres, tracked.y, tracked.z);
},
});
_setTrackedSatelliteRefreshStateForTest({
noradId: 25544,
name: 'ISS (ZARYA)',
satrec: twoline2satrec(L1, L2),
entity,
point,
viewer,
now: () => nowMs,
neighbours: [
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) },
{ noradId: 55556, name: 'SOYUZ-MS 12', point: near(-90) },
],
});
_runSatellitePreRenderForTest();
nowMs += 2000;
viewer.scene.frameState.frameNumber = 2;
_runSatellitePreRenderForTest();
const details = entity.gevLabelModel.details;
setLocale('en');
return details;
}
test('es: tracked ISS card is localized', () => {
const details = run('es');
assert.equal(details[0], 'ESTACIÓN · ISS');
assert.doesNotMatch(
details[2],
/^DOCKED/,
'companions line should be localized',
);
});
test('ru: tracked ISS card is localized', () => {
const details = run('ru');
assert.equal(details[0], 'СТАНЦИЯ · ISS');
assert.match(details[1], /км/, 'ru glossary: km → км');
assert.doesNotMatch(
details[2],
/^DOCKED/,
'companions line should be localized',
);
});
+41 -1
View File
@@ -4,6 +4,8 @@
* after the vessel cards moved out of their dedicated canvas renderer.
*/
import { t, msg } from '../i18n/index.js';
export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels';
/** Existing selector grid size; one ambient winner is retained per cell. */
export const VESSEL_LABEL_GRID_PX = 118;
@@ -13,6 +15,8 @@ export const VESSEL_DEFAULT_LABEL_LIMIT = 900;
export const VESSEL_OVERLAY_MAX_COHORT = VESSEL_DEFAULT_LABEL_LIMIT;
/** Existing ambient vessel-card distance fade reaches zero at 5000 km. */
export const VESSEL_CARD_FADE_DISTANCE_M = 5_000_000;
/** Share of VESSEL_CARD_FADE_DISTANCE_M at which ambient cards begin to fade. */
export const VESSEL_CARD_FADE_START_RATIO = 0.7;
/**
* AIS type family → chevron hue + card accent. Single source of truth for
@@ -64,6 +68,42 @@ const NUMERIC_TYPE_FAMILIES = {
9: 'OTHER',
};
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
// and tests match on them.
const TYPE_DISPLAY_KEYS = Object.freeze({
FISHING: msg('FISHING'),
TOWING: msg('TOWING'),
DREDGER: msg('DREDGER'),
'DIVE OPS': msg('DIVE OPS'),
MILITARY: msg('MILITARY'),
SAILING: msg('SAILING'),
PLEASURE: msg('PLEASURE'),
PILOT: msg('PILOT'),
SAR: msg('SAR'),
TUG: msg('TUG'),
'PORT TENDER': msg('PORT TENDER'),
'ANTI-POLLUTION': msg('ANTI-POLLUTION'),
'LAW ENFORCE': msg('LAW ENFORCE'),
MEDICAL: msg('MEDICAL'),
'HIGH-SPEED': msg('HIGH-SPEED'),
PASSENGER: msg('PASSENGER'),
CARGO: msg('CARGO'),
TANKER: msg('TANKER'),
OTHER: msg('OTHER'),
});
/**
* Card-display form of a normalized AIS type: known tokens translate through
* the catalog; free-text types and unknown codes render unchanged.
* @param {string} type Raw AIS type.
* @returns {string}
*/
export function displayVesselType(type) {
const normalized = normalizeVesselType(type);
const key = TYPE_DISPLAY_KEYS[normalized.toUpperCase()];
return key ? t(key) : normalized;
}
/**
* Resolve an AIS type to display text: bare numeric ship-type codes map to
* family names ("71" → "CARGO"); text types pass through unchanged.
@@ -148,7 +188,7 @@ export function applyVesselOverlayPolicy(
verticalOnly: !selected,
viewportMargin: 4,
maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance,
distanceFadeStartRatio: 0.7,
distanceFadeStartRatio: VESSEL_CARD_FADE_START_RATIO,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
+96
View File
@@ -0,0 +1,96 @@
// A translated vessel type is never cut mid-word on the card.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { buildVesselCard, buildSelectedVesselCard } from './aisLiveVessels.js';
import { displayVesselType } from './vesselLabels.js';
import { setLocale } from '../i18n/index.js';
const POS = new Cesium.Cartesian3(1, 2, 3);
const rec = (type) => ({
name: 'X',
mmsi: '1',
type,
destination: '',
speed: 5,
course: 10,
heading: 10,
lastPositionUtc: '',
missedRefreshes: 0,
position: POS,
billboard: null,
});
// Every AIS ShipType code with its own display token.
const CODES = [
'30',
'31',
'33',
'34',
'35',
'36',
'37',
'50',
'51',
'52',
'53',
'54',
'55',
'58',
'40',
'60',
'70',
'80',
'90',
];
// The card label is acceptable when it is the whole translation, or when it
// was shortened at a word boundary and marked with an ellipsis.
function assertNotCutMidWord(label, full, where) {
if (label === full) return;
const head = label.replace(/(…|\.\.\.)$/, '');
assert.notEqual(
head,
label,
`${where}: "${label}" cut from "${full}" without an ellipsis`,
);
assert.ok(
full.startsWith(head),
`${where}: "${label}" is not a prefix of "${full}"`,
);
assert.match(
full.slice(head.length),
/^[\s-]/,
`${where}: "${label}" cuts "${full}" mid-word`,
);
}
for (const locale of ['es', 'ru']) {
test(`${locale}: vessel type on cards is never cut mid-word`, () => {
setLocale(locale);
try {
for (const code of CODES) {
const full = displayVesselType(code).toUpperCase();
const ambient = buildVesselCard(rec(code)).details[0].split(' · ')[0];
const selected = buildSelectedVesselCard(rec(code)).details[0].split(
' · ',
)[0];
assertNotCutMidWord(ambient, full, `${locale} ${code} ambient`);
assertNotCutMidWord(selected, full, `${locale} ${code} selected`);
}
} finally {
setLocale('en');
}
});
}
test('en: free feed text is still bounded to the card width', () => {
setLocale('en');
assert.equal(
buildVesselCard(rec('Crude Oil Tanker')).details[0].split(' · ')[0],
'CRUDE OIL TANK',
);
assert.equal(
buildSelectedVesselCard(rec('54')).details[0].split(' · ')[0],
'ANTI-POLLUTION',
);
});