mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
Merge remote-tracking branch 'origin/main' into feat/viewport-context-decision-upstream
This commit is contained in:
commit
3ef3b9d555
66 files changed
+2470
-143
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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 & 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:
|
||||
|
||||
@@ -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'], {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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": "تم استهلاك حصة حركة المرور الشهرية",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "局タグ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 сопоставляется, где он есть",
|
||||
|
||||
@@ -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": "ТЕГ СТАНЦІЇ",
|
||||
|
||||
@@ -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": "电台标签",
|
||||
|
||||
@@ -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": "電臺標籤",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+4
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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),
|
||||
{
|
||||
|
||||
@@ -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,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),
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Vendored
+95
-20
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: [{}] },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in new issue
Block a user