Merge remote-tracking branch 'origin/main' into feat/viewport-context-decision-upstream

This commit is contained in:
manjunath22466 committed 2026-11-07 21:52:46 +05:30
commit 3ef3b9d555
66 files changed
+2470 -143

No files matched your search

+2
View File
@@ -67,6 +67,8 @@ export const _extractPickedCameraIdForTest =
export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest;
export const focusCctvRecord = layer.focusCctvRecord;
export const maybeAutoHop = layer.maybeAutoHop;
export const openingCameraId = layer.openingCameraId;
export const reopenNearestCamera = layer.reopenNearestCamera;
export const cctvCycleIndex = layer.cctvCycleIndex;
export {
CCTV_CALIBRATION_STORAGE_KEY_V1,
+116
View File
@@ -61,6 +61,8 @@ import cctvLayer, {
materializeCctvActiveCoverageEntities,
materializeCctvVisibleCoverageEntities,
maybeAutoHop,
openingCameraId,
reopenNearestCamera,
prioritizeActiveCctvGeometryRecord,
processCctvGeometryDrainBatch,
processCctvGeometryQueueBatch,
@@ -1296,6 +1298,120 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
}
});
test('CCTV opens on the camera nearest the viewer, not the first in the catalog', () => {
// QA 2026-11-05: at King's Cross, turning CCTV on showed a Sydney camera,
// the first catalog record, instead of the TfL camera 0.3 km away.
const viewer = makeDeselectViewer();
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
-0.1209,
51.5295,
187,
);
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
-0.1209,
51.5295,
187,
);
const sydney = makeDeselectRecord('au-sydney');
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
const london = makeDeselectRecord('gb-kings-cross');
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
try {
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: null,
enabled: false,
});
assert.equal(openingCameraId(), 'gb-kings-cross');
_setCctvCoverageStateForTest({
viewer: null,
records: [sydney, london],
activeCameraId: null,
enabled: false,
});
assert.equal(
openingCameraId(),
'au-sydney',
'with no viewer position the first record is the fallback',
);
_setCctvCoverageStateForTest({ viewer, records: [], enabled: false });
assert.equal(openingCameraId(), null);
} finally {
_setCctvCoverageStateForTest({ enabled: false });
}
});
test('the opening camera follows a shared view that lands after CCTV turned on, until someone picks one', () => {
// A share link turns CCTV on before its flight lands, so enable() picks
// from the startup view; the flight's settled move re-picks near London.
const viewer = makeDeselectViewer();
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
-0.1209,
51.5295,
187,
);
const sydney = makeDeselectRecord('au-sydney');
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
const london = makeDeselectRecord('gb-kings-cross');
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
for (const record of [sydney, london])
Object.assign(record, {
groundResolved: {},
groundSamples: {},
groundPrior: 0,
frustumGeometry: null,
frustumPositions: null,
probeClampRangeM: null,
});
_setCctvOverlayHostForTest({
setEntries() {},
setVisible() {},
clearSource() {},
});
// Activation builds the projection canvas and frame image.
const originalDocument = globalThis.document;
const originalImage = globalThis.Image;
globalThis.document = {
createElement: () => ({ getContext: () => null }),
};
globalThis.Image = class {};
try {
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: 'au-sydney',
enabled: true,
openingCameraProvisional: true,
});
assert.equal(reopenNearestCamera(), true);
assert.equal(cctvLayer.getUIState().activeCameraId, 'gb-kings-cross');
assert.equal(reopenNearestCamera(), false, 'already the nearest');
// Someone chooses a camera: settled moves leave it alone.
setActiveCamera('au-sydney');
assert.equal(reopenNearestCamera(), false);
assert.equal(cctvLayer.getUIState().activeCameraId, 'au-sydney');
// A deselect is a choice too.
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: 'au-sydney',
enabled: true,
openingCameraProvisional: true,
});
deactivateActiveCamera();
assert.equal(reopenNearestCamera(), false);
assert.equal(cctvLayer.getUIState().activeCameraId, null);
} finally {
globalThis.document = originalDocument;
globalThis.Image = originalImage;
_setCctvOverlayHostForTest();
_setCctvCoverageStateForTest({ enabled: false });
}
});
test('CCTV disable clears and hides its shared-host source through the real layer lifecycle', () => {
const calls = [];
_setCctvOverlayHostForTest({
+429
View File
@@ -0,0 +1,429 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import {
DEFAULT_ICELAND_MAX_SOURCES,
ICELAND_CAMERAS_URL,
ICELAND_IMAGE_ORIGIN,
ICELAND_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
import { isLikelyIcelandCoordinate } from '../../server/providers/cctv/normalize.js';
import {
icelandCameraToSource,
icelandHeadingFromDescription,
loadIcelandSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
/** Set (or, for `undefined`, delete) environment variables for one test. */
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;
}
}
/** One row shaped like the live `vefmyndavelar2014_1` payload. */
const row = (over = {}) => ({
Maelist_nr: 7001,
Myndavel: 'Hellisheiði',
Vegheiti: 'Hringvegur',
NrVegur: '1',
Skyring: 'Hellisheiði séð til vesturs',
Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`,
PntX: 395000,
PntY: 390000,
Breidd: 64.0136,
Lengd: -21.4,
...over,
});
/** A live response body plus a flag that flips when the stream is cancelled. */
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('['));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
const json = (value) =>
new Response(JSON.stringify(value), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
test('an Iceland row maps to a source on the pinned frame directory', () => {
const source = icelandCameraToSource(row());
assert.equal(source.id, 'is-vegagerdin-hellisheidi_1');
assert.equal(source.name, 'Hellisheiði séð til vesturs');
assert.equal(source.city, 'Iceland');
assert.equal(source.cityId, 'iceland');
assert.equal(source.provider, 'Vegagerðin (IRCA)');
assert.equal(source.lat, 64.0136);
assert.equal(source.lon, -21.4);
assert.equal(source.headingDeg, 270);
assert.equal(source.headingConfidence, 'high');
assert.equal(source.pitchDeg, -24);
assert.equal(source.fovDeg, 56);
assert.equal(source.rangeM, 210);
assert.equal(source.mountHeightM, 10);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'iceland-vegagerdin');
assert.equal(source.code, 'HELLISHEIÐI');
assert.match(source.license, /CC BY 4\.0/);
assert.ok(source.url.startsWith(ICELAND_IMAGE_ORIGIN));
assert.equal(source.snapshotUrl, source.url);
});
test('a view that states no facing takes the low-confidence id-hash pose', () => {
const source = icelandCameraToSource(
row({
Skyring: 'Hellisheiði séð niður á veg',
Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg`,
}),
);
assert.equal(source.headingConfidence, 'low');
assert.equal(source.headingDeg % 22.5, 0);
assert.equal(
source.headingDeg,
icelandCameraToSource(
row({ Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg` }),
).headingDeg,
'the fallback depends on the id, not on the text',
);
});
test('the label collapses the feed’s doubled spaces and repeated words', () => {
assert.equal(
icelandCameraToSource(
row({ Skyring: 'Strandarheiði séð niður á veg til til austurs' }),
).name,
'Strandarheiði séð niður á veg til austurs',
);
const label = (Skyring) => icelandCameraToSource(row({ Skyring })).name;
// Repeats of words with Icelandic letters collapse too.
assert.equal(
label('Hellisheiði séð séð til austurs'),
'Hellisheiði séð til austurs',
);
assert.equal(label('Brú á á Jökulsá'), 'Brú á Jökulsá');
// A word ending in a letter the next word starts with is not a repeat.
for (const text of [
'Þverá Árnessýslu',
'brú Úlfarsá',
'Vesturlandsvegur við Elliðaá Ártúnsbrekku',
])
assert.equal(label(text), text);
});
test('a row with no label, station or station number is named by its frame', () => {
const source = icelandCameraToSource(
row({ Skyring: '', Myndavel: '', Maelist_nr: undefined }),
);
assert.equal(source.name, 'Vegagerðin hellisheidi_1');
});
test('only an explicit facing sets a heading', () => {
const cases = [
['Hellisheiði séð til vesturs', 270],
['Holtavörðuheiði til norðurs', 0],
['Klettsháls í suður', 180],
['Arnarneshæð séð austur', 90],
['Bústaðabrú séð norður Kringlumýrarbraut', 0],
['Staður séð til norðausturs', 45],
['Staður séð til suðausturs', 135],
['Staður séð í suðvestur', 225],
['Staður séð til norðvesturs', 315],
['STAÐUR SÉÐ TIL VESTURS', 270],
['Brú séð til austurs og Selfoss', 90], // "og" then a place, not a facing
['Staður séð til austurs og Vesturlandsvegur', 90],
];
for (const [text, heading] of cases) {
assert.equal(icelandHeadingFromDescription(text), heading, text);
}
for (const text of [
'',
null,
'Gautsdalur niður á veg',
'Miklabraut austur', // a road direction, not a facing
'Biskupsháls - norður',
'Hringtorg norðan Selfoss',
'Norðurárdalur niður á veg', // a place name that contains a cardinal
'Staður séð til suðurlands', // a longer word, not "suður"
'Staður séð til norðurs og til suðurs', // two facings: ambiguous
'Staður séð til austurs og vesturs', // the second facing shares "til"
'Staður séð í norður og suður',
'Staður séð í norður og í suður',
'Norðfjarðargöng séð til Eskifjarðar',
]) {
assert.ok(Number.isNaN(icelandHeadingFromDescription(text)), String(text));
}
});
test('rows off the pinned directory, or with unusable frames or places, are dropped', () => {
const bad = [
{ Slod: 'https://example.com/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'http://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'https://user:[email protected]/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is:8443/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/other/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/../x/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a%2Fb_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.php' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a b_1.jpg' },
{ Slod: '' },
{ Slod: null },
{ Breidd: 0, Lengd: 0 },
{ Breidd: null, Lengd: null },
{ Breidd: '64.01', Lengd: '-21.4' },
{ Breidd: '64.01' }, // each coordinate must be a number on its own
{ Lengd: '-21.4' },
{ Breidd: 59.9, Lengd: 10.7 }, // Oslo
{ Breidd: 64.01, Lengd: 21.4 }, // wrong hemisphere
];
for (const over of bad) {
assert.equal(icelandCameraToSource(row(over)), null, JSON.stringify(over));
}
assert.equal(icelandCameraToSource(null), null);
assert.equal(icelandCameraToSource({}), null);
assert.equal(isLikelyIcelandCoordinate(64.1466, -21.9426), true);
assert.equal(isLikelyIcelandCoordinate(66.3, -14.0), true);
});
test('the Iceland loader reads the list with redirects refused and keeps the nearest cameras', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: '8' });
const rows = Array.from({ length: 12 }, (_, i) =>
row({
Maelist_nr: 8000 + i,
Slod: `${ICELAND_IMAGE_ORIGIN}far_${i}.jpg`,
Skyring: `Far ${i}`,
Breidd: 66.2,
Lengd: -15.9 + i * 0.01,
}),
);
// Reykjavík cameras come last in the feed but first in the priority order.
// A frame the feed repeats keeps its last row, as the other packs do.
rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Skyring: 'Old' }));
rows.push(
row({
Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`,
Skyring: 'Kringlumýrarbraut',
Breidd: 64.1466,
Lengd: -21.9426,
}),
);
const seen = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return json(rows);
});
const sources = await loadIcelandSourcesFromOpenData();
assert.deepEqual(seen, [[ICELAND_CAMERAS_URL, 'manual']]);
assert.equal(sources.length, 8);
assert.equal(sources[0].id, 'is-vegagerdin-rvk_1');
assert.equal(sources[0].name, 'Kringlumýrarbraut');
assert.equal(sources[0].lat, 64.1466);
assert.equal(
new Set(sources.map((s) => s.id)).size,
sources.length,
'a frame the feed repeats collapses to one camera',
);
assert.equal(sources.available, 13);
});
test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => {
t.mock.method(console, 'log', () => {});
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
const rows = Array.from(
{ length: DEFAULT_ICELAND_MAX_SOURCES + 40 },
(_, i) => row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
);
t.mock.method(globalThis, 'fetch', async () => json(rows));
assert.equal(
(await loadIcelandSourcesFromOpenData()).length,
DEFAULT_ICELAND_MAX_SOURCES,
);
});
test('CCTV_ICELAND_MAX_SOURCES is clamped to 8..600 and ignored when not a number', async (t) => {
t.mock.method(console, 'log', () => {});
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
const rows = Array.from({ length: 650 }, (_, i) =>
row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
);
t.mock.method(globalThis, 'fetch', async () => json(rows));
for (const [value, expected] of [
['0', 8],
['3', 8],
['1000', 600],
['abc', DEFAULT_ICELAND_MAX_SOURCES],
]) {
process.env.CCTV_ICELAND_MAX_SOURCES = value;
assert.equal(
(await loadIcelandSourcesFromOpenData()).length,
expected,
value,
);
}
});
test('a redirect on the Iceland frame host stays inside vefmyndavelar/', async () => {
const frame = `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`;
const redirectTo = (location) => async (url) =>
url === frame
? new Response(null, { status: 302, headers: { location } })
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
for (const location of [
'/vgdata/other/x.jpg',
'/vgdata/vefmyndavelar/../x.jpg',
'/',
])
assert.equal(
await fetchWithinHost(frame, {}, redirectTo(location)),
null,
location,
);
const followed = await fetchWithinHost(
frame,
{},
redirectTo('/vgdata/vefmyndavelar/hellisheidi_1b.jpg'),
);
assert.equal(await followed.text(), 'frame');
});
test('the Iceland loader fails soft on HTTP errors, bad payloads and network errors', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(
globalThis,
'fetch',
async () => new Response('x', { status: 503 }),
);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => json({ rows: [] }));
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.method(
globalThis,
'fetch',
async () => new Response('<html>', { status: 200 }),
);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
});
test('the Iceland catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
const redirected = streamingResponse({
status: 302,
headers: {
location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1',
},
});
t.mock.method(globalThis, 'fetch', async () => redirected.response);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 500 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the error body is cancelled');
const valid = JSON.stringify([row()]);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
return new Response(valid, {
headers: { 'content-length': String(ICELAND_MAX_CATALOG_BYTES + 1) },
});
});
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const padded = valid + ' '.repeat(ICELAND_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(padded));
controller.close();
},
}),
);
});
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
});
test('CCTV catalog merges Iceland cameras and CCTV_ICELAND_ENABLED=0 skips the request', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const sourceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-'),
);
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
withEnv(t, {
CCTV_SOURCES_FILE: undefined,
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: undefined,
CCTV_PREFER_AUSTIN: undefined,
CCTV_MAX_SOURCES: undefined,
CCTV_CALTRANS_DISTRICTS: undefined,
CCTV_TFL_ENABLED: undefined,
CCTV_ICELAND_ENABLED: undefined,
CCTV_ICELAND_MAX_SOURCES: undefined,
});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.redirect]);
if (String(url) !== ICELAND_CAMERAS_URL) {
return new Response('unavailable', { status: 503 });
}
return json([row()]);
});
const sources = await createCctvCatalog({ sourceRoot })();
assert.deepEqual(
sources.map((source) => source.id),
['is-vegagerdin-hellisheidi_1'],
);
process.env.CCTV_ICELAND_ENABLED = '0';
requested.length = 0;
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
assert.ok(requested.length > 0, 'the other live packs still load');
assert.equal(
requested.some(([url]) => url === ICELAND_CAMERAS_URL),
false,
'a disabled pack makes no request',
);
});
+9
View File
@@ -321,6 +321,15 @@ export const DATA_CREDITS = [
'(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' +
'(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel',
},
{
key: 'iceland-cctv',
html:
'CCTV cameras &amp; frames (Iceland): road camera images, fetched live, from the ' +
'<a href="https://www.vegagerdin.is/" target="_blank" rel="noopener">Icelandic Road and Coastal Administration (Vegagerðin)</a> — ' +
'<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a> ' +
'(<a href="https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions" target="_blank" rel="noopener">terms</a>); ' +
'no endorsement by IRCA is implied',
},
{
key: 'kingcounty-cctv',
html:
+3
View File
@@ -335,6 +335,9 @@ const OPTION_GROUPS = Object.freeze({
osm: 'o',
hybrid: 'h',
}),
// Street Traffic row toggles: flow-colored road lines, moving dots.
booleanOption('lines', 'l', false),
booleanOption('dots', 'd', true),
]),
'weather-lightning': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
+1 -1
View File
@@ -85,7 +85,7 @@ test('Warendorf webcam display introduces the shared OSM credit', (t) => {
const v = viewer(t), empty = new Proxy({}, { get: () => noop });
const lifecycle = createCctvLifecycle({ state: { _viewer: v, _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost },
services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty },
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} });
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty, navigation: empty }, source: {} });
lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable();
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
});
+7
View File
@@ -301,6 +301,8 @@
"AVAILABLE MISSIONS": "المهام المتاحة",
"SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها",
"LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا",
"Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. حاول مرة أخرى لاحقًا.",
"30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح",
"RADIO": "الراديو",
"ENABLE": "تفعيل",
"Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة",
@@ -1747,6 +1749,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى (يحتاج إلى مفتاح TomTom)",
"Hybrid": "هجين",
"Lines": "خطوط",
"Dots": "نقاط",
"Road lines colored by live flow: green to red": "خطوط الطرق ملوّنة حسب التدفق المباشر: من الأخضر إلى الأحمر",
"Moving dots colored by live flow": "نقاط متحركة ملوّنة حسب التدفق المباشر",
"Moving dots": "نقاط متحركة",
"TomTom key unavailable": "مفتاح TomTom غير متاح",
"TomTom daily budget reached": "بلغت الحصة اليومية لـ TomTom",
"Monthly traffic allowance used": "تم استهلاك حصة حركة المرور الشهرية",
+7
View File
@@ -760,6 +760,8 @@
"AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN",
"SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN",
"LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN",
"Space Missions unavailable: the mission index could not load. Try again later.": "Raummissionen nicht verfügbar: Der Missionsindex konnte nicht geladen werden. Später erneut versuchen.",
"30-DAY MISSION INDEX UNAVAILABLE": "30-TAGE-MISSIONSINDEX NICHT VERFÜGBAR",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL",
"Internet radio and local SDR": "Internetradio und lokales SDR",
"STATION TAG": "KATEGORIE",
@@ -1665,6 +1667,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert (braucht einen TomTom-Schlüssel)",
"Hybrid": "Hybrid",
"Lines": "Linien",
"Dots": "Punkte",
"Road lines colored by live flow: green to red": "Straßenlinien nach Live-Verkehrsfluss gefärbt: grün bis rot",
"Moving dots colored by live flow": "Bewegte Punkte nach Live-Verkehrsfluss gefärbt",
"Moving dots": "Bewegte Punkte",
"TomTom key unavailable": "TomTom-Schlüssel nicht verfügbar",
"TomTom daily budget reached": "TomTom-Tagesbudget erreicht",
"Monthly traffic allowance used": "Monatliches Verkehrskontingent aufgebraucht",
+7
View File
@@ -192,6 +192,8 @@
"AVAILABLE MISSIONS": "MISIONES DISPONIBLES",
"SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR",
"LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Misiones espaciales no disponibles: no se pudo cargar el índice de misiones. Inténtalo más tarde.",
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISIONES DE 30 DÍAS NO DISPONIBLE",
"RADIO": "RADIO",
"ENABLE": "ACTIVAR",
"Previous filtered radio station": "Emisora de radio filtrada anterior",
@@ -2697,6 +2699,11 @@
"Zoom in for vector tile coverage": "Acércate para cargar las teselas vectoriales",
"Camera coverage unavailable": "Cobertura de cámaras no disponible",
"Hybrid": "Híbrido",
"Lines": "Líneas",
"Dots": "Puntos",
"Road lines colored by live flow: green to red": "Líneas de vías coloreadas por el flujo en vivo: de verde a rojo",
"Moving dots colored by live flow": "Puntos en movimiento coloreados por el flujo en vivo",
"Moving dots": "Puntos en movimiento",
"TomTom roads only, each with its live flow": "Solo vías de TomTom, cada una con su flujo en vivo",
"TomTom roads only, each with its live flow (needs a TomTom key)": "Solo vías de TomTom, cada una con su flujo en vivo (necesita una clave de TomTom)",
"OpenStreetMap roads, with TomTom flow matched when available": "Vías de OpenStreetMap, con el flujo de TomTom emparejado cuando está disponible",
+7
View File
@@ -1103,6 +1103,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation (nécessite une clé TomTom)",
"Hybrid": "Hybride",
"Lines": "Lignes",
"Dots": "Points",
"Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge",
"Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct",
"Moving dots": "Points mobiles",
"TomTom key unavailable": "Clé TomTom indisponible",
"TomTom daily budget reached": "Quota quotidien TomTom atteint",
"Monthly traffic allowance used": "Quota mensuel de trafic épuisé",
@@ -2805,6 +2810,8 @@
"AVAILABLE MISSIONS": "MISSIONS DISPONIBLES",
"SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER",
"LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.",
"30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE",
"Internet radio and local SDR": "Radio Internet et SDR local",
"STATION TAG": "TAG DE STATION",
+7
View File
@@ -1103,6 +1103,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato (richiede una chiave TomTom)",
"Hybrid": "Ibrido",
"Lines": "Linee",
"Dots": "Punti",
"Road lines colored by live flow: green to red": "Strade colorate in base al traffico in tempo reale: dal verde al rosso",
"Moving dots colored by live flow": "Punti in movimento colorati in base al traffico in tempo reale",
"Moving dots": "Punti in movimento",
"TomTom key unavailable": "Chiave TomTom non disponibile",
"TomTom daily budget reached": "Budget giornaliero TomTom esaurito",
"Monthly traffic allowance used": "Quota mensile del traffico esaurita",
@@ -2805,6 +2810,8 @@
"AVAILABLE MISSIONS": "MISSIONI DISPONIBILI",
"SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE",
"LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l’indice delle missioni. Riprova più tardi.",
"30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA",
"Internet radio and local SDR": "Radio internet e SDR locale",
"STATION TAG": "TAG STAZIONE",
+7
View File
@@ -1071,6 +1071,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション)",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション、TomTomキーが必要)",
"Hybrid": "ハイブリッド",
"Lines": "ライン",
"Dots": "ドット",
"Road lines colored by live flow: green to red": "ライブの交通流で色分けした道路ライン(緑から赤)",
"Moving dots colored by live flow": "ライブの交通流で色分けした移動ドット",
"Moving dots": "移動ドット",
"TomTom key unavailable": "TomTomキーを利用できません",
"TomTom daily budget reached": "TomTomの1日の上限に達しました",
"Monthly traffic allowance used": "今月の交通情報の上限に達しました",
@@ -2663,6 +2668,8 @@
"AVAILABLE MISSIONS": "利用可能なミッション",
"SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示",
"LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中",
"Space Missions unavailable: the mission index could not load. Try again later.": "宇宙ミッションを利用できません:ミッション一覧を読み込めませんでした。後でもう一度お試しください。",
"30-DAY MISSION INDEX UNAVAILABLE": "30日間のミッション一覧を取得できません",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択",
"Internet radio and local SDR": "インターネットラジオとローカルSDR",
"STATION TAG": "局タグ",
+7
View File
@@ -1101,6 +1101,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado (precisa de uma chave do TomTom)",
"Hybrid": "Híbrido",
"Lines": "Linhas",
"Dots": "Pontos",
"Road lines colored by live flow: green to red": "Vias coloridas pelo fluxo ao vivo: do verde ao vermelho",
"Moving dots colored by live flow": "Pontos em movimento coloridos pelo fluxo ao vivo",
"Moving dots": "Pontos em movimento",
"TomTom key unavailable": "Chave do TomTom indisponível",
"TomTom daily budget reached": "Limite diário do TomTom atingido",
"Monthly traffic allowance used": "Cota mensal de trânsito esgotada",
@@ -2807,6 +2812,8 @@
"AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS",
"SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR",
"LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missões espaciais indisponíveis: não foi possível carregar o índice de missões. Tente novamente mais tarde.",
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISSÕES DE 30 DIAS INDISPONÍVEL",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA",
"Internet radio and local SDR": "Rádio pela internet e SDR local",
"STATION TAG": "TAG DA ESTAÇÃO",
+7
View File
@@ -722,6 +722,8 @@
"Contact Context actions": "Действия контекста контакта",
"Available Space Missions": "Доступные космические миссии",
"LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ",
"Space Missions unavailable: the mission index could not load. Try again later.": "Космические миссии недоступны: не удалось загрузить список миссий. Повторите попытку позже.",
"30-DAY MISSION INDEX UNAVAILABLE": "ИНДЕКС МИССИЙ ЗА 30 ДНЕЙ НЕДОСТУПЕН",
"Radio playback": "Воспроизведение радио",
"Radio volume": "Громкость радио",
"STATION TAG": "ТЕГ СТАНЦИИ",
@@ -2755,6 +2757,11 @@
"Zoom in for vector tile coverage": "Приблизьте карту, чтобы загрузить векторные тайлы",
"Camera coverage unavailable": "Покрытие камер недоступно",
"Hybrid": "Гибрид",
"Lines": "Линии",
"Dots": "Точки",
"Road lines colored by live flow: green to red": "Линии дорог по потоку онлайн: от зелёного к красному",
"Moving dots colored by live flow": "Движущиеся точки, окрашенные по потоку онлайн",
"Moving dots": "Движущиеся точки",
"TomTom roads only, each with its live flow": "Только дороги TomTom, у каждой — свой поток онлайн",
"TomTom roads only, each with its live flow (needs a TomTom key)": "Только дороги TomTom, у каждой — свой поток онлайн (нужен ключ TomTom)",
"OpenStreetMap roads, with TomTom flow matched when available": "Дороги OpenStreetMap; поток TomTom сопоставляется, где он есть",
+7
View File
@@ -1118,6 +1118,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку (потрібен ключ TomTom)",
"Hybrid": "Гібрид",
"Lines": "Лінії",
"Dots": "Точки",
"Road lines colored by live flow: green to red": "Лінії доріг за потоком онлайн: від зеленого до червоного",
"Moving dots colored by live flow": "Рухомі точки, забарвлені за потоком онлайн",
"Moving dots": "Рухомі точки",
"TomTom key unavailable": "Ключ TomTom недоступний",
"TomTom daily budget reached": "Вичерпано денний ліміт TomTom",
"Monthly traffic allowance used": "Місячний ліміт даних про трафік вичерпано",
@@ -2875,6 +2880,8 @@
"AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ",
"SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ",
"LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ",
"Space Missions unavailable: the mission index could not load. Try again later.": "Космічні місії недоступні: не вдалося завантажити список місій. Спробуйте пізніше.",
"30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР",
"Internet radio and local SDR": "Інтернет-радіо та локальний SDR",
"STATION TAG": "ТЕГ СТАНЦІЇ",
+7
View File
@@ -1047,6 +1047,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况(需要 TomTom 密钥)",
"Hybrid": "混合",
"Lines": "线条",
"Dots": "圆点",
"Road lines colored by live flow: green to red": "按实时路况着色的道路线条:从绿到红",
"Moving dots colored by live flow": "按实时路况着色的移动圆点",
"Moving dots": "移动圆点",
"TomTom key unavailable": "TomTom 密钥不可用",
"TomTom daily budget reached": "已达 TomTom 每日配额",
"Monthly traffic allowance used": "本月交通流量额度已用完",
@@ -2562,6 +2567,8 @@
"AVAILABLE MISSIONS": "可用任务",
"SELECT A MISSION TO INSPECT": "选择一项任务以查看",
"LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引",
"Space Missions unavailable: the mission index could not load. Try again later.": "太空任务不可用:无法加载任务索引。请稍后重试。",
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任务索引不可用",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择",
"Internet radio and local SDR": "网络电台与本地 SDR",
"STATION TAG": "电台标签",
+7
View File
@@ -1042,6 +1042,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況(需要 TomTom 密鑰)",
"Hybrid": "混合",
"Lines": "線條",
"Dots": "圓點",
"Road lines colored by live flow: green to red": "依即時路況著色的道路線條:由綠到紅",
"Moving dots colored by live flow": "依即時路況著色的移動圓點",
"Moving dots": "移動圓點",
"TomTom key unavailable": "TomTom 密鑰不可用",
"TomTom daily budget reached": "已達 TomTom 每日配額",
"Monthly traffic allowance used": "本月交通流量額度已用完",
@@ -2550,6 +2555,8 @@
"AVAILABLE MISSIONS": "可用任務",
"SELECT A MISSION TO INSPECT": "選擇一項任務以查看",
"LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引",
"Space Missions unavailable: the mission index could not load. Try again later.": "太空任務無法使用:無法載入任務索引。請稍後再試。",
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇",
"Internet radio and local SDR": "網絡電臺與本地 SDR",
"STATION TAG": "電臺標籤",
+2
View File
@@ -112,6 +112,8 @@ export function createCctvLayer({ services, source }) {
_setCctvCoverageStateForTest: parts.testing._setCctvCoverageStateForTest,
focusCctvRecord: parts.navigation.focusCctvRecord,
maybeAutoHop: parts.navigation.maybeAutoHop,
openingCameraId: parts.navigation.openingCameraId,
reopenNearestCamera: parts.navigation.reopenNearestCamera,
cctvCycleIndex: parts.navigation.cctvCycleIndex,
},
);
+7 -6
View File
@@ -151,6 +151,7 @@ export function createLifecycle({
layerState._enabled = false;
layerState._activeCameraId = null;
layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = false;
layerState._lastHopAt = 0;
layerState._lastViewContext = '';
layerState._calibrationById = parts.calibration.loadCalibrationStore();
@@ -299,11 +300,8 @@ export function createLifecycle({
}
layerState._count = layerState._records.length;
if (layerState._records.length > 0) {
// Projection runtime + first frame fetch are deferred to enable() so
// initializing the catalog stays render-cheap.
layerState._activeCameraId = layerState._records[0].camera.id;
}
// No camera is picked here: enable() opens on the one nearest the
// viewer, unless restored params selected one first.
// Task 5: if the prior batch lost init's bounded race, apply it post-hoc
// when it lands (pure recomputes — applyLateGroundPriors guards against
@@ -361,6 +359,7 @@ export function createLifecycle({
layerState._horizonCullListener = () => {
layerState._cameraMoving = false;
stopViewMotionTimer();
parts.navigation.reopenNearestCamera();
settleView();
};
layerState._viewer.camera.moveEnd.addEventListener(
@@ -508,8 +507,9 @@ export function createLifecycle({
return Boolean(coverage && layerState._recordById.has(coverage[1]));
});
if (!layerState._activeCameraId && layerState._records.length) {
layerState._activeCameraId = layerState._records[0].camera.id;
layerState._activeCameraId = parts.navigation.openingCameraId();
layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = true;
}
const activeRecord = parts.selection.getActiveRecord();
if (activeRecord) {
@@ -629,6 +629,7 @@ export function createLifecycle({
layerState._enabled = false;
layerState._activeCameraId = null;
layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = false;
// Clear existing subscribers rather than replacing the Set —
// replacing would silently orphan any unsubscribe() closures
layerState._listeners.clear();
+32
View File
@@ -33,6 +33,36 @@ export function createNavigation({
return best?.id || null;
}
/**
* The camera the layer opens on when none is selected: the one nearest the
* viewer, or the first record when the viewer has no position yet.
* @returns {string|null} Camera ID, or null when the catalog is empty.
*/
function openingCameraId() {
return (
nearestCameraIdToViewer() || layerState._records[0]?.camera?.id || null
);
}
/**
* After a settled move, moves the opening camera to the one nearest the
* view, until someone chooses a camera. A shared view's flight lands after
* enable() picked from the startup view.
* @returns {boolean} True when the active camera changed.
*/
function reopenNearestCamera() {
if (!layerState._openingCameraProvisional || !layerState._enabled)
return false;
const nearest = nearestCameraIdToViewer();
if (!nearest || nearest === layerState._activeCameraId) return false;
parts.selection.setActiveCamera(nearest);
// setActiveCamera ends the opening pick; this is still that pick.
layerState._openingCameraProvisional = true;
return true;
}
/**
* Flies the Cesium viewer camera to frame the specified CCTV camera,
* looking along its heading from above.
@@ -149,6 +179,8 @@ export function createNavigation({
}
return {
nearestCameraIdToViewer,
openingCameraId,
reopenNearestCamera,
focusCctvRecord,
focusCamera,
maybeAutoHop,
+2
View File
@@ -83,6 +83,7 @@ export function createSelection({
}
layerState._activeCameraId = cameraId;
layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = false;
// A real activation creates projection work — wake the self-stopping loop.
parts.projection.startProjectionLoop();
if (previousActiveRecord && previousActiveRecord !== record) {
@@ -169,6 +170,7 @@ export function createSelection({
if (!record) return false;
layerState._activeCameraId = null;
layerState._autoHopSuspended = true;
layerState._openingCameraProvisional = false;
record.activationDone = false;
parts.geometry.clearProbeClampOnDeactivation(record, (previous) => {
parts.geometry.applyFrustumGeometry(
+6
View File
@@ -36,6 +36,12 @@ export function createState({ services }) {
layerState._autoHop = false;
// True while the active camera is the one enable() opened on and nobody has
// chosen a camera since: each settled move then re-picks the one nearest
// the view, so a share flight that lands after enable still opens nearby.
layerState._openingCameraProvisional = false;
// An explicit empty-space deselect keeps AUTO HOP configured but prevents its
// timer from silently choosing a replacement. A later explicit activation or
// AUTO HOP toggle-on releases the hold.
+2
View File
@@ -91,6 +91,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
coverageMode = 'on',
showProjection = false,
healthById = new Map(),
openingCameraProvisional = false,
} = {}) {
layerState._viewer = viewer;
layerState._records = Array.isArray(records) ? records : [];
@@ -104,6 +105,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
layerState._billboards = null;
layerState._activeCameraId = activeCameraId;
layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = !!openingCameraProvisional;
layerState._enabled = !!enabled;
layerState._coverageMode = parts.model.normalizeCoverageMode(
coverageMode,
+4
View File
@@ -12,6 +12,9 @@ export function createControls({ state: layerState, services, parts, source }) {
/** Release only Space Mission camera ownership, preserving layer and selection state. */
releaseCameraOwnership() {
// Navigation takes the camera; a failed entry must not restore the old view.
layerState._entryCameraView = null;
layerState._cameraReleased = true;
parts.panel.clearMissionRosterHover();
parts.replay.stopMissionReplay();
parts.selection.stopMissionZoomAnchor();
@@ -57,6 +60,7 @@ export function createControls({ state: layerState, services, parts, source }) {
orbitMatches: layerState._orbitMatches,
lastUpdate: layerState._lastUpdate,
error: layerState._lastError,
errorAt: layerState._lastErrorAt,
stale: layerState._stale,
};
},
+495
View File
@@ -0,0 +1,495 @@
// The real manager, Context coordinator and launches ingestion run against a
// stubbed /api/launches, so a failed first load takes the browser's path.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { DataLayerManager } from '../../data/manager.js';
import { ContextControls } from '../../ui/contextControls.js';
import { spaceMissionsIndexFailure } from '../../ui/contextActions.js';
import { createLaunchSource } from './source.js';
import { createState } from './state.js';
import { createModel } from './model.js';
import { createPolicyHelpers } from './policyHelpers.js';
import { createIngestion } from './ingestion.js';
import { createPanel } from './panel.js';
import { createControls } from './controls.js';
import { createLifecycle } from './lifecycle.js';
import { createReplay } from './replay.js';
const noop = () => {};
const launchRecord = {
id: 'fixture-launch',
name: 'Fixture mission',
net: new Date(Date.now() - 3600000).toISOString(),
status: { name: 'Launch Successful' },
pad: { name: 'Fixture pad', latitude: 28.6, longitude: -80.6 },
rocket: { payloads: [] },
};
function plainLayer(id, name, params = {}) {
let current = { ...params };
return {
id,
name,
icon: '',
source: 'test',
updateInterval: -1,
init: noop,
enable: noop,
disable: noop,
update: noop,
getParams: () => ({ ...current }),
setParams(next) {
current = { ...current, ...next };
return true;
},
};
}
function fakeCamera() {
const camera = {
position: new Cesium.Cartesian3(1, 2, 3),
heading: 0.1,
pitch: -0.5,
roll: 0,
flights: 0,
cancelled: 0,
flight: null,
get positionWC() {
return camera.position;
},
// Like Cesium, a new flight cancels the one in progress.
flyTo({ destination, complete, cancel }) {
camera.flight?.cancel?.();
camera.flight = { complete, cancel };
camera.flights += 1;
camera.position = Cesium.Cartesian3.clone(destination);
camera.heading = 0;
camera.pitch = -Math.PI / 2;
},
land() {
const flight = camera.flight;
camera.flight = null;
flight?.complete?.();
},
cancelFlight() {
camera.cancelled += 1;
const flight = camera.flight;
camera.flight = null;
flight?.cancel?.();
},
lookAtTransform: noop,
setView({ destination, orientation }) {
camera.position = Cesium.Cartesian3.clone(destination);
camera.heading = orientation.heading;
camera.pitch = orientation.pitch;
camera.roll = orientation.roll;
},
};
return camera;
}
async function fixture(t) {
const upstream = { status: 429, results: [launchRecord] };
const source = createLaunchSource({
async fetchImpl(url) {
if (url === '/api/celestrak/active') return new Response('');
if (upstream.hold) await upstream.hold;
return Response.json(
upstream.status === 200 ? { results: upstream.results } : {},
{ status: upstream.status },
);
},
});
const services = {
overlays: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
};
const state = createState({ services });
const camera = fakeCamera();
const canvas = Object.assign(new EventTarget(), {
disableRootEvents: true,
onwheel: null,
style: {},
clientWidth: 1200,
clientHeight: 800,
});
const viewer = {
camera,
dataSources: new Cesium.DataSourceCollection(),
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
};
// Scene presentation is stubbed; ingestion, lifecycle and panel are real.
const parts = {
overlays: {
shortMissionLabel: (name) => name,
createReplayVehicleOverlay: noop,
clearMissionOverlaySources: noop,
hideReplayVehicleOverlay: noop,
destroyReplayVehicleOverlay: noop,
syncMissionOverlayEntries: noop,
},
selection: {
focusFullGlobe(target, flight) {
target.camera.flyTo({
destination: new Cesium.Cartesian3(9, 9, 9),
...flight,
});
return true;
},
setSelectedMission: noop,
focusMission: noop,
stopMissionZoomAnchor: noop,
entityLaunchId: () => null,
},
orbitRendering: {
removeMissionOrbitPrimitives: noop,
syncMissionOrbitPrimitiveVisibility: noop,
},
launchPad: {
initLaunchPadZonePrimitive: noop,
hideLaunchPadZone: noop,
destroyLaunchPadZonePrimitive: noop,
},
rendering: {
updateMissionFrame: noop,
addLaunchEntity(record) {
state._dataSource.entities.add({
id: record.id,
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
});
},
},
};
const context = { state, services, parts, source };
parts.policyHelpers = createPolicyHelpers(context);
parts.model = createModel(context);
parts.panel = createPanel(context);
parts.replay = createReplay(context);
parts.ingestion = createIngestion(context);
parts.controls = createControls(context);
parts.lifecycle = createLifecycle(context);
const missions = Object.assign(
{},
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion.methods,
);
const rosters = [];
parts.panel.subscribePanel((view) => rosters.push(view.roster));
const manager = new DataLayerManager(viewer);
manager.register(plainLayer('earthquakes', 'Earthquakes (24h)'));
manager.register(plainLayer('satellites', 'Satellites'));
manager.register(plainLayer('wind', 'Wind'));
manager.register(missions);
missions.attachDataManager(manager);
const toasts = [];
const controls = new ContextControls({
elements: {},
installations: {},
actions: {
getCockpit: () => null,
claimVisualAuthority: noop,
showToast: (message) => toasts.push(message),
setClearBusy: noop,
syncDetection: noop,
scheduleLayout: noop,
refreshRadio: noop,
setPanelCollapsed: noop,
},
});
controls.connect(manager);
t.after(async () => {
controls.stop();
controls.disconnect();
for (const id of ['rocket-launches', 'satellites', 'earthquakes', 'wind'])
await manager.setEnabled(id, false);
await missions.destroy(viewer);
});
const enabledIds = () => [...manager.getEnabledLayerIds()].sort();
const view = () => ({
x: camera.position.x,
y: camera.position.y,
z: camera.position.z,
heading: camera.heading,
pitch: camera.pitch,
});
const settle = async () => {
await controls._waitForContextLayerSettlement();
for (const id of ['rocket-launches', 'satellites', 'earthquakes'])
await manager.waitForLayerSettled(id);
await controls._waitForContextLayerSettlement();
};
return {
upstream,
manager,
controls,
missions,
toasts,
state,
camera,
rosters,
enabledIds,
view,
settle,
};
}
const entries = {
direct: (f) =>
f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
'Context-panel': async (f) =>
(
await f.controls.setContextMode('space-missions', {
claimVisualAuthority: false,
})
).ok,
};
for (const [path, enter] of Object.entries(entries))
test(`a ${path} Space Missions entry that gets HTTP 429 rolls back instead of isolating`, async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
const before = f.view();
const entered = await enter(f);
await f.settle();
assert.equal(entered, false, 'a failed mission index fails the enable');
assert.deepEqual(f.enabledIds(), ['earthquakes']);
assert.equal(f.controls.getContextModeState().mode, null);
assert.equal(f.controls.getContextModeState().active, false);
assert.equal(f.missions.getStats().error, 'HTTP 429');
assert.deepEqual(f.view(), before, 'the pre-entry view comes back');
assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled');
assert.equal(
await f.manager.setEnabled('wind', true, { origin: 'user' }),
true,
'an unrelated layer is not blocked after the failed entry',
);
});
// Holds the /api/launches reply until the returned release is called.
function holdReply(f) {
let release;
f.upstream.hold = new Promise((resolve) => {
release = resolve;
});
return () => {
f.upstream.hold = null;
release();
};
}
async function untilFlown(f) {
for (let i = 0; i < 200 && f.camera.flights === 0; i++)
await new Promise((resolve) => setTimeout(resolve, 5));
assert.ok(f.camera.flights > 0, 'the entry started its full-globe flight');
}
test('a landed entry flight still gives the view back after a failed first load', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
const before = f.view();
const release = holdReply(f);
const entering = f.manager.setEnabled('rocket-launches', true, {
origin: 'user',
});
await untilFlown(f);
f.camera.land();
release();
assert.equal(await entering, false);
await f.settle();
assert.deepEqual(f.view(), before);
});
test('a camera move after the entry flight survives a failed first load', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
const release = holdReply(f);
const entering = f.manager.setEnabled('rocket-launches', true, {
origin: 'user',
});
await untilFlown(f);
f.camera.land();
// The user drags the globe while the mission index is still loading.
f.camera.setView({
destination: new Cesium.Cartesian3(7, 7, 7),
orientation: { heading: 0.3, pitch: -0.2, roll: 0 },
});
const moved = f.view();
release();
assert.equal(await entering, false);
await f.settle();
assert.deepEqual(f.view(), moved);
});
test('a navigation during a failed first load is not cancelled by the rollback', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
const release = holdReply(f);
const entering = f.manager.setEnabled('rocket-launches', true, {
origin: 'user',
});
await untilFlown(f);
// Search or Home: navigation releases mission camera ownership, then flies.
f.missions.releaseCameraOwnership();
let navigationCancelled = false;
f.camera.flyTo({
destination: new Cesium.Cartesian3(5, 5, 5),
cancel: () => {
navigationCancelled = true;
},
});
const cancelled = f.camera.cancelled;
const navigated = f.view();
release();
assert.equal(await entering, false);
await f.settle();
assert.equal(
navigationCancelled,
false,
'the rollback does not cancel the Search/Home flight',
);
assert.equal(f.camera.cancelled, cancelled);
assert.deepEqual(f.view(), navigated);
});
test('the roster reports a failed mission index instead of loading forever', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
const roster = f.rosters.at(-1);
assert.ok(roster, 'a failed load publishes a roster view');
assert.deepEqual(roster.entries, []);
assert.equal(roster.empty, '30-DAY MISSION INDEX UNAVAILABLE');
// A new attempt starts from the loading state, then shows the index.
f.upstream.status = 200;
const loadingFrom = f.rosters.length;
assert.equal(
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
true,
);
await f.settle();
assert.equal(f.rosters.slice(loadingFrom).includes(null), true);
assert.equal(f.rosters.at(-1).entries.length, 1);
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
});
test('a valid empty mission index is a successful entry', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
f.upstream.results = [];
assert.equal(
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
true,
);
await f.settle();
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
assert.equal(
f.rosters.at(-1).empty,
'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW',
);
assert.ok(f.camera.flights > 0, 'a successful entry frames the globe');
});
test('a refresh failure after a loaded index keeps the mode and its records', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
const framed = f.view();
f.upstream.status = 429;
await f.missions.update();
await f.settle();
assert.equal(f.manager.isEnabled('rocket-launches'), true);
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
assert.equal(f.missions.getStats().count, 1);
assert.equal(f.missions.getStats().error, 'HTTP 429');
assert.equal(f.rosters.at(-1).entries.length, 1);
// Leaving a loaded mode does not restore the pre-entry view.
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
await f.settle();
assert.deepEqual(f.view(), framed);
});
test('exiting during a successful entry flight cancels the mission-owned flight', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
assert.equal(
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
true,
);
await f.settle();
assert.ok(f.camera.flight, 'the 2.4 s entry flight is still in progress');
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
await f.settle();
assert.equal(
f.camera.flight,
null,
'leaving Space Missions stops the flight it started (main cancels it)',
);
});
// The rollback hides the roster, so the toast has to name the cause.
test('a failed first load tells the user the mission index is unavailable', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
await f.controls.setContextMode('space-missions', {
claimVisualAuthority: false,
});
await f.settle();
assert.equal(f.controls.getContextModeState().mode, null, 'rolled back');
assert.ok(
f.toasts.some((message) => /mission index/i.test(message)),
`visible feedback names the failure, got ${JSON.stringify(f.toasts)}`,
);
});
test('navigation taking the camera keeps a mission flight it asked to preserve', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
const flight = f.camera.flight;
assert.ok(flight, 'the entry flight is in progress');
f.missions.releaseCameraOwnership();
assert.equal(f.camera.flight, flight, 'navigation decides about the flight');
// After exit an inactive layer never cancels another owner's flight.
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
await f.settle();
f.camera.flyTo({ destination: new Cesium.Cartesian3(1, 1, 1) });
const other = f.camera.flight;
f.missions.releaseCameraOwnership();
assert.equal(f.camera.flight, other);
});
test("an earlier attempt's index error is not blamed for a later failure", async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
assert.match(spaceMissionsIndexFailure(f.manager), /mission index/);
assert.equal(spaceMissionsIndexFailure(f.manager, Date.now() + 1), null);
});
+14 -4
View File
@@ -50,8 +50,9 @@ export function createIngestion({
layerState._postTleRetryCount++;
layerState._retryTimer = setTimeout(() => {
layerState._retryTimer = null;
// A failed retry is already recorded in the layer stats.
if (layerState._enabled && token === layerState._lifecycleToken)
requestMissionUpdate();
requestMissionUpdate().catch(() => {});
}, POST_TLE_RETRY_DELAY_MS);
}
@@ -201,6 +202,10 @@ export function createIngestion({
if (layerState._renderedTleText === layerState._activeTleText)
clearPostTleRetry();
layerState._count = launches.length;
// The roster reads these to tell a loaded empty index from loading.
layerState._lastUpdate = Date.now();
layerState._lastError = null;
layerState._stale = stale === true;
parts.panel.renderMissionRoster();
if (
!layerState._selectedLaunchId ||
@@ -219,13 +224,18 @@ export function createIngestion({
}
layerState._focusAfterActiveLookup = false;
parts.panel.renderMissionPanel();
layerState._lastUpdate = Date.now();
layerState._lastError = null;
layerState._stale = stale === true;
layerState._entryCameraView = null;
} catch (error) {
if (layerState._enabled && token === layerState._lifecycleToken) {
layerState._lastError = error.message;
layerState._lastErrorAt = Date.now();
console.warn('[Data:RocketLaunches] Fetch error:', error);
// No index yet: fail the enable so Context rolls back.
// After a loaded index, a failed refresh keeps its records.
if (layerState._lastUpdate === null) {
parts.panel.renderMissionRoster();
throw error;
}
}
}
}
+68 -1
View File
@@ -111,6 +111,43 @@ export function createLaunchHover({
};
}
/** The camera pose to give back, in world coordinates. */
function captureCameraView(viewer) {
const camera = viewer?.camera;
if (!camera?.positionWC) return null;
return {
destination: Cesium.Cartesian3.clone(camera.positionWC),
orientation: {
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
},
};
}
function sameCameraView(camera, view) {
return (
Cesium.Cartesian3.equalsEpsilon(
camera.positionWC,
view.destination,
0,
1,
) &&
['heading', 'pitch', 'roll'].every((key) =>
Cesium.Math.equalsEpsilon(camera[key], view.orientation[key], 0, 1e-6),
)
);
}
/** Restore the pre-entry view if the entry flight still holds the camera. */
function restoreCameraView(viewer, view, landedView) {
const camera = viewer?.camera;
if (!camera) return;
if (!landedView) camera.cancelFlight?.();
else if (!sameCameraView(camera, landedView)) return;
camera.setView(view);
}
export function createLifecycle({
state: layerState,
services,
@@ -200,7 +237,27 @@ export function createLifecycle({
layerState._updateDirty = false;
if (layerState._dataSource) layerState._dataSource.show = true;
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
parts.selection.focusFullGlobe(layerState._viewer);
if (layerState._lastUpdate === null) {
// A new attempt at an index that never loaded starts from loading.
layerState._lastError = null;
parts.panel.renderMissionRoster();
// Kept until the mission index loads, so an entry that never does
// gives the view back. A newer flight takes the camera and drops it.
layerState._entryCameraView = captureCameraView(layerState._viewer);
layerState._entryLandedView = null;
}
const token = layerState._lifecycleToken;
const flying = parts.selection.focusFullGlobe(layerState._viewer, {
complete: () => {
if (token === layerState._lifecycleToken)
layerState._entryLandedView = captureCameraView(layerState._viewer);
},
cancel: () => {
if (token === layerState._lifecycleToken)
layerState._entryCameraView = null;
},
});
if (!flying) layerState._entryCameraView = null;
if (layerState._selectedLaunchId)
parts.selection.setSelectedMission(
layerState._selectedLaunchId,
@@ -214,12 +271,21 @@ export function createLifecycle({
hover?.reset();
layerState._sourceController.abort();
layerState._enabled = false;
const entryView = layerState._entryCameraView;
layerState._entryCameraView = null;
if (entryView)
restoreCameraView(
layerState._viewer,
entryView,
layerState._entryLandedView,
);
releaseContinuousRender('rocket-launches');
layerState._lifecycleToken++;
layerState._updateDirty = false;
parts.ingestion.clearPostTleRetry();
parts.panel.clearMissionRosterHover();
parts.replay.stopMissionReplay();
layerState._cameraReleased = true;
parts.selection.stopMissionZoomAnchor();
if (layerState._dataSource) layerState._dataSource.show = false;
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
@@ -244,6 +310,7 @@ export function createLifecycle({
await parts.ingestion.restoreSatelliteDependency();
parts.panel.clearMissionRosterHover();
parts.replay.stopMissionReplay();
layerState._cameraReleased = true;
parts.selection.stopMissionZoomAnchor();
parts.ingestion.clearPostTleRetry();
if (layerState._clickHandler) layerState._clickHandler.destroy();
+9 -1
View File
@@ -263,8 +263,16 @@ export function createPanel({ state: layerState, services, parts, source }) {
publishPanel();
}
/** The roster entries as the list shows them. */
/** The roster as listed; null until the first index loads or fails. */
function presentRoster() {
if (layerState._lastUpdate === null && !layerState._launches.length) {
if (!layerState._lastError) return null;
return {
count: '—',
entries: [],
empty: t('30-DAY MISSION INDEX UNAVAILABLE'),
};
}
const entries = parts.policyHelpers
.missionRosterEntries(layerState._launches)
.map(({ launch, index }) => {
+1
View File
@@ -229,6 +229,7 @@ export function createPolicyHelpers({
layerState._viewer.camera.positionCartographic?.height,
);
const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat);
layerState._cameraReleased = false;
layerState._viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(position, 0),
{
+8 -2
View File
@@ -314,9 +314,15 @@ export function createReplay({ state: layerState, services, parts, source }) {
layerState._replayCameraToken++;
if (layerState._replayCameraRemover) layerState._replayCameraRemover();
layerState._replayCameraRemover = null;
layerState._viewer?.camera?.cancelFlight();
if (layerState._viewer?.camera)
// Cancel the layer's own flights, never one navigation started after
// taking the camera.
if (
(stoppedLaunchId || !layerState._cameraReleased) &&
layerState._viewer?.camera
) {
layerState._viewer.camera.cancelFlight();
layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
}
layerState._replayCameraLaunchId = null;
layerState._replayPaused = false;
layerState._replayPausedAtMs = null;
+9 -2
View File
@@ -9,7 +9,8 @@ export function createSelection({
}) {
const { getKeyholeGeometry } = services.geometry;
function focusFullGlobe(viewer, duration = 2.4) {
/** @returns {boolean} Whether the flight started. */
function focusFullGlobe(viewer, { duration = 2.4, complete, cancel } = {}) {
const canvas = viewer?.scene?.canvas;
const height = canvas?.clientHeight || canvas?.height;
const width = canvas?.clientWidth || canvas?.width;
@@ -23,7 +24,7 @@ export function createSelection({
fovy <= 0 ||
fovy >= Math.PI
)
return;
return false;
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
const keyholeRadius = getKeyholeGeometry(width, height).radius;
const targetScreenRadius = keyholeRadius * 0.61;
@@ -32,6 +33,7 @@ export function createSelection({
);
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
layerState._cameraReleased = false;
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromRadians(
cartographic.longitude,
@@ -45,7 +47,10 @@ export function createSelection({
},
duration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
complete,
cancel,
});
return true;
}
function entityLaunchId(entity) {
@@ -187,6 +192,7 @@ export function createSelection({
Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
);
layerState._cameraReleased = false;
layerState._viewer.camera.flyTo({
destination,
orientation: { direction, up },
@@ -209,6 +215,7 @@ export function createSelection({
layerState._viewer.selectedEntity = layerState._dataSource.entities.getById(
`rocket-launch:${launch.id}`,
);
layerState._cameraReleased = false;
layerState._viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(launchPosition, 0),
{
+9
View File
@@ -19,6 +19,15 @@ export function createState({ services }) {
state._lastError = null;
state._lastErrorAt = null;
state._entryCameraView = null;
state._entryLandedView = null;
/** Navigation took the camera; set until the layer flies again. */
state._cameraReleased = false;
state._stale = false;
state._orbitMatches = 0;
+16 -3
View File
@@ -21,9 +21,20 @@ export function createAnimation({
source,
}) {
function syncRenderDemand() {
// Hidden dots (`dots` off) need no frames. Lines need them only while
// their asynchronous geometry builds (or old lines wait to be retired),
// and the heat-line prototype's jam batch while it pulses.
const lines = [
layerState._heatJamPrim,
layerState._heatSlowPrim,
layerState._heatFreePrim,
].filter(Boolean);
const needed = Boolean(
layerState._enabled &&
(layerState._dots.length || layerState._heatJamPrim),
((layerState._showDots !== false && layerState._dots.length) ||
(layerState._heatJamPrim && !layerState._showLines) ||
layerState._heatRetired?.length ||
lines.some((primitive) => !primitive.ready)),
);
// Height easing only runs in a held frame. Settle it before releasing, so
// a population emptied mid-correction cannot report loading forever.
@@ -224,6 +235,7 @@ export function createAnimation({
*/
function animate() {
parts.rendering.retireHeatLines?.();
syncRenderDemand();
if (!layerState._renderHeld) return;
const now = Date.now();
@@ -317,8 +329,9 @@ export function createAnimation({
}
// Jam heat-lines throb (~1.6 s period) — one shared material uniform for
// the whole jam batch, no geometry rebuild. Null outside live heatline mode.
if (layerState._heatJamPrim?.appearance) {
// the whole jam batch, no geometry rebuild. Null outside live heatline
// mode; the steady flow lines (`lines` toggle) never pulse.
if (layerState._heatJamPrim?.appearance && !layerState._showLines) {
layerState._heatJamPrim.appearance.material.uniforms.color.alpha =
HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260);
}
+95 -20
View File
@@ -1,7 +1,7 @@
import {
normalizeRoadMode,
resolveRoadMode,
TRAFFIC_ROAD_MODES,
roadModesSimulate,
} from './roadModes.js';
import {
trafficBucketTier,
@@ -19,19 +19,25 @@ export function createControls({ state: layerState, services, parts, source }) {
const notes = [feed.loadingLabel];
if (!layerState._liveMode || feed.error)
notes.push(t('Roads: {source}', { source }));
// An explicit TomTom or Hybrid choice without a key draws OpenStreetMap.
// An explicit TomTom or Hybrid choice without a key draws OpenStreetMap;
// a host without OpenStreetMap draws nothing.
const requested = roadModesSimulate(layerState._roadModes)
? layerState._roadMode
: resolveRoadMode(layerState._roadMode, true, layerState._roadModes);
// A host without OpenStreetMap waits for the probe before naming a key.
if (
!layerState._liveMode &&
['tomtom', 'hybrid'].includes(layerState._roadMode)
['tomtom', 'hybrid'].includes(requested) &&
(roadModesSimulate(layerState._roadModes) || layerState._flowStatusKnown)
)
notes.push(
layerState._flowStatusUnavailable
? layerState._roadMode === 'tomtom'
? requested === 'tomtom'
? t(
'TomTom roads unavailable while the traffic service is unreachable',
)
: t('Hybrid unavailable while the traffic service is unreachable')
: layerState._roadMode === 'tomtom'
: requested === 'tomtom'
? t('TomTom roads need a TomTom key')
: t('Hybrid needs a TomTom key'),
);
@@ -62,10 +68,13 @@ export function createControls({ state: layerState, services, parts, source }) {
updateInterval: 0,
/**
* Update user-adjustable parameters (road source, density and speed scaling).
* Update user-adjustable parameters (road source, row toggles, density and
* speed scaling).
*
* @param {Object} [params]
* @param {'tomtom'|'osm'|'hybrid'|null} [params.roadMode] - Road source; null = default.
* @param {'tomtom'|'osm'|'hybrid'|null} [params.roadMode] - Road source; null = default; outside the host's modes = ignored.
* @param {boolean} [params.lines] - Draw flow-colored road lines (live mode).
* @param {boolean} [params.dots] - Draw the moving dots.
* @param {number} [params.densityScale] - Dot density multiplier (clamped 0.2–2.5).
* @param {number} [params.speedScale] - Dot speed multiplier (clamped 0.3–3.0).
* @param {{origin?:string}} [options] - Intent origin; restores never beat `?trafficRoads=`.
@@ -75,7 +84,8 @@ export function createControls({ state: layerState, services, parts, source }) {
const previousSpeed = layerState._speedScale;
if (
Object.hasOwn(params, 'roadMode') &&
(params.roadMode === null || normalizeRoadMode(params.roadMode))
(params.roadMode === null ||
normalizeRoadMode(params.roadMode, layerState._roadModes))
) {
const explicit = ['user', 'voice', 'tool'].includes(origin);
const mode =
@@ -98,6 +108,31 @@ export function createControls({ state: layerState, services, parts, source }) {
parts.viewport.onCameraChanged({ immediate: true });
}
}
if (
typeof params.lines === 'boolean' &&
params.lines !== layerState._showLines
) {
layerState._showLines = params.lines;
// Rebuild in place from the roads on screen: no refetch, no respawn.
if (layerState._enabled)
parts.rendering.rebuildHeatLines(
parts.rendering.visibleRoadsForAltitude(
layerState._roads,
layerState._lastRenderAltitude,
),
);
}
if (
typeof params.dots === 'boolean' &&
params.dots !== layerState._showDots
) {
layerState._showDots = params.dots;
// Hidden dots are kept, so showing them again is instant.
if (layerState._enabled && layerState._pointCollection)
layerState._pointCollection.show = params.dots;
parts.animation.syncRenderDemand();
layerState._viewer?.scene?.requestRender?.();
}
if (typeof params.densityScale === 'number') {
layerState._densityScale = Math.max(
0.2,
@@ -154,11 +189,14 @@ export function createControls({ state: layerState, services, parts, source }) {
/**
* Return the current user-adjustable parameters.
* @returns {{densityScale:number, speedScale:number}}
* @returns {{roadMode:string|null, lines:boolean, dots:boolean,
* densityScale:number, speedScale:number}}
*/
getParams() {
return {
roadMode: layerState._roadMode,
lines: layerState._showLines,
dots: layerState._showDots,
densityScale: layerState._densityScale,
speedScale: layerState._speedScale,
uncoveredRoads: layerState._uncoveredMode,
@@ -167,10 +205,15 @@ export function createControls({ state: layerState, services, parts, source }) {
};
},
/** Road-source chips on the Street Traffic row (TomTom / OSM / Hybrid). */
/**
* Street Traffic row chips: a road-source chip per offered mode (none for
* a single mode), then Lines and Dots toggles while live flow colors them.
*/
getRowControls() {
const modes = layerState._roadModes;
const selected =
layerState._roadMode || resolveRoadMode(null, layerState._liveMode);
layerState._roadMode ||
resolveRoadMode(null, layerState._liveMode, modes);
const live = layerState._liveMode;
const titles = {
tomtom: live
@@ -187,14 +230,36 @@ export function createControls({ state: layerState, services, parts, source }) {
};
// TomTom and OSM are provider names and stay as they are.
const labels = { tomtom: 'TomTom', osm: 'OSM', hybrid: t('Hybrid') };
const lines = {
id: 'lines',
label: t('Lines'),
title: t('Road lines colored by live flow: green to red'),
active: layerState._showLines,
params: { lines: !layerState._showLines },
};
const dots = {
id: 'dots',
label: t('Dots'),
title: live ? t('Moving dots colored by live flow') : t('Moving dots'),
active: layerState._showDots,
params: { dots: !layerState._showDots },
};
// Lines need live flow. Dots, once hidden (a live choice, a restore or
// a share link), stay offered so they can always be shown again.
const toggles = live ? [lines, dots] : layerState._showDots ? [] : [dots];
return {
chips: TRAFFIC_ROAD_MODES.map((mode) => ({
id: `roads-${mode}`,
label: labels[mode],
title: titles[mode],
active: mode === selected,
params: { roadMode: mode },
})),
chips: [
...(modes.length > 1
? modes.map((mode) => ({
id: `roads-${mode}`,
label: labels[mode],
title: titles[mode],
active: mode === selected,
params: { roadMode: mode },
}))
: []),
...toggles,
],
};
},
@@ -211,7 +276,13 @@ export function createControls({ state: layerState, services, parts, source }) {
* @returns {Array<{position:Cesium.Cartesian3, id:string, type:string}>}
*/
getDetectableObjects(options = {}) {
if (!layerState._enabled || layerState._dots.length === 0) return [];
// Hidden dots are not on screen to detect.
if (
!layerState._enabled ||
!layerState._showDots ||
layerState._dots.length === 0
)
return [];
const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(options.maxCount))
: layerState._dots.length;
@@ -382,7 +453,11 @@ export function createControls({ state: layerState, services, parts, source }) {
// in one had better be the honest one. This is also where LIVE vs
// SIMULATED mode is surfaced, and it must never imply a live feed the
// layer does not have.
roadMode: resolveRoadMode(layerState._roadMode, layerState._liveMode),
roadMode: resolveRoadMode(
layerState._roadMode,
layerState._liveMode,
layerState._roadModes,
),
roadModeRequested: layerState._roadMode,
roadSource: layerState._roadSource,
source: layerState._roadSource,
+326
View File
@@ -0,0 +1,326 @@
// A host's road-source offer (createTrafficSource `roadModes`) and the
// Street Traffic Lines / Dots row toggles.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
normalizeRoadModes,
resolveRoadMode,
roadModesSimulate,
TRAFFIC_ROAD_MODES,
} from './roadModes.js';
import { createTrafficSource } from './source.js';
import { createTrafficLayer } from './index.js';
import {
createDefaultLayerState,
decodeLayerStateParams,
encodeLayerStateParams,
parseStoredLayerState,
serializeStoredLayerState,
} from '../../data/layerState.js';
const box = { west: -97.75, east: -97.74, south: 30.26, north: 30.27 };
const flow = {
coords: [
[-97.744, 30.267],
[-97.742, 30.267],
],
trafficLevel: 0.3,
closure: false,
roadType: 'Major road',
};
function tomtomOnlySource() {
let requests = 0;
const source = createTrafficSource({
roadModes: ['tomtom'],
mapTiles: {
async fetchBounds() {
requests++;
return { tiles: [], partial: false };
},
async getMetadata() {
requests++;
},
clear() {},
},
});
return { source, requests: () => requests };
}
test('host road modes are a non-empty subset in canonical order', () => {
assert.equal(normalizeRoadModes(undefined), TRAFFIC_ROAD_MODES);
assert.deepEqual(normalizeRoadModes(['hybrid', 'tomtom']), [
'tomtom',
'hybrid',
]);
assert.ok(Object.isFrozen(normalizeRoadModes(['tomtom'])));
for (const bad of [[], ['bogus'], ['tomtom', 'tomtom'], 'tomtom', null])
assert.throws(() => normalizeRoadModes(bad), TypeError, String(bad));
assert.throws(() => createTrafficSource({ roadModes: [] }), TypeError);
assert.equal(roadModesSimulate(), true);
assert.equal(roadModesSimulate(['tomtom', 'hybrid']), false);
});
test('a TomTom-only host resolves every request to TomTom, keyed or not', () => {
for (const value of [null, 'osm', 'hybrid', 'tomtom'])
for (const hasKey of [true, false])
assert.equal(resolveRoadMode(value, hasKey, ['tomtom']), 'tomtom');
// Without Hybrid the default is the first offered mode.
assert.equal(resolveRoadMode(null, true, ['tomtom', 'osm']), 'tomtom');
assert.equal(resolveRoadMode(null, false, ['tomtom', 'osm']), 'osm');
});
test('a TomTom-only source draws TomTom roads and never requests OpenStreetMap', async () => {
const f = tomtomOnlySource();
assert.deepEqual(f.source.roadModes, ['tomtom']);
for (const roadMode of [null, 'osm', 'hybrid']) {
const data = await (
await f.source.requestRoads(box, {
roadMode,
flowSnapshot: Promise.resolve({ hasKey: true, segments: [flow] }),
})
).json();
assert.equal(data.roadSource, 'TomTom', String(roadMode));
assert.equal(data.roads.length, 1);
assert.equal(data.roads[0].directFlow, true);
}
assert.equal(f.requests(), 0);
await assert.rejects(f.source.requestRoads(box, {}), TypeError);
});
test('a TomTom-only source never simulates: keyless or unreachable loads fail with the reason', async () => {
const f = tomtomOnlySource();
const failure = (snapshot) =>
f.source
.requestRoads(box, { flowSnapshot: Promise.resolve(snapshot) })
.then(
() => assert.fail('a keyless load drew roads'),
(error) => error,
);
const keyless = await failure({ hasKey: false, segments: [] });
assert.equal(keyless.message, 'TomTom roads need a TomTom key');
assert.equal(keyless.retryable, false);
const unreachable = await failure({
hasKey: false,
segments: [],
statusUnavailable: true,
});
assert.equal(
unreachable.message,
'TomTom roads unavailable while the traffic service is unreachable',
);
assert.equal(unreachable.retryable, true);
const late = await failure({
hasKey: false,
segments: [],
error: 'TomTom flow timed out',
});
assert.equal(late.message, 'TomTom flow timed out');
assert.equal(f.requests(), 0);
});
test('a TomTom-only layer offers no road chips and ignores other road choices', () => {
const oldLocation = globalThis.location;
globalThis.location = { search: '?trafficRoads=osm' };
try {
const { source } = tomtomOnlySource();
const layer = createTrafficLayer({
services: { credits: {}, render: {} },
source,
});
assert.equal(layer.getParams().roadMode, null);
for (const origin of ['user', 'local-restore', 'share-restore'])
layer.setParams({ roadMode: 'osm' }, { origin });
assert.equal(layer.getParams().roadMode, null);
assert.deepEqual(layer.getRowControls().chips, []);
const stats = layer.getStats();
assert.equal(stats.roadMode, 'tomtom');
// No key verdict before the status probe settles.
assert.doesNotMatch(stats.loadingLabel, /need a TomTom key/);
} finally {
if (oldLocation === undefined) delete globalThis.location;
else globalThis.location = oldLocation;
}
});
test('Lines and Dots are boolean params that only appear as chips with live flow', () => {
const layer = createTrafficLayer({
services: { credits: {}, render: {} },
source: tomtomOnlySource().source,
});
assert.equal(layer.getParams().lines, false);
assert.equal(layer.getParams().dots, true);
// Keyless (the status probe has not reported a key): nothing to color.
assert.deepEqual(layer.getRowControls().chips, []);
layer.setParams({ lines: true });
assert.equal(layer.getParams().lines, true);
layer.setParams({ lines: 'no', dots: 0 });
assert.equal(layer.getParams().lines, true, 'non-boolean ignored');
assert.equal(layer.getParams().dots, true, 'non-boolean ignored');
layer.setParams({ lines: false, dots: false });
assert.deepEqual(
[layer.getParams().lines, layer.getParams().dots],
[false, false],
);
assert.deepEqual(layer.getDetectableObjects(), []);
// Hidden dots stay recoverable without live flow (restore, share link).
assert.deepEqual(
layer.getRowControls().chips.map((chip) => [chip.id, chip.title]),
[['dots', 'Moving dots']],
);
layer.setParams(layer.getRowControls().chips[0].params, { origin: 'user' });
assert.equal(layer.getParams().dots, true);
assert.deepEqual(layer.getRowControls().chips, []);
});
test('live chips toggle each control without touching the other', async () => {
const { createState } = await import('./state.js');
const { createControls } = await import('./controls.js');
const state = createState({
services: {},
source: { roadModes: ['tomtom'] },
});
state._liveMode = true;
const controls = createControls({
state,
services: {},
source: { getFlowSessionStats: () => ({}) },
parts: {
animation: { syncRenderDemand() {} },
rendering: {},
},
}).methods;
const chip = (id) =>
controls.getRowControls().chips.find((entry) => entry.id === id);
assert.deepEqual(
controls.getRowControls().chips.map((entry) => entry.id),
['lines', 'dots'],
);
assert.equal(chip('lines').active, false);
assert.equal(chip('dots').active, true);
controls.setParams(chip('lines').params, { origin: 'user' });
assert.equal(chip('lines').active, true);
assert.equal(chip('dots').active, true);
controls.setParams(chip('dots').params, { origin: 'user' });
assert.equal(chip('dots').active, false);
assert.equal(chip('lines').active, true);
// Every mode keeps its road chips beside the toggles.
const all = createState({ services: {}, source: {} });
all._liveMode = true;
assert.deepEqual(
createControls({
state: all,
services: {},
source: { getFlowSessionStats: () => ({}) },
parts: {},
})
.methods.getRowControls()
.chips.map((entry) => entry.id),
['roads-tomtom', 'roads-osm', 'roads-hybrid', 'lines', 'dots'],
);
});
test('hiding dots hides the point collection of an enabled layer', async () => {
const { createState } = await import('./state.js');
const { createControls } = await import('./controls.js');
const state = createState({ services: {}, source: {} });
let demand = 0;
state._enabled = true;
state._pointCollection = { show: true };
const controls = createControls({
state,
services: {},
source: { getFlowSessionStats: () => ({}) },
parts: { animation: { syncRenderDemand: () => demand++ } },
}).methods;
controls.setParams({ dots: false });
assert.equal(state._pointCollection.show, false);
controls.setParams({ dots: true });
assert.equal(state._pointCollection.show, true);
assert.equal(demand, 2);
});
test('Lines and Dots persist in layer state and local storage', () => {
const state = createDefaultLayerState();
assert.equal(state.options.traffic.lines, false);
assert.equal(state.options.traffic.dots, true);
state.enabledLayerIds = ['traffic'];
state.options.traffic.lines = true;
state.options.traffic.dots = false;
const params = new URLSearchParams('v=2');
encodeLayerStateParams(params, state);
const decoded = decodeLayerStateParams(params).options.traffic;
assert.equal(decoded.lines, true);
assert.equal(decoded.dots, false);
const stored = parseStoredLayerState(serializeStoredLayerState(state)).options
.traffic;
assert.equal(stored.lines, true);
assert.equal(stored.dots, false);
});
test('rebuilt lines keep the old ones drawn until the new geometry is ready', async () => {
const { createRendering } = await import('./rendering.js');
const removed = [];
const state = {
_viewer: {
scene: { groundPrimitives: { remove: (p) => removed.push(p) } },
},
_liveMode: false,
_heatFreePrim: { ready: true, id: 'old' },
_heatRetired: [],
};
const parts = {
style: { heatlineOn: () => true },
animation: { syncRenderDemand() {} },
};
const rendering = createRendering({ state, services: {}, parts, source: {} });
// Nothing new to draw (not live): the old line goes at once.
rendering.rebuildHeatLines([]);
assert.deepEqual(
removed.map((p) => p.id),
['old'],
);
// A pending successor holds the retired line until it is ready.
const next = { ready: false, id: 'new' };
state._heatRetired = [{ id: 'older' }];
state._heatJamPrim = next;
rendering.retireHeatLines();
assert.equal(state._heatRetired.length, 1);
next.ready = true;
rendering.retireHeatLines();
assert.deepEqual(state._heatRetired, []);
assert.deepEqual(
removed.map((p) => p.id),
['old', 'older'],
);
});
test('lines hold frames only while building; hidden dots hold none', async () => {
const { createAnimation } = await import('./animation.js');
const calls = [];
const state = {
_enabled: true,
_dots: [{}],
_showDots: false,
_showLines: true,
_motion: { rebuilds: 0 },
};
const animation = createAnimation({
state,
services: {
render: {
holdContinuousRender: () => calls.push('hold'),
releaseContinuousRender: () => calls.push('release'),
},
},
parts: {},
});
animation.syncRenderDemand();
assert.deepEqual(calls, []);
state._heatFreePrim = { ready: false };
animation.syncRenderDemand();
assert.deepEqual(calls, ['hold']);
state._heatFreePrim.ready = true;
animation.syncRenderDemand();
assert.deepEqual(calls, ['hold', 'release']);
});
+1 -1
View File
@@ -24,7 +24,7 @@ export function createTrafficLayer({ services, source }) {
].every((key) => typeof source?.[key] === 'function')
)
throw new TypeError('A traffic source is required');
const state = createState({ services });
const state = createState({ services, source });
const parts = {};
const context = { state, services, parts, source };
parts.style = createStyle(context);
+35 -7
View File
@@ -1,6 +1,11 @@
import * as Cesium from 'cesium';
import { requestWithDeadline } from '../../sources/requestDeadline.js';
import { resolveRoadMode, ROAD_SOURCE_LABELS } from './roadModes.js';
import {
normalizeRoadMode,
resolveRoadMode,
roadModesSimulate,
ROAD_SOURCE_LABELS,
} from './roadModes.js';
import { phaseTiming } from '../../sources/phaseTiming.js';
import { RoadRequestError, roadRequestError } from './source.js';
import {
@@ -294,10 +299,14 @@ export function createIngestion({
// settled; before that (a session's first load) the cache is skipped
// rather than waiting on the probe. Writes are decided by the snapshot.
const cacheMode =
layerState._roadMode === 'osm'
normalizeRoadMode(layerState._roadMode, layerState._roadModes) === 'osm'
? 'osm'
: layerState._flowStatusKnown
? resolveRoadMode(layerState._roadMode, layerState._liveMode)
? resolveRoadMode(
layerState._roadMode,
layerState._liveMode,
layerState._roadModes,
)
: null;
// Cache key: fixed-precision bounding-box string for deterministic lookups
const cacheKey =
@@ -378,6 +387,7 @@ export function createIngestion({
(segments) => ({
segments,
hasKey: layerState._liveMode,
statusUnavailable: layerState._flowStatusUnavailable,
partial: source.getFlowSessionStats?.().partial,
}),
(error) => {
@@ -388,6 +398,7 @@ export function createIngestion({
return {
segments: [],
hasKey: layerState._liveMode,
statusUnavailable: layerState._flowStatusUnavailable,
error: reason,
};
},
@@ -397,7 +408,9 @@ export function createIngestion({
// This uses the same shared XYZ cache and cap, with no extra ring.
if (
altitude <= FAST_FETCH_ALTITUDE &&
layerState._roadMode !== 'tomtom'
resolveRoadMode(layerState._roadMode, true, layerState._roadModes) !==
'tomtom' &&
(roadModesSimulate(layerState._roadModes) || layerState._liveMode)
) {
source
.requestOsmRoads?.(clamped, {
@@ -411,8 +424,19 @@ export function createIngestion({
.catch(() => {});
}
requestSignal.throwIfAborted();
// Until the probe settles only OpenStreetMap roads can be drawn.
layerState._roadSource = ROAD_SOURCE_LABELS[cacheMode || 'osm'];
// Until the probe settles only OpenStreetMap roads can be drawn (or,
// for a host without them, the TomTom roads still to come).
layerState._roadSource =
ROAD_SOURCE_LABELS[
cacheMode ||
(roadModesSimulate(layerState._roadModes)
? 'osm'
: resolveRoadMode(
layerState._roadMode,
true,
layerState._roadModes,
))
];
let cache =
cacheMode === 'osm' ? layerState._tileCache.get(cacheKey) : null;
if (cache) {
@@ -581,7 +605,11 @@ export function createIngestion({
if (generation === layerState._loadGeneration && !renderedSomething)
layerState._roadSource =
ROAD_SOURCE_LABELS[
resolveRoadMode(layerState._roadMode, layerState._liveMode)
resolveRoadMode(
layerState._roadMode,
layerState._liveMode,
layerState._roadModes,
)
];
retryable = !isUnavailableCapability(e);
layerState._roadRetryStopped = !retryable;
+8 -2
View File
@@ -1,6 +1,7 @@
import { observeTrafficSurface } from './surface.js';
import * as Cesium from 'cesium';
import { TRAFFIC_TIMING_ENABLED } from './policy.js';
import { resolveRoadMode } from './roadModes.js';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
@@ -81,10 +82,15 @@ export function createLifecycle({
if (layerState._enabled) return;
layerState._enabled = true;
layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene);
if (layerState._roadMode !== 'tomtom') source.prefetch?.();
// OpenFreeMap metadata, unless only TomTom roads can be drawn.
if (
resolveRoadMode(layerState._roadMode, true, layerState._roadModes) !==
'tomtom'
)
source.prefetch?.();
parts.animation.syncRenderDemand();
layerState._lastAnimTime = 0;
layerState._pointCollection.show = true;
layerState._pointCollection.show = layerState._showDots;
layerState._preRenderRemover = viewer.scene.preRender.addEventListener(
parts.animation.animate,
+17
View File
@@ -117,6 +117,23 @@ export const FLOW_BUCKET_COLORS = {
jam: Cesium.Color.fromCssColorString('#e05252').withAlpha(0.9),
};
// ─── Flow road lines (`lines` row toggle, live mode only) ────────────────
/**
* @const {number} Max flow-colored road lines per render. Jam first, then
* slow, then free-flow, longest first within a bucket, so a dense city keeps
* its congestion when the cap trims the free roads.
*/
export const FLOW_LINE_CAP = 1500;
/** @const {number} Px — flow road-line width (every bucket). */
export const FLOW_LINE_WIDTH = 4;
/** @const {number} Flow road-line opacity: the bucket colors, steady. */
export const FLOW_LINE_ALPHA = 0.8;
// ─── Jam-viz prototype (live mode only — see 2026-07-21 design doc) ────────
/** @const {number} Max congestion heat-line polylines per render (jam first). */
+131 -60
View File
@@ -2,6 +2,10 @@ import * as Cesium from 'cesium';
import { flowBucket } from '../../data/trafficFlowStyle.js';
import { trafficStyleProfile } from '../../data/trafficPresetStyle.js';
import {
FLOW_BUCKET_COLORS,
FLOW_LINE_ALPHA,
FLOW_LINE_CAP,
FLOW_LINE_WIDTH,
HEAT_LINE_CAP,
HEAT_JAM_COLOR,
HEAT_SLOW_COLOR,
@@ -34,45 +38,81 @@ export function createRendering({
: roads;
}
/** Remove both heat-line ground primitives from the scene. */
const LINE_KEYS = ['_heatJamPrim', '_heatSlowPrim', '_heatFreePrim'];
function removeHeatLines() {
if (layerState._heatJamPrim) {
layerState._viewer?.scene.groundPrimitives.remove(
layerState._heatJamPrim,
);
layerState._heatJamPrim = null;
}
if (layerState._heatSlowPrim) {
layerState._viewer?.scene.groundPrimitives.remove(
layerState._heatSlowPrim,
);
layerState._heatSlowPrim = null;
/** Detach the current line primitives, leaving them in the scene. */
function takeHeatLines() {
const taken = [];
for (const key of LINE_KEYS) {
if (layerState[key]) taken.push(layerState[key]);
layerState[key] = null;
}
layerState._heatLineCount = 0;
return taken;
}
function removePrimitives(primitives) {
for (const primitive of primitives)
layerState._viewer?.scene.groundPrimitives.remove(primitive);
}
/** Remove the heat-line / flow-line ground primitives from the scene. */
function removeHeatLines() {
removePrimitives(takeHeatLines());
removePrimitives(layerState._heatRetired || []);
layerState._heatRetired = [];
parts.animation.syncRenderDemand?.();
}
/**
* Rebuild the congestion heat-line underlay (jam-viz heatline prototype):
* slow/jam roads drape a corridor line onto the rendered 3D tiles — glowing
* pulsing red for jam, faint flat amber for slow — with the dots animating
* on top. Two batched GroundPolylinePrimitives (one per bucket) so the jam
* batch pulses through one shared material. Capped at HEAT_LINE_CAP (jam
* first, longest first); overflow is logged. No-op in sim mode, when the
* heatline mode is off, or without ground-primitive support.
* Replaced lines stay drawn until their successors finish building
* (GroundPolylinePrimitive geometry is asynchronous), so a rebuild never
* blinks. Called every held frame.
*/
function retireHeatLines() {
if (!layerState._heatRetired?.length) return;
if (LINE_KEYS.some((key) => layerState[key] && !layerState[key].ready))
return;
removePrimitives(layerState._heatRetired);
layerState._heatRetired = [];
}
/**
* Rebuild the road-line underlay drawn from live flow, in one of two looks:
*
* - Flow lines (the `lines` row toggle): every road with flow drapes a
* steady line in its bucket color — green free-flow, amber slow, red jam.
* Capped at FLOW_LINE_CAP.
* - Heat-lines (jam-viz `heatline` prototype): only slow/jam roads, a
* glowing pulsing red for jam and a faint flat amber for slow. Capped at
* HEAT_LINE_CAP.
*
* One batched GroundPolylinePrimitive per bucket, so the heat-line jam batch
* pulses through one shared material. Kept roads are jam first, then slow,
* then free, longest first; overflow is logged. Lines drape on 3D tiles when
* the globe is hidden (photoreal) and on terrain or tiles otherwise. No-op in
* sim mode, when both looks are off, or without ground-primitive support.
*
* @param {Array} roads - Road objects visible in the current render.
*/
function rebuildHeatLines(roads) {
removeHeatLines();
const previous = takeHeatLines();
const finish = () => {
// Nothing new to wait for: the old lines go now.
if (LINE_KEYS.some((key) => layerState[key]))
(layerState._heatRetired ||= []).push(...previous);
else removePrimitives(previous);
parts.animation.syncRenderDemand?.();
};
if (
!layerState._viewer ||
!layerState._liveMode ||
!parts.style.heatlineOn()
)
return;
return finish();
if (layerState._heatSupported === null) {
layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported(
layerState._viewer.scene,
@@ -82,22 +122,25 @@ export function createRendering({
'[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled',
);
}
if (!layerState._heatSupported) return;
if (!layerState._heatSupported) return finish();
const flowLines = layerState._showLines;
const cap = flowLines ? FLOW_LINE_CAP : HEAT_LINE_CAP;
const rank = { jam: 0, slow: 1, free: 2 };
const candidates = [];
for (const road of roads) {
const flow = road.flow;
if (!flow || flow.closure) continue;
const bucket = flowBucket(flow.level);
if (bucket === 'free') continue;
if (bucket === 'free' && !flowLines) continue;
let len = 0;
for (const d of road.segmentDist) len += d;
candidates.push({ road, bucket, len });
}
candidates.sort((a, b) =>
a.bucket === b.bucket ? b.len - a.len : a.bucket === 'jam' ? -1 : 1,
a.bucket === b.bucket ? b.len - a.len : rank[a.bucket] - rank[b.bucket],
);
const kept = candidates.slice(0, HEAT_LINE_CAP);
const kept = candidates.slice(0, cap);
const instancesFor = (bucket, width) =>
kept
@@ -112,51 +155,72 @@ export function createRendering({
}),
);
// Mono presets (NVG/FLIR/noir) discard hue — heat-lines re-encode in
// luminance like the dots: jam = white glow, slow = faint gray.
const monoHeat =
// Mono presets (NVG/FLIR/noir) discard hue — lines re-encode in
// luminance like the dots: jam = white, slow = gray, free = dim gray.
const mono =
layerState._presetDots === 'on' &&
trafficStyleProfile(layerState._stylePreset) === 'mono';
const jamLineColor = monoHeat ? Cesium.Color.WHITE : HEAT_JAM_COLOR;
const slowLineColor = monoHeat
? new Cesium.Color(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha)
: HEAT_SLOW_COLOR;
const jamInstances = instancesFor('jam', HEAT_LINE_JAM_WIDTH);
if (jamInstances.length) {
layerState._heatJamPrim = layerState._viewer.scene.groundPrimitives.add(
new Cesium.GroundPolylinePrimitive({
geometryInstances: jamInstances,
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
appearance: new Cesium.PolylineMaterialAppearance({
material: Cesium.Material.fromType('PolylineGlow', {
color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA),
glowPower: 0.25,
const gray = (level, alpha) => new Cesium.Color(level, level, level, alpha);
const classificationType =
layerState._viewer.scene.globe?.show === false
? Cesium.ClassificationType.CESIUM_3D_TILE
: flowLines
? Cesium.ClassificationType.BOTH
: Cesium.ClassificationType.CESIUM_3D_TILE;
const add = (instances, material) =>
instances.length
? layerState._viewer.scene.groundPrimitives.add(
new Cesium.GroundPolylinePrimitive({
geometryInstances: instances,
classificationType,
appearance: new Cesium.PolylineMaterialAppearance({ material }),
}),
)
: null;
// Lines take the dots' active (preset-aware) colors.
const dotColor = (bucket) =>
layerState._activeBucketColors?.[bucket] || FLOW_BUCKET_COLORS[bucket];
if (flowLines) {
const lineColor = {
jam: mono ? gray(1, FLOW_LINE_ALPHA) : dotColor('jam'),
slow: mono ? gray(0.7, FLOW_LINE_ALPHA) : dotColor('slow'),
free: mono ? gray(0.45, FLOW_LINE_ALPHA) : dotColor('free'),
};
for (const [bucket, key] of [
['free', '_heatFreePrim'],
['slow', '_heatSlowPrim'],
['jam', '_heatJamPrim'],
])
layerState[key] = add(
instancesFor(bucket, FLOW_LINE_WIDTH),
Cesium.Material.fromType('Color', {
color: lineColor[bucket].withAlpha(FLOW_LINE_ALPHA),
}),
);
} else {
const jamLineColor = mono ? Cesium.Color.WHITE : HEAT_JAM_COLOR;
const slowLineColor = mono
? gray(0.7, HEAT_SLOW_COLOR.alpha)
: HEAT_SLOW_COLOR;
layerState._heatJamPrim = add(
instancesFor('jam', HEAT_LINE_JAM_WIDTH),
Cesium.Material.fromType('PolylineGlow', {
color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA),
glowPower: 0.25,
}),
);
}
const slowInstances = instancesFor('slow', HEAT_LINE_SLOW_WIDTH);
if (slowInstances.length) {
layerState._heatSlowPrim = layerState._viewer.scene.groundPrimitives.add(
new Cesium.GroundPolylinePrimitive({
geometryInstances: slowInstances,
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
appearance: new Cesium.PolylineMaterialAppearance({
material: Cesium.Material.fromType('Color', {
color: slowLineColor,
}),
}),
}),
layerState._heatSlowPrim = add(
instancesFor('slow', HEAT_LINE_SLOW_WIDTH),
Cesium.Material.fromType('Color', { color: slowLineColor }),
);
}
layerState._heatLineCount = kept.length;
parts.animation.syncRenderDemand?.();
finish();
if (candidates.length > kept.length) {
console.log(
`[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`,
`[Data:Traffic] ${flowLines ? 'Flow lines' : 'Heat-lines'} capped at ${cap} (${candidates.length} roads in view)`,
);
}
}
@@ -270,7 +334,13 @@ export function createRendering({
renderMetrics,
)
: null;
if (!label.includes('tile')) rebuildHeatLines(filteredRoads);
// The flow recolor above already rebuilt the lines from every retained
// road; without dots to recolor, rebuild them here.
if (
!label.includes('tile') &&
!(layerState._liveMode && layerState._dots.length)
)
rebuildHeatLines(visibleRoadsForAltitude(layerState._roads, altitude));
if (state) {
const heatEnd = parts.timing.trafficTimingMark(
state,
@@ -314,6 +384,7 @@ export function createRendering({
return {
visibleRoadsForAltitude,
removeHeatLines,
retireHeatLines,
rebuildHeatLines,
renderRoadsForAltitude,
};
+40 -5
View File
@@ -46,24 +46,59 @@ const PAIRED_SIDE_SAMPLES = [0.25, 0.5, 0.75];
const CONTROLLED_ACCESS_FLOW = new Set(['Motorway', 'International road']);
const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']);
/**
* Validate a host's road-source list (`createTrafficSource({ roadModes })`):
* a non-empty subset of TRAFFIC_ROAD_MODES, kept in canonical order.
* @param {*} value - Candidate list; undefined means every mode.
* @returns {ReadonlyArray<'tomtom'|'osm'|'hybrid'>}
*/
export function normalizeRoadModes(value) {
if (value === undefined) return TRAFFIC_ROAD_MODES;
const modes = Array.isArray(value)
? TRAFFIC_ROAD_MODES.filter((mode) => value.includes(mode))
: [];
if (!modes.length || modes.length !== value.length)
throw new TypeError(
`Traffic road modes must be a non-empty subset of ${TRAFFIC_ROAD_MODES.join(', ')}`,
);
return Object.freeze(modes);
}
/**
* Whether a host's modes allow simulated roads. Without OpenStreetMap the
* layer never simulates: keyless, it draws no roads and says why.
* @param {ReadonlyArray<string>} [modes]
* @returns {boolean}
*/
export function roadModesSimulate(modes = TRAFFIC_ROAD_MODES) {
return modes.includes('osm');
}
/**
* Validate an explicit choice.
* @param {*} value - Candidate mode.
* @param {ReadonlyArray<string>} [modes] - The host's offered modes.
* @returns {'tomtom'|'osm'|'hybrid'|null} Null means "use the default".
*/
export function normalizeRoadMode(value) {
return TRAFFIC_ROAD_MODES.includes(value) ? value : null;
export function normalizeRoadMode(value, modes = TRAFFIC_ROAD_MODES) {
return modes.includes(value) ? value : null;
}
/**
* Resolve the mode actually drawn. Without a TomTom key every choice draws
* OpenStreetMap roads; with one, an explicit choice wins over Hybrid.
* OpenStreetMap roads when the host offers them; with one, an explicit
* choice wins over Hybrid. A host that offers no OpenStreetMap mode keeps
* its TomTom choice keyless (nothing is drawn; the status names the reason),
* and a host without Hybrid defaults to its first offered mode.
* @param {*} value - Requested mode (null for the default).
* @param {boolean} hasKey - The server reported a TomTom key.
* @param {ReadonlyArray<string>} [modes] - The host's offered modes.
* @returns {'tomtom'|'osm'|'hybrid'}
*/
export function resolveRoadMode(value, hasKey) {
return hasKey ? normalizeRoadMode(value) || 'hybrid' : 'osm';
export function resolveRoadMode(value, hasKey, modes = TRAFFIC_ROAD_MODES) {
const chosen = normalizeRoadMode(value, modes);
if (!hasKey && roadModesSimulate(modes)) return 'osm';
return chosen || (modes.includes('hybrid') ? 'hybrid' : modes[0]);
}
const FLOW_ROAD_TYPES = Object.freeze({
+42 -1
View File
@@ -74,7 +74,23 @@ function roadChips(liveMode) {
source: { getFlowSessionStats: () => ({}) },
parts: {},
});
return controls.methods.getRowControls().chips;
return controls.methods
.getRowControls()
.chips.filter((chip) => chip.id.startsWith('roads-'));
}
function toggleChips() {
const state = createState({ services: {} });
state._liveMode = true;
const controls = createControls({
state,
services: {},
source: { getFlowSessionStats: () => ({}) },
parts: {},
});
return controls.methods
.getRowControls()
.chips.filter((chip) => !chip.id.startsWith('roads-'));
}
test('en: road-source chips keep their English labels and titles', () => {
@@ -124,3 +140,28 @@ for (const [locale, hybrid] of [
}
});
}
test('en: the Lines and Dots toggles keep their English labels and titles', () => {
setLocale('en');
assert.deepEqual(
toggleChips().map((chip) => [chip.id, chip.label, chip.title]),
[
['lines', 'Lines', 'Road lines colored by live flow: green to red'],
['dots', 'Dots', 'Moving dots colored by live flow'],
],
);
});
for (const locale of ['ru', 'es', 'ja']) {
test(`${locale}: the Lines and Dots toggles are translated`, () => {
setLocale(locale);
try {
for (const chip of toggleChips()) {
assert.doesNotMatch(chip.label, /^(Lines|Dots)$/, locale);
assert.doesNotMatch(chip.title, /colored by live flow/, locale);
}
} finally {
setLocale('en');
}
});
}
+50 -7
View File
@@ -1,7 +1,10 @@
import {
createHybridFill,
flowSegmentsToRoads,
normalizeRoadMode,
normalizeRoadModes,
resolveRoadMode,
roadModesSimulate,
ROAD_SOURCE_LABELS,
} from './roadModes.js';
import { phaseTiming } from '../../sources/phaseTiming.js';
@@ -80,7 +83,15 @@ export function roadRequestError(status, cause) {
});
}
/** Supply tile-derived road geometry and flow availability without Overpass queries. */
/**
* Supply tile-derived road geometry and flow availability without Overpass queries.
*
* `roadModes` is the host's offer of road sources (default: all three). The
* layer shows a chip per offered mode (none for a single mode) and never
* draws one outside it. Without `osm` nothing is simulated: keyless, or with
* the traffic service unreachable, requests fail with the reason instead of
* drawing white OpenStreetMap dots.
*/
export function createTrafficSource({
fetchImpl = (...args) => globalThis.fetch(...args),
tileFetchImpl = (...args) => globalThis.fetch(...args),
@@ -88,10 +99,13 @@ export function createTrafficSource({
fetchImpl: tileFetchImpl,
selectDecoded: ({ roads }) => ({ roads }),
}),
roadModes,
} = {}) {
const modes = normalizeRoadModes(roadModes);
const flow = createFlowTileSource({ fetchImpl });
const api = {
...flow,
roadModes: modes,
prefetch: () => mapTiles.getMetadata().catch(() => {}),
resetFlowTileCache() {
flow.resetFlowTileCache();
@@ -247,8 +261,8 @@ export function createTrafficSource({
*
* @param {{south:number, west:number, north:number, east:number}} box
* @param {Object} [options]
* @param {'tomtom'|'osm'|'hybrid'|null} [options.roadMode] - Requested mode (null = default).
* @param {Promise<{segments:Array, hasKey:boolean, error?:string, partial?:boolean}>} [options.flowSnapshot]
* @param {'tomtom'|'osm'|'hybrid'|null} [options.roadMode] - Requested mode (null = default; outside `roadModes` = default).
* @param {Promise<{segments:Array, hasKey:boolean, error?:string, partial?:boolean, statusUnavailable?:boolean}>} [options.flowSnapshot]
* @param {() => boolean} [options.liveModeHint] - Key state if the snapshot misses the pass deadline.
* @param {number} [options.timeoutSec=20] - Pass deadline, also bounding the snapshot wait.
* @param {(data:Object) => void} [options.onTile] - Incremental snapshots; `replace` resets.
@@ -269,7 +283,16 @@ export function createTrafficSource({
box.east - box.west > 10
)
throw new TypeError('A bounded road viewport is required');
if (roadMode === 'osm' || !flowSnapshot)
roadMode = normalizeRoadMode(roadMode, modes);
if (!roadModesSimulate(modes) && !flowSnapshot)
throw new TypeError(
'Road selection requires a flow availability snapshot',
);
if (
roadMode === 'osm' ||
!flowSnapshot ||
(modes.length === 1 && modes[0] === 'osm')
)
return api.requestOsmRoads(box, { ...options, onTile });
const signal = options.signal;
const osm = [];
@@ -332,7 +355,13 @@ export function createTrafficSource({
osmError = error;
}
};
const osmJob = roadMode === 'tomtom' ? null : loadOsm();
// A host without OpenStreetMap waits for the key before fetching fill.
const osmJob =
roadMode === 'tomtom' ||
modes.every((entry) => entry === 'tomtom') ||
!roadModesSimulate(modes)
? null
: loadOsm();
osmJob?.catch(() => {});
// The snapshot waits on the status probe and flow tiles; never let it
// hold the road pass past its own deadline. A late snapshot counts as a
@@ -357,7 +386,21 @@ export function createTrafficSource({
throw new TypeError(
'Road selection requires a flow availability snapshot',
);
mode = resolveRoadMode(roadMode, live.hasKey);
mode = resolveRoadMode(roadMode, live.hasKey, modes);
// A host without OpenStreetMap never simulates: name why no roads draw.
if (!live.hasKey && !roadModesSimulate(modes))
throw Object.assign(
new RoadRequestError(
live.error ||
(live.statusUnavailable
? msg(
'TomTom roads unavailable while the traffic service is unreachable',
)
: msg('TomTom roads need a TomTom key')),
),
// A known missing key cannot recover this session; the rest can.
{ retryable: Boolean(live.error || live.statusUnavailable) },
);
if (mode === 'tomtom' && live.error) throw new RoadRequestError(live.error);
if (mode !== 'osm') {
tomtom = flowSegmentsToRoads(live.segments);
@@ -365,7 +408,7 @@ export function createTrafficSource({
publish(composed(), true);
}
if (osmJob) await osmJob;
else if (mode === 'osm') await loadOsm();
else if (mode !== 'tomtom') await loadOsm();
signal?.throwIfAborted();
if (osmError && (mode === 'osm' || !tomtom.length)) throw osmError;
const data = snapshot(composed(), true);
+21 -2
View File
@@ -1,14 +1,17 @@
import { normalizeRoadMode } from './roadModes.js';
import { normalizeRoadMode, normalizeRoadModes } from './roadModes.js';
import * as Cesium from 'cesium';
import { FLOW_BUCKET_COLORS } from './policy.js';
export function createState({ services }) {
export function createState({ services, source }) {
const layerState = {};
/** The host's offered road sources (createTrafficSource `roadModes`). */
layerState._roadModes = normalizeRoadModes(source?.roadModes);
layerState._roadMode =
typeof location === 'undefined'
? null
: normalizeRoadMode(
new URLSearchParams(location.search).get('trafficRoads'),
layerState._roadModes,
);
// The query override is consumed at construction and shields passive restoration.
layerState._roadModeUrlOverride = layerState._roadMode;
@@ -167,6 +170,14 @@ export function createState({ services }) {
layerState._jamViz = 'density';
/**
* Row toggles (`lines` / `dots` params): flow-colored road lines draped on
* the surface, and the moving dots. Live mode only for lines — the keyless
* simulation has no flow to color them with.
*/
layerState._showLines = false;
layerState._showDots = true;
/**
* Congestion heat-lines are GroundPolylinePrimitive batches draped onto the
* rendered 3D tiles (ClassificationType.CESIUM_3D_TILE). One
@@ -181,6 +192,14 @@ export function createState({ services }) {
layerState._heatSlowPrim = null;
/** @type {Cesium.GroundPolylinePrimitive|null} Free-flow road-line batch (`lines` only). */
layerState._heatFreePrim = null;
/** @type {Cesium.GroundPolylinePrimitive[]} Replaced lines kept until successors are ready. */
layerState._heatRetired = [];
/** @type {number} Heat-lines in the current render (stats). */
layerState._heatLineCount = 0;
+6 -2
View File
@@ -137,10 +137,14 @@ export function createStyle({ state: layerState, services, parts, source }) {
return layerState._jamViz === 'density' || layerState._jamViz === 'both';
}
/** @returns {boolean} Heat-line prototype active. */
/** @returns {boolean} Road lines drawn: the `lines` toggle or the heat-line prototype. */
function heatlineOn() {
return layerState._jamViz === 'heatline' || layerState._jamViz === 'both';
return (
layerState._showLines ||
layerState._jamViz === 'heatline' ||
layerState._jamViz === 'both'
);
}
return {
presetProfileActive,
+1 -1
View File
@@ -253,7 +253,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([
*/
export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({
'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }),
traffic: Object.freeze({ roadMode: null }),
traffic: Object.freeze({ roadMode: null, lines: false, dots: true }),
'weather-lightning': Object.freeze({ opacity: 'strong' }),
'weather-radar': Object.freeze({ opacity: 'strong' }),
'weather-satellite': Object.freeze({
+31 -1
View File
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
import { createGeminiChatHandler } from '../server/providers/gemini/chat.js';
import { createOpenAiChatHandler } from '../server/providers/openai/chat.js';
import {
chatSchema,
createOpenAiChatHandler,
} from '../server/providers/openai/chat.js';
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
import {
realtimeDirectives,
@@ -534,6 +537,11 @@ for (const [name, provider] of Object.entries(providers)) {
assert.equal(offered.includes('osm_query'), overpass);
assert.equal(prompt.includes('osm_query'), overpass);
assert.ok(offered.includes('resolve_area'));
// Chat Completions answers 400 invalid_function_parameters for a
// pattern with Unicode property classes, as resolve_area's area ids
// use; OpenAI chat leaves those patterns out.
if (name === 'openai')
assert.doesNotMatch(JSON.stringify(body.tools), /\\\\[pP]\{/);
}
});
test(`${name} throttled replies say they are rate limited`, async (t) => {
@@ -697,3 +705,25 @@ test('OpenAI empty tool arrays are removed before history is returned', async (t
const body = await (await f.send()).json();
assert.equal(body.content.tool_calls, undefined);
});
test('OpenAI chat schemas drop only the patterns Chat Completions refuses', () => {
assert.match(JSON.stringify(GEV_REALTIME_TOOLS), /\\\\[pP]\{/);
assert.deepEqual(
chatSchema({
type: 'object',
properties: {
id: { type: 'string', pattern: '^ne:[\\p{L}]+$', maxLength: 80 },
code: { type: 'string', pattern: '^[A-Z]{2}$' },
list: { type: 'array', items: [{ pattern: '\\P{N}' }] },
},
}),
{
type: 'object',
properties: {
id: { type: 'string', maxLength: 80 },
code: { type: 'string', pattern: '^[A-Z]{2}$' },
list: { type: 'array', items: [{}] },
},
},
);
});
+17 -1
View File
@@ -4,6 +4,19 @@ import {
contextModeWord,
} from '../contextModePolicy.js';
/** Why a Space Missions entry failed: its mission index never loaded, and
* the error is no older than `since` (an earlier attempt's is not the cause). */
export function spaceMissionsIndexFailure(dataManager, since = 0) {
const stats = dataManager?.layers
?.get('rocket-launches')
?.module?.getStats?.();
return stats?.error && stats.lastUpdate == null && stats.errorAt >= since
? t(
'Space Missions unavailable: the mission index could not load. Try again later.',
)
: null;
}
export async function _runUserFacingContextAction(
operation,
message = t('Context could not restore every layer; try again'),
@@ -19,7 +32,10 @@ export async function _runUserFacingContextAction(
onFailure: (error) => {
if (this.destroyed) return;
console.warn('[Context] user-facing transition failed', error);
this.showToast(localizedMessage || message);
this.showToast(
localizedMessage ||
(typeof message === 'function' ? message() : message),
);
},
});
} finally {
+7 -3
View File
@@ -1,4 +1,5 @@
import { t } from '../i18n/index.js';
import { spaceMissionsIndexFailure } from './contextActions.js';
import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js';
export function _initGlobalContextPanel() {
@@ -59,12 +60,15 @@ export function _initGlobalContextPanel() {
const nextMode =
this._contextMode === 'space-missions' ? null : 'space-missions';
this._claimContextVisualAuthority();
const startedAt = Date.now();
void this._runUserFacingContextAction(
(notificationToken) =>
this._selectContextMode(nextMode, { notificationToken }),
t(
'Space Missions could not complete the requested transition; try again',
),
() =>
(nextMode && spaceMissionsIndexFailure(this._dataManager, startedAt)) ||
t(
'Space Missions could not complete the requested transition; try again',
),
).then((succeeded) => {
if (
!this.destroyed &&
+9 -4
View File
@@ -9,6 +9,7 @@ import {
spaceMissionEntryCancellationDisposition,
} from '../contextModePolicy.js';
import { localizeLayerName } from './layerNames.js';
import { spaceMissionsIndexFailure } from './contextActions.js';
/**
* A blocked-visibility reason in the UI language. Reasons stay English (voice
@@ -120,10 +121,14 @@ export function _handleContextLayerChange(change) {
return;
}
if (change?.type === 'visibility-failed') {
const failureMessage = t('{layerId} could not {action} cleanly', {
layerId: change.layerId,
action: change.enabled ? t('start') : t('stop'),
});
const failureMessage =
(change.layerId === 'rocket-launches' &&
change.enabled &&
spaceMissionsIndexFailure(this._dataManager)) ||
t('{layerId} could not {action} cleanly', {
layerId: change.layerId,
action: change.enabled ? t('start') : t('stop'),
});
// A failed direct Context-shell START has already had its siblings
// cleared by the visibility guard. Wait outside the synchronous manager
// notification for this queue to settle, then reconcile the complete