mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
Merge main into feat/transit-icon-heading
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
dfd4ba04f8
149 files changed
+43917
-668
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { resolveCatalogSources } from './sourceComposition.js';
|
||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||
import { createApplicationCyclones } from './layers/cyclones.js';
|
||||
import { createWindLayer } from '../layers/wind/index.js';
|
||||
import { createLayerCatalog } from './catalog.js';
|
||||
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||
@@ -182,7 +182,7 @@ export function createApplicationCatalog({
|
||||
id: 'weather-lightning',
|
||||
clock: weatherClock,
|
||||
}),
|
||||
createCyclonesLayer({ feed: sources.cyclones }),
|
||||
createApplicationCyclones({ feed: sources.cyclones }),
|
||||
...createOsmOverlayLayers(),
|
||||
...createInfrastructureLayers(localGeoJsonServices),
|
||||
createApplicationCables({ source: sources.cables }),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createCyclonesLayer } from '../../layers/cyclones/index.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
|
||||
/** Wire cyclone selection to the application shared context. */
|
||||
export function createApplicationCyclones(options) {
|
||||
return createCyclonesLayer({
|
||||
context: typeof window !== 'undefined' ? context : null,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
CALGARY_MAX_CATALOG_BYTES,
|
||||
DEFAULT_CALGARY_ROWS_URL,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
|
||||
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
|
||||
@@ -384,6 +384,6 @@ test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t)
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
});
|
||||
@@ -6,9 +6,12 @@ import {
|
||||
} from '../../server/providers/cctv/cap.js';
|
||||
import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { QUEBEC_CAMERA_SOURCE_COUNT } from '../../server/providers/cctv/quebec.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const pack = (name, count, prefix = name) => ({
|
||||
@@ -99,6 +102,68 @@ test('duplicate ids resolve last-pack-wins and count against the winning pack',
|
||||
]);
|
||||
});
|
||||
|
||||
/** Shared packs plus two reserved ones, shaped like the default catalog. */
|
||||
const reservedFixture = () => [
|
||||
...Array.from({ length: 20 }, (_, i) => pack(`shared${i}`, 250)),
|
||||
{ ...pack('quebec', 1120), reservedCount: 1120 },
|
||||
{ ...pack('world', 2230), reservedCount: 2230 },
|
||||
];
|
||||
const keptByName = (packs) =>
|
||||
Object.fromEntries(packs.map(({ name, kept }) => [name, kept]));
|
||||
|
||||
test('the default cap is the shared floor plus the reserved Québec and World packs', () => {
|
||||
assert.equal(
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS +
|
||||
QUEBEC_CAMERA_SOURCE_COUNT +
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
test('at the default cap reserved packs are whole and shared packs keep the floor', () => {
|
||||
const { sources, packs } = allocateSourceCap(
|
||||
reservedFixture(),
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
);
|
||||
const kept = keptByName(packs);
|
||||
assert.equal(sources.length, DEFAULT_CCTV_MAX_SOURCES);
|
||||
assert.equal(kept.quebec, 1120);
|
||||
assert.equal(kept.world, 2230);
|
||||
for (let i = 0; i < 20; i++) assert.equal(kept[`shared${i}`], 200);
|
||||
assert.match(
|
||||
sources[0].id,
|
||||
/^shared0-/,
|
||||
'the first camera stays in a shared pack',
|
||||
);
|
||||
});
|
||||
|
||||
test('a cap at the shared floor reserves nothing and thins every pack evenly', () => {
|
||||
const { packs } = allocateSourceCap(reservedFixture(), 4000);
|
||||
const kept = keptByName(packs);
|
||||
// 22 lanes share 4,000 round-robin: nobody is wiped out by the reservation.
|
||||
for (const count of Object.values(kept))
|
||||
assert.ok(count >= 181 && count <= 182);
|
||||
});
|
||||
|
||||
test('a small cap (dev-secure 48) still reaches the shared packs first', () => {
|
||||
const { sources, packs } = allocateSourceCap(reservedFixture(), 48);
|
||||
const kept = keptByName(packs);
|
||||
assert.equal(sources.length, 48);
|
||||
for (let i = 0; i < 20; i++) assert.ok(kept[`shared${i}`] >= 2);
|
||||
assert.match(sources[0].id, /^shared0-/);
|
||||
});
|
||||
|
||||
test('reservations fill only the room above the floor, round-robin between reserved packs', () => {
|
||||
const { packs } = allocateSourceCap(reservedFixture(), 4100);
|
||||
const kept = keptByName(packs);
|
||||
const shared = Object.entries(kept)
|
||||
.filter(([name]) => name.startsWith('shared'))
|
||||
.reduce((sum, [, count]) => sum + count, 0);
|
||||
// 100 reserved slots split between Québec and World, then 4,000 round-robin.
|
||||
assert.ok(shared >= 4000 - 2 * 182, `shared packs kept ${shared}`);
|
||||
assert.equal(shared + kept.quebec + kept.world, 4100);
|
||||
});
|
||||
|
||||
test('resolveCatalogCap bounds the env value and falls back to the default', () => {
|
||||
assert.equal(resolveCatalogCap(undefined), DEFAULT_CCTV_MAX_SOURCES);
|
||||
assert.equal(resolveCatalogCap('nonsense'), DEFAULT_CCTV_MAX_SOURCES);
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
DEFAULT_ONTARIO_MAX_SOURCES,
|
||||
DEFAULT_FINTRAFFIC_MAX_SOURCES,
|
||||
DEFAULT_TXDOT_MAX_SOURCES,
|
||||
DEFAULT_OTTAWA_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_TALLINN_MAX_SOURCES,
|
||||
DEFAULT_TARKTEE_MAX_SOURCES,
|
||||
DEFAULT_NSW_MAX_SOURCES,
|
||||
@@ -222,7 +224,7 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r
|
||||
);
|
||||
});
|
||||
|
||||
test('default caps of the original packs fit inside the default catalog cap', () => {
|
||||
test('default caps of the original packs fit inside the shared catalog slots', () => {
|
||||
// Every default pack at its own cap (Warendorf ships 1 curated camera).
|
||||
const packs =
|
||||
DEFAULT_AUSTIN_MAX_SOURCES +
|
||||
@@ -231,15 +233,18 @@ test('default caps of the original packs fit inside the default catalog cap', ()
|
||||
DEFAULT_ONTARIO_MAX_SOURCES +
|
||||
DEFAULT_FINTRAFFIC_MAX_SOURCES +
|
||||
DEFAULT_DRIVEBC_MAX_SOURCES +
|
||||
DEFAULT_OTTAWA_MAX_SOURCES +
|
||||
DEFAULT_TXDOT_MAX_SOURCES +
|
||||
DEFAULT_TALLINN_MAX_SOURCES +
|
||||
DEFAULT_TARKTEE_MAX_SOURCES +
|
||||
DEFAULT_NSW_MAX_SOURCES +
|
||||
DEFAULT_CATALONIA_MAX_SOURCES +
|
||||
1;
|
||||
// The reserved Québec and World packs sit above the shared floor, so the
|
||||
// shared packs must fit the floor itself, not the whole default cap.
|
||||
assert.ok(
|
||||
packs <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
`default packs (${packs}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`,
|
||||
packs <= CCTV_SHARED_SOURCE_SLOTS,
|
||||
`default packs (${packs}) would be thinned by the shared slots (${CCTV_SHARED_SOURCE_SLOTS})`,
|
||||
);
|
||||
assert.ok(
|
||||
DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
parseHkCameraLocationsXml,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_HK_CAMERAS_URL,
|
||||
DEFAULT_HK_MAX_SOURCES,
|
||||
HK_ANCHORS,
|
||||
@@ -396,8 +396,8 @@ test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
});
|
||||
|
||||
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
|
||||
@@ -406,7 +406,7 @@ test('the HK pack default sits above the full TD catalog and anchors the New Ter
|
||||
'default cap should keep nearly all published TD cameras',
|
||||
);
|
||||
assert.ok(
|
||||
DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_HK_MAX_SOURCES <= CCTV_SHARED_SOURCE_SLOTS,
|
||||
'a single pack must not outgrow the catalog-wide ceiling alone',
|
||||
);
|
||||
const places = HK_ANCHORS.map(
|
||||
|
||||
@@ -10,6 +10,77 @@ const playlist =
|
||||
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n';
|
||||
const base = 'https://camera.example/live/list.m3u8';
|
||||
|
||||
test('high-resolution HLS segments play while the retained session cache stays bounded', async (t) => {
|
||||
// Giroc publishes roughly ten-second TS segments; one measured 6,437,120
|
||||
// bytes. The former 4 MiB download cap rejected it before playback began.
|
||||
const segmentBytes = 6_437_120;
|
||||
const master =
|
||||
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=4570000\ntracks-v1a1/mono.m3u8\n';
|
||||
const media =
|
||||
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:100\n' +
|
||||
[0, 1, 2, 3, 4].map((i) => `#EXTINF:10.272,\n${i}.ts\n`).join('');
|
||||
const manager = createHlsPuller({
|
||||
limits: { ...HLS_LIMITS, pollMs: 100000 },
|
||||
fetchImpl: async (url) => {
|
||||
if (url === base) return new Response(master);
|
||||
if (url === 'https://camera.example/live/tracks-v1a1/mono.m3u8')
|
||||
return new Response(media);
|
||||
const match =
|
||||
/^https:\/\/camera\.example\/live\/tracks-v1a1\/([0-4])\.ts$/.exec(url);
|
||||
if (!match) throw new Error('Unexpected playlist reference');
|
||||
// No Content-Length: the actual streamed bytes must also be accepted.
|
||||
return new Response(Buffer.alloc(segmentBytes, Number(match[1])));
|
||||
},
|
||||
});
|
||||
t.after(() => manager.shutdown());
|
||||
const entry = await manager.ensure('giroc', base);
|
||||
await entry.polling;
|
||||
const result = await manager.buildPlaylist(entry, 'giroc');
|
||||
assert.ok(result, 'large valid segments should produce a playable playlist');
|
||||
assert.match(result, /#EXT-X-MEDIA-SEQUENCE:2\n/);
|
||||
assert.equal(manager.getSegment('giroc', entry.token, 0), null);
|
||||
const newest = manager.getSegment('giroc', entry.token, 4);
|
||||
assert.equal(newest.length, segmentBytes);
|
||||
assert.equal(newest[0], 4);
|
||||
assert.deepEqual(manager.stats(), { sessions: 1, bytes: 19_311_360 });
|
||||
});
|
||||
|
||||
test('HLS downloads still reject declared and chunked bodies above 8 MiB', async () => {
|
||||
for (const declared of [true, false]) {
|
||||
let chunks = 0;
|
||||
let cancelled = false;
|
||||
await assert.rejects(
|
||||
fetchHlsBytes(base, {
|
||||
maxBytes: HLS_LIMITS.segmentBytes,
|
||||
fetchImpl: async () =>
|
||||
new Response(
|
||||
declared
|
||||
? 'small body'
|
||||
: new ReadableStream({
|
||||
pull(controller) {
|
||||
// Each chunk fits on its own; only their cumulative size
|
||||
// crosses the cap. Leave it open to verify cancellation.
|
||||
controller.enqueue(
|
||||
Buffer.alloc(4 * 1024 * 1024 + (++chunks === 2 ? 1 : 0)),
|
||||
);
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: declared
|
||||
? { 'content-length': String(8 * 1024 * 1024 + 1) }
|
||||
: {},
|
||||
},
|
||||
),
|
||||
}),
|
||||
/HLS response too large/,
|
||||
);
|
||||
if (!declared) assert.equal(cancelled, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('playlist parser uses sequence, refuses escaping and unsupported references', () => {
|
||||
assert.deepEqual(
|
||||
parseHlsMedia(playlist, base).map((s) => s.seq),
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
loadHoustonSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_HOUSTON_MAX_SOURCES,
|
||||
HOUSTON_CAMERAS_URL,
|
||||
HOUSTON_GROUND_ELEVATION_M,
|
||||
@@ -387,8 +387,8 @@ test('Houston stays unloaded unless CCTV_HOUSTON_ENABLED=1', async (t) => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
nycCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_NYC_MAX_SOURCES,
|
||||
NYC_CAMERAS_URL,
|
||||
NYC_GROUND_ELEVATION_M,
|
||||
@@ -375,8 +375,8 @@ for (const [label, flag] of [
|
||||
});
|
||||
}
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
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 {
|
||||
OTTAWA_CAMERAS_URL,
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
fallbackHeadingFromId,
|
||||
pinnedCameraUrl,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import {
|
||||
_resetOttawaCertificateWarningForTest,
|
||||
loadOttawaSourcesFromOpenData,
|
||||
ottawaAuthorizedFrameUrl,
|
||||
} from '../../server/providers/cctv/ottawa.js';
|
||||
|
||||
const CERT = { OTTAWA_CAMERA_CERTIFICATE: 'CERT123' };
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence the loader's progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/** One documented `/map/service/camera` row carrying the fields the loader reads. */
|
||||
function ottawaRow(number, lat, lon, overrides = {}) {
|
||||
return {
|
||||
id: number + 10000,
|
||||
camera_number: number,
|
||||
name: `Camera ${number}`,
|
||||
name_french: `Caméra ${number}`,
|
||||
cameraOwner: 'CITY',
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** The feed's envelope around its rows. */
|
||||
const ottawaPayload = (rows) => Response.json({ cameras: rows });
|
||||
|
||||
test('without OTTAWA_CAMERA_CERTIFICATE the Ottawa pack is skipped with no request, logged once', async (t) => {
|
||||
_resetOttawaCertificateWarningForTest();
|
||||
const lines = [];
|
||||
t.mock.method(console, 'warn', (line) => lines.push(String(line)));
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('no request expected');
|
||||
});
|
||||
assert.deepEqual(await loadOttawaSourcesFromOpenData({}, {}), []);
|
||||
assert.deepEqual(
|
||||
await loadOttawaSourcesFromOpenData({}, { OTTAWA_CAMERA_CERTIFICATE: ' ' }),
|
||||
[],
|
||||
);
|
||||
assert.equal(fetchMock.mock.callCount(), 0);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], /OTTAWA_CAMERA_CERTIFICATE/);
|
||||
});
|
||||
|
||||
test('Ottawa frame URLs are signed only inside the opendata camera path', () => {
|
||||
assert.equal(
|
||||
ottawaAuthorizedFrameUrl('https://traffic.ottawa.ca/opendata/camera?c=16', CERT),
|
||||
'https://traffic.ottawa.ca/opendata/camera?c=16&certificate=CERT123&id=gev',
|
||||
);
|
||||
assert.equal(
|
||||
ottawaAuthorizedFrameUrl('https://traffic.ottawa.ca/opendata/camera?c=16', {}),
|
||||
'',
|
||||
'no certificate, no signed URL',
|
||||
);
|
||||
for (const url of [
|
||||
'https://traffic.ottawa.ca/opendata/camera?c=16x',
|
||||
'https://traffic.ottawa.ca/opendata/other?c=16',
|
||||
'https://traffic.ottawa.ca/map/camera?id=16',
|
||||
'https://traffic.ottawa.ca.evil.test/opendata/camera?c=16',
|
||||
'http://traffic.ottawa.ca/opendata/camera?c=16',
|
||||
])
|
||||
assert.equal(ottawaAuthorizedFrameUrl(url, CERT), '', url);
|
||||
});
|
||||
|
||||
test('Ottawa loader keeps in-city cameras and builds keyless frame URLs from the camera number', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_OTTAWA_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return ottawaPayload([
|
||||
ottawaRow(2025, 45.341343, -75.81467, {
|
||||
name: '(MTO) Hwy 416 NB ramp to Hwy 417 East',
|
||||
cameraOwner: 'MTO',
|
||||
}),
|
||||
ottawaRow(1, 45.4215, -75.6972, {
|
||||
name: 'Bank St at Somerset St',
|
||||
camera_owner: 'CITY',
|
||||
cameraOwner: undefined,
|
||||
}),
|
||||
ottawaRow(1, 45.4216, -75.6973, { name: 'duplicate number' }),
|
||||
ottawaRow(0, 45.4215, -75.6972),
|
||||
ottawaRow(-3, 45.4215, -75.6972),
|
||||
ottawaRow(7, null, -75.7),
|
||||
ottawaRow(8, 45.4, null),
|
||||
ottawaRow(9, 43.6532, -79.3832, { description: 'Toronto' }),
|
||||
ottawaRow(10, 45.9, -75.7),
|
||||
]);
|
||||
});
|
||||
|
||||
const cameras = await loadOttawaSourcesFromOpenData({}, CERT);
|
||||
|
||||
assert.deepEqual(requested, [OTTAWA_CAMERAS_URL]);
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'ottawa-1',
|
||||
'ottawa-2025',
|
||||
]);
|
||||
|
||||
const mto = cameras.find((camera) => camera.id === 'ottawa-2025');
|
||||
const city = cameras.find((camera) => camera.id === 'ottawa-1');
|
||||
assert.equal(mto.url, 'https://traffic.ottawa.ca/opendata/camera?c=2025');
|
||||
assert.doesNotMatch(mto.url, /certificate/, 'the catalog URL carries no key');
|
||||
assert.equal(mto.snapshotUrl, mto.url);
|
||||
assert.equal(mto.frameRefreshMs, 60000, 'the API allows one fetch a minute');
|
||||
assert.equal(city.name, 'Bank St at Somerset St');
|
||||
assert.equal(mto.name, '(MTO) Hwy 416 NB ramp to Hwy 417 East');
|
||||
assert.equal(mto.city, 'Ottawa');
|
||||
assert.equal(mto.provider, 'City of Ottawa');
|
||||
assert.equal(mto.feedType, 'image');
|
||||
assert.equal(mto.sourceKind, 'ottawa-traffic');
|
||||
assert.equal(mto.headingConfidence, 'low');
|
||||
assert.equal(
|
||||
mto.headingDeg,
|
||||
fallbackHeadingFromId('ottawa-2025'),
|
||||
'the feed publishes no bearing, so the id-hash fallback is deterministic',
|
||||
);
|
||||
assert.match(mto.license, /City of Ottawa/);
|
||||
assert.equal(
|
||||
mto.credit,
|
||||
'Ministry of Transportation of Ontario (MTO) camera',
|
||||
'a provincial camera carried on the city feed names its owner',
|
||||
);
|
||||
assert.equal(city.credit, '', 'a city camera carries no partner credit');
|
||||
});
|
||||
|
||||
test('Ottawa loader keeps the cameras nearest downtown under its cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_OTTAWA_MAX_SOURCES: '8' });
|
||||
const rows = [
|
||||
// Far suburbs come first in payload order, so only distance can drop them.
|
||||
...[45.55, 45.54, 45.53, 45.52].map((lat, i) =>
|
||||
ottawaRow(100 + i, lat, -76.1),
|
||||
),
|
||||
...[45.42, 45.421, 45.422, 45.423, 45.424, 45.425, 45.426, 45.427].map(
|
||||
(lat, i) => ottawaRow(200 + i, lat, -75.7),
|
||||
),
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () => ottawaPayload(rows));
|
||||
|
||||
const cameras = await loadOttawaSourcesFromOpenData({}, CERT);
|
||||
|
||||
assert.equal(cameras.length, 8);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id).filter((id) => /^ottawa-10\d$/.test(id)),
|
||||
[],
|
||||
'the far-suburb cameras are the ones dropped',
|
||||
);
|
||||
});
|
||||
|
||||
test('Ottawa loader fails soft on HTTP errors, unexpected payloads and network errors', async (t) => {
|
||||
quiet(t);
|
||||
let respond;
|
||||
t.mock.method(globalThis, 'fetch', (...args) => respond(...args));
|
||||
for (respond of [
|
||||
async () => new Response('unavailable', { status: 503 }),
|
||||
async () => Response.json({ cameras: [] }),
|
||||
async () => Response.json([ottawaRow(1, 45.4215, -75.6972)]),
|
||||
async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
},
|
||||
]) {
|
||||
assert.deepEqual(await loadOttawaSourcesFromOpenData({}, CERT), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV catalog merges Ottawa cameras with a certificate and CCTV_OTTAWA_ENABLED=0 skips the request', async (t) => {
|
||||
quiet(t);
|
||||
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-ottawa-'));
|
||||
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_OTTAWA_ENABLED: undefined,
|
||||
CCTV_OTTAWA_MAX_SOURCES: undefined,
|
||||
CCTV_QUEBEC_ENABLED: '0',
|
||||
CCTV_WORLD_CAMERAS_ENABLED: '0',
|
||||
OTTAWA_CAMERA_CERTIFICATE: 'CERT123',
|
||||
});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
// Every other live pack is down; only Ottawa answers.
|
||||
return String(url) === OTTAWA_CAMERAS_URL
|
||||
? ottawaPayload([ottawaRow(1, 45.4215, -75.6972)])
|
||||
: new Response('unavailable', { status: 503 });
|
||||
});
|
||||
|
||||
const sources = await createCctvCatalog({ sourceRoot })();
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['ottawa-1'],
|
||||
);
|
||||
assert.equal(sources[0].url, 'https://traffic.ottawa.ca/opendata/camera?c=1');
|
||||
|
||||
process.env.CCTV_OTTAWA_ENABLED = '0';
|
||||
requested.length = 0;
|
||||
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
|
||||
assert.ok(requested.length > 0, 'the other live packs still load');
|
||||
assert.equal(
|
||||
requested.includes(OTTAWA_CAMERAS_URL),
|
||||
false,
|
||||
'a disabled pack makes no request',
|
||||
);
|
||||
});
|
||||
|
||||
test('Ottawa frames stay inside the city image directory', () => {
|
||||
assert.ok(
|
||||
pinnedCameraUrl(
|
||||
'https://traffic.ottawa.ca/opendata/camera?c=309',
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
),
|
||||
);
|
||||
for (const url of [
|
||||
'https://traffic.ottawa.ca.evil.test/opendata/camera?c=309',
|
||||
'https://traffic.ottawa.ca/map/camera?id=309',
|
||||
'https://user:[email protected]/opendata/camera?c=309',
|
||||
'https://traffic.ottawa.ca:8443/opendata/camera?c=309',
|
||||
'http://traffic.ottawa.ca/opendata/camera?c=309',
|
||||
])
|
||||
assert.equal(pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN), null, url);
|
||||
});
|
||||
@@ -313,3 +313,234 @@ test('CCTV upstream frame fetch accepts only raster image types and sends their
|
||||
assert.equal(state.cancelled, true, `${contentType}: body cancelled`);
|
||||
}
|
||||
});
|
||||
|
||||
function multipartResponse(contentType, chunks, { onCancel = () => {} } = {}) {
|
||||
let nextChunk = 0;
|
||||
const body = new ReadableStream(
|
||||
{
|
||||
pull(controller) {
|
||||
if (nextChunk >= chunks.length) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(chunks[nextChunk++]);
|
||||
},
|
||||
cancel() {
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 0 },
|
||||
);
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': contentType },
|
||||
});
|
||||
}
|
||||
|
||||
function jpegPart(boundary, body, contentType = 'image/jpeg', length = body.length) {
|
||||
return Buffer.concat([
|
||||
Buffer.from(
|
||||
'--' +
|
||||
boundary +
|
||||
'\r\nContent-Type: ' +
|
||||
contentType +
|
||||
'\r\nContent-Length: ' +
|
||||
length +
|
||||
'\r\n\r\n',
|
||||
),
|
||||
body,
|
||||
Buffer.from('\r\n'),
|
||||
]);
|
||||
}
|
||||
|
||||
test('CCTV upstream frame fetch extracts only the first JPEG from MJPEG', async () => {
|
||||
const boundary = 'DIGIEVER';
|
||||
const first = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
|
||||
const second = Buffer.from([0xff, 0xd8, 0x33, 0x44, 0xff, 0xd9]);
|
||||
for (const type of [
|
||||
'multipart/x-mixed-replace; boundary=DIGIEVER',
|
||||
'multipart/x-mixed-replace; boundary="DIGIEVER"',
|
||||
]) {
|
||||
let cancelled = false;
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
maxBytes: 1024,
|
||||
fetchImpl: async () =>
|
||||
multipartResponse(
|
||||
type,
|
||||
[
|
||||
jpegPart(boundary, first),
|
||||
jpegPart(boundary, second),
|
||||
Buffer.from('--' + boundary + '--\r\n'),
|
||||
],
|
||||
{ onCancel: () => { cancelled = true; } },
|
||||
),
|
||||
},
|
||||
);
|
||||
assert.equal(result?.contentType, 'image/jpeg', type);
|
||||
assert.deepEqual(result?.body, first, type);
|
||||
assert.equal(cancelled, true, type);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch rejects malformed and non-JPEG MJPEG parts', async () => {
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0x01, 0x02, 0xff, 0xd9]);
|
||||
for (const [type, chunks] of [
|
||||
['multipart/x-mixed-replace', []],
|
||||
[
|
||||
'multipart/x-mixed-replace; boundary=DIGIEVER',
|
||||
[jpegPart('DIGIEVER', jpeg, 'image/png')],
|
||||
],
|
||||
]) {
|
||||
let cancelled = false;
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
fetchImpl: async () =>
|
||||
multipartResponse(type, chunks, { onCancel: () => { cancelled = true; } }),
|
||||
},
|
||||
);
|
||||
assert.equal(result, null, type);
|
||||
assert.equal(cancelled, true, type);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch rejects truncated or oversized MJPEG parts', async () => {
|
||||
const boundary = 'DIGIEVER';
|
||||
const full = Buffer.from([0xff, 0xd8, 0x01, 0x02, 0xff, 0xd9]);
|
||||
const truncated = Buffer.from([0xff, 0xd8, 0x01, 0x02]);
|
||||
for (const [chunks, maxBytes] of [
|
||||
[jpegPart(boundary, truncated, 'image/jpeg', full.length), 1024],
|
||||
[jpegPart(boundary, full), 5],
|
||||
]) {
|
||||
let cancelled = false;
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
maxBytes,
|
||||
fetchImpl: async () =>
|
||||
multipartResponse(
|
||||
'multipart/x-mixed-replace; boundary=' + boundary,
|
||||
[chunks],
|
||||
{ onCancel: () => { cancelled = true; } },
|
||||
),
|
||||
},
|
||||
);
|
||||
assert.equal(result, null);
|
||||
assert.equal(cancelled, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch does not cross into a later part when the first JPEG is truncated', async () => {
|
||||
const boundary = 'DIGIEVER';
|
||||
const truncated = Buffer.from([0xff, 0xd8, 0x01, 0x02]);
|
||||
const valid = Buffer.from([0xff, 0xd8, 0x03, 0x04, 0xff, 0xd9]);
|
||||
const wire = Buffer.concat([
|
||||
Buffer.from(
|
||||
'--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n',
|
||||
),
|
||||
truncated,
|
||||
Buffer.from(
|
||||
'\r\n--' +
|
||||
boundary +
|
||||
'\r\nContent-Type: image/jpeg\r\nContent-Length: ' +
|
||||
valid.length +
|
||||
'\r\n\r\n',
|
||||
),
|
||||
valid,
|
||||
Buffer.from('\r\n--' + boundary + '--\r\n'),
|
||||
]);
|
||||
let cancelled = false;
|
||||
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
maxBytes: 1024,
|
||||
fetchImpl: async () =>
|
||||
multipartResponse(
|
||||
'multipart/x-mixed-replace; boundary=' + boundary,
|
||||
[wire],
|
||||
{ onCancel: () => { cancelled = true; } },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
test('CCTV upstream frame fetch ignores MJPEG unless the source is registered as MJPEG', async () => {
|
||||
const boundary = 'DIGIEVER';
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
|
||||
let cancelled = false;
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
fetchImpl: async () =>
|
||||
multipartResponse(
|
||||
'multipart/x-mixed-replace; boundary=' + boundary,
|
||||
[jpegPart(boundary, jpeg)],
|
||||
{ onCancel: () => { cancelled = true; } },
|
||||
),
|
||||
},
|
||||
);
|
||||
assert.equal(result, null);
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch accepts a CRLF preamble and a boundary that already starts with --', async () => {
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
|
||||
for (const [type, wire] of [
|
||||
[
|
||||
'multipart/x-mixed-replace; boundary=DIGIEVER',
|
||||
Buffer.concat([Buffer.from('\r\n'), jpegPart('DIGIEVER', jpeg)]),
|
||||
],
|
||||
[
|
||||
'multipart/x-mixed-replace; boundary=--myboundary',
|
||||
Buffer.concat([
|
||||
Buffer.from(
|
||||
'--myboundary\r\nContent-Type: image/jpeg\r\nContent-Length: ' +
|
||||
jpeg.length +
|
||||
'\r\n\r\n',
|
||||
),
|
||||
jpeg,
|
||||
Buffer.from('\r\n--myboundary\r\n'),
|
||||
]),
|
||||
],
|
||||
]) {
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
fetchImpl: async () => multipartResponse(type, [wire]),
|
||||
},
|
||||
);
|
||||
assert.deepEqual(result?.body, jpeg, type);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV MJPEG part without Content-Length ends at the next delimiter, not at an inner JPEG end marker', async () => {
|
||||
const boundary = 'DIGIEVER';
|
||||
// An EXIF thumbnail's own FF D9 sits inside the image.
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0x01, 0xff, 0xd9, 0x02, 0x03, 0xff, 0xd9]);
|
||||
const wire = Buffer.concat([
|
||||
Buffer.from('--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n'),
|
||||
jpeg,
|
||||
Buffer.from('\r\n--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n'),
|
||||
]);
|
||||
// Delivered one byte at a time, as a trickling upstream would.
|
||||
const chunks = [...wire].map((byte) => Buffer.from([byte]));
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
fetchImpl: async () =>
|
||||
multipartResponse('multipart/x-mixed-replace; boundary=' + boundary, chunks),
|
||||
},
|
||||
);
|
||||
assert.deepEqual(result?.body, jpeg);
|
||||
});
|
||||
@@ -182,6 +182,86 @@ test('loader deduplicates cameras that share a camera number', async (t) => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to Highway Bureau MJPEG cameras when the Freeway catalog fails', async (t) => {
|
||||
const highwayUrl = 'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
|
||||
const requests = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
const href = String(url);
|
||||
requests.push(href);
|
||||
assert.equal(options.redirect, 'manual');
|
||||
if (href === TAIWAN_FREEWAY_CCTV_URL)
|
||||
return new Response('', { status: 503 });
|
||||
if (href === highwayUrl) {
|
||||
return new Response(
|
||||
cctvXml(
|
||||
block({
|
||||
id: 'CCTV-14-0620-009-002',
|
||||
stream: 'https://cctv-ss02.thb.gov.tw:443/T62-9K+020',
|
||||
lat: '24.9967',
|
||||
lon: '121.4521',
|
||||
}),
|
||||
),
|
||||
{ headers: { 'content-type': 'application/xml' } },
|
||||
);
|
||||
}
|
||||
throw new Error('Unexpected Taiwan feed URL: ' + href);
|
||||
});
|
||||
|
||||
const sources = await loadTaiwanSources();
|
||||
|
||||
assert.deepEqual(requests, [TAIWAN_FREEWAY_CCTV_URL, highwayUrl]);
|
||||
assert.equal(sources.length, 1);
|
||||
assert.equal(sources[0].id, 'tw-thb-cctv-14-0620-009-002');
|
||||
assert.equal(sources[0].cityId, 'taiwan');
|
||||
assert.equal(sources[0].provider, 'Highway Bureau, MOTC');
|
||||
assert.equal(sources[0].feedType, 'mjpeg');
|
||||
assert.equal(sources[0].sourceKind, 'taiwan-highway-motc');
|
||||
assert.equal(new URL(sources[0].url).hostname, 'cctv-ss02.thb.gov.tw');
|
||||
assert.equal(new URL(sources[0].url).pathname, '/T62-9K+020');
|
||||
});
|
||||
|
||||
test('the Highway Bureau fallback registers only approved HTTPS camera servers', async (t) => {
|
||||
const highwayUrl = 'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (String(url) === TAIWAN_FREEWAY_CCTV_URL)
|
||||
return new Response('', { status: 503 });
|
||||
if (String(url) === highwayUrl) {
|
||||
return new Response(
|
||||
cctvXml(
|
||||
block({
|
||||
id: 'CCTV-14-0620-009-001',
|
||||
stream: 'https://cctv-ss09.thb.gov.tw/T62-9K+019',
|
||||
}),
|
||||
block({
|
||||
id: 'CCTV-14-0620-009-003',
|
||||
stream: 'http://cctv-ss02.thb.gov.tw/T62-9K+021',
|
||||
}),
|
||||
block({
|
||||
id: 'CCTV-14-0620-009-004',
|
||||
stream: 'https://cctv-ss02.thb.gov.tw:8443/T62-9K+022',
|
||||
}),
|
||||
block({
|
||||
id: 'CCTV-14-0620-009-002',
|
||||
stream: 'https://cctv-ss02.thb.gov.tw:443/T62-9K+020',
|
||||
}),
|
||||
),
|
||||
{ headers: { 'content-type': 'application/xml' } },
|
||||
);
|
||||
}
|
||||
throw new Error('Unexpected Taiwan feed URL: ' + String(url));
|
||||
});
|
||||
|
||||
const sources = await loadTaiwanSources();
|
||||
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['tw-thb-cctv-14-0620-009-002'],
|
||||
);
|
||||
});
|
||||
test('a redirected or failed catalog contributes an empty pack', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
for (const status of [302, 503]) {
|
||||
@@ -214,13 +294,17 @@ test('the Taiwan pack is in the catalog by default', async (t) => {
|
||||
);
|
||||
});
|
||||
|
||||
test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => {
|
||||
test('CCTV_TAIWAN_ENABLED=0 makes no Taiwan catalog request', async (t) => {
|
||||
const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0');
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('freeway.gov.tw')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('cctv-maintain.thb.gov.tw')),
|
||||
false,
|
||||
);
|
||||
assert.ok(!sources.some((c) => c.cityId === 'taiwan'));
|
||||
});
|
||||
|
||||
@@ -250,12 +334,12 @@ test('a failed download is not retried until the backoff ends', async (t) => {
|
||||
t.mock.method(Date, 'now', () => 1_000_000);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
assert.equal(calls, 1, 'the second refresh made no request');
|
||||
assert.equal(calls, 2, 'the second refresh made no request');
|
||||
Date.now.mock.mockImplementation(
|
||||
() => 1_000_000 + TAIWAN_RETRY_AFTER_FAILURE_MS + 1,
|
||||
);
|
||||
await loadTaiwanSources();
|
||||
assert.equal(calls, 2, 'the backoff ends after an hour');
|
||||
assert.equal(calls, 4, 'the backoff ends after an hour');
|
||||
});
|
||||
|
||||
test('a failed download serves the last good table', async (t) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
TORONTO_RESCU_LIST_URL,
|
||||
TORONTO_RESCU_IMAGE_ORIGIN,
|
||||
TORONTO_MAX_CATALOG_BYTES,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
|
||||
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
|
||||
@@ -418,6 +418,6 @@ test('CCTV_TORONTO_ENABLED=0 keeps the lane from being loaded at all', async (t)
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
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 {
|
||||
DEFAULT_WORLD_CCTV_SOURCE_FILE,
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
isMlitRiverAdjustmentImage,
|
||||
loadWorldCameraSources,
|
||||
} from '../../server/providers/cctv/worldCameras.js';
|
||||
import {
|
||||
loadQuebecSources,
|
||||
QUEBEC_CAMERA_SOURCE_COUNT,
|
||||
quebec511VideoUrl,
|
||||
quebecBrowserVideoEnabled,
|
||||
} from '../../server/providers/cctv/quebec.js';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dirname, '../..');
|
||||
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0xff, 0xd9]);
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** A temporary source root holding the given World and Québec catalogs. */
|
||||
function sourceRoot(t, { world = [], quebec = null } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-world-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const worldFile = path.join(root, DEFAULT_WORLD_CCTV_SOURCE_FILE);
|
||||
fs.mkdirSync(path.dirname(worldFile), { recursive: true });
|
||||
fs.writeFileSync(worldFile, JSON.stringify(world));
|
||||
if (quebec) {
|
||||
fs.mkdirSync(path.join(root, 'public'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'public/quebec-cameras.json'),
|
||||
JSON.stringify({ cameras: quebec }),
|
||||
);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
const oregonRow = (overrides = {}) => ({
|
||||
id: 'world-or-764',
|
||||
name: '3rd St at Harwood MP18.35',
|
||||
provider: 'TripCheck (ODOT)',
|
||||
lat: 44.3029,
|
||||
lon: -120.8549,
|
||||
sourceKind: 'tripcheck-odot',
|
||||
url: 'https://www.tripcheck.com/roadcams/cams/Harwood_pid3527.jpg',
|
||||
snapshotUrl: 'https://www.tripcheck.com/roadcams/cams/Harwood_pid3527.jpg',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('the shipped World catalog loads every curated row', (t) => {
|
||||
quiet(t);
|
||||
const sources = loadWorldCameraSources({ sourceRoot: REPO_ROOT });
|
||||
assert.equal(sources.length, WORLD_CCTV_SOURCE_COUNT);
|
||||
const byKind = Object.groupBy(sources, (source) => source.sourceKind);
|
||||
// Iceland comes from the live Iceland pack, never a second World copy.
|
||||
assert.equal(byKind['vegagerdin-road'], undefined);
|
||||
assert.equal(byKind['tripcheck-odot'].length, 1176);
|
||||
assert.equal(byKind['mlit-river'].length, 1054);
|
||||
for (const source of byKind['mlit-river'])
|
||||
assert.equal(source.frameRefreshMs, 10 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('the World loader accepts a pinned row and rejects anything outside its policy', (t) => {
|
||||
quiet(t);
|
||||
const rejected = [
|
||||
['unknown kind', { sourceKind: 'nzta-journeys' }],
|
||||
[
|
||||
'Iceland duplicates the Iceland pack',
|
||||
{
|
||||
sourceKind: 'vegagerdin-road',
|
||||
lat: 64.291877,
|
||||
lon: -15.028035,
|
||||
url: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg',
|
||||
snapshotUrl: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg',
|
||||
},
|
||||
],
|
||||
['wrong id prefix', { id: 'world-jp-river-7075' }],
|
||||
['outside bounds', { lat: 40, lon: -15 }],
|
||||
['other host', { url: 'https://evil.test/roadcams/cams/a.jpg' }],
|
||||
['other directory', { url: 'https://www.tripcheck.com/other/a.jpg' }],
|
||||
['query string', { url: oregonRow().url + '?x=1' }],
|
||||
['not a jpg', { url: 'https://www.tripcheck.com/roadcams/cams/a.png' }],
|
||||
['plain http', { url: oregonRow().url.replace('https:', 'http:') }],
|
||||
[
|
||||
'url and snapshot differ',
|
||||
{ snapshotUrl: 'https://www.tripcheck.com/roadcams/cams/b.jpg' },
|
||||
],
|
||||
];
|
||||
const root = sourceRoot(t, {
|
||||
world: [
|
||||
oregonRow(),
|
||||
oregonRow(), // duplicate id
|
||||
...rejected.map(([, overrides], i) =>
|
||||
oregonRow({ id: `world-or-${9000 + i}`, ...overrides }),
|
||||
),
|
||||
],
|
||||
});
|
||||
const sources = loadWorldCameraSources({ sourceRoot: root });
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['world-or-764'],
|
||||
);
|
||||
assert.equal(sources[0].frameRefreshMs, 5 * 60 * 1000);
|
||||
assert.equal(sources[0].feedType, 'image');
|
||||
});
|
||||
|
||||
test('the World loader fails soft on a missing or malformed catalog', (t) => {
|
||||
quiet(t);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-world-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
assert.deepEqual(loadWorldCameraSources({ sourceRoot: root }), []);
|
||||
const file = path.join(root, DEFAULT_WORLD_CCTV_SOURCE_FILE);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, '{not json');
|
||||
assert.deepEqual(loadWorldCameraSources({ sourceRoot: root }), []);
|
||||
});
|
||||
|
||||
test('only the exact MLIT adjustment image is recognised', () => {
|
||||
assert.equal(isMlitRiverAdjustmentImage({ ok: true, body: JPEG }), false);
|
||||
assert.equal(isMlitRiverAdjustmentImage(null), false);
|
||||
assert.equal(isMlitRiverAdjustmentImage({ ok: false, body: JPEG }), false);
|
||||
});
|
||||
|
||||
test('the shipped Québec catalog loads every location', (t) => {
|
||||
quiet(t);
|
||||
const sources = loadQuebecSources({ sourceRoot: REPO_ROOT });
|
||||
assert.equal(sources.length, QUEBEC_CAMERA_SOURCE_COUNT);
|
||||
for (const source of sources) {
|
||||
assert.equal(source.url, '', 'the server never fetches Québec media');
|
||||
assert.equal(source.snapshotUrl, '');
|
||||
}
|
||||
});
|
||||
|
||||
test('the Québec loader maps ids by kind, keeps https viewer links, and splits the MTQ credit', (t) => {
|
||||
quiet(t);
|
||||
const at = { lat: 45.51651, lon: -73.5502 };
|
||||
const root = sourceRoot(t, {
|
||||
quebec: [
|
||||
{ id: '3735', kind: 'traffic', name: 'Route 136', viewerUrl: 'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735', ...at },
|
||||
{ id: 'mtl-465', kind: 'montreal', name: 'Montréal 465', viewerUrl: 'http://ville.montreal.qc.ca/x', ...at },
|
||||
{ id: 'web-1', kind: 'webcam', name: 'Tremblant', operator: 'Station Mont-Tremblant', viewerUrl: 'https://user:[email protected]/', ...at },
|
||||
{ id: 'abc', kind: 'traffic', ...at }, // non-numeric traffic id
|
||||
{ id: '3735', kind: 'traffic', ...at }, // duplicate
|
||||
{ id: '9', kind: 'traffic', lat: 40, lon: -73.5 }, // outside Québec
|
||||
],
|
||||
});
|
||||
const sources = loadQuebecSources({ sourceRoot: root });
|
||||
const byId = new Map(sources.map((source) => [source.id, source]));
|
||||
assert.deepEqual([...byId.keys()], ['qc-511-3735', 'qc-mtl-465', 'qc-web-1']);
|
||||
|
||||
const mtq = byId.get('qc-511-3735');
|
||||
assert.equal(mtq.sourceKind, 'quebec-511-browser');
|
||||
assert.match(mtq.credit, /Locations: MTQ · CC BY 4\.0/);
|
||||
assert.match(mtq.credit, /Video: Québec 511/);
|
||||
assert.doesNotMatch(mtq.license, /^Données Québec \/ MTQ, CC BY 4\.0$/);
|
||||
assert.equal(byId.get('qc-mtl-465').sourceUrl, '', 'http viewer links are dropped');
|
||||
assert.equal(byId.get('qc-web-1').sourceUrl, '', 'credentialed links are dropped');
|
||||
assert.equal(
|
||||
quebec511VideoUrl('qc-511-3735'),
|
||||
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4',
|
||||
);
|
||||
assert.equal(quebec511VideoUrl('qc-mtl-465'), '');
|
||||
});
|
||||
|
||||
test('Québec 511 browser video is opt-in', () => {
|
||||
assert.equal(quebecBrowserVideoEnabled({}), false);
|
||||
assert.equal(quebecBrowserVideoEnabled({ CCTV_QUEBEC_VIDEO_ENABLED: '0' }), false);
|
||||
assert.equal(quebecBrowserVideoEnabled({ CCTV_QUEBEC_VIDEO_ENABLED: ' 1 ' }), true);
|
||||
});
|
||||
|
||||
/** Mount the real proxy with a fixed fetch, and drive requests through it. */
|
||||
function mount(t, root, respond) {
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: 'absent-source-file.json',
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: '0',
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
// A configured key makes any Street View fallback observable.
|
||||
GOOGLE_MAPS_SERVER_API_KEY: 'test-google-key',
|
||||
});
|
||||
const requests = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init = {}) => {
|
||||
requests.push(String(url));
|
||||
return respond(String(url), init) || new Response('down', { status: 503 });
|
||||
});
|
||||
let handler;
|
||||
cctvProxy({ sourceRoot: root }).configureServer({
|
||||
middlewares: {
|
||||
use(_route, fn) {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
const call = async (url) => {
|
||||
const res = {
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, { status, headers });
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
on() {},
|
||||
once() {},
|
||||
};
|
||||
await handler({ url, method: 'GET', headers: {}, on() {} }, res);
|
||||
return res;
|
||||
};
|
||||
return { call, requests };
|
||||
}
|
||||
|
||||
test('/sources lists Québec browser media only when the operator opts in', async (t) => {
|
||||
quiet(t);
|
||||
const root = sourceRoot(t, {
|
||||
quebec: [{ id: '3735', kind: 'traffic', name: 'Route 136', lat: 45.5, lon: -73.5 }],
|
||||
});
|
||||
withEnv(t, { CCTV_QUEBEC_VIDEO_ENABLED: undefined });
|
||||
const app = mount(t, root, () => null);
|
||||
const off = JSON.parse((await app.call('/sources')).body).sources;
|
||||
assert.equal(off.find((s) => s.id === 'qc-511-3735').browserMediaUrl, '');
|
||||
|
||||
process.env.CCTV_QUEBEC_VIDEO_ENABLED = '1';
|
||||
const on = JSON.parse((await app.call('/sources')).body).sources;
|
||||
assert.match(on.find((s) => s.id === 'qc-511-3735').browserMediaUrl, /camera\.ashx\?id=3735&format=mp4$/);
|
||||
});
|
||||
|
||||
test('a World frame is fetched once per refresh floor and the cooldown never spends Street View', async (t) => {
|
||||
quiet(t);
|
||||
const root = sourceRoot(t, { world: [oregonRow()] });
|
||||
let answer = () => new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } });
|
||||
const app = mount(t, root, (url) =>
|
||||
url === oregonRow().url ? answer() : null,
|
||||
);
|
||||
const frame = '/frame/world-or-764';
|
||||
const upstreamHits = () =>
|
||||
app.requests.filter((url) => url === oregonRow().url).length;
|
||||
|
||||
const first = await app.call(frame);
|
||||
assert.equal(first.headers['X-CCTV-Source'], 'upstream-image');
|
||||
const second = await app.call(frame);
|
||||
assert.equal(second.headers['X-CCTV-Source'], 'upstream-image');
|
||||
assert.equal(upstreamHits(), 1, 'the second view reuses the cached frame');
|
||||
|
||||
// A camera whose first fetch fails is inside its failure backoff: it gets
|
||||
// the placeholder, not Google Street View, and no second upstream request.
|
||||
const failRoot = sourceRoot(t, { world: [oregonRow()] });
|
||||
answer = () => new Response('down', { status: 503 });
|
||||
const failing = mount(t, failRoot, (url) =>
|
||||
url === oregonRow().url ? answer() : null,
|
||||
);
|
||||
const failed = await failing.call(frame);
|
||||
assert.notEqual(failed.headers['X-CCTV-Source'], 'upstream-image');
|
||||
const retry = await failing.call(frame);
|
||||
assert.equal(retry.headers['X-CCTV-Source'], 'synthetic');
|
||||
assert.equal(
|
||||
failing.requests.filter((url) => url === oregonRow().url).length,
|
||||
1,
|
||||
'the backoff holds the provider cadence',
|
||||
);
|
||||
assert.equal(
|
||||
failing.requests.filter((url) => url.includes('maps.googleapis.com/maps/api/streetview')).length,
|
||||
1,
|
||||
'only the first miss, outside any cooldown, may try Street View',
|
||||
);
|
||||
});
|
||||
|
||||
test('Ottawa frames are signed with the certificate at fetch time only', async (t) => {
|
||||
quiet(t);
|
||||
const root = sourceRoot(t);
|
||||
withEnv(t, { OTTAWA_CAMERA_CERTIFICATE: 'CERT123' });
|
||||
const app = mount(t, root, (url) => {
|
||||
if (url === 'https://traffic.ottawa.ca/map/service/camera')
|
||||
return Response.json({
|
||||
cameras: [
|
||||
{ camera_number: 16, name: 'Bank & Hunt Club', latitude: 45.35, longitude: -75.65, cameraOwner: 'CITY' },
|
||||
],
|
||||
});
|
||||
if (url.startsWith('https://traffic.ottawa.ca/opendata/camera'))
|
||||
return new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } });
|
||||
return null;
|
||||
});
|
||||
const sources = JSON.parse((await app.call('/sources')).body).sources;
|
||||
const ottawa = sources.find((source) => source.id === 'ottawa-16');
|
||||
assert.ok(ottawa);
|
||||
assert.doesNotMatch(JSON.stringify(sources), /CERT123/, 'no catalog field carries the certificate');
|
||||
|
||||
const frame = await app.call('/frame/ottawa-16');
|
||||
assert.equal(frame.headers['X-CCTV-Source'], 'upstream-image');
|
||||
await app.call('/frame/ottawa-16');
|
||||
const hits = app.requests.filter((url) => url.includes('/opendata/camera'));
|
||||
assert.deepEqual(hits, [
|
||||
'https://traffic.ottawa.ca/opendata/camera?c=16&certificate=CERT123&id=gev',
|
||||
], 'one signed request per camera per minute');
|
||||
});
|
||||
@@ -299,6 +299,38 @@ export const DATA_CREDITS = [
|
||||
'CCTV static data (MOTC 2.0), released under the ' +
|
||||
'<a href="https://data.gov.tw/license" target="_blank" rel="noopener">Open Government Data License, Taiwan (Version 1.0)</a>',
|
||||
},
|
||||
{
|
||||
key: 'ottawa-cctv',
|
||||
html:
|
||||
'Traffic cameras (Ottawa, with an access certificate): ' +
|
||||
'<a href="https://traffic.ottawa.ca/en/traffic-map-data-lists-and-resources/about-the-data" target="_blank" rel="noopener">City of Ottawa Traffic Camera Images API</a>',
|
||||
},
|
||||
{
|
||||
key: 'quebec-cctv',
|
||||
html:
|
||||
'Camera locations (Québec): Ministère des Transports et de la Mobilité durable via ' +
|
||||
'<a href="https://www.donneesquebec.ca/" target="_blank" rel="noopener">Données Québec</a> ' +
|
||||
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>); ' +
|
||||
'Montréal camera locations © OpenStreetMap contributors (ODbL). ' +
|
||||
'Video (when enabled) and linked webcam pages stay with ' +
|
||||
'<a href="https://www.quebec511.info/" target="_blank" rel="noopener">Québec 511</a> and each operator, under their own terms',
|
||||
},
|
||||
{
|
||||
key: 'tripcheck-cctv',
|
||||
html:
|
||||
'Road camera stills (Oregon): camera courtesy of ODOT, via ' +
|
||||
'<a href="https://www.tripcheck.com/" target="_blank" rel="noopener">TripCheck</a>; ' +
|
||||
'partner cameras (counties, cities, Caltrans, WSDOT) are named in the camera label',
|
||||
},
|
||||
{
|
||||
key: 'mlit-river-cctv',
|
||||
html:
|
||||
'River camera stills (Japan): 出典:国土交通省 ' +
|
||||
'<a href="https://www.river.go.jp/" target="_blank" rel="noopener">川の防災情報</a> ' +
|
||||
'(Ministry of Land, Infrastructure, Transport and Tourism; ' +
|
||||
'<a href="https://www.river.go.jp/kawabou/kwb_apend/html/caution.html" target="_blank" rel="noopener">terms</a>), ' +
|
||||
'fetched on view, at most every ten minutes per camera',
|
||||
},
|
||||
{
|
||||
key: 'calgary-cctv',
|
||||
html:
|
||||
@@ -321,6 +353,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:
|
||||
@@ -345,6 +386,14 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://data.gov.hk/en-data/dataset/hk-td-tis_2-traffic-snapshot-images" target="_blank" rel="noopener">Transport Department / DATA.GOV.HK</a> ' +
|
||||
'(<a href="https://data.gov.hk/en/terms-and-conditions" target="_blank" rel="noopener">Terms of Use</a> — attribution required)',
|
||||
},
|
||||
{
|
||||
key: 'webcamromania-cctv',
|
||||
html:
|
||||
'Giroc camera (when configured): ' +
|
||||
'<a href="https://webcamromania.ro/webcam-orase/webcam-giroc/" target="_blank" rel="noopener">Webcam Romania</a>. ' +
|
||||
'Video rights remain with the camera operator; ' +
|
||||
'<a href="https://webcamromania.ro/termeni-si-conditii/" target="_blank" rel="noopener">provider terms</a> apply.',
|
||||
},
|
||||
{
|
||||
key: 'vegvesen-cctv',
|
||||
html:
|
||||
|
||||
@@ -153,3 +153,17 @@ test('every Street Level imagery source is in the attribution lightbox', () => {
|
||||
`${key} has no credit`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the PR #1506 camera packs are credited with their sources and terms', () => {
|
||||
const html = (key) => DATA_CREDITS.find((entry) => entry.key === key)?.html;
|
||||
assert.match(html('ottawa-cctv') || '', /traffic\.ottawa\.ca/);
|
||||
assert.match(html('quebec-cctv') || '', /CC BY 4\.0/);
|
||||
assert.match(html('quebec-cctv') || '', /OpenStreetMap contributors/);
|
||||
assert.match(html('tripcheck-cctv') || '', /courtesy of ODOT/);
|
||||
// MLIT's terms ask for the source in this form, with the page link.
|
||||
assert.match(html('mlit-river-cctv') || '', /出典:国土交通省/);
|
||||
assert.match(
|
||||
html('mlit-river-cctv') || '',
|
||||
/href="https:\/\/www\.river\.go\.jp\//,
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
# Test fixtures
|
||||
|
||||
- `photon-moscow-search.json` - Photon responses for `Москва`, captured
|
||||
2026-11-05 with and without a proximity bias near the Tajikistan namesake.
|
||||
The fixture is trimmed to the fields the keyless geocoder consumes and is
|
||||
used only by offline search tests. Data from OpenStreetMap contributors,
|
||||
ODbL 1.0 (https://www.openstreetmap.org/copyright).
|
||||
- `tomtom-flow-austin-12-935-1686.pbf` — one real TomTom traffic-flow vector
|
||||
tile (Mapbox Vector Tile protobuf, layer `"Traffic flow"`), downtown Austin
|
||||
z12 x935 y1686, captured 2026-07-16 from
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"biased": {
|
||||
"features": [
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [71.8339931, 38.948412]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Тоҷикистон"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.6063916, 55.625578]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.6174782, 55.7505412]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.0536908, 55.7098009]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [32.163334, 56.917778]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"unbiased": {
|
||||
"features": [
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.6063916, 55.625578]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.6174782, 55.7505412]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.0536908, 55.7098009]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [32.163334, 56.917778]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [71.8339931, 38.948412]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Тоҷикистон"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ test('infrastructure factory preserves identity and creates independent state wi
|
||||
|
||||
test('dataset URLs still name the complete bundled sources', () => {
|
||||
for (const [file, count] of [
|
||||
['datacenters', 4351],
|
||||
['datacenters', 4352],
|
||||
['dams', 704],
|
||||
]) {
|
||||
const lines = readFileSync(
|
||||
|
||||
@@ -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'], {
|
||||
|
||||
@@ -5,7 +5,7 @@ layer.
|
||||
|
||||
- Source: OpenStreetMap contributors
|
||||
- License: Open Database License (ODbL) 1.0
|
||||
- Feature count: 4,351
|
||||
- Feature count: 4,352
|
||||
- Runtime file: `datacenters.geojsonl`
|
||||
|
||||
The public-release snapshot removes contact-oriented tags such as email, phone,
|
||||
@@ -16,3 +16,12 @@ ODbL 1.0 with the required OpenStreetMap attribution.
|
||||
|
||||
The original extraction date and query were not recorded alongside this
|
||||
snapshot. Future refreshes should record both before replacing the file.
|
||||
|
||||
## Norway refresh
|
||||
|
||||
Features inside Geofabrik's Norway boundary (`norway.poly`, with Svalbard) come
|
||||
from a newer extract than the rest of the file. Contact tags are dropped as above.
|
||||
|
||||
- Extract: Geofabrik `norway-261005.osm.pbf`, OSM data
|
||||
2026-10-05T20:21:35Z, MD5 `1d2cfdeb98980fce11b8891d0f9146c0`
|
||||
- Filter: `osmium tags-filter nwr/telecom=data_center nwr/building=data_center`
|
||||
@@ -3770,15 +3770,15 @@
|
||||
{"id":939340882,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021364183,52.315373019],[21.02232433,52.315435883],[21.022363641,52.315209656],[21.021403578,52.315146875],[21.021364183,52.315373019]]]},"properties":{"id":11407,"tags":{"name":"T-Mobile Warszawa-Szlachecka","telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":939340882}}
|
||||
{"id":1005973518,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021325375,52.315595978],[21.022285857,52.315658423],[21.02232433,52.315435883],[21.021364183,52.315373019],[21.021325375,52.315595978]]]},"properties":{"id":11976,"tags":{"telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":1005973518}}
|
||||
{"id":1156823776,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.115130857,52.235385942],[21.115189866,52.236349777],[21.115232027,52.236667954],[21.115818174,52.237174556],[21.116175578,52.237468677],[21.117262124,52.236985628],[21.11728157,52.236976576],[21.117262795,52.236960985],[21.117340495,52.236926033],[21.117360276,52.2369433],[21.117447532,52.236904491],[21.11804097,52.236654459],[21.118030996,52.236645742],[21.118041054,52.236641719],[21.116603055,52.235624072],[21.116518984,52.235676375],[21.115986231,52.235696659],[21.115954966,52.235361551],[21.115130857,52.235385942]]]},"properties":{"id":15342,"tags":{"name":"Atman Data Center Warsaw-1","telecom":"data_center","website":"https://www.atman.pl"},"type":"data_center","osm_id":1156823776}}
|
||||
{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.598350034,58.860111293]},"properties":{"id":24849,"tags":{"name":"Maan Group","telecom":"data_center","operator":"Privat"},"type":"data_center","osm_id":7405586142}}
|
||||
{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.5983501,58.8601113]},"properties":{"id":24849,"tags":{"name":"Maan Group","opening_hours":"24/7","operator":"Privat","telecom":"data_center"},"type":"data_center","osm_id":7405586142}}
|
||||
{"id":481615983,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[9.572510149,56.481777951],[9.574069938,56.484921751],[9.57465357,56.48483349],[9.574639572,56.484805243],[9.574700676,56.484795939],[9.574714925,56.484824857],[9.575298557,56.484736595],[9.57454888,56.483225422],[9.574609229,56.483216369],[9.574510155,56.483016629],[9.574449722,56.483025849],[9.573738769,56.481592795],[9.573155137,56.481680972],[9.573170643,56.481712321],[9.573109539,56.481721625],[9.573093781,56.48168969],[9.572510149,56.481777951]]]},"properties":{"id":7881,"tags":{"telecom":"data_center","building":"data_center","operator":"Apple","wikidata":"Q19721053","wikipedia":"en:Foulum Data Center","description":"Apple Datacenter"},"type":"data_center","osm_id":481615983}}
|
||||
{"id":618756435,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.500360092,57.318668738],[10.500399236,57.318671672],[10.500382891,57.318734871],[10.500552038,57.318744175],[10.500545249,57.318787677],[10.500501495,57.318785666],[10.500458999,57.318957746],[10.500970295,57.31899647],[10.501009187,57.318847189],[10.501084959,57.318852888],[10.50111966,57.318719197],[10.500843896,57.318698159],[10.500865521,57.318615178],[10.500859235,57.318614759],[10.500869461,57.318575699],[10.500954537,57.318582153],[10.501022347,57.31832223],[10.500736691,57.318300437],[10.500669133,57.318559689],[10.500674162,57.318560025],[10.500664272,57.318598498],[10.500383981,57.318577124],[10.500360092,57.318668738]]]},"properties":{"id":8842,"tags":{"ref":"AAL01-01","name":"Prime Data Centers - Saeby Campus AAL01","telecom":"data_center","website":"https://primedatacenters.com/locations/saeby/","building":"industrial","operator":"Prime Data Centers"},"type":"data_center","osm_id":618756435}}
|
||||
{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002243225,59.633471864],[11.003903261,59.633446551],[11.007046726,59.633399696],[11.006983443,59.632420941],[11.006982772,59.63227057],[11.006911023,59.632265289],[11.006852936,59.632253973],[11.006765848,59.632228576],[11.00671782,59.632205275],[11.006119687,59.632213573],[11.006119352,59.632202592],[11.006118933,59.63219245],[11.005930424,59.632194378],[11.005923635,59.632120869],[11.005980799,59.632120031],[11.005979542,59.632104524],[11.005977027,59.63206865],[11.00597225,59.631998242],[11.005412171,59.632035541],[11.005286945,59.632043839],[11.00527848,59.632044342],[11.003707962,59.632147272],[11.003107483,59.63219178],[11.002741864,59.632235533],[11.002729375,59.63223788],[11.002345987,59.632309294],[11.002308352,59.632316251],[11.002302066,59.632477351],[11.002277088,59.632913881],[11.002243225,59.633471864]]]},"properties":{"id":107,"tags":{"name":"STACK Infrastructure OSL04","type":"multipolygon","telecom":"data_center","website":"https://www.stackinfra.com","operator":"Stack Infrastructure","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":-14367602}}
|
||||
{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.0022433,59.6334719],[11.0022771,59.6329139],[11.0023021,59.6324774],[11.0023084,59.6323163],[11.002346,59.6323093],[11.0027294,59.6322379],[11.0027419,59.6322356],[11.0031075,59.6321918],[11.003708,59.6321473],[11.0052785,59.6320444],[11.005287,59.6320439],[11.0054122,59.6320356],[11.0059723,59.6319983],[11.0059771,59.6320687],[11.0059796,59.6321046],[11.0059808,59.6321201],[11.0059237,59.6321209],[11.0059305,59.6321944],[11.006119,59.6321925],[11.0061194,59.6322026],[11.0061197,59.6322136],[11.0067179,59.6322053],[11.0067659,59.6322286],[11.006853,59.632254],[11.0069111,59.6322653],[11.0069828,59.6322706],[11.0069835,59.632421],[11.0070468,59.6333997],[11.0039033,59.6334466],[11.0022433,59.6334719]]]},"properties":{"id":107,"tags":{"landuse":"industrial","name":"STACK Infrastructure OSL04","operator":"Stack Infrastructure","operator:wikidata":"Q115477173","telecom":"data_center","website":"https://www.stackinfra.com","type":"multipolygon"},"type":"data_center","osm_id":-14367602}}
|
||||
{"id":1028687251,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.004111886,59.632578437],[11.004142983,59.633037262],[11.004275166,59.633034999],[11.004274998,59.63303173],[11.00505133,59.633019157],[11.005056443,59.633099372],[11.005224416,59.63309669],[11.005190637,59.632562344],[11.005060885,59.632564439],[11.005060969,59.632567457],[11.004587978,59.632575419],[11.00458538,59.632536527],[11.004459819,59.632538623],[11.004462418,59.632576844],[11.004242476,59.6325807],[11.004242141,59.632576174],[11.004111886,59.632578437]]]},"properties":{"id":12226,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1028687251}}
|
||||
{"id":1164840308,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002600126,59.632599392],[11.002631223,59.633058217],[11.00276349,59.633055954],[11.002763238,59.633052685],[11.00353957,59.633040196],[11.003544683,59.633120327],[11.00371274,59.633117645],[11.003678961,59.632583382],[11.003549042,59.632585478],[11.003549293,59.632588495],[11.003076135,59.632596458],[11.00307362,59.632557566],[11.002948143,59.632559661],[11.002950657,59.632597883],[11.002730716,59.632601571],[11.002730465,59.632597129],[11.002600126,59.632599392]]]},"properties":{"id":15459,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840308}}
|
||||
{"id":1164840309,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.005613672,59.632723444],[11.005644769,59.633182269],[11.005776951,59.633179922],[11.005776784,59.633176737],[11.006553199,59.633164164],[11.006558229,59.633244463],[11.006726286,59.633241781],[11.006692423,59.632707351],[11.006562671,59.632709446],[11.006562839,59.632712464],[11.006089764,59.632720426],[11.006087166,59.632681534],[11.005961772,59.632683797],[11.005964287,59.632721851],[11.005744262,59.632725623],[11.005743927,59.632721181],[11.005613672,59.632723444]]]},"properties":{"id":15460,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840309}}
|
||||
{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.991467366,59.759024056],[10.992780978,59.759406942],[10.992773937,59.759423957],[10.992948197,59.759474248],[10.993073842,59.759363859],[10.993022293,59.759348939],[10.993291688,59.759112653],[10.993128827,59.759065547],[10.993181885,59.759019027],[10.993083062,59.758990445],[10.992976947,59.759083484],[10.992845519,59.759045346],[10.992813584,59.759073342],[10.9927876,59.759065798],[10.993409789,59.758524243],[10.99263002,59.758297094],[10.991938848,59.758898663],[10.991693594,59.758827165],[10.991467366,59.759024056]]]},"properties":{"id":11377,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"warehouse","ref:bygningsnr":"300752944"},"type":"data_center","osm_id":935092287}}
|
||||
{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.992826324,59.757448761],[10.994530281,59.757601396],[10.994634888,59.757303168],[10.992928332,59.757152545],[10.992826324,59.757448761]]]},"properties":{"id":15426,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"industrial"},"type":"data_center","osm_id":1161764012}}
|
||||
{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9914674,59.7590241],[10.9916936,59.7588272],[10.9919389,59.7588987],[10.9926301,59.7582971],[10.9934098,59.7585243],[10.9927876,59.7590658],[10.9928136,59.7590734],[10.9928456,59.7590454],[10.992977,59.7590835],[10.9930831,59.7589905],[10.9931819,59.7590191],[10.9931289,59.7590656],[10.9932917,59.7591127],[10.9930223,59.759349],[10.9930739,59.7593639],[10.9929482,59.7594743],[10.992774,59.759424],[10.992781,59.759407],[10.9914674,59.7590241]]]},"properties":{"id":11377,"tags":{"building":"warehouse","name":"Green Mountain Ytre Enebakk","ref:bygningsnr":"300752944","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":935092287}}
|
||||
{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9928264,59.7574488],[10.9929284,59.7571526],[10.9946349,59.7573032],[10.9945303,59.7576014],[10.9928264,59.7574488]]]},"properties":{"id":15426,"tags":{"building":"industrial","name":"Green Mountain Ytre Enebakk","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":1161764012}}
|
||||
{"id":30724580,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.808790995,59.924984985],[10.808893925,59.925031923],[10.80893793,59.92500594],[10.808982941,59.925025972],[10.809042955,59.92501298],[10.809083943,59.925031923],[10.809012948,59.925077856],[10.80926583,59.925189755],[10.80955115,59.925025972],[10.809676543,59.925081293],[10.81032899,59.924701928],[10.809890365,59.924508138],[10.80982155,59.924515347],[10.809691631,59.924457847],[10.808820667,59.924557592],[10.809099365,59.924699581],[10.808806418,59.924865291],[10.808918149,59.924914828],[10.808875988,59.924937962],[10.808790995,59.924984985]]]},"properties":{"id":261,"tags":{"name":"Stack Infrastructure OSLO1A","telecom":"data_center","building":"office","operator":"Stack Infrastructure","ref:bygningsnr":"80877293","building:levels":"4","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":30724580}}
|
||||
{"id":80871917,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.83408892,59.938588981],[10.834305927,59.938720996],[10.834575992,59.938720996],[10.834574987,59.938736],[10.834581943,59.938736922],[10.834579932,59.938771958],[10.834736925,59.938771958],[10.834734997,59.93893792],[10.836717988,59.938944961],[10.836717988,59.938983937],[10.836817984,59.938983937],[10.83681899,59.938944961],[10.836844974,59.938944961],[10.836846985,59.938774976],[10.836841956,59.938774976],[10.836842962,59.938744968],[10.836849919,59.938744968],[10.836851931,59.938653941],[10.836683957,59.938652935],[10.836684125,59.938640278],[10.836686975,59.938414973],[10.83490297,59.938406926],[10.834899953,59.938646984],[10.834812949,59.938645978],[10.83481496,59.938551933],[10.834579932,59.938550927],[10.834580938,59.938564925],[10.834567946,59.938564925],[10.834567946,59.938551933],[10.834089926,59.938549922],[10.83408892,59.938588981]]]},"properties":{"id":1464,"tags":{"name":"Bulk Data Centers Oslo Internet Exchange","telecom":"data_center","website":"https://bulkinfrastructure.com/data-centers/locations/os-ix","building":"industrial","ref:bygningsnr":"80709943"},"type":"data_center","osm_id":80871917}}
|
||||
{"id":12158409495,"type":"Feature","geometry":{"type":"Point","coordinates":[9.689363435,62.59439936]},"properties":{"id":28723,"tags":{"name":"VisBook AS","telecom":"data_center","website":"https://visbook.com/"},"type":"data_center","osm_id":12158409495}}
|
||||
@@ -4349,3 +4349,4 @@
|
||||
{"id":540895898,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.71747909,56.303855963],[101.71782015,56.304420065],[101.721113819,56.303806929],[101.720772759,56.303242994],[101.71747909,56.303855963]]]},"properties":{"id":8271,"tags":{"name":"Битривер Рус","name:en":"Bitriver Rus","name:ru":"Битривер Рус","telecom":"data_center","website":"https://bitriver.farm/","building":"data_center"},"type":"data_center","osm_id":540895898}}
|
||||
{"id":1082803064,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[123.948021193,47.340264299],[123.948354625,47.340268239],[123.948490663,47.340269831],[123.948488568,47.340349292],[123.948809762,47.340353064],[123.948811942,47.340267485],[123.948918643,47.340268658],[123.948922918,47.340137817],[123.948023959,47.340126082],[123.948021193,47.340264299]]]},"properties":{"id":13377,"tags":{"name":"联通云数据中心·齐齐哈尔","name:en":"Unicom Data Center of Qiqihar","name:zh":"联通云数据中心·齐齐哈尔","telecom":"data_center","building":"yes","name:zh-Hans":"联通云数据中心·齐齐哈尔","name:zh-Hant":"聯通雲數據中心·齊齊哈爾"},"type":"data_center","osm_id":1082803064}}
|
||||
{"id":442479059,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[142.76019226,46.910096898],[142.760192427,46.910325724],[142.760447656,46.910328155],[142.760460397,46.910102263],[142.76019226,46.910096898]]]},"properties":{"id":7471,"tags":{"telecom":"data_center","building":"yes"},"type":"data_center","osm_id":442479059}}
|
||||
{"id":1551029155,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[7.1069147,62.8627625],[7.1071207,62.8621617],[7.1075917,62.8616616],[7.1088647,62.8620476],[7.1106308,62.862645],[7.1098655,62.8632827],[7.1089604,62.8630042],[7.1086734,62.8632156],[7.1069147,62.8627625]]]},"properties":{"id":29471,"tags":{"layer":"-1","loc_name":"Troll Housing Klempertåsen","name":"Troll Housing","operator":"Troll Housing AS","telecom":"data_center","website":"https://www.trollhousing.no/"},"type":"data_center","osm_id":1551029155}}
|
||||
+19
-9
@@ -11,17 +11,16 @@ const FOLD = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Comparable form of a place name, shared by the bundled packs, the
|
||||
* OpenFreeMap street and grounds matchers and the Nominatim server: case
|
||||
* folded, accents removed from Latin letters only, punctuation and symbols
|
||||
* collapsed to single spaces, and a leading "the" dropped. Other scripts keep
|
||||
* their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while "São Paulo",
|
||||
* "sao paulo" and "SÃO PAULO!" meet.
|
||||
* Script-aware fold of a place name: case folded, accents removed from Latin
|
||||
* letters only, punctuation and symbols collapsed to single spaces. Other
|
||||
* scripts keep their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while
|
||||
* "São Paulo", "sao paulo" and "SÃO PAULO!" meet. Every word is kept: "The
|
||||
* Hague" is not "Hague".
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function foldPlaceName(value) {
|
||||
export function foldPlaceText(value) {
|
||||
return String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[ßøæłđıœþ]/g, (c) => FOLD[c])
|
||||
@@ -29,8 +28,19 @@ export function foldPlaceName(value) {
|
||||
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
|
||||
.trim()
|
||||
.replace(/^the /, '');
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparable form of a place name, shared by the bundled packs, the
|
||||
* OpenFreeMap street and grounds matchers and the Nominatim server:
|
||||
* `foldPlaceText` with a leading "the" dropped.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function foldPlaceName(value) {
|
||||
return foldPlaceText(value).replace(/^the /, '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+38
-20
@@ -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": "تم استهلاك حصة حركة المرور الشهرية",
|
||||
@@ -3226,7 +3233,7 @@
|
||||
"Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة",
|
||||
"Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية",
|
||||
"High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط",
|
||||
"Fire intensity (FRP)": "الشدة (FRP)",
|
||||
"Fire intensity (FRP)": "شدة الحريق (FRP)",
|
||||
"≥ {value} MW": "{value} MW على الأقل",
|
||||
"Confidence": "الثقة",
|
||||
"Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط",
|
||||
@@ -3244,15 +3251,15 @@
|
||||
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
|
||||
"Voice place search (needs an Overpass server)": "البحث الصوتي عن الأماكن (يتطلب خادم Overpass)",
|
||||
"OSM Places": "أماكن OSM",
|
||||
"state or province": "ولاية أو مقاطعة",
|
||||
"county or district": "محافظة أو قضاء",
|
||||
"state or province": "ولاية أو إقليم",
|
||||
"county or district": "مقاطعة أو قضاء",
|
||||
"neighbourhood": "حي",
|
||||
"natural region": "منطقة طبيعية",
|
||||
"Which {place}?": "أي {place} تقصد؟",
|
||||
"Which place?": "أي مكان تقصد؟",
|
||||
"Not drawn: {parts}": "لم يُرسم: {parts}",
|
||||
"Not drawn: {parts}": "لم يُرسم المخطط: {parts}",
|
||||
"approximate": "تقريبي",
|
||||
"{pct}% cloud": "غيوم {pct}%",
|
||||
"{pct}% cloud": "الغيوم {pct}%",
|
||||
"Over {area}": "فوق {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"few": "الصور المطابقة: {count} من {total}",
|
||||
@@ -3263,12 +3270,12 @@
|
||||
"other": "الصور المطابقة: {count} من {total}"
|
||||
},
|
||||
"{n} acquisitions searched": {
|
||||
"few": "الصور التي جرى البحث فيها: {n}",
|
||||
"many": "الصور التي جرى البحث فيها: {n}",
|
||||
"one": "الصور التي جرى البحث فيها: {n}",
|
||||
"two": "الصور التي جرى البحث فيها: {n}",
|
||||
"zero": "الصور التي جرى البحث فيها: {n}",
|
||||
"other": "الصور التي جرى البحث فيها: {n}"
|
||||
"zero": "الصور المفحوصة: {n}",
|
||||
"one": "الصور المفحوصة: {n}",
|
||||
"two": "الصور المفحوصة: {n}",
|
||||
"few": "الصور المفحوصة: {n}",
|
||||
"many": "الصور المفحوصة: {n}",
|
||||
"other": "الصور المفحوصة: {n}"
|
||||
},
|
||||
"No matching imagery": "لا توجد صور مطابقة",
|
||||
"Unknown number of {kind}": "{kind}: العدد غير معروف",
|
||||
@@ -3276,10 +3283,10 @@
|
||||
"At least {count} {kind}": "{kind}: {count} على الأقل",
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "في {area}",
|
||||
"Area around {place}": "المنطقة المحيطة بـ{place}",
|
||||
"Area around landmark": "المنطقة المحيطة بمعلم",
|
||||
"Outline {place}": "تحديد مخطط {place}",
|
||||
"Outline area": "تحديد مخطط منطقة",
|
||||
"Area around {place}": "المنطقة حول {place}",
|
||||
"Area around landmark": "المنطقة حول المعلم",
|
||||
"Outline {place}": "رسم مخطط {place}",
|
||||
"Outline area": "رسم مخطط منطقة",
|
||||
"Find {place}": "البحث عن {place}",
|
||||
"Find area": "البحث عن منطقة",
|
||||
"Find recent imagery": "البحث عن صور حديثة",
|
||||
@@ -3322,9 +3329,9 @@
|
||||
"zero": "التكرارات المدمجة: {count}"
|
||||
},
|
||||
"listed the first {limit}": "عُرض أول {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "عُدّ ضمن المستطيل المحيط ({area})، لا ضمن الحدود",
|
||||
"counted over the bounding box of {area}, not its outline": "جرى العدّ ضمن المستطيل المحيط بمنطقة {area}، لا ضمن حدودها الفعلية",
|
||||
"Search cancelled": "أُلغي البحث",
|
||||
"Sea Marks": "علامات بحرية",
|
||||
"Sea Marks": "العلامات البحرية",
|
||||
"Ski Pistes": "مسارات التزلج",
|
||||
"Fly to Tokyo": "انتقل إلى طوكيو",
|
||||
"Show live flights": "اعرض تتبّع الرحلات المباشر",
|
||||
@@ -3399,7 +3406,7 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "طلبات دردشة كثيرة جدًا. أعد المحاولة بعد {seconds} ث.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "رفض المزوّد الوصول باستخدام هذا المفتاح. تحقّق من مشروع المفتاح أو منطقته أو أذوناته.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "نفد رصيد حساب المزوّد. تحقّق من الفوترة، أو اختر مزوّد ذكاء اصطناعي آخر في الإعدادات.",
|
||||
"{lower} to {upper}": "{lower} إلى {upper}",
|
||||
"{lower} to {upper}": "من {lower} إلى {upper}",
|
||||
"Up to {upper}": "حتى {upper}",
|
||||
"From {lower}": "من {lower}",
|
||||
"Informational only · not for flight planning": "للعلم فقط · لا يصلح لتخطيط الرحلات",
|
||||
@@ -3407,7 +3414,7 @@
|
||||
"Restricted airspace source unavailable": "مصدر المجال الجوي المقيّد غير متاح",
|
||||
"Prohibited (P)": "محظور (P)",
|
||||
"Restricted (R)": "مقيّد (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي الخاص المحظور والمقيّد لدى FAA فقط، ولا يشمل UAS Facility Map أو قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي المحظور والمقيّد ذو الاستخدام الخاص لدى FAA فقط، ولا يشمل UAS Facility Map ولا قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "محظور",
|
||||
"Restricted": "مقيّد",
|
||||
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد",
|
||||
@@ -3466,5 +3473,16 @@
|
||||
"Open {name} on Acartia": "فتح {name} على Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "مساهمو iNaturalist · التراخيص لكل سجل",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل",
|
||||
"{days}d": "{days}ي",
|
||||
"All markers": "كل العلامات",
|
||||
"Show markers of every age": "عرض العلامات من جميع الأعمار",
|
||||
"Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة",
|
||||
"event age filter|All": "الكل",
|
||||
"Event age": "عمر الحدث",
|
||||
"Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ",
|
||||
"Close camera preview": "إغلاق معاينة الكاميرا",
|
||||
"Refresh map still": "تحديث صورة الخريطة الثابتة",
|
||||
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك."
|
||||
}
|
||||
+36
-18
@@ -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",
|
||||
@@ -2918,7 +2925,7 @@
|
||||
"From {lower}": "Ab {lower}",
|
||||
"Informational only · not for flight planning": "Nur zur Information · nicht für die Flugplanung",
|
||||
"Unnamed zone": "Unbenannte Zone",
|
||||
"Restricted airspace source unavailable": "Quelle für Sperrgebiete nicht verfügbar",
|
||||
"Restricted airspace source unavailable": "Quelle für Sperr- und Flugbeschränkungsgebiete nicht verfügbar",
|
||||
"Prohibited (P)": "Sperrgebiet (P)",
|
||||
"Restricted (R)": "Flugbeschränkungsgebiet (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Nur Sperr- und Flugbeschränkungsgebiete der FAA (Luftraum mit besonderer Nutzung), nicht die UAS Facility Map oder UAS-Flugbeschränkungen. Nur zur Information – kein Ersatz für offizielle UAS-Flugplanungstools (B4UFLY / LAANC).",
|
||||
@@ -2955,13 +2962,13 @@
|
||||
"fire confidence filter|All": "Alle",
|
||||
"fire confidence filter|Nominal+": "Mittel+",
|
||||
"fire confidence filter|High": "Hoch",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen — viele der stärksten Brände haben eine niedrige Konfidenz",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen – viele der stärksten Brände haben eine niedrige Konfidenz",
|
||||
"Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz",
|
||||
"High confidence detections only": "Nur Erkennungen mit hoher Konfidenz",
|
||||
"Fire intensity (FRP)": "Intensität (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "Konfidenz",
|
||||
"Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt",
|
||||
"Minimum fire radiative power, megawatts": "Mindestwert der Strahlungsleistung des Brandes in Megawatt",
|
||||
"fire intensity filter|All": "Alle",
|
||||
"Detection confidence": "Konfidenz der Erkennung",
|
||||
"{shown} of {total} shown": "{shown} von {total} angezeigt",
|
||||
@@ -3001,9 +3008,9 @@
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "Gebiet: {area}",
|
||||
"Area around {place}": "Gebiet um {place}",
|
||||
"Area around landmark": "Gebiet um ein Wahrzeichen",
|
||||
"Outline {place}": "{place} umreißen",
|
||||
"Outline area": "Gebiet umreißen",
|
||||
"Area around landmark": "Gebiet um einen Ort",
|
||||
"Outline {place}": "Umriss von {place} zeichnen",
|
||||
"Outline area": "Umriss zeichnen",
|
||||
"Find {place}": "{place} finden",
|
||||
"Find area": "Gebiet finden",
|
||||
"Find recent imagery": "Aktuelle Bilddaten finden",
|
||||
@@ -3014,27 +3021,27 @@
|
||||
"Loading imagery": "Bilddaten werden geladen",
|
||||
"Searching OpenStreetMap": "OpenStreetMap wird durchsucht",
|
||||
"voice area|Area": "Gebiet",
|
||||
"buffer sized to the place": "Puffer in Größe des Ortes",
|
||||
"buffer sized to the place": "Puffer entsprechend der Ortsgröße",
|
||||
"enclosing mapped area": "umschließendes kartiertes Gebiet",
|
||||
"the map was cleared while looking it up": "die Karte wurde während der Suche geleert",
|
||||
"OpenStreetMap coverage varies by place": "Die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich",
|
||||
"the map was cleared while looking it up": "die Zeichenfläche wurde während der Suche geleert",
|
||||
"OpenStreetMap coverage varies by place": "die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich",
|
||||
"the catalog covers the last 30 days": "der Katalog umfasst die letzten 30 Tage",
|
||||
"there are more": "es gibt weitere",
|
||||
"{radius} m around the landmark": "{radius} m um das Wahrzeichen",
|
||||
"{radius} m around the landmark": "{radius} m um den Ort",
|
||||
"{count} parts": {
|
||||
"one": "Teile: {count}",
|
||||
"other": "Teile: {count}"
|
||||
"one": "{count} Teil",
|
||||
"other": "{count} Teile"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"one": "Gezeichnete Teile: {drawn} von {total}",
|
||||
"other": "Gezeichnete Teile: {drawn} von {total}"
|
||||
"one": "{drawn} von {total} Teil gezeichnet",
|
||||
"other": "{drawn} von {total} Teilen gezeichnet"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"one": "Zusammengeführte Doppelkartierungen: {count}",
|
||||
"other": "Zusammengeführte Doppelkartierungen: {count}"
|
||||
"one": "{count} Duplikat zusammengeführt",
|
||||
"other": "{count} Duplikate zusammengeführt"
|
||||
},
|
||||
"listed the first {limit}": "die ersten {limit} aufgelistet",
|
||||
"counted over the bounding box of {area}, not its outline": "über das umgebende Rechteck gezählt ({area}), nicht über den Umriss",
|
||||
"counted over the bounding box of {area}, not its outline": "im umgebenden Rechteck von {area} gezählt, nicht innerhalb des Umrisses",
|
||||
"Search cancelled": "Suche abgebrochen",
|
||||
"Sea Marks": "Seezeichen",
|
||||
"Ski Pistes": "Skipisten",
|
||||
@@ -3166,5 +3173,16 @@
|
||||
"Open {name} on Acartia": "{name} auf Acartia öffnen",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist-Beitragende · Lizenzen je Eintrag",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag",
|
||||
"{days}d": "{days}T",
|
||||
"All markers": "Alle Markierungen",
|
||||
"Show markers of every age": "Markierungen jeden Alters anzeigen",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben",
|
||||
"event age filter|All": "Alle",
|
||||
"Event age": "Ereignisalter",
|
||||
"Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben",
|
||||
"Close camera preview": "Kameravorschau schließen",
|
||||
"Refresh map still": "Kartenstandbild aktualisieren",
|
||||
"The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht."
|
||||
}
|
||||
+33
-15
@@ -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",
|
||||
@@ -2992,7 +2999,7 @@
|
||||
"fire confidence filter|All": "Todas",
|
||||
"fire confidence filter|Nominal+": "Nominal+",
|
||||
"fire confidence filter|High": "Alta",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos se clasifican con confianza baja",
|
||||
"Nominal and high confidence detections": "Detecciones de confianza nominal y alta",
|
||||
"High confidence detections only": "Solo detecciones de confianza alta",
|
||||
"Fire intensity (FRP)": "Intensidad (FRP)",
|
||||
@@ -3001,8 +3008,8 @@
|
||||
"Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios",
|
||||
"fire intensity filter|All": "Todo",
|
||||
"Detection confidence": "Confianza de la detección",
|
||||
"{shown} of {total} shown": "{shown} de {total} mostrados",
|
||||
"{lower} to {upper}": "{lower} a {upper}",
|
||||
"{shown} of {total} shown": "Mostrando {shown} de {total}",
|
||||
"{lower} to {upper}": "De {lower} a {upper}",
|
||||
"Up to {upper}": "Hasta {upper}",
|
||||
"From {lower}": "Desde {lower}",
|
||||
"Informational only · not for flight planning": "Solo informativo · no apto para planificar vuelos",
|
||||
@@ -3010,9 +3017,9 @@
|
||||
"Restricted airspace source unavailable": "Fuente de espacio aéreo restringido no disponible",
|
||||
"Prohibited (P)": "Prohibido (P)",
|
||||
"Restricted (R)": "Restringido (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo espacio aéreo de uso especial prohibido y restringido de la FAA, sin el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo: no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Prohibido",
|
||||
"Restricted": "Restringido",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo espacio aéreo de uso especial prohibido y restringido de la FAA; no incluye el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo — no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zona prohibida",
|
||||
"Restricted": "Zona restringida",
|
||||
"Prohibited & Restricted Airspace": "Espacio aéreo prohibido y restringido",
|
||||
"routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
|
||||
"route proxy error": "error del proxy de rutas",
|
||||
@@ -3050,11 +3057,11 @@
|
||||
"county or district": "condado o distrito",
|
||||
"neighbourhood": "barrio",
|
||||
"natural region": "región natural",
|
||||
"Which {place}?": "¿Cuál {place}?",
|
||||
"Which {place}?": "¿Qué {place}?",
|
||||
"Which place?": "¿Qué lugar?",
|
||||
"Not drawn: {parts}": "Sin dibujar: {parts}",
|
||||
"approximate": "aproximado",
|
||||
"{pct}% cloud": "{pct} % de nubes",
|
||||
"approximate": "aproximada",
|
||||
"{pct}% cloud": "{pct}% de nubes",
|
||||
"Over {area}": "Zona: {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Adquisiciones coincidentes: {count} de {total}",
|
||||
@@ -3068,7 +3075,7 @@
|
||||
},
|
||||
"No matching imagery": "No hay imágenes que coincidan",
|
||||
"Unknown number of {kind}": "{kind}: número desconocido",
|
||||
"About {count} {kind}": "{kind}: unos {count}",
|
||||
"About {count} {kind}": "{kind}: aproximadamente {count}",
|
||||
"At least {count} {kind}": "{kind}: al menos {count}",
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "En {area}",
|
||||
@@ -3087,12 +3094,12 @@
|
||||
"Searching OpenStreetMap": "Buscando en OpenStreetMap",
|
||||
"voice area|Area": "Área",
|
||||
"buffer sized to the place": "zona aproximada del tamaño del lugar",
|
||||
"enclosing mapped area": "zona cartografiada que lo rodea",
|
||||
"enclosing mapped area": "zona mapeada que contiene el punto de interés",
|
||||
"the map was cleared while looking it up": "el mapa se borró durante la búsqueda",
|
||||
"OpenStreetMap coverage varies by place": "La cobertura de OpenStreetMap varía según el lugar",
|
||||
"OpenStreetMap coverage varies by place": "la cobertura de OpenStreetMap varía según el lugar",
|
||||
"the catalog covers the last 30 days": "el catálogo cubre los últimos 30 días",
|
||||
"there are more": "hay más",
|
||||
"{radius} m around the landmark": "{radius} m alrededor del lugar",
|
||||
"{radius} m around the landmark": "{radius} m alrededor del punto de interés",
|
||||
"{count} parts": {
|
||||
"many": "Partes: {count}",
|
||||
"one": "Partes: {count}",
|
||||
@@ -3109,7 +3116,7 @@
|
||||
"other": "Duplicados fusionados: {count}"
|
||||
},
|
||||
"listed the first {limit}": "se listaron los primeros {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contado en el rectángulo que lo rodea ({area}), no en su contorno",
|
||||
"counted over the bounding box of {area}, not its outline": "se contó en el rectángulo que abarca {area}, no dentro de su contorno",
|
||||
"Search cancelled": "Búsqueda cancelada",
|
||||
"Sea Marks": "Señales marítimas",
|
||||
"Ski Pistes": "Pistas de esquí",
|
||||
@@ -3241,5 +3248,16 @@
|
||||
"Open {name} on Acartia": "Abrir {name} en Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Colaboradores de iNaturalist · licencias por registro",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro",
|
||||
"{days}d": "{days}d",
|
||||
"All markers": "Todos los marcadores",
|
||||
"Show markers of every age": "Mostrar marcadores de cualquier antigüedad",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha",
|
||||
"event age filter|All": "Todos",
|
||||
"Event age": "Antigüedad",
|
||||
"Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha",
|
||||
"Close camera preview": "Cerrar vista previa de la cámara",
|
||||
"Refresh map still": "Actualizar imagen fija del mapa",
|
||||
"The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP."
|
||||
}
|
||||
+46
-28
@@ -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",
|
||||
@@ -2983,18 +2990,18 @@
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.",
|
||||
"Malformed cyclone snapshot": "Instantané cyclonique mal formé",
|
||||
"Cyclone request timed out": "Délai dépassé pour la requête cyclonique",
|
||||
"{lower} to {upper}": "{lower} à {upper}",
|
||||
"Up to {upper}": "Jusqu’à {upper}",
|
||||
"{lower} to {upper}": "De {lower} à {upper}",
|
||||
"Up to {upper}": "Jusqu'à {upper}",
|
||||
"From {lower}": "À partir de {lower}",
|
||||
"Informational only · not for flight planning": "À titre informatif uniquement · ne pas utiliser pour la planification de vol",
|
||||
"Unnamed zone": "Zone sans nom",
|
||||
"Restricted airspace source unavailable": "Source de l’espace aérien réglementé indisponible",
|
||||
"Informational only · not for flight planning": "À titre indicatif · ne pas utiliser pour la préparation des vols",
|
||||
"Unnamed zone": "Sans nom",
|
||||
"Restricted airspace source unavailable": "Source de l'espace aérien réglementé indisponible",
|
||||
"Prohibited (P)": "Zone interdite (P)",
|
||||
"Restricted (R)": "Zone réglementée (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre informatif uniquement — ne remplace pas les outils officiels de planification de vol des drones (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre indicatif uniquement — ne remplace pas les outils officiels de préparation des vols de drones (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zone interdite",
|
||||
"Restricted": "Zone réglementée",
|
||||
"Prohibited & Restricted Airspace": "Zones interdites et réglementées",
|
||||
"Prohibited & Restricted Airspace": "Espaces aériens interdits et réglementés",
|
||||
"scene|PLAY": "LIRE",
|
||||
"AUTHORING": "CRÉATION",
|
||||
"Create and edit scenes": "Créer et modifier des scènes",
|
||||
@@ -3025,7 +3032,7 @@
|
||||
"fire confidence filter|All": "Toutes",
|
||||
"fire confidence filter|Nominal+": "Nominale+",
|
||||
"fire confidence filter|High": "Élevée",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — beaucoup des feux les plus intenses ont une confiance faible",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — de nombreux feux parmi les plus intenses sont classés en confiance faible",
|
||||
"Nominal and high confidence detections": "Détections de confiance nominale et élevée",
|
||||
"High confidence detections only": "Détections de confiance élevée uniquement",
|
||||
"Fire intensity (FRP)": "Intensité (FRP)",
|
||||
@@ -3034,7 +3041,7 @@
|
||||
"Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts",
|
||||
"fire intensity filter|All": "Toutes",
|
||||
"Detection confidence": "Confiance de la détection",
|
||||
"{shown} of {total} shown": "{shown} sur {total} affichées",
|
||||
"{shown} of {total} shown": "Affichés : {shown} sur {total}",
|
||||
"Name of {name}": "Nom de {name}",
|
||||
"Change the colour of {name}": "Changer la couleur de {name}",
|
||||
"Colour of {name}": "Couleur de {name}",
|
||||
@@ -3044,7 +3051,7 @@
|
||||
"UTILITIES": "OUTILS",
|
||||
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
|
||||
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
|
||||
"Voice place search (needs an Overpass server)": "Recherche de lieux par la voix (nécessite un serveur Overpass)",
|
||||
"Voice place search (needs an Overpass server)": "Recherche vocale de lieux (nécessite un serveur Overpass)",
|
||||
"OSM Places": "Lieux OSM",
|
||||
"state or province": "État ou province",
|
||||
"county or district": "comté ou district",
|
||||
@@ -3052,9 +3059,9 @@
|
||||
"natural region": "région naturelle",
|
||||
"Which {place}?": "Quel lieu nommé {place} ?",
|
||||
"Which place?": "Quel lieu ?",
|
||||
"Not drawn: {parts}": "Non dessiné : {parts}",
|
||||
"Not drawn: {parts}": "Contour non tracé : {parts}",
|
||||
"approximate": "approximatif",
|
||||
"{pct}% cloud": "{pct} % de nuages",
|
||||
"{pct}% cloud": "{pct} % de nuages",
|
||||
"Over {area}": "Zone : {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Acquisitions correspondantes : {count} sur {total}",
|
||||
@@ -3073,43 +3080,43 @@
|
||||
"{count} {kind}": "{kind} : {count}",
|
||||
"In {area}": "Zone : {area}",
|
||||
"Area around {place}": "Environs : {place}",
|
||||
"Area around landmark": "Environs d'un repère",
|
||||
"Area around landmark": "Environs d'un lieu",
|
||||
"Outline {place}": "Délimiter {place}",
|
||||
"Outline area": "Délimiter une zone",
|
||||
"Find {place}": "Trouver {place}",
|
||||
"Find area": "Trouver une zone",
|
||||
"Find recent imagery": "Trouver des images récentes",
|
||||
"Find {what}": "Trouver {what}",
|
||||
"Find {what}": "Trouver : {what}",
|
||||
"Find places": "Trouver des lieux",
|
||||
"Drawing outline": "Dessin du contour",
|
||||
"Searching imagery catalog": "Recherche dans le catalogue d'images",
|
||||
"Loading imagery": "Chargement des images",
|
||||
"Searching OpenStreetMap": "Recherche dans OpenStreetMap",
|
||||
"voice area|Area": "Zone",
|
||||
"buffer sized to the place": "zone tampon à la taille du lieu",
|
||||
"buffer sized to the place": "zone tampon adaptée à la taille du lieu",
|
||||
"enclosing mapped area": "zone cartographiée englobante",
|
||||
"the map was cleared while looking it up": "la carte a été effacée pendant la recherche",
|
||||
"OpenStreetMap coverage varies by place": "La couverture OpenStreetMap varie selon les lieux",
|
||||
"OpenStreetMap coverage varies by place": "la couverture OpenStreetMap varie selon les lieux",
|
||||
"the catalog covers the last 30 days": "le catalogue couvre les 30 derniers jours",
|
||||
"there are more": "il y en a d'autres",
|
||||
"{radius} m around the landmark": "{radius} m autour du lieu",
|
||||
"{count} parts": {
|
||||
"many": "Parties : {count}",
|
||||
"one": "Parties : {count}",
|
||||
"other": "Parties : {count}"
|
||||
"many": "Parties : {count}",
|
||||
"one": "Parties : {count}",
|
||||
"other": "Parties : {count}"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"many": "Parties tracées : {drawn} sur {total}",
|
||||
"one": "Parties tracées : {drawn} sur {total}",
|
||||
"other": "Parties tracées : {drawn} sur {total}"
|
||||
"many": "Parties tracées : {drawn} sur {total}",
|
||||
"one": "Parties tracées : {drawn} sur {total}",
|
||||
"other": "Parties tracées : {drawn} sur {total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"many": "Doublons fusionnés : {count}",
|
||||
"one": "Doublons fusionnés : {count}",
|
||||
"other": "Doublons fusionnés : {count}"
|
||||
"many": "Doublons fusionnés : {count}",
|
||||
"one": "Doublons fusionnés : {count}",
|
||||
"other": "Doublons fusionnés : {count}"
|
||||
},
|
||||
"listed the first {limit}": "les {limit} premiers sont listés",
|
||||
"counted over the bounding box of {area}, not its outline": "compté sur le rectangle englobant ({area}), pas sur le contour",
|
||||
"listed the first {limit}": "liste limitée à {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "compté dans le rectangle englobant ({area}), pas dans son contour exact",
|
||||
"Search cancelled": "Recherche annulée",
|
||||
"Sea Marks": "Balisage maritime",
|
||||
"Ski Pistes": "Pistes de ski",
|
||||
@@ -3241,5 +3248,16 @@
|
||||
"Open {name} on Acartia": "Ouvrir {name} sur Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation",
|
||||
"{days}d": "{days}j",
|
||||
"All markers": "Tous les marqueurs",
|
||||
"Show markers of every age": "Afficher les marqueurs de tout âge",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
|
||||
"event age filter|All": "Tous",
|
||||
"Event age": "Ancienneté",
|
||||
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés",
|
||||
"Close camera preview": "Fermer l’aperçu de la caméra",
|
||||
"Refresh map still": "Actualiser l’image fixe sur la carte",
|
||||
"The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP."
|
||||
}
|
||||
+39
-21
@@ -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",
|
||||
@@ -2983,18 +2990,18 @@
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.",
|
||||
"Malformed cyclone snapshot": "Snapshot dei cicloni non valido",
|
||||
"Cyclone request timed out": "La richiesta dei cicloni è andata in timeout",
|
||||
"{lower} to {upper}": "da {lower} a {upper}",
|
||||
"{lower} to {upper}": "Da {lower} a {upper}",
|
||||
"Up to {upper}": "Fino a {upper}",
|
||||
"From {lower}": "Da {lower}",
|
||||
"Informational only · not for flight planning": "Solo a scopo informativo · non per la pianificazione del volo",
|
||||
"Unnamed zone": "Zona senza nome",
|
||||
"Unnamed zone": "Senza nome",
|
||||
"Restricted airspace source unavailable": "Fonte dello spazio aereo regolamentato non disponibile",
|
||||
"Prohibited (P)": "Zona proibita (P)",
|
||||
"Prohibited (P)": "Zona vietata (P)",
|
||||
"Restricted (R)": "Zona regolamentata (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone proibite e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zona proibita",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone vietate e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zona vietata",
|
||||
"Restricted": "Zona regolamentata",
|
||||
"Prohibited & Restricted Airspace": "Zone proibite e regolamentate",
|
||||
"Prohibited & Restricted Airspace": "Spazio aereo vietato e regolamentato",
|
||||
"scene|PLAY": "AVVIA",
|
||||
"AUTHORING": "CREAZIONE",
|
||||
"Create and edit scenes": "Crea e modifica le scene",
|
||||
@@ -3025,7 +3032,7 @@
|
||||
"fire confidence filter|All": "Tutti",
|
||||
"fire confidence filter|Nominal+": "Nominale+",
|
||||
"fire confidence filter|High": "Alta",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti — molti degli incendi più intensi hanno confidenza bassa",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti: molti degli incendi più intensi hanno confidenza bassa",
|
||||
"Nominal and high confidence detections": "Rilevamenti con confidenza nominale e alta",
|
||||
"High confidence detections only": "Solo rilevamenti con confidenza alta",
|
||||
"Fire intensity (FRP)": "Intensità (FRP)",
|
||||
@@ -3034,7 +3041,7 @@
|
||||
"Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt",
|
||||
"fire intensity filter|All": "Tutti",
|
||||
"Detection confidence": "Confidenza del rilevamento",
|
||||
"{shown} of {total} shown": "{shown} di {total} mostrati",
|
||||
"{shown} of {total} shown": "Mostrati: {shown} su {total}",
|
||||
"Name of {name}": "Nome di {name}",
|
||||
"Change the colour of {name}": "Cambia il colore di {name}",
|
||||
"Colour of {name}": "Colore di {name}",
|
||||
@@ -3052,10 +3059,10 @@
|
||||
"natural region": "regione naturale",
|
||||
"Which {place}?": "Quale {place}?",
|
||||
"Which place?": "Quale luogo?",
|
||||
"Not drawn: {parts}": "Non disegnati: {parts}",
|
||||
"approximate": "approssimativo",
|
||||
"Not drawn: {parts}": "Contorno non disegnato: {parts}",
|
||||
"approximate": "approssimata",
|
||||
"{pct}% cloud": "{pct}% di nuvole",
|
||||
"Over {area}": "Zona: {area}",
|
||||
"Over {area}": "Area: {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Acquisizioni corrispondenti: {count} su {total}",
|
||||
"one": "Acquisizioni corrispondenti: {count} su {total}",
|
||||
@@ -3074,8 +3081,8 @@
|
||||
"In {area}": "Area: {area}",
|
||||
"Area around {place}": "Area intorno a {place}",
|
||||
"Area around landmark": "Area intorno a un punto di interesse",
|
||||
"Outline {place}": "Traccia contorno di {place}",
|
||||
"Outline area": "Traccia contorno area",
|
||||
"Outline {place}": "Traccia il contorno di {place}",
|
||||
"Outline area": "Traccia il contorno dell'area",
|
||||
"Find {place}": "Trova {place}",
|
||||
"Find area": "Trova area",
|
||||
"Find recent imagery": "Trova immagini recenti",
|
||||
@@ -3083,16 +3090,16 @@
|
||||
"Find places": "Trova luoghi",
|
||||
"Drawing outline": "Disegno del contorno",
|
||||
"Searching imagery catalog": "Ricerca nel catalogo immagini",
|
||||
"Loading imagery": "Caricamento immagini",
|
||||
"Loading imagery": "Caricamento delle immagini",
|
||||
"Searching OpenStreetMap": "Ricerca in OpenStreetMap",
|
||||
"voice area|Area": "Area",
|
||||
"buffer sized to the place": "zona tampone delle dimensioni del luogo",
|
||||
"enclosing mapped area": "area mappata circostante",
|
||||
"the map was cleared while looking it up": "la mappa è stata cancellata durante la ricerca",
|
||||
"OpenStreetMap coverage varies by place": "La copertura di OpenStreetMap varia da luogo a luogo",
|
||||
"enclosing mapped area": "area mappata che contiene il punto di interesse",
|
||||
"the map was cleared while looking it up": "la mappa è stata ripulita durante la ricerca",
|
||||
"OpenStreetMap coverage varies by place": "la copertura di OpenStreetMap varia da luogo a luogo",
|
||||
"the catalog covers the last 30 days": "il catalogo copre gli ultimi 30 giorni",
|
||||
"there are more": "ce ne sono altri",
|
||||
"{radius} m around the landmark": "{radius} m attorno al luogo",
|
||||
"{radius} m around the landmark": "{radius} m intorno al punto di interesse",
|
||||
"{count} parts": {
|
||||
"many": "Parti: {count}",
|
||||
"one": "Parti: {count}",
|
||||
@@ -3108,8 +3115,8 @@
|
||||
"one": "Duplicati uniti: {count}",
|
||||
"other": "Duplicati uniti: {count}"
|
||||
},
|
||||
"listed the first {limit}": "elencati i primi {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contato sul riquadro di delimitazione ({area}), non sul contorno",
|
||||
"listed the first {limit}": "elenco limitato ai primi risultati: {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "conteggio nel riquadro di delimitazione di {area}, non entro il suo contorno",
|
||||
"Search cancelled": "Ricerca annullata",
|
||||
"Sea Marks": "Segnalamenti marittimi",
|
||||
"Ski Pistes": "Piste da sci",
|
||||
@@ -3241,5 +3248,16 @@
|
||||
"Open {name} on Acartia": "Apri {name} su Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Contributori di iNaturalist · licenze per osservazione",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione",
|
||||
"{days}d": "{days}g",
|
||||
"All markers": "Tutti gli indicatori",
|
||||
"Show markers of every age": "Mostra gli indicatori di qualsiasi età",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti",
|
||||
"event age filter|All": "Tutti",
|
||||
"Event age": "Età degli eventi",
|
||||
"Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti",
|
||||
"Close camera preview": "Chiudi anteprima della telecamera",
|
||||
"Refresh map still": "Aggiorna immagine fissa sulla mappa",
|
||||
"The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP."
|
||||
}
|
||||
+53
-35
@@ -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": "局タグ",
|
||||
@@ -2843,18 +2850,18 @@
|
||||
"route proxy error": "ルート検索プロキシでエラーが発生しました",
|
||||
"Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です",
|
||||
"Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました",
|
||||
"{lower} to {upper}": "{lower}~{upper}",
|
||||
"{lower} to {upper}": "{lower}–{upper}",
|
||||
"Up to {upper}": "{upper}まで",
|
||||
"From {lower}": "{lower}から",
|
||||
"Informational only · not for flight planning": "参考情報のみ · 飛行計画には使用しないでください",
|
||||
"Unnamed zone": "名称なしのゾーン",
|
||||
"Unnamed zone": "名称不明の区域",
|
||||
"Restricted airspace source unavailable": "制限空域のデータソースを利用できません",
|
||||
"Prohibited (P)": "飛行禁止区域 (P)",
|
||||
"Restricted (R)": "制限区域 (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの飛行禁止区域と制限区域(特別使用空域)のみで、UAS Facility MapやUAS飛行制限は含みません。参考情報のみ — 公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。",
|
||||
"Prohibited (P)": "飛行禁止区域(P)",
|
||||
"Restricted (R)": "飛行制限区域(R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの特別使用空域のうち、飛行禁止区域と飛行制限区域のみです。UAS Facility MapやUASの飛行制限は含みません。参考情報であり、公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。",
|
||||
"Prohibited": "飛行禁止区域",
|
||||
"Restricted": "制限区域",
|
||||
"Prohibited & Restricted Airspace": "飛行禁止区域と制限区域",
|
||||
"Restricted": "飛行制限区域",
|
||||
"Prohibited & Restricted Airspace": "飛行禁止・飛行制限区域",
|
||||
"scene|PLAY": "再生",
|
||||
"AUTHORING": "作成",
|
||||
"Create and edit scenes": "シーンを作成・編集",
|
||||
@@ -2885,10 +2892,10 @@
|
||||
"fire confidence filter|All": "すべて",
|
||||
"fire confidence filter|Nominal+": "中以上",
|
||||
"fire confidence filter|High": "高",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "すべての検知 — 最も強い火災の多くは信頼度が低と判定されています",
|
||||
"Nominal and high confidence detections": "信頼度が中・高の検知",
|
||||
"High confidence detections only": "信頼度が高の検知のみ",
|
||||
"Fire intensity (FRP)": "強度(FRP)",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "すべての検知。最も強い火災の多くは信頼度「低」と判定されています",
|
||||
"Nominal and high confidence detections": "信頼度「中」以上の検知",
|
||||
"High confidence detections only": "信頼度「高」の検知のみ",
|
||||
"Fire intensity (FRP)": "火災強度(FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "信頼度",
|
||||
"Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)",
|
||||
@@ -2906,16 +2913,16 @@
|
||||
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
|
||||
"Voice place search (needs an Overpass server)": "音声による場所検索(Overpassサーバーが必要)",
|
||||
"OSM Places": "OSMの場所",
|
||||
"state or province": "州・省",
|
||||
"state or province": "州・県",
|
||||
"county or district": "郡・地区",
|
||||
"neighbourhood": "近隣地区",
|
||||
"natural region": "自然地域",
|
||||
"Which {place}?": "どの{place}ですか?",
|
||||
"Which {place}?": "どの「{place}」ですか?",
|
||||
"Which place?": "どの場所ですか?",
|
||||
"Not drawn: {parts}": "未描画:{parts}",
|
||||
"approximate": "概算",
|
||||
"{pct}% cloud": "雲量{pct}%",
|
||||
"Over {area}": "{area}上空",
|
||||
"approximate": "近似",
|
||||
"{pct}% cloud": "雲量 {pct}%",
|
||||
"Over {area}": "範囲:{area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"other": "一致した画像:{count}/{total}件"
|
||||
},
|
||||
@@ -2924,45 +2931,45 @@
|
||||
},
|
||||
"No matching imagery": "一致する画像はありません",
|
||||
"Unknown number of {kind}": "{kind}:件数不明",
|
||||
"About {count} {kind}": "{kind}:約{count}件",
|
||||
"At least {count} {kind}": "{kind}:{count}件以上",
|
||||
"{count} {kind}": "{kind}:{count}件",
|
||||
"About {count} {kind}": "{kind}:約{count}か所",
|
||||
"At least {count} {kind}": "{kind}:{count}か所以上",
|
||||
"{count} {kind}": "{kind}:{count}か所",
|
||||
"In {area}": "{area}内",
|
||||
"Area around {place}": "{place}周辺のエリア",
|
||||
"Area around landmark": "ランドマーク周辺のエリア",
|
||||
"Outline {place}": "{place}の輪郭を描く",
|
||||
"Outline area": "エリアの輪郭を描く",
|
||||
"Outline {place}": "{place}の輪郭を描画",
|
||||
"Outline area": "エリアの輪郭を描画",
|
||||
"Find {place}": "{place}を検索",
|
||||
"Find area": "エリアを検索",
|
||||
"Find recent imagery": "最近の衛星画像を検索",
|
||||
"Find {what}": "{what}を検索",
|
||||
"Find places": "場所を検索",
|
||||
"Drawing outline": "輪郭を作図中",
|
||||
"Drawing outline": "輪郭を地図に描画中",
|
||||
"Searching imagery catalog": "画像カタログを検索中",
|
||||
"Loading imagery": "画像を読み込み中",
|
||||
"Searching OpenStreetMap": "OpenStreetMapを検索中",
|
||||
"voice area|Area": "エリア",
|
||||
"buffer sized to the place": "場所の大きさに合わせたバッファ",
|
||||
"enclosing mapped area": "周囲の地図上のエリア",
|
||||
"the map was cleared while looking it up": "検索中に地図がクリアされました",
|
||||
"OpenStreetMap coverage varies by place": "OpenStreetMap の網羅度は場所によって異なります",
|
||||
"the catalog covers the last 30 days": "カタログは過去 30 日分です",
|
||||
"buffer sized to the place": "場所の大きさに合わせたバッファー",
|
||||
"enclosing mapped area": "ランドマークを含むマップ済みのエリア",
|
||||
"the map was cleared while looking it up": "検索中に地図が消去されました",
|
||||
"OpenStreetMap coverage varies by place": "OpenStreetMapの網羅度は場所によって異なります",
|
||||
"the catalog covers the last 30 days": "カタログの対象は過去30日間です",
|
||||
"there are more": "ほかにもあります",
|
||||
"{radius} m around the landmark": "ランドマークの周囲 {radius} m",
|
||||
"{radius} m around the landmark": "ランドマークから半径{radius} m",
|
||||
"{count} parts": {
|
||||
"other": "パーツ数: {count}"
|
||||
"other": "パーツ数:{count}"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"other": "描画したパーツ: {drawn}/{total}"
|
||||
"other": "描画したパーツ:{drawn}/{total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"other": "統合した重複: {count}"
|
||||
"other": "重複登録{count}件を統合"
|
||||
},
|
||||
"listed the first {limit}": "最初の {limit} 件を表示",
|
||||
"counted over the bounding box of {area}, not its outline": "外接矩形 ({area}) で数えた結果で、輪郭ではありません",
|
||||
"listed the first {limit}": "最初の{limit}件を表示",
|
||||
"counted over the bounding box of {area}, not its outline": "{area}の輪郭ではなく外接矩形の範囲で数えています",
|
||||
"Search cancelled": "検索をキャンセルしました",
|
||||
"Sea Marks": "航路標識",
|
||||
"Ski Pistes": "スキーゲレンデ",
|
||||
"Ski Pistes": "スキーコース",
|
||||
"Fly to Tokyo": "東京へ移動して",
|
||||
"Show live flights": "ライブフライトを表示して",
|
||||
"Reset to globe view": "地球全体ビューに戻して",
|
||||
@@ -3091,5 +3098,16 @@
|
||||
"Open {name} on Acartia": "Acartiaで{name}を開く",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist の投稿者 · ライセンスは記録ごと",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと",
|
||||
"{days}d": "{days}日",
|
||||
"All markers": "すべてのマーカー",
|
||||
"Show markers of every age": "日付にかかわらずすべてのマーカーを表示",
|
||||
"Maximum event age: {days}d; undated markers are kept": "イベントの最大経過日数:{days}日。日付のないマーカーは保持",
|
||||
"event age filter|All": "すべて",
|
||||
"Event age": "経過日数",
|
||||
"Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持",
|
||||
"Close camera preview": "カメラのプレビューを閉じる",
|
||||
"Refresh map still": "地図上の静止画を更新",
|
||||
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。"
|
||||
}
|
||||
+41
-23
@@ -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",
|
||||
@@ -2987,14 +2994,14 @@
|
||||
"Up to {upper}": "Até {upper}",
|
||||
"From {lower}": "A partir de {lower}",
|
||||
"Informational only · not for flight planning": "Apenas informativo · não usar para planejamento de voo",
|
||||
"Unnamed zone": "Zona sem nome",
|
||||
"Unnamed zone": "Sem nome",
|
||||
"Restricted airspace source unavailable": "Fonte de espaço aéreo restrito indisponível",
|
||||
"Prohibited (P)": "Área proibida (P)",
|
||||
"Restricted (R)": "Área restrita (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Apenas áreas proibidas e restritas da FAA (espaço aéreo de uso especial), sem o UAS Facility Map ou as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Somente o espaço aéreo de uso especial proibido e restrito da FAA; não inclui o UAS Facility Map nem as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Área proibida",
|
||||
"Restricted": "Área restrita",
|
||||
"Prohibited & Restricted Airspace": "Áreas proibidas e restritas",
|
||||
"Prohibited & Restricted Airspace": "Espaço aéreo proibido e restrito",
|
||||
"scene|PLAY": "INICIAR",
|
||||
"AUTHORING": "CRIAÇÃO",
|
||||
"Create and edit scenes": "Criar e editar cenas",
|
||||
@@ -3034,7 +3041,7 @@
|
||||
"Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts",
|
||||
"fire intensity filter|All": "Todas",
|
||||
"Detection confidence": "Confiança da detecção",
|
||||
"{shown} of {total} shown": "{shown} de {total} exibidas",
|
||||
"{shown} of {total} shown": "Detecções exibidas: {shown} de {total}",
|
||||
"Name of {name}": "Nome de {name}",
|
||||
"Change the colour of {name}": "Mudar a cor de {name}",
|
||||
"Colour of {name}": "Cor de {name}",
|
||||
@@ -3053,18 +3060,18 @@
|
||||
"Which {place}?": "Qual {place}?",
|
||||
"Which place?": "Qual lugar?",
|
||||
"Not drawn: {parts}": "Não desenhado: {parts}",
|
||||
"approximate": "aproximado",
|
||||
"approximate": "aproximada",
|
||||
"{pct}% cloud": "{pct}% de nuvens",
|
||||
"Over {area}": "Área: {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Aquisições correspondentes: {count} de {total}",
|
||||
"one": "Aquisições correspondentes: {count} de {total}",
|
||||
"other": "Aquisições correspondentes: {count} de {total}"
|
||||
"one": "Imagens correspondentes: {count} de {total}",
|
||||
"many": "Imagens correspondentes: {count} de {total}",
|
||||
"other": "Imagens correspondentes: {count} de {total}"
|
||||
},
|
||||
"{n} acquisitions searched": {
|
||||
"many": "Aquisições pesquisadas: {n}",
|
||||
"one": "Aquisições pesquisadas: {n}",
|
||||
"other": "Aquisições pesquisadas: {n}"
|
||||
"one": "Imagens pesquisadas: {n}",
|
||||
"many": "Imagens pesquisadas: {n}",
|
||||
"other": "Imagens pesquisadas: {n}"
|
||||
},
|
||||
"No matching imagery": "Nenhuma imagem correspondente",
|
||||
"Unknown number of {kind}": "{kind}: número desconhecido",
|
||||
@@ -3076,23 +3083,23 @@
|
||||
"Area around landmark": "Área ao redor de um ponto de referência",
|
||||
"Outline {place}": "Traçar contorno de {place}",
|
||||
"Outline area": "Traçar contorno da área",
|
||||
"Find {place}": "Encontrar {place}",
|
||||
"Find area": "Encontrar área",
|
||||
"Find recent imagery": "Encontrar imagens recentes",
|
||||
"Find {what}": "Encontrar {what}",
|
||||
"Find places": "Encontrar lugares",
|
||||
"Find {place}": "Buscar {place}",
|
||||
"Find area": "Buscar área",
|
||||
"Find recent imagery": "Buscar imagens recentes",
|
||||
"Find {what}": "Buscar {what}",
|
||||
"Find places": "Buscar lugares",
|
||||
"Drawing outline": "Desenhando contorno",
|
||||
"Searching imagery catalog": "Buscando no catálogo de imagens",
|
||||
"Loading imagery": "Carregando imagens",
|
||||
"Searching OpenStreetMap": "Buscando no OpenStreetMap",
|
||||
"voice area|Area": "Área",
|
||||
"buffer sized to the place": "zona de margem do tamanho do local",
|
||||
"enclosing mapped area": "área mapeada envolvente",
|
||||
"buffer sized to the place": "círculo do tamanho aproximado do lugar",
|
||||
"enclosing mapped area": "área mapeada que contém o ponto de referência",
|
||||
"the map was cleared while looking it up": "o mapa foi limpo durante a busca",
|
||||
"OpenStreetMap coverage varies by place": "A cobertura do OpenStreetMap varia de lugar para lugar",
|
||||
"the catalog covers the last 30 days": "o catálogo cobre os últimos 30 dias",
|
||||
"there are more": "há mais",
|
||||
"{radius} m around the landmark": "{radius} m ao redor do local",
|
||||
"{radius} m around the landmark": "{radius} m ao redor do ponto de referência",
|
||||
"{count} parts": {
|
||||
"many": "Partes: {count}",
|
||||
"one": "Partes: {count}",
|
||||
@@ -3108,10 +3115,10 @@
|
||||
"one": "Duplicatas mescladas: {count}",
|
||||
"other": "Duplicatas mescladas: {count}"
|
||||
},
|
||||
"listed the first {limit}": "listados os primeiros {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contado na caixa delimitadora ({area}), não no contorno",
|
||||
"listed the first {limit}": "lista limitada a {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contagem na caixa delimitadora de {area}, não no contorno",
|
||||
"Search cancelled": "Busca cancelada",
|
||||
"Sea Marks": "Sinais marítimos",
|
||||
"Sea Marks": "Sinalização náutica",
|
||||
"Ski Pistes": "Pistas de esqui",
|
||||
"Fly to Tokyo": "Voe para Tóquio",
|
||||
"Show live flights": "Mostre os voos ao vivo",
|
||||
@@ -3241,5 +3248,16 @@
|
||||
"Open {name} on Acartia": "Abrir {name} no Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Colaboradores do iNaturalist · licenças por registro",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores do iNaturalist · Acartia {count} (mar de Salish) · licenças por registro"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores do iNaturalist · Acartia {count} (mar de Salish) · licenças por registro",
|
||||
"{days}d": "{days}d",
|
||||
"All markers": "Todos os marcadores",
|
||||
"Show markers of every age": "Mostrar marcadores de qualquer idade",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos",
|
||||
"event age filter|All": "Todos",
|
||||
"Event age": "Idade do evento",
|
||||
"Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos",
|
||||
"Close camera preview": "Fechar prévia da câmera",
|
||||
"Refresh map still": "Atualizar imagem fixa no mapa",
|
||||
"The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP."
|
||||
}
|
||||
+39
-21
@@ -128,7 +128,7 @@
|
||||
"SRC {value}": "ИСТ. {value}",
|
||||
"{value} CONTEXT": "КОНТЕКСТ {value}",
|
||||
"Focus fire detection {title}, {details}": "Фокус на очаге пожара {title}, {details}",
|
||||
"{value} conf": "увер.: {value}",
|
||||
"{value} conf": "дост.: {value}",
|
||||
"{value} ago": "{value} назад",
|
||||
"sensor n/a": "сенсор н/д",
|
||||
"FIRE · {frp} MW": "ПОЖАР · {frp} МВт",
|
||||
@@ -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 сопоставляется, где он есть",
|
||||
@@ -3062,27 +3069,27 @@
|
||||
"fire confidence filter|All": "Все",
|
||||
"fire confidence filter|Nominal+": "Средняя+",
|
||||
"fire confidence filter|High": "Высокая",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая",
|
||||
"Nominal and high confidence detections": "Детекции средней и высокой достоверности",
|
||||
"High confidence detections only": "Только детекции высокой достоверности",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Все очаги — многим сильнейшим пожарам присвоена низкая достоверность",
|
||||
"Nominal and high confidence detections": "Очаги средней и высокой достоверности",
|
||||
"High confidence detections only": "Только очаги высокой достоверности",
|
||||
"Fire intensity (FRP)": "Интенсивность (FRP)",
|
||||
"≥ {value} MW": "≥ {value} МВт",
|
||||
"Confidence": "Достоверность",
|
||||
"Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты",
|
||||
"fire intensity filter|All": "Все",
|
||||
"Detection confidence": "Достоверность детекции",
|
||||
"{shown} of {total} shown": "показано {shown} из {total}",
|
||||
"{lower} to {upper}": "от {lower} до {upper}",
|
||||
"Detection confidence": "Достоверность обнаружения",
|
||||
"{shown} of {total} shown": "Показано {shown} из {total}",
|
||||
"{lower} to {upper}": "От {lower} до {upper}",
|
||||
"Up to {upper}": "До {upper}",
|
||||
"From {lower}": "От {lower}",
|
||||
"Informational only · not for flight planning": "Только для информации · не для планирования полётов",
|
||||
"Unnamed zone": "Зона без названия",
|
||||
"Restricted airspace source unavailable": "Источник данных об ограниченном воздушном пространстве недоступен",
|
||||
"Unnamed zone": "Без названия",
|
||||
"Restricted airspace source unavailable": "Источник данных о зонах ограничения полётов недоступен",
|
||||
"Prohibited (P)": "Запретная зона (P)",
|
||||
"Restricted (R)": "Зона ограничения (R)",
|
||||
"Restricted (R)": "Зона ограничения полётов (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Только запретные зоны и зоны ограничения полётов FAA (воздушное пространство особого использования), без UAS Facility Map и ограничений полётов БАС. Только для информации — не заменяет официальные инструменты планирования полётов БАС (B4UFLY / LAANC).",
|
||||
"Prohibited": "Запретная зона",
|
||||
"Restricted": "Зона ограничения",
|
||||
"Restricted": "Зона ограничения полётов",
|
||||
"Prohibited & Restricted Airspace": "Запретные зоны и зоны ограничения",
|
||||
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
|
||||
"route proxy error": "ошибка прокси маршрутов",
|
||||
@@ -3116,13 +3123,13 @@
|
||||
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
|
||||
"Voice place search (needs an Overpass server)": "Голосовой поиск мест (нужен сервер Overpass)",
|
||||
"OSM Places": "Места OSM",
|
||||
"state or province": "штат или провинция",
|
||||
"state or province": "штат, провинция или область",
|
||||
"county or district": "округ или район",
|
||||
"neighbourhood": "район города",
|
||||
"natural region": "природный регион",
|
||||
"Which {place}?": "Какой из вариантов «{place}»?",
|
||||
"Which place?": "Какое место?",
|
||||
"Not drawn: {parts}": "Не нарисовано: {parts}",
|
||||
"Not drawn: {parts}": "Контур не нарисован: {parts}",
|
||||
"approximate": "приблизительно",
|
||||
"{pct}% cloud": "Облачность {pct}%",
|
||||
"Over {area}": "Область: {area}",
|
||||
@@ -3143,7 +3150,7 @@
|
||||
"About {count} {kind}": "{kind}: около {count}",
|
||||
"At least {count} {kind}": "{kind}: не менее {count}",
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "Территория: {area}",
|
||||
"In {area}": "Область: {area}",
|
||||
"Area around {place}": "Окрестности: {place}",
|
||||
"Area around landmark": "Окрестности ориентира",
|
||||
"Outline {place}": "Контур: {place}",
|
||||
@@ -3159,12 +3166,12 @@
|
||||
"Searching OpenStreetMap": "Поиск в OpenStreetMap",
|
||||
"voice area|Area": "Область",
|
||||
"buffer sized to the place": "буферная зона по размеру места",
|
||||
"enclosing mapped area": "охватывающая область на карте",
|
||||
"enclosing mapped area": "область на карте, включающая ориентир",
|
||||
"the map was cleared while looking it up": "карта была очищена во время поиска",
|
||||
"OpenStreetMap coverage varies by place": "Покрытие OpenStreetMap различается в разных местах",
|
||||
"the catalog covers the last 30 days": "каталог охватывает последние 30 дней",
|
||||
"there are more": "есть ещё",
|
||||
"{radius} m around the landmark": "{radius} м вокруг объекта",
|
||||
"there are more": "есть и другие",
|
||||
"{radius} m around the landmark": "{radius} м вокруг ориентира",
|
||||
"{count} parts": {
|
||||
"few": "Частей: {count}",
|
||||
"many": "Частей: {count}",
|
||||
@@ -3183,11 +3190,11 @@
|
||||
"one": "Объединено дублей: {count}",
|
||||
"other": "Объединено дублей: {count}"
|
||||
},
|
||||
"listed the first {limit}": "показаны первые {limit}",
|
||||
"listed the first {limit}": "показаны первые результаты: {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "подсчитано по ограничивающему прямоугольнику ({area}), а не по контуру",
|
||||
"Search cancelled": "Поиск отменён",
|
||||
"Sea Marks": "Морские знаки",
|
||||
"Ski Pistes": "Горнолыжные трассы",
|
||||
"Sea Marks": "Навигационные знаки",
|
||||
"Ski Pistes": "Лыжные трассы",
|
||||
"Fly to Tokyo": "Перелети в Токио",
|
||||
"Show live flights": "Покажи рейсы в реальном времени",
|
||||
"Reset to globe view": "Вернись к виду всего глобуса",
|
||||
@@ -3316,5 +3323,16 @@
|
||||
"Open {name} on Acartia": "Открыть {name} в Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Участники iNaturalist · лицензия у каждой записи",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Сэлиш) · лицензия у каждой записи"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Сэлиш) · лицензия у каждой записи",
|
||||
"{days}d": "{days}д",
|
||||
"All markers": "Все метки",
|
||||
"Show markers of every age": "Показать метки любого возраста",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены",
|
||||
"event age filter|All": "Все",
|
||||
"Event age": "Возраст события",
|
||||
"Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены",
|
||||
"Close camera preview": "Закрыть просмотр камеры",
|
||||
"Refresh map still": "Обновить кадр на карте",
|
||||
"The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес."
|
||||
}
|
||||
+40
-22
@@ -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": "ТЕГ СТАНЦІЇ",
|
||||
@@ -3063,19 +3070,19 @@
|
||||
"Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати",
|
||||
"fire intensity filter|All": "Усі",
|
||||
"Detection confidence": "Достовірність виявлення",
|
||||
"{shown} of {total} shown": "показано {shown} з {total}",
|
||||
"{lower} to {upper}": "від {lower} до {upper}",
|
||||
"{shown} of {total} shown": "Показано {shown} з {total}",
|
||||
"{lower} to {upper}": "Від {lower} до {upper}",
|
||||
"Up to {upper}": "До {upper}",
|
||||
"From {lower}": "Від {lower}",
|
||||
"Informational only · not for flight planning": "Лише для довідки · не для планування польотів",
|
||||
"Unnamed zone": "Зона без назви",
|
||||
"Restricted airspace source unavailable": "Джерело даних про обмежений повітряний простір недоступне",
|
||||
"Unnamed zone": "Без назви",
|
||||
"Restricted airspace source unavailable": "Джерело даних про зони обмеження польотів недоступне",
|
||||
"Prohibited (P)": "Заборонена зона (P)",
|
||||
"Restricted (R)": "Зона обмеження (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Лише заборонені зони та зони обмеження FAA (повітряний простір особливого використання), без UAS Facility Map і обмежень польотів БпЛА. Лише для довідки — не замінює офіційні інструменти планування польотів БпЛА (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Лише заборонені зони та зони обмеження польотів FAA (повітряний простір особливого використання), без UAS Facility Map і обмежень польотів БпЛА. Лише для довідки — не замінює офіційні інструменти планування польотів БпЛА (B4UFLY / LAANC).",
|
||||
"Prohibited": "Заборонена зона",
|
||||
"Restricted": "Зона обмеження",
|
||||
"Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження",
|
||||
"Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження польотів",
|
||||
"Malformed cyclone snapshot": "Некоректний знімок циклонів",
|
||||
"Cyclone request timed out": "Минув час очікування запиту циклонів",
|
||||
"scene|PLAY": "СТАРТ",
|
||||
@@ -3116,7 +3123,7 @@
|
||||
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
|
||||
"Voice place search (needs an Overpass server)": "Голосовий пошук місць (потрібен сервер Overpass)",
|
||||
"OSM Places": "Місця OSM",
|
||||
"state or province": "штат або провінція",
|
||||
"state or province": "область, штат або провінція",
|
||||
"county or district": "округ або район",
|
||||
"neighbourhood": "район міста",
|
||||
"natural region": "природний регіон",
|
||||
@@ -3147,9 +3154,9 @@
|
||||
"Area around {place}": "Околиці: {place}",
|
||||
"Area around landmark": "Околиці орієнтира",
|
||||
"Outline {place}": "Контур: {place}",
|
||||
"Outline area": "Контур області",
|
||||
"Outline area": "Контур території",
|
||||
"Find {place}": "Пошук: {place}",
|
||||
"Find area": "Знайти область",
|
||||
"Find area": "Знайти територію",
|
||||
"Find recent imagery": "Знайти свіжі знімки",
|
||||
"Find {what}": "Пошук: {what}",
|
||||
"Find places": "Знайти місця",
|
||||
@@ -3157,14 +3164,14 @@
|
||||
"Searching imagery catalog": "Пошук у каталозі знімків",
|
||||
"Loading imagery": "Завантаження знімків",
|
||||
"Searching OpenStreetMap": "Пошук в OpenStreetMap",
|
||||
"voice area|Area": "Область",
|
||||
"voice area|Area": "Територія",
|
||||
"buffer sized to the place": "буферна зона за розміром місця",
|
||||
"enclosing mapped area": "охопна область на карті",
|
||||
"enclosing mapped area": "нанесений контур, що охоплює орієнтир",
|
||||
"the map was cleared while looking it up": "карту було очищено під час пошуку",
|
||||
"OpenStreetMap coverage varies by place": "Покриття OpenStreetMap відрізняється залежно від місця",
|
||||
"OpenStreetMap coverage varies by place": "покриття OpenStreetMap різниться залежно від місця",
|
||||
"the catalog covers the last 30 days": "каталог охоплює останні 30 днів",
|
||||
"there are more": "є ще",
|
||||
"{radius} m around the landmark": "{radius} м навколо об’єкта",
|
||||
"there are more": "є й інші",
|
||||
"{radius} m around the landmark": "{radius} м навколо орієнтира",
|
||||
"{count} parts": {
|
||||
"few": "Частин: {count}",
|
||||
"many": "Частин: {count}",
|
||||
@@ -3178,16 +3185,16 @@
|
||||
"other": "Намальовано частин: {drawn} з {total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"few": "Об’єднано дублікатів: {count}",
|
||||
"many": "Об’єднано дублікатів: {count}",
|
||||
"one": "Об’єднано дублікатів: {count}",
|
||||
"other": "Об’єднано дублікатів: {count}"
|
||||
"one": "Обʼєднано дублікатів: {count}",
|
||||
"few": "Обʼєднано дублікатів: {count}",
|
||||
"many": "Обʼєднано дублікатів: {count}",
|
||||
"other": "Обʼєднано дублікатів: {count}"
|
||||
},
|
||||
"listed the first {limit}": "показано перші {limit}",
|
||||
"listed the first {limit}": "показано перші результати: {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "підраховано за обмежувальним прямокутником ({area}), а не за контуром",
|
||||
"Search cancelled": "Пошук скасовано",
|
||||
"Sea Marks": "Морські знаки",
|
||||
"Ski Pistes": "Гірськолижні траси",
|
||||
"Sea Marks": "Навігаційні знаки",
|
||||
"Ski Pistes": "Лижні траси",
|
||||
"Fly to Tokyo": "Полети до Токіо",
|
||||
"Show live flights": "Покажи рейси онлайн",
|
||||
"Reset to globe view": "Поверни вигляд глобуса",
|
||||
@@ -3316,5 +3323,16 @@
|
||||
"Open {name} on Acartia": "Відкрити {name} в Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Учасники iNaturalist · ліцензія в кожного запису",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Учасники iNaturalist · Acartia {count} (море Селіш) · ліцензія в кожного запису"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Учасники iNaturalist · Acartia {count} (море Селіш) · ліцензія в кожного запису",
|
||||
"{days}d": "{days}д",
|
||||
"All markers": "Усі позначки",
|
||||
"Show markers of every age": "Показати позначки будь-якого віку",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено",
|
||||
"event age filter|All": "Усі",
|
||||
"Event age": "Вік події",
|
||||
"Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено",
|
||||
"Close camera preview": "Закрити перегляд камери",
|
||||
"Refresh map still": "Оновити кадр на карті",
|
||||
"The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу."
|
||||
}
|
||||
+39
-21
@@ -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": "电台标签",
|
||||
@@ -2850,26 +2857,26 @@
|
||||
},
|
||||
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
|
||||
"fire confidence filter|All": "全部",
|
||||
"fire confidence filter|Nominal+": "标称+",
|
||||
"fire confidence filter|Nominal+": "中+",
|
||||
"fire confidence filter|High": "高",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低",
|
||||
"Nominal and high confidence detections": "标称和高置信度的检测",
|
||||
"High confidence detections only": "仅高置信度的检测",
|
||||
"Fire intensity (FRP)": "火势强度 (FRP)",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有火点探测——许多强度最高的火点被评为低置信度",
|
||||
"Nominal and high confidence detections": "中、高置信度的火点探测",
|
||||
"High confidence detections only": "仅高置信度的火点探测",
|
||||
"Fire intensity (FRP)": "火势强度(FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "置信度",
|
||||
"Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)",
|
||||
"fire intensity filter|All": "全部",
|
||||
"Detection confidence": "检测置信度",
|
||||
"Detection confidence": "探测置信度",
|
||||
"{shown} of {total} shown": "显示 {shown} / {total}",
|
||||
"{lower} to {upper}": "{lower} 至 {upper}",
|
||||
"Up to {upper}": "最高 {upper}",
|
||||
"From {lower}": "自 {lower} 起",
|
||||
"From {lower}": "最低 {lower}",
|
||||
"Informational only · not for flight planning": "仅供参考 · 不可用于飞行规划",
|
||||
"Unnamed zone": "未命名区域",
|
||||
"Restricted airspace source unavailable": "受限空域数据源不可用",
|
||||
"Prohibited (P)": "禁区 (P)",
|
||||
"Restricted (R)": "限制区 (R)",
|
||||
"Unnamed zone": "未命名空域",
|
||||
"Restricted airspace source unavailable": "禁区与限制区空域数据源不可用",
|
||||
"Prohibited (P)": "禁区(P)",
|
||||
"Restricted (R)": "限制区(R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "仅包含 FAA 禁区和限制区特殊用途空域,不含 UAS Facility Map 和无人机飞行限制。仅供参考,不能替代官方无人机飞行规划工具(B4UFLY / LAANC)。",
|
||||
"Prohibited": "禁区",
|
||||
"Restricted": "限制区",
|
||||
@@ -2915,7 +2922,7 @@
|
||||
"Not drawn: {parts}": "未绘制:{parts}",
|
||||
"approximate": "近似",
|
||||
"{pct}% cloud": "云量 {pct}%",
|
||||
"Over {area}": "{area} 上空",
|
||||
"Over {area}": "覆盖 {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"other": "匹配影像:{count}/{total}"
|
||||
},
|
||||
@@ -2930,36 +2937,36 @@
|
||||
"In {area}": "位于 {area}",
|
||||
"Area around {place}": "{place} 周边区域",
|
||||
"Area around landmark": "地标周边区域",
|
||||
"Outline {place}": "勾勒 {place} 轮廓",
|
||||
"Outline area": "勾勒区域轮廓",
|
||||
"Outline {place}": "绘制 {place} 轮廓",
|
||||
"Outline area": "绘制区域轮廓",
|
||||
"Find {place}": "查找 {place}",
|
||||
"Find area": "查找区域",
|
||||
"Find recent imagery": "查找近期影像",
|
||||
"Find {what}": "查找 {what}",
|
||||
"Find places": "查找地点",
|
||||
"Drawing outline": "正在勾勒轮廓",
|
||||
"Drawing outline": "正在地图上绘制轮廓",
|
||||
"Searching imagery catalog": "正在搜索影像目录",
|
||||
"Loading imagery": "正在加载影像",
|
||||
"Searching OpenStreetMap": "正在搜索 OpenStreetMap",
|
||||
"voice area|Area": "区域",
|
||||
"buffer sized to the place": "按地点大小设置的缓冲区",
|
||||
"enclosing mapped area": "包含该地点的地图区域",
|
||||
"enclosing mapped area": "包含该地标的地图标注区域",
|
||||
"the map was cleared while looking it up": "查找期间地图已清空",
|
||||
"OpenStreetMap coverage varies by place": "OpenStreetMap 的覆盖程度因地而异",
|
||||
"the catalog covers the last 30 days": "目录涵盖最近 30 天",
|
||||
"there are more": "还有更多",
|
||||
"{radius} m around the landmark": "地标周围 {radius} 米",
|
||||
"{radius} m around the landmark": "地标周围 {radius} m",
|
||||
"{count} parts": {
|
||||
"other": "部分数:{count}"
|
||||
"other": "{count} 个部分"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"other": "已绘制部分:{drawn}/{total}"
|
||||
"other": "已绘制 {drawn}/{total} 个部分"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"other": "已合并重复项:{count}"
|
||||
},
|
||||
"listed the first {limit}": "已列出前 {limit} 个",
|
||||
"counted over the bounding box of {area}, not its outline": "按外接矩形({area})计数,而非其轮廓",
|
||||
"counted over the bounding box of {area}, not its outline": "按 {area} 的外接矩形计数,而非其轮廓",
|
||||
"Search cancelled": "搜索已取消",
|
||||
"Sea Marks": "航标",
|
||||
"Ski Pistes": "滑雪道",
|
||||
@@ -3091,5 +3098,16 @@
|
||||
"Open {name} on Acartia": "在 Acartia 上打开 {name}",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist 贡献者 · 各记录单独授权",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海) · 各记录单独授权"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海) · 各记录单独授权",
|
||||
"{days}d": "{days}天",
|
||||
"All markers": "所有标记",
|
||||
"Show markers of every age": "显示所有时间的标记",
|
||||
"Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记",
|
||||
"event age filter|All": "全部",
|
||||
"Event age": "事件时间",
|
||||
"Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记",
|
||||
"Close camera preview": "关闭摄像头预览",
|
||||
"Refresh map still": "刷新地图静止画面",
|
||||
"The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。"
|
||||
}
|
||||
@@ -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": "電臺標籤",
|
||||
@@ -2853,16 +2860,16 @@
|
||||
"route proxy error": "路線代理出錯",
|
||||
"{lower} to {upper}": "{lower} 至 {upper}",
|
||||
"Up to {upper}": "最高 {upper}",
|
||||
"From {lower}": "自 {lower} 起",
|
||||
"From {lower}": "最低 {lower}",
|
||||
"Informational only · not for flight planning": "僅供參考 · 不可用於飛行規劃",
|
||||
"Unnamed zone": "未命名區域",
|
||||
"Restricted airspace source unavailable": "限制空域資料來源無法使用",
|
||||
"Prohibited (P)": "禁航區 (P)",
|
||||
"Restricted (R)": "限航區 (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "僅包含 FAA 禁航區與限航區特殊用途空域,不含 UAS Facility Map 與無人機飛行限制。僅供參考,不能取代官方無人機飛行規劃工具(B4UFLY / LAANC)。",
|
||||
"Prohibited": "禁航區",
|
||||
"Restricted": "限航區",
|
||||
"Prohibited & Restricted Airspace": "禁航區與限航區空域",
|
||||
"Unnamed zone": "未命名空域",
|
||||
"Restricted airspace source unavailable": "禁區與限制區空域數據源不可用",
|
||||
"Prohibited (P)": "禁區(P)",
|
||||
"Restricted (R)": "限制區(R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "僅包含 FAA 禁區和限制區特殊用途空域,不含 UAS Facility Map 和無人機飛行限制。僅供參考,不能替代官方無人機飛行規劃工具(B4UFLY / LAANC)。",
|
||||
"Prohibited": "禁區",
|
||||
"Restricted": "限制區",
|
||||
"Prohibited & Restricted Airspace": "禁區與限制區空域",
|
||||
"AUTHORING": "創作",
|
||||
"Create and edit scenes": "創建和編輯場景",
|
||||
"SCENE": "場景",
|
||||
@@ -2889,17 +2896,17 @@
|
||||
"Left panels": "左側面板",
|
||||
"Right panels": "右側面板",
|
||||
"fire confidence filter|All": "全部",
|
||||
"fire confidence filter|Nominal+": "標稱+",
|
||||
"fire confidence filter|Nominal+": "中+",
|
||||
"fire confidence filter|High": "高",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有檢測——許多最強的火點置信度為低",
|
||||
"Nominal and high confidence detections": "標稱和高置信度的檢測",
|
||||
"High confidence detections only": "僅高置信度的檢測",
|
||||
"Fire intensity (FRP)": "火勢強度 (FRP)",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有火點探測——許多強度最高的火點被評為低置信度",
|
||||
"Nominal and high confidence detections": "中、高置信度的火點探測",
|
||||
"High confidence detections only": "僅高置信度的火點探測",
|
||||
"Fire intensity (FRP)": "火勢強度(FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "置信度",
|
||||
"Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)",
|
||||
"fire intensity filter|All": "全部",
|
||||
"Detection confidence": "檢測置信度",
|
||||
"Detection confidence": "探測置信度",
|
||||
"{shown} of {total} shown": "顯示 {shown} / {total}",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
|
||||
@@ -2915,7 +2922,7 @@
|
||||
"Not drawn: {parts}": "未繪製:{parts}",
|
||||
"approximate": "近似",
|
||||
"{pct}% cloud": "雲量 {pct}%",
|
||||
"Over {area}": "{area} 上空",
|
||||
"Over {area}": "覆蓋 {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"other": "匹配影像:{count}/{total}"
|
||||
},
|
||||
@@ -2930,36 +2937,36 @@
|
||||
"In {area}": "位於 {area}",
|
||||
"Area around {place}": "{place} 周邊區域",
|
||||
"Area around landmark": "地標周邊區域",
|
||||
"Outline {place}": "勾勒 {place} 輪廓",
|
||||
"Outline area": "勾勒區域輪廓",
|
||||
"Outline {place}": "繪製 {place} 輪廓",
|
||||
"Outline area": "繪製區域輪廓",
|
||||
"Find {place}": "查找 {place}",
|
||||
"Find area": "查找區域",
|
||||
"Find recent imagery": "查找近期影像",
|
||||
"Find {what}": "查找 {what}",
|
||||
"Find places": "查找地點",
|
||||
"Drawing outline": "正在勾勒輪廓",
|
||||
"Drawing outline": "正在地圖上繪製輪廓",
|
||||
"Searching imagery catalog": "正在搜索影像目錄",
|
||||
"Loading imagery": "正在加載影像",
|
||||
"Searching OpenStreetMap": "正在搜索 OpenStreetMap",
|
||||
"voice area|Area": "區域",
|
||||
"buffer sized to the place": "按地點大小設置的緩衝區",
|
||||
"enclosing mapped area": "包含該地點的地圖區域",
|
||||
"enclosing mapped area": "包含該地標的地圖標註區域",
|
||||
"the map was cleared while looking it up": "查找期間地圖已清空",
|
||||
"OpenStreetMap coverage varies by place": "OpenStreetMap 的覆蓋程度因地而異",
|
||||
"the catalog covers the last 30 days": "目錄涵蓋最近 30 天",
|
||||
"there are more": "還有更多",
|
||||
"{radius} m around the landmark": "地標周圍 {radius} 米",
|
||||
"{radius} m around the landmark": "地標周圍 {radius} m",
|
||||
"{count} parts": {
|
||||
"other": "部分數:{count}"
|
||||
"other": "{count} 個部分"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"other": "已繪製部分:{drawn}/{total}"
|
||||
"other": "已繪製 {drawn}/{total} 個部分"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"other": "已合併重複項:{count}"
|
||||
},
|
||||
"listed the first {limit}": "已列出前 {limit} 個",
|
||||
"counted over the bounding box of {area}, not its outline": "按外接矩形({area})計數,而非其輪廓",
|
||||
"counted over the bounding box of {area}, not its outline": "按 {area} 的外接矩形計數,而非其輪廓",
|
||||
"Search cancelled": "搜索已取消",
|
||||
"Sea Marks": "航標",
|
||||
"Ski Pistes": "滑雪道",
|
||||
@@ -3091,5 +3098,16 @@
|
||||
"Open {name} on Acartia": "在 Acartia 上打開 {name}",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist 貢獻者 · 各紀錄個別授權",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海) · 各紀錄個別授權"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海) · 各紀錄個別授權",
|
||||
"{days}d": "{days}天",
|
||||
"All markers": "所有標記",
|
||||
"Show markers of every age": "顯示所有時間的標記",
|
||||
"Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記",
|
||||
"event age filter|All": "全部",
|
||||
"Event age": "事件時間",
|
||||
"Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記",
|
||||
"Close camera preview": "關閉攝影機預覽",
|
||||
"Refresh map still": "重新整理地圖靜態畫面",
|
||||
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。"
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { foldPlaceText } from './data/placeNames.js';
|
||||
|
||||
/** Photon/OpenStreetMap place-search adapter. */
|
||||
const PHOTON_ENDPOINT = 'https://photon.komoot.io/api/';
|
||||
|
||||
@@ -233,19 +235,15 @@ export function photonSearchUrl(
|
||||
}
|
||||
|
||||
/**
|
||||
* Place name reduced to comparable form: unaccented, lowercased, punctuation
|
||||
* collapsed to single spaces. "Huế" and "Hue" have to compare equal, because a
|
||||
* user typing either means the same city.
|
||||
* Place name reduced to comparable form: lowercased, Latin accents removed,
|
||||
* other scripts preserved, and punctuation collapsed to single spaces. "Huế"
|
||||
* and "Hue" have to compare equal, because a user typing either means the same
|
||||
* city.
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeToponym(text) {
|
||||
return String(text ?? '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
return foldPlaceText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,12 @@ import {
|
||||
import { geocodeNavigationMode } from './locations.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const MOSCOW_RESPONSES = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(ROOT, 'src/data/fixtures/photon-moscow-search.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
|
||||
/** Photon's real answer for "Hanoi", trimmed to the fields we consume. */
|
||||
const HANOI = {
|
||||
@@ -249,6 +255,58 @@ test('a biased near-miss is refused, and the unbiased answer is taken instead',
|
||||
assert.equal(fetchImpl.urls.length, 2, 'the refused bias costs exactly one extra request');
|
||||
});
|
||||
|
||||
test('a non-Latin exact match keeps the nearby namesake', async () => {
|
||||
const fetchImpl = photonPair(
|
||||
MOSCOW_RESPONSES.biased.features,
|
||||
MOSCOW_RESPONSES.unbiased.features,
|
||||
);
|
||||
|
||||
const result = await geocodeKeyless('Москва', {
|
||||
bias: '38.90,71.80|39.00,71.90',
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
assert.equal(result.label, 'Москва, Тоҷикистон');
|
||||
assert.equal(fetchImpl.urls.length, 1);
|
||||
});
|
||||
|
||||
test('toponym folding preserves non-Latin scripts', () => {
|
||||
const cases = [
|
||||
['Huế', 'hue'],
|
||||
['Москва', 'москва'],
|
||||
['Αθήνα', 'αθήνα'],
|
||||
['القاهرة', 'القاهرة'],
|
||||
['東京', '東京'],
|
||||
['São 東京', 'sao 東京'],
|
||||
['Straße', 'strasse'],
|
||||
['Tromsø', 'tromso'],
|
||||
['Łódź', 'lodz'],
|
||||
['The Hague', 'the hague'],
|
||||
];
|
||||
|
||||
for (const [input, expected] of cases) {
|
||||
assert.equal(normalizeToponym(input), expected, input);
|
||||
}
|
||||
});
|
||||
|
||||
test('a leading "the" is part of the name a nearby match must equal', async () => {
|
||||
// "The Hague" over upstate New York means the Dutch city, not Hague, NY.
|
||||
const hagueNy = {
|
||||
geometry: { type: 'Point', coordinates: [-73.5004, 43.6967] },
|
||||
properties: { osm_key: 'place', osm_value: 'town', type: 'city', name: 'Hague', country: 'United States' },
|
||||
};
|
||||
const theHague = {
|
||||
geometry: { type: 'Point', coordinates: [4.3113, 52.0799] },
|
||||
properties: { osm_key: 'place', osm_value: 'city', type: 'city', name: 'The Hague', country: 'Nederland' },
|
||||
};
|
||||
const fetchImpl = photonPair([hagueNy], [theHague]);
|
||||
|
||||
const result = await geocodeKeyless('The Hague', { bias: '43.65,-73.55|43.75,-73.45', fetchImpl });
|
||||
|
||||
assert.equal(Math.round(result.lat), 52, `got ${result.label}`);
|
||||
assert.equal(fetchImpl.urls.length, 2);
|
||||
});
|
||||
|
||||
test('a biased match that leads with the name is taken without a second request', async () => {
|
||||
// The other half of the rule, and the reason bias exists at all: "Sixth
|
||||
// Street" over Austin must stay Austin's.
|
||||
|
||||
@@ -132,9 +132,11 @@ export function createCards({ state: layerState, services, parts, source }) {
|
||||
record.camera.lon,
|
||||
),
|
||||
inView,
|
||||
isVideo: parts.model.isVideoFeedType(
|
||||
parts.model.normalizeFeedType(record.camera.feedType),
|
||||
),
|
||||
// Québec's browser-only sources have no registered frame endpoint;
|
||||
// keep them out of still-card fill just like video feeds.
|
||||
isVideo:
|
||||
parts.model.isVideoFeedCamera(record.camera) ||
|
||||
parts.model.isBrowserOverlayCamera(record.camera),
|
||||
sx,
|
||||
sy,
|
||||
});
|
||||
|
||||
@@ -237,9 +237,14 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
groundElevationM,
|
||||
absoluteHeightM: groundElevationM + mountHeightM,
|
||||
pitchDeg,
|
||||
frameRefreshMs: parts.model.safeNumber(source.frameRefreshMs, NaN),
|
||||
license: String(source.license || source.licenseNote || ''),
|
||||
credit: String(source.credit || ''),
|
||||
code: String(source.code || ''),
|
||||
sourceUrl: String(source.sourceUrl || ''),
|
||||
browserMediaUrl: String(source.browserMediaUrl || ''),
|
||||
accuracyM: parts.model.safeNumber(source.accuracyM, NaN),
|
||||
positionSource: String(source.positionSource || ''),
|
||||
// Shipped precompute (see server/providers/cctv/groundHeights.js).
|
||||
groundHeights:
|
||||
source.groundHeights && typeof source.groundHeights === 'object'
|
||||
|
||||
@@ -255,11 +255,20 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
||||
// clears this latch so the next normal tick can refresh.
|
||||
if (runtime.imageLoading) return;
|
||||
const now = Date.now();
|
||||
const refreshMs =
|
||||
const defaultRefreshMs =
|
||||
record.camera.id === layerState._activeCameraId
|
||||
? PROJECTION_ACTIVE_REFRESH_MS
|
||||
: PROJECTION_IDLE_REFRESH_MS;
|
||||
if (!force && now - runtime.lastImageRefreshAt < refreshMs) return;
|
||||
const sourceRefreshMs = parts.model.safeNumber(
|
||||
record.camera.frameRefreshMs,
|
||||
0,
|
||||
);
|
||||
const refreshMs = Math.max(defaultRefreshMs, sourceRefreshMs);
|
||||
const sinceLastRefresh = now - runtime.lastImageRefreshAt;
|
||||
// Forced activation may skip the normal active interval, but it cannot
|
||||
// bypass a provider's explicit minimum between image requests.
|
||||
if (sourceRefreshMs > 0 && sinceLastRefresh < sourceRefreshMs) return;
|
||||
if (!force && sinceLastRefresh < refreshMs) return;
|
||||
runtime.lastImageRefreshAt = now;
|
||||
|
||||
const frameUrl = frameUrlFor(record.camera, refreshMs);
|
||||
|
||||
@@ -49,9 +49,8 @@ export function createHover({ state: layerState, services, parts, source }) {
|
||||
!!record &&
|
||||
cameraId !== layerState._activeCameraId &&
|
||||
!layerState._cardIds.has(cameraId) &&
|
||||
!parts.model.isVideoFeedType(
|
||||
parts.model.normalizeFeedType(record.camera.feedType),
|
||||
);
|
||||
!parts.model.isBrowserOverlayCamera(record.camera) &&
|
||||
!parts.model.isVideoFeedCamera(record.camera);
|
||||
if (eligible) {
|
||||
cancelHoverRelease();
|
||||
layerState._hoverCardId = cameraId;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -96,6 +96,19 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
return feedType === 'mp4' || feedType === 'hls' || feedType === 'webm';
|
||||
}
|
||||
|
||||
/** A browser-only source still counts as live-capable for map icon/LOD policy. */
|
||||
function isVideoFeedCamera(camera) {
|
||||
return (
|
||||
isVideoFeedType(normalizeFeedType(camera?.feedType)) ||
|
||||
Boolean(camera?.browserMediaUrl)
|
||||
);
|
||||
}
|
||||
|
||||
/** Québec source pages and browser-played MP4s use the DOM overlay path. */
|
||||
function isBrowserOverlayCamera(camera) {
|
||||
return String(camera?.sourceKind || '').startsWith('quebec-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces a value to a finite number or returns the fallback.
|
||||
*
|
||||
@@ -447,7 +460,12 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
|
||||
function projectionLoopIsNeeded() {
|
||||
if (!layerState._enabled || !layerState._viewer) return false;
|
||||
if (layerState._showProjection && parts.selection.getActiveRecord())
|
||||
const active = parts.selection.getActiveRecord();
|
||||
if (
|
||||
layerState._showProjection &&
|
||||
active &&
|
||||
!isBrowserOverlayCamera(active.camera)
|
||||
)
|
||||
return true;
|
||||
return focusPassIsNeeded(
|
||||
getFocusTarget(),
|
||||
@@ -553,6 +571,8 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
angularDeltaAbs,
|
||||
normalizeFeedType,
|
||||
isVideoFeedType,
|
||||
isVideoFeedCamera,
|
||||
isBrowserOverlayCamera,
|
||||
safeNumber,
|
||||
headingFromId,
|
||||
quantize,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -160,9 +160,14 @@ export function createPresentation({
|
||||
const camera = record.camera;
|
||||
const health = layerState._healthById.get(camera.id) || null;
|
||||
const isActive = camera.id === resolvedActiveId;
|
||||
const refreshMs = isActive
|
||||
const defaultRefreshMs = isActive
|
||||
? ACTIVE_FRAME_REFRESH_MS
|
||||
: IDLE_FRAME_REFRESH_MS;
|
||||
const sourceRefreshMs = Number(camera.frameRefreshMs);
|
||||
const refreshMs =
|
||||
Number.isFinite(sourceRefreshMs) && sourceRefreshMs > 0
|
||||
? Math.max(defaultRefreshMs, sourceRefreshMs)
|
||||
: defaultRefreshMs;
|
||||
return {
|
||||
id: camera.id,
|
||||
name: camera.name,
|
||||
@@ -192,13 +197,18 @@ export function createPresentation({
|
||||
mountHeightM: camera.mountHeightM,
|
||||
active: isActive,
|
||||
feedType: camera.feedType,
|
||||
sourceUrl: camera.sourceUrl || '',
|
||||
browserMediaUrl: camera.browserMediaUrl || '',
|
||||
accuracyM: Number.isFinite(camera.accuracyM) ? camera.accuracyM : null,
|
||||
positionSource: camera.positionSource || '',
|
||||
isVideo:
|
||||
parts.model.isVideoFeedType(camera.feedType) &&
|
||||
parts.model.isVideoFeedCamera(camera) &&
|
||||
record.projection?.mode !== 'image',
|
||||
sourceKind:
|
||||
health?.sourceKind ||
|
||||
camera.sourceKind ||
|
||||
(camera.feedConfigured ? 'configured' : 'seed'),
|
||||
sourceKind: camera.sourceKind?.startsWith('quebec-')
|
||||
? camera.sourceKind
|
||||
: health?.sourceKind ||
|
||||
camera.sourceKind ||
|
||||
(camera.feedConfigured ? 'configured' : 'seed'),
|
||||
sourceStatus: health?.status || 'unknown',
|
||||
sourceMessage: health?.message || '',
|
||||
sourceLabel: health?.label || camera.provider || '',
|
||||
@@ -276,6 +286,7 @@ export function createPresentation({
|
||||
hoverId: layerState._hoverCardId,
|
||||
},
|
||||
activeCameraId: activeId,
|
||||
activeCameraReselection: layerState._activeCameraReselection,
|
||||
activeCamera: active ? getPublicCameraState(active, activeId) : null,
|
||||
cameraIndex: cameraIndex(),
|
||||
summary: buildSummaryText(),
|
||||
|
||||
@@ -171,6 +171,9 @@ export function createProjection({
|
||||
|
||||
function createProjectionRuntime(record) {
|
||||
if (!layerState._viewer) return null;
|
||||
// Québec 511 denies CORS pixel access; Québec map previews are native DOM
|
||||
// videos positioned over the globe, never Cesium video textures.
|
||||
if (parts.model.isBrowserOverlayCamera(record.camera)) return null;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = PROJECTION_CANVAS_WIDTH;
|
||||
canvas.height = PROJECTION_CANVAS_HEIGHT;
|
||||
|
||||
@@ -35,8 +35,7 @@ export function createRendering({
|
||||
function cameraColorFor(record) {
|
||||
if (record.camera.id === layerState._activeCameraId)
|
||||
return ACTIVE_CAMERA_COLOR;
|
||||
if (parts.model.isVideoFeedType(record.camera.feedType))
|
||||
return LIVE_CAMERA_COLOR;
|
||||
if (parts.model.isVideoFeedCamera(record.camera)) return LIVE_CAMERA_COLOR;
|
||||
return IDLE_CAMERA_COLOR;
|
||||
}
|
||||
|
||||
@@ -405,8 +404,9 @@ export function createRendering({
|
||||
if (
|
||||
layerState._enabled &&
|
||||
isActive &&
|
||||
!parts.model.isBrowserOverlayCamera(record.camera) &&
|
||||
(layerState._showProjection ||
|
||||
parts.model.isVideoFeedType(record.camera.feedType))
|
||||
parts.model.isVideoFeedCamera(record.camera))
|
||||
) {
|
||||
parts.projection.ensureProjectionRuntime(record);
|
||||
}
|
||||
@@ -416,7 +416,8 @@ export function createRendering({
|
||||
const planeShowing = !!(
|
||||
layerState._enabled &&
|
||||
layerState._showProjection &&
|
||||
isActive
|
||||
isActive &&
|
||||
!parts.model.isBrowserOverlayCamera(record.camera)
|
||||
);
|
||||
|
||||
const inVisibleSet = coverageVisible.has(record.camera.id);
|
||||
|
||||
@@ -56,7 +56,12 @@ test('focus de-emphasis fades each icon from its own color', () => {
|
||||
const { refreshCctvFocusStyles } = createRendering({
|
||||
state,
|
||||
services: { focus },
|
||||
parts: { model: { isVideoFeedType } },
|
||||
parts: {
|
||||
model: {
|
||||
isVideoFeedCamera: (camera) =>
|
||||
isVideoFeedType(camera.feedType) || Boolean(camera.browserMediaUrl),
|
||||
},
|
||||
},
|
||||
source: {},
|
||||
});
|
||||
|
||||
|
||||
@@ -79,10 +79,17 @@ export function createSelection({
|
||||
record,
|
||||
)
|
||||
) {
|
||||
// Browser-only feeds have a separate player; a reselect retries it
|
||||
// without re-running activation or touching the projection geometry.
|
||||
if (parts.model.isBrowserOverlayCamera(record.camera)) {
|
||||
layerState._activeCameraReselection += 1;
|
||||
parts.presentation.notifyListeners();
|
||||
}
|
||||
return CCTV_ACTIVATION_RESULT.UNCHANGED;
|
||||
}
|
||||
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 +176,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(
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CCTV_ACTIVATION_RESULT } from '../../cctvFocusRequest.js';
|
||||
import { createSelection } from './selection.js';
|
||||
|
||||
function makeSelection(sourceKind) {
|
||||
const camera = { id: 'same-camera', sourceKind };
|
||||
const record = { camera };
|
||||
const state = {
|
||||
_activeCameraId: camera.id,
|
||||
_activeCameraReselection: 0,
|
||||
_recordById: new Map([[camera.id, record]]),
|
||||
};
|
||||
let notifications = 0;
|
||||
const selection = createSelection({
|
||||
state,
|
||||
services: {
|
||||
activation: { CCTV_ACTIVATION_RESULT },
|
||||
picking: { resolvePickId: () => null },
|
||||
},
|
||||
parts: {
|
||||
model: {
|
||||
cctvRecordNeedsActivation: () => false,
|
||||
isBrowserOverlayCamera: (value) =>
|
||||
String(value?.sourceKind || '').startsWith('quebec-'),
|
||||
},
|
||||
presentation: { notifyListeners: () => notifications++ },
|
||||
},
|
||||
source: {},
|
||||
});
|
||||
return { selection, state, notifications: () => notifications };
|
||||
}
|
||||
|
||||
test('same-camera selection does not notify or mutate snapshot-feed state', () => {
|
||||
const fixture = makeSelection('tripcheck-odot');
|
||||
|
||||
assert.equal(
|
||||
fixture.selection.setActiveCamera('same-camera'),
|
||||
CCTV_ACTIVATION_RESULT.UNCHANGED,
|
||||
);
|
||||
assert.equal(fixture.state._activeCameraReselection, 0);
|
||||
assert.equal(fixture.notifications(), 0);
|
||||
});
|
||||
|
||||
test('same-camera selection notifies browser-overlay feeds so playback can retry', () => {
|
||||
const fixture = makeSelection('quebec-511-browser');
|
||||
|
||||
assert.equal(
|
||||
fixture.selection.setActiveCamera('same-camera'),
|
||||
CCTV_ACTIVATION_RESULT.UNCHANGED,
|
||||
);
|
||||
assert.equal(fixture.state._activeCameraReselection, 1);
|
||||
assert.equal(fixture.notifications(), 1);
|
||||
});
|
||||
@@ -29,6 +29,10 @@ export function createState({ services }) {
|
||||
|
||||
layerState._activeCameraId = null;
|
||||
|
||||
// Advances when a user reselects the already-active camera. The geometry
|
||||
// activation remains a no-op, but browser-only camera UI can retry playback.
|
||||
layerState._activeCameraReselection = 0;
|
||||
|
||||
layerState._coverageMode = 'on';
|
||||
// 'off' | 'on' (wireframes) | 'viewshed' (color-coded volumes)
|
||||
|
||||
@@ -36,6 +40,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,
|
||||
|
||||
@@ -67,6 +67,7 @@ export function createCyclonesLayer({
|
||||
feed,
|
||||
hitTestOverlay = hitTestWorldOverlay,
|
||||
overlayHost,
|
||||
context = null,
|
||||
cesium = Cesium,
|
||||
createRendering = createCycloneRendering,
|
||||
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
||||
@@ -82,6 +83,7 @@ export function createCyclonesLayer({
|
||||
selectedId = null,
|
||||
selectionIntent = 'auto',
|
||||
navigationGeneration = 0,
|
||||
selectedContextStormId = null,
|
||||
clickHandler = null,
|
||||
removeClickCapture = null;
|
||||
let enabled = false,
|
||||
@@ -92,6 +94,62 @@ export function createCyclonesLayer({
|
||||
const notify = () => listener?.();
|
||||
const selected = () =>
|
||||
snapshot?.storms.find((storm) => storm.id === selectedId) || null;
|
||||
function clearSelectedContext({ evicted = false } = {}) {
|
||||
if (selectedContextStormId) {
|
||||
if (!evicted)
|
||||
context.clearSelectedEntityContextForLayer('weather-cyclones');
|
||||
context.removeEntityContextsForLayer('weather-cyclones');
|
||||
}
|
||||
selectedContextStormId = null;
|
||||
}
|
||||
|
||||
// Callers publish only while selectedId names a storm in the snapshot.
|
||||
function publishSelectedContext({ announce = false } = {}) {
|
||||
const storm = selected();
|
||||
if (!context) return;
|
||||
const entity = {};
|
||||
const contextId = `cyclone:${storm.id}`;
|
||||
context.registerEntityContext(entity, {
|
||||
id: contextId,
|
||||
layerId: 'weather-cyclones',
|
||||
layerName: 'Cyclone advisories',
|
||||
source: 'NOAA NHC / CPHC',
|
||||
label: storm.name,
|
||||
latitude: storm.position.latitude,
|
||||
longitude: storm.position.longitude,
|
||||
// At most 12 properties reach the voice summary; keep the list short.
|
||||
properties: {
|
||||
classification: storm.classification,
|
||||
classificationName: Object.hasOwn(
|
||||
CLASSIFICATION_NAMES,
|
||||
storm.classification,
|
||||
)
|
||||
? CLASSIFICATION_NAMES[storm.classification]
|
||||
: undefined,
|
||||
basin: storm.basin,
|
||||
advisoryNumber: storm.advisoryNumber,
|
||||
issuedAt: storm.issuedAt,
|
||||
positionAt: storm.positionAt,
|
||||
windKt: storm.windKt,
|
||||
pressureHpa: storm.pressureHpa,
|
||||
// Flat scalars: the voice context summary drops nested objects.
|
||||
movementDirectionDegrees: storm.movement?.directionDegrees,
|
||||
movementSpeedKt: storm.movement?.speedKt,
|
||||
advisoryUrl: storm.advisoryUrl,
|
||||
// Only while the proxy serves its cache because NHC is unreachable.
|
||||
staleCacheFetchedAt:
|
||||
snapshot.stale && Number.isFinite(snapshot.fetchedAt)
|
||||
? new Date(snapshot.fetchedAt).toISOString()
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
selectedContextStormId = storm.id;
|
||||
if (announce) context.selectEntityContext(entity);
|
||||
context.removeEntityContextsForLayer('weather-cyclones', {
|
||||
retainIds: new Set([contextId]),
|
||||
});
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
if (selectedId !== id) ++navigationGeneration;
|
||||
selectedId = id;
|
||||
@@ -236,6 +294,7 @@ export function createCyclonesLayer({
|
||||
selectedId = null;
|
||||
selectionIntent = 'auto';
|
||||
++navigationGeneration;
|
||||
clearSelectedContext();
|
||||
rendering?.clear();
|
||||
},
|
||||
async update(_viewer, { signal } = {}) {
|
||||
@@ -259,6 +318,7 @@ export function createCyclonesLayer({
|
||||
snapshot = next;
|
||||
selectedId = null;
|
||||
++navigationGeneration;
|
||||
clearSelectedContext({ evicted: true });
|
||||
error = next.reason || ADVISORIES_UNAVAILABLE;
|
||||
return true;
|
||||
}
|
||||
@@ -281,6 +341,10 @@ export function createCyclonesLayer({
|
||||
: snapshot.storms[0]?.id || null,
|
||||
);
|
||||
else rendering.setSelection(selectedId);
|
||||
if (selectedContextStormId) {
|
||||
if (selectedId === selectedContextStormId) publishSelectedContext();
|
||||
else clearSelectedContext({ evicted: true });
|
||||
}
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
@@ -290,6 +354,7 @@ export function createCyclonesLayer({
|
||||
snapshot = null;
|
||||
selectedId = null;
|
||||
++navigationGeneration;
|
||||
clearSelectedContext({ evicted: true });
|
||||
return true;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
@@ -305,6 +370,7 @@ export function createCyclonesLayer({
|
||||
if (params.clear === true || params.stormId === null) {
|
||||
selectionIntent = 'cleared';
|
||||
select(null);
|
||||
clearSelectedContext();
|
||||
notify();
|
||||
} else if (
|
||||
typeof params.stormId === 'string' &&
|
||||
@@ -312,6 +378,7 @@ export function createCyclonesLayer({
|
||||
) {
|
||||
selectionIntent = 'user';
|
||||
select(params.stormId);
|
||||
publishSelectedContext({ announce: true });
|
||||
notify();
|
||||
}
|
||||
const storm = selected();
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
isPointerFree,
|
||||
} from '../../data/inputOwnership.js';
|
||||
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
|
||||
import * as contextStore from '../../data/contextStore.js';
|
||||
import { createGevActionRunner } from '../../voice/gevActions.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const time = '2026-09-16T03:00:00.000Z';
|
||||
const storm = (id = 'ep152026') => ({
|
||||
@@ -338,7 +341,7 @@ test('a click on a storm card or lead-hour label selects that storm without pick
|
||||
});
|
||||
function harness(
|
||||
feed = { getSnapshot: async () => snapshot() },
|
||||
{ reducedMotion = false } = {},
|
||||
{ reducedMotion = false, context = null } = {},
|
||||
) {
|
||||
const applied = [],
|
||||
navigation = [],
|
||||
@@ -368,6 +371,7 @@ function harness(
|
||||
};
|
||||
const layer = createCyclonesLayer({
|
||||
feed,
|
||||
context,
|
||||
createRendering: () => rendering,
|
||||
matchMedia: () => ({ matches: reducedMotion }),
|
||||
openLink: (url) => opened.push(url),
|
||||
@@ -401,6 +405,256 @@ function harness(
|
||||
},
|
||||
};
|
||||
}
|
||||
// Real shared-context store on a stand-in window; the voice runner reads it.
|
||||
async function withSharedContext(t, body) {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const host = new EventTarget();
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: host,
|
||||
});
|
||||
const layers = [];
|
||||
t.after(() => {
|
||||
try {
|
||||
for (const layer of layers) layer.destroy();
|
||||
} finally {
|
||||
if (previousWindow)
|
||||
Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else delete globalThis.window;
|
||||
}
|
||||
});
|
||||
const events = [];
|
||||
host.addEventListener('gev:entity-selected', ({ detail }) =>
|
||||
events.push(['selected', detail.id]),
|
||||
);
|
||||
host.addEventListener('gev:entity-selection-cleared', ({ detail }) =>
|
||||
events.push(['cleared', detail.layerId, detail.reason]),
|
||||
);
|
||||
// The voice runner bounds its basemap wait with the window timers.
|
||||
Object.assign(host, { setTimeout, clearTimeout });
|
||||
const position = Cesium.Cartesian3.fromDegrees(-125.8, 15.5, 5_000_000);
|
||||
const runner = createGevActionRunner({
|
||||
viewer: {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: {
|
||||
moveEnd: { addEventListener() {} },
|
||||
positionWC: position,
|
||||
positionCartographic: Cesium.Cartographic.fromCartesian(position),
|
||||
heading: 0,
|
||||
pitch: 0,
|
||||
},
|
||||
},
|
||||
styleManager: {},
|
||||
dataManager: {
|
||||
layers: new Map(),
|
||||
isEnabled: (id) => id === 'weather-cyclones',
|
||||
getAll: () => [],
|
||||
},
|
||||
});
|
||||
const selectedProperties = async () => {
|
||||
const result = await runner('get_entity_context', { scope: 'selected' });
|
||||
assert.equal(result.scope, 'selected');
|
||||
return result.selected.properties;
|
||||
};
|
||||
const store = contextStore.getContextStore();
|
||||
const open = (feed) => {
|
||||
const h = harness(feed, { context: contextStore });
|
||||
layers.push(h.layer);
|
||||
return h;
|
||||
};
|
||||
await body({ events, selectedProperties, store, open });
|
||||
}
|
||||
|
||||
test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => {
|
||||
await withSharedContext(
|
||||
t,
|
||||
async ({ events, selectedProperties, store, open }) => {
|
||||
let next = snapshot([
|
||||
{ ...storm(), movement: { directionDegrees: 315, speedKt: 12 } },
|
||||
{
|
||||
...storm('ep162026'),
|
||||
movement: { directionDegrees: 0, speedKt: 10 },
|
||||
},
|
||||
]);
|
||||
const h = open({ getSnapshot: async () => next });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.deepEqual(
|
||||
events,
|
||||
[],
|
||||
'the automatic first-storm row selection must not claim shared context',
|
||||
);
|
||||
assert.equal(store.entities.size, 0);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
assert.equal(store.entities.get('cyclone:ep152026').label, 'Fifteen-E');
|
||||
let properties = await selectedProperties();
|
||||
assert.equal(properties.classification, 'PTC');
|
||||
assert.equal(properties.classificationName, 'Post-tropical cyclone');
|
||||
assert.equal(properties.advisoryNumber, '10');
|
||||
assert.equal(
|
||||
properties.movementDirectionDegrees,
|
||||
'315',
|
||||
'selected storm movement should survive voice summarization',
|
||||
);
|
||||
assert.equal(properties.movementSpeedKt, '12');
|
||||
assert.equal(
|
||||
properties.advisoryUrl,
|
||||
'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
|
||||
);
|
||||
assert.equal(properties.staleCacheFetchedAt, undefined);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep162026' });
|
||||
properties = await selectedProperties();
|
||||
assert.equal(
|
||||
properties.movementDirectionDegrees,
|
||||
'0',
|
||||
'a due-north heading is a value, not a missing one',
|
||||
);
|
||||
assert.equal(properties.movementSpeedKt, '10');
|
||||
assert.deepEqual(events, [
|
||||
['selected', 'cyclone:ep152026'],
|
||||
['selected', 'cyclone:ep162026'],
|
||||
]);
|
||||
assert.deepEqual([...store.entities.keys()], ['cyclone:ep162026']);
|
||||
|
||||
next = snapshot([
|
||||
{ ...storm(), movement: { directionDegrees: 315, speedKt: 12 } },
|
||||
{ ...storm('ep162026'), movement: { directionDegrees: 0, speedKt: 0 } },
|
||||
]);
|
||||
await h.layer.update();
|
||||
assert.equal(events.length, 2, 'refresh must not announce selection');
|
||||
properties = await selectedProperties();
|
||||
assert.equal(
|
||||
properties.movementSpeedKt,
|
||||
'0',
|
||||
'stationary is a value, refreshed into the selected record',
|
||||
);
|
||||
|
||||
h.layer.setParams({ clear: true });
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'deliberate',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep162026' });
|
||||
next = snapshot([storm()]);
|
||||
await h.layer.update();
|
||||
assert.deepEqual(
|
||||
events.at(-1),
|
||||
['cleared', 'weather-cyclones', 'evicted'],
|
||||
'a vanished user-selected storm must clear instead of transferring context to the fallback row',
|
||||
);
|
||||
assert.equal(store.entities.size, 0);
|
||||
assert.equal(store.selectedEntityId, null);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
h.layer.disable();
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'deliberate',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('shared cyclone context is evicted when the source fails and marks a stale cache', async (t) => {
|
||||
await withSharedContext(
|
||||
t,
|
||||
async ({ events, selectedProperties, store, open }) => {
|
||||
let next = snapshot([{ ...storm(), classification: 'XX' }]);
|
||||
let fail = false;
|
||||
const h = open({
|
||||
getSnapshot: async () => {
|
||||
if (fail) throw new Error('Network unavailable');
|
||||
return next;
|
||||
},
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
let properties = await selectedProperties();
|
||||
assert.equal(properties.classification, 'XX');
|
||||
assert.equal(
|
||||
properties.classificationName,
|
||||
undefined,
|
||||
'an unknown code is not given a guessed meaning',
|
||||
);
|
||||
|
||||
next = { ...snapshot(), stale: true };
|
||||
await h.layer.update();
|
||||
properties = await selectedProperties();
|
||||
assert.equal(properties.staleCacheFetchedAt, time);
|
||||
assert.equal(events.length, 1, 'refresh must not announce selection');
|
||||
|
||||
next = {
|
||||
...snapshot([]),
|
||||
unavailable: true,
|
||||
reason: 'Cyclone data unavailable',
|
||||
};
|
||||
await h.layer.update();
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'evicted',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
|
||||
next = snapshot();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
assert.equal(store.selectedEntityId, 'cyclone:ep152026');
|
||||
fail = true;
|
||||
await h.layer.update();
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'evicted',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
assert.equal(store.selectedEntityId, null);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('cyclone clears leave another layer selection and its records alone', async (t) => {
|
||||
await withSharedContext(t, async ({ events, store, open }) => {
|
||||
const h = open({ getSnapshot: async () => snapshot() });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
const foreign = {};
|
||||
contextStore.registerEntityContext(foreign, {
|
||||
id: 'flight:abc123',
|
||||
layerId: 'flights',
|
||||
label: 'Foreign',
|
||||
});
|
||||
contextStore.selectEntityContext(foreign);
|
||||
const before = events.length;
|
||||
|
||||
h.layer.setParams({ clear: true });
|
||||
assert.equal(store.selectedEntityId, 'flight:abc123');
|
||||
assert.ok(store.entities.has('flight:abc123'));
|
||||
assert.equal(store.entities.has('cyclone:ep152026'), false);
|
||||
assert.equal(
|
||||
events.length,
|
||||
before,
|
||||
'no clear is reported for a foreign selection',
|
||||
);
|
||||
|
||||
// A cyclone clear does not report a dangling foreign selection as evicted.
|
||||
store.entities.delete('flight:abc123');
|
||||
h.layer.disable();
|
||||
assert.equal(store.selectedEntityId, 'flight:abc123');
|
||||
assert.equal(events.length, before);
|
||||
});
|
||||
});
|
||||
|
||||
test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
|
||||
let next = snapshot([storm(), storm('ep162026')]);
|
||||
const h = harness({ getSnapshot: async () => next });
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import {
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
HIDDEN_CONTACT_DR_INTERVAL_MS,
|
||||
HIDDEN_CONTACT_DR_TICKS,
|
||||
hiddenContactDrPhase,
|
||||
} from './policy.js';
|
||||
|
||||
// Contacts hidden behind the horizon re-reckon on one fleet tick per rotation
|
||||
// instead of every tick. Their positions still feed hidden-inclusive proximity
|
||||
// queries, so the rotation must reach every contact about once a second, and
|
||||
// it must spread them across ticks so no single tick carries the whole fleet.
|
||||
|
||||
const icao = (n) => n.toString(16).padStart(6, '0');
|
||||
|
||||
test('the rotation revisits each hidden contact about once a second', () => {
|
||||
const periodMs = HIDDEN_CONTACT_DR_TICKS * FLEET_DR_INTERVAL_MS;
|
||||
assert.ok(HIDDEN_CONTACT_DR_TICKS > 1);
|
||||
assert.ok(
|
||||
Math.abs(periodMs - HIDDEN_CONTACT_DR_INTERVAL_MS) <= FLEET_DR_INTERVAL_MS,
|
||||
);
|
||||
});
|
||||
|
||||
test('a contact keeps the same slot, inside the rotation', () => {
|
||||
for (let n = 0; n < 500; n++) {
|
||||
const phase = hiddenContactDrPhase(icao(n * 7919));
|
||||
assert.ok(Number.isInteger(phase));
|
||||
assert.ok(phase >= 0 && phase < HIDDEN_CONTACT_DR_TICKS);
|
||||
assert.equal(hiddenContactDrPhase(icao(n * 7919)), phase);
|
||||
}
|
||||
});
|
||||
|
||||
test('a worldwide fleet is spread evenly enough that no tick carries a burst', () => {
|
||||
const fleet = 13000;
|
||||
const perSlot = new Array(HIDDEN_CONTACT_DR_TICKS).fill(0);
|
||||
// Real ICAO24 addresses cluster by national block; sample several blocks.
|
||||
for (let n = 0; n < fleet; n++)
|
||||
perSlot[hiddenContactDrPhase(icao(0x0a0000 + n * 53))]++;
|
||||
const fair = fleet / HIDDEN_CONTACT_DR_TICKS;
|
||||
for (const count of perSlot) assert.ok(count < fair * 1.5, `${count} > 1.5×`);
|
||||
});
|
||||
|
||||
/** Real fleet tick and horizon culling, with motion and presentation injected. */
|
||||
function fleetFixture() {
|
||||
let now = 1000;
|
||||
let reckonings = 0;
|
||||
const services = {
|
||||
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
|
||||
militaryRegistry: { isMilitaryIcao: () => false },
|
||||
aircraftPresentation: { isTr3b: () => false },
|
||||
camera: {},
|
||||
focus: {
|
||||
focusNowMs: () => now,
|
||||
getFocusTarget: () => null,
|
||||
nearFarScalarValueAtDistance: () => 1,
|
||||
advanceProjectedSpriteFocus: () => ({ factor: 1 }),
|
||||
},
|
||||
recession: {
|
||||
applyAircraftBillboardTreatment: () => ({
|
||||
factors: { scale: 1 },
|
||||
alpha: 1,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const flightState = createFlightState({ services });
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100),
|
||||
positionCartographic: { height: 100 },
|
||||
heading: 0,
|
||||
pitch: 0,
|
||||
roll: 0,
|
||||
frustum: {
|
||||
computeCullingVolume: () => ({
|
||||
computeVisibility: () => Cesium.Intersect.INSIDE,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const id = 'abc123';
|
||||
// 111 km away at ground level is beyond a 100 m camera's horizon, but
|
||||
// inside the model keep radius. No WebGL scene is needed for the culler.
|
||||
const billboard = {
|
||||
show: false,
|
||||
position: Cesium.Cartesian3.fromDegrees(1, 0, 0),
|
||||
};
|
||||
flightState._viewer = { camera, scene: {} };
|
||||
flightState._billboardCollection = { show: true };
|
||||
flightState._billboards.set(id, billboard);
|
||||
flightState.records.data.set(id, {});
|
||||
flightState._models3dEnabled = false;
|
||||
// Far cockpit contacts avoid glyph rasterization, keeping the fixture DOM-free.
|
||||
flightState._cockpitContactMode = true;
|
||||
const parts = {
|
||||
tracking: { _refreshCockpitNearContacts() {} },
|
||||
motion: {
|
||||
_deadReckon() {
|
||||
reckonings++;
|
||||
return billboard.position;
|
||||
},
|
||||
_floorGroundedDisplayPosition(id, info, position) {
|
||||
return position;
|
||||
},
|
||||
},
|
||||
};
|
||||
const rendering = createRendering({ flightState, services, parts });
|
||||
return {
|
||||
flightState,
|
||||
camera,
|
||||
billboard,
|
||||
id,
|
||||
reckonings: () => reckonings,
|
||||
tick() {
|
||||
now += FLEET_DR_INTERVAL_MS;
|
||||
rendering._fleetTick();
|
||||
},
|
||||
offTurn() {
|
||||
flightState._fleetTickSeq =
|
||||
(hiddenContactDrPhase(id) + 1) % HIDDEN_CONTACT_DR_TICKS;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('the fleet tick dead-reckons a hidden contact once per complete rotation', () => {
|
||||
const f = fleetFixture();
|
||||
for (let i = 0; i < HIDDEN_CONTACT_DR_TICKS; i++) f.tick();
|
||||
assert.equal(f.reckonings(), 1);
|
||||
assert.equal(f.billboard.show, false);
|
||||
});
|
||||
|
||||
test('a camera reveal bypasses the hidden rotation on the same fleet tick', () => {
|
||||
const f = fleetFixture();
|
||||
f.offTurn();
|
||||
f.tick();
|
||||
assert.equal(f.reckonings(), 0);
|
||||
f.camera.positionWC = Cesium.Cartesian3.fromDegrees(1, 0, 100);
|
||||
f.offTurn();
|
||||
f.tick();
|
||||
assert.equal(f.reckonings(), 1);
|
||||
assert.equal(f.billboard.show, true);
|
||||
});
|
||||
|
||||
test('a hidden contact with a retained fleet model is never rotation-skipped', () => {
|
||||
const f = fleetFixture();
|
||||
f.flightState._models3dEnabled = true;
|
||||
const model = { ready: true, show: true };
|
||||
f.flightState._models.set(f.id, model);
|
||||
f.offTurn();
|
||||
f.tick();
|
||||
assert.equal(f.reckonings(), 1);
|
||||
assert.equal(
|
||||
f.flightState._models.get(f.id),
|
||||
model,
|
||||
'model remains in keep range',
|
||||
);
|
||||
assert.equal(
|
||||
model.show,
|
||||
false,
|
||||
'the full pass hides its model beyond the horizon',
|
||||
);
|
||||
});
|
||||
@@ -198,6 +198,37 @@ export const FLEET_DR_INTERVAL_MS = 80;
|
||||
|
||||
export const ROTATION_REFRESH_MS = 1000;
|
||||
|
||||
/**
|
||||
* @constant {number} Dead-reckoning cadence (ms) for contacts hidden behind the
|
||||
* horizon. The worldwide feed carries ~13k contacts and a zoomed-in view shows
|
||||
* well under 1% of them; the rest still need positions for hidden-inclusive
|
||||
* proximity queries, but ~1 s old (≈250 m for an airliner) is well inside the
|
||||
* 30 s poll's own uncertainty. Each hidden contact is re-reckoned on one tick in
|
||||
* every HIDDEN_CONTACT_DR_TICKS, staggered by id so no tick carries them all.
|
||||
*/
|
||||
|
||||
export const HIDDEN_CONTACT_DR_INTERVAL_MS = 1000;
|
||||
|
||||
export const HIDDEN_CONTACT_DR_TICKS = Math.max(
|
||||
1,
|
||||
Math.round(HIDDEN_CONTACT_DR_INTERVAL_MS / FLEET_DR_INTERVAL_MS),
|
||||
);
|
||||
|
||||
/**
|
||||
* Stable slot in [0, HIDDEN_CONTACT_DR_TICKS) for a contact id, so each hidden
|
||||
* contact re-reckons on the same tick of every rotation.
|
||||
* @param {string} id Contact id (ICAO24).
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
export function hiddenContactDrPhase(id) {
|
||||
const text = String(id);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++)
|
||||
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
|
||||
return hash % HIDDEN_CONTACT_DR_TICKS;
|
||||
}
|
||||
|
||||
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
|
||||
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
ROTATION_REFRESH_MS,
|
||||
COURSE_MAX_DPS,
|
||||
HIDDEN_CONTACT_DR_TICKS,
|
||||
hiddenContactDrPhase,
|
||||
} from './policy.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||
|
||||
@@ -839,6 +841,12 @@ export function createRendering({
|
||||
}
|
||||
}
|
||||
|
||||
/** The contact's hidden-rotation slot, cached on its billboard. */
|
||||
function _hiddenDrPhase(bb, icao24) {
|
||||
bb._gevHiddenDrPhase ??= hiddenContactDrPhase(icao24);
|
||||
return bb._gevHiddenDrPhase;
|
||||
}
|
||||
|
||||
function _fleetTick() {
|
||||
if (
|
||||
!flightState._viewer ||
|
||||
@@ -965,9 +973,25 @@ export function createRendering({
|
||||
for (const icao of toRelease) _releaseModel(icao);
|
||||
}
|
||||
|
||||
const hiddenDrPhase = flightState._fleetTickSeq++ % HIDDEN_CONTACT_DR_TICKS;
|
||||
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
|
||||
|
||||
// A contact still behind the horizon at its last position would only be
|
||||
// re-reckoned to be hidden again, so off its turn it keeps that position
|
||||
// (see HIDDEN_CONTACT_DR_TICKS). Anything the stale position puts in view,
|
||||
// and any contact with a fleet model, takes the full pass below at once.
|
||||
if (
|
||||
!bb.show &&
|
||||
_hiddenDrPhase(bb, icao24) !== hiddenDrPhase &&
|
||||
!flightState._models.has(icao24) &&
|
||||
!occluder.isPointVisible(
|
||||
flightState._cullPositions.get(icao24) || bb.position,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
|
||||
const info = flightState.records.data.get(icao24);
|
||||
|
||||
const dr = parts.motion._deadReckon(
|
||||
|
||||
@@ -204,6 +204,10 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._lastFleetTickMs = 0;
|
||||
|
||||
/** @type {number} Fleet ticks run; selects which hidden contacts re-reckon */
|
||||
|
||||
flightState._fleetTickSeq = 0;
|
||||
|
||||
/** @type {string} Camera pose signature at the last rotation pass */
|
||||
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
+190
-57
@@ -16,7 +16,15 @@ import {
|
||||
} from '../../i18n/index.js';
|
||||
import { createHorizonCull } from '../streetLevel/view.js';
|
||||
import { readGhostMapsKml } from './kml.js';
|
||||
import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js';
|
||||
import {
|
||||
fieldLabel,
|
||||
GHOSTMAPS_AGE_OPTIONS,
|
||||
GHOSTMAPS_DEFAULT_AGE_DAYS,
|
||||
ghostMapsEventDate,
|
||||
isGhostMapsEventExpired,
|
||||
parseDescriptionFields,
|
||||
wrapCardText,
|
||||
} from './records.js';
|
||||
export * from './catalog.js';
|
||||
export * from './records.js';
|
||||
export { readGhostMapsKml } from './kml.js';
|
||||
@@ -26,6 +34,7 @@ export const GHOSTMAPS_LAYER_ID = 's2-ghostmaps';
|
||||
export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps';
|
||||
const PICK_PREFIX = 'ghostmaps:';
|
||||
const STORAGE_KEY = 'gev.ghostmaps.products';
|
||||
const AGE_STORAGE_KEY = 'gev.ghostmaps.ageDays';
|
||||
const ACCENT = '#c8ff00';
|
||||
const MAX_ICON_BYTES = 512 * 1024;
|
||||
const CARD_HOST_OPTIONS = Object.freeze({
|
||||
@@ -82,6 +91,19 @@ function writeStoredSelection(storage, selected) {
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredAge(storage) {
|
||||
try {
|
||||
const raw = storage?.getItem(AGE_STORAGE_KEY);
|
||||
if (raw !== null && raw !== undefined) {
|
||||
const value = JSON.parse(raw);
|
||||
if (GHOSTMAPS_AGE_OPTIONS.includes(value)) return value;
|
||||
}
|
||||
} catch {
|
||||
/* unavailable or invalid storage uses the default */
|
||||
}
|
||||
return GHOSTMAPS_DEFAULT_AGE_DAYS;
|
||||
}
|
||||
|
||||
/** A round marker for placemarks whose icon is missing or remote. */
|
||||
const markerCache = new Map();
|
||||
function markerImage(color) {
|
||||
@@ -137,6 +159,7 @@ export function createGhostMapsLayer({
|
||||
pointer = null,
|
||||
openExternal = null,
|
||||
storage = defaultStorage(),
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
if (typeof source?.getCatalog !== 'function')
|
||||
throw new TypeError('GhostMaps layer requires a catalog source');
|
||||
@@ -154,7 +177,9 @@ export function createGhostMapsLayer({
|
||||
let _selectedLink = null;
|
||||
let _lifetime = new AbortController();
|
||||
let _selection = readStoredSelection(storage);
|
||||
/** productId → {version, dataSource, urls, count, features} */
|
||||
let _ageDays = readStoredAge(storage);
|
||||
let _expiryTimer = null;
|
||||
/** productId → {version, dataSource, urls, features: Map<index, record>, pins} */
|
||||
const _loaded = new Map();
|
||||
/** productId → in-flight version */
|
||||
const _loading = new Map();
|
||||
@@ -183,17 +208,40 @@ export function createGhostMapsLayer({
|
||||
return (_catalog?.products || []).filter(wanted).map(({ id }) => id);
|
||||
}
|
||||
|
||||
function unload(id) {
|
||||
function unload(id, keepSelection = false) {
|
||||
const item = _loaded.get(id);
|
||||
if (!item) return;
|
||||
_loaded.delete(id);
|
||||
_viewer?.dataSources?.remove(item.dataSource, true);
|
||||
item.urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
if (_selectedKey?.startsWith(`${id}:`)) clearSelection();
|
||||
if (!keepSelection && _selectedKey?.startsWith(`${id}:`)) clearSelection();
|
||||
}
|
||||
|
||||
// Remove expired entities, rather than hiding them: horizon culling must
|
||||
// never make an old pin visible again. Map keys keep surviving picks stable.
|
||||
function expireEvents() {
|
||||
const at = now();
|
||||
let changed = false;
|
||||
for (const [productId, item] of _loaded) {
|
||||
for (const [index, feature] of item.features) {
|
||||
if (!isGhostMapsEventExpired(feature.dateMs, at, _ageDays)) continue;
|
||||
for (const entity of feature.entities)
|
||||
item.dataSource.entities.remove(entity);
|
||||
item.features.delete(index);
|
||||
if (_selectedKey === `${productId}:${index}`) clearSelection();
|
||||
changed = true;
|
||||
}
|
||||
item.pins = item.pins.filter((pin) => item.features.has(pin.index));
|
||||
}
|
||||
if (changed) {
|
||||
notifyRow();
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one product's data source off-scene, then swap it in. */
|
||||
async function load(product, signal) {
|
||||
const ageDays = _ageDays;
|
||||
const bytes = await source.getFile(product.id, product.version, { signal });
|
||||
signal.throwIfAborted();
|
||||
const entries = await readZipEntries(bytes);
|
||||
@@ -223,12 +271,16 @@ export function createGhostMapsLayer({
|
||||
const dataSource = new Cesium.CustomDataSource(
|
||||
`${GHOSTMAPS_LAYER_ID}:${product.id}`,
|
||||
);
|
||||
const features = [];
|
||||
const features = new Map();
|
||||
const pins = [];
|
||||
const at = now();
|
||||
dataSource.entities.suspendEvents();
|
||||
parsed.features.forEach((feature, index) => {
|
||||
const dateMs = ghostMapsEventDate(feature.description);
|
||||
if (isGhostMapsEventExpired(dateMs, at, ageDays)) return;
|
||||
const key = `${product.id}:${index}`;
|
||||
const { style } = feature;
|
||||
const entities = [];
|
||||
feature.geometries.forEach((geometry, part) => {
|
||||
const id = `${PICK_PREFIX}${key}:${part}`;
|
||||
if (geometry.type === 'point') {
|
||||
@@ -255,7 +307,9 @@ export function createGhostMapsLayer({
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
entities.push(entity);
|
||||
pins.push({
|
||||
index,
|
||||
position,
|
||||
get show() {
|
||||
return entity.show;
|
||||
@@ -265,61 +319,76 @@ export function createGhostMapsLayer({
|
||||
},
|
||||
});
|
||||
} else if (geometry.type === 'line') {
|
||||
dataSource.entities.add({
|
||||
id,
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||
geometry.positions.flat(),
|
||||
),
|
||||
width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)),
|
||||
material: cesiumColor(style.lineColor, color),
|
||||
clampToGround: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const ring = (points) =>
|
||||
Cesium.Cartesian3.fromDegreesArray(points.flat());
|
||||
dataSource.entities.add({
|
||||
id,
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
ring(geometry.outer),
|
||||
geometry.holes.map(
|
||||
(hole) => new Cesium.PolygonHierarchy(ring(hole)),
|
||||
),
|
||||
),
|
||||
material:
|
||||
style.fill === false
|
||||
? Cesium.Color.TRANSPARENT
|
||||
: cesiumColor(
|
||||
style.polyColor,
|
||||
color,
|
||||
style.polyColor ? undefined : 0.25,
|
||||
),
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
},
|
||||
});
|
||||
if (style.outline !== false)
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
id: `${id}:outline`,
|
||||
id,
|
||||
polyline: {
|
||||
positions: ring([...geometry.outer, geometry.outer[0]]),
|
||||
width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)),
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||
geometry.positions.flat(),
|
||||
),
|
||||
width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)),
|
||||
material: cesiumColor(style.lineColor, color),
|
||||
clampToGround: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const ring = (points) =>
|
||||
Cesium.Cartesian3.fromDegreesArray(points.flat());
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
id,
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
ring(geometry.outer),
|
||||
geometry.holes.map(
|
||||
(hole) => new Cesium.PolygonHierarchy(ring(hole)),
|
||||
),
|
||||
),
|
||||
material:
|
||||
style.fill === false
|
||||
? Cesium.Color.TRANSPARENT
|
||||
: cesiumColor(
|
||||
style.polyColor,
|
||||
color,
|
||||
style.polyColor ? undefined : 0.25,
|
||||
),
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (style.outline !== false)
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
id: `${id}:outline`,
|
||||
polyline: {
|
||||
positions: ring([...geometry.outer, geometry.outer[0]]),
|
||||
width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)),
|
||||
material: cesiumColor(style.lineColor, color),
|
||||
clampToGround: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
features.push({
|
||||
features.set(index, {
|
||||
name: feature.name,
|
||||
folder: feature.folder,
|
||||
description: feature.description,
|
||||
dateMs,
|
||||
entities,
|
||||
anchor: anchorOf(feature.geometries[0]),
|
||||
});
|
||||
});
|
||||
dataSource.entities.resumeEvents();
|
||||
return { version: product.version, dataSource, urls, features, pins };
|
||||
return {
|
||||
version: product.version,
|
||||
ageDays,
|
||||
dataSource,
|
||||
urls,
|
||||
features,
|
||||
pins,
|
||||
};
|
||||
} catch (error) {
|
||||
urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
throw error;
|
||||
@@ -330,6 +399,7 @@ export function createGhostMapsLayer({
|
||||
function sync() {
|
||||
const run = _queue.then(async () => {
|
||||
if (!_enabled || !_viewer || !_catalog) return;
|
||||
expireEvents();
|
||||
const signal = _lifetime.signal;
|
||||
const products = new Map(_catalog.products.map((p) => [p.id, p]));
|
||||
for (const id of [..._loaded.keys()]) {
|
||||
@@ -338,21 +408,33 @@ export function createGhostMapsLayer({
|
||||
}
|
||||
for (const product of _catalog.products) {
|
||||
if (!wanted(product) || signal.aborted) continue;
|
||||
if (_loaded.get(product.id)?.version === product.version) continue;
|
||||
const loaded = _loaded.get(product.id);
|
||||
if (loaded?.version === product.version && loaded.ageDays === _ageDays)
|
||||
continue;
|
||||
_loading.set(product.id, product.version);
|
||||
_failed.delete(product.id);
|
||||
notifyRow();
|
||||
try {
|
||||
const next = await load(product, signal);
|
||||
if (signal.aborted || !_enabled) {
|
||||
if (signal.aborted || !_enabled || next.ageDays !== _ageDays) {
|
||||
next.urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
return;
|
||||
}
|
||||
// Cull before the first frame so far-side pins never flash.
|
||||
_horizon.update(next.pins);
|
||||
await _viewer.dataSources.add(next.dataSource);
|
||||
// The new edition is on the globe before the old one leaves.
|
||||
unload(product.id);
|
||||
// The new edition is on the globe before the old one leaves. The card
|
||||
// stays only if its index still holds the same report: the server
|
||||
// may answer a pinned version with newer bytes.
|
||||
const index = Number(_selectedKey?.split(':')[1]);
|
||||
const before = loaded?.features.get(index);
|
||||
const after = next.features.get(index);
|
||||
unload(
|
||||
product.id,
|
||||
Boolean(before && after) &&
|
||||
before.name === after.name &&
|
||||
before.description === after.description,
|
||||
);
|
||||
_loaded.set(product.id, next);
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
@@ -391,12 +473,38 @@ export function createGhostMapsLayer({
|
||||
void sync();
|
||||
}
|
||||
|
||||
function setAgeDays(ageDays) {
|
||||
if (!GHOSTMAPS_AGE_OPTIONS.includes(ageDays) || ageDays === _ageDays)
|
||||
return;
|
||||
_ageDays = ageDays;
|
||||
try {
|
||||
storage?.setItem(AGE_STORAGE_KEY, JSON.stringify(ageDays));
|
||||
} catch {
|
||||
/* private window or blocked storage: keep the session choice */
|
||||
}
|
||||
expireEvents();
|
||||
// Narrowing only removes markers. A product loaded with a narrower window
|
||||
// keeps its old ageDays, so sync() restores omitted placemarks from the
|
||||
// cached KMZ even though its upstream version has not changed.
|
||||
for (const [id, item] of _loaded)
|
||||
if (
|
||||
item.ageDays === null ||
|
||||
(ageDays !== null && item.ageDays >= ageDays)
|
||||
) {
|
||||
item.ageDays = ageDays;
|
||||
// A failed wider reload no longer matters; a stale edition retries.
|
||||
_failed.delete(id);
|
||||
}
|
||||
notifyRow();
|
||||
return sync();
|
||||
}
|
||||
|
||||
// ---- selection card ----------------------------------------------------
|
||||
function featureFor(key) {
|
||||
const [productId, index] = key.split(':');
|
||||
return {
|
||||
product: _catalog?.products.find((p) => p.id === productId) || null,
|
||||
feature: _loaded.get(productId)?.features[Number(index)] || null,
|
||||
feature: _loaded.get(productId)?.features.get(Number(index)) || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -490,7 +598,7 @@ export function createGhostMapsLayer({
|
||||
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
|
||||
return null;
|
||||
const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':');
|
||||
return _loaded.get(productId)?.features[Number(index)]
|
||||
return _loaded.get(productId)?.features.has(Number(index))
|
||||
? `${productId}:${index}`
|
||||
: null;
|
||||
}
|
||||
@@ -542,7 +650,7 @@ export function createGhostMapsLayer({
|
||||
|
||||
function featureCount() {
|
||||
let count = 0;
|
||||
for (const item of _loaded.values()) count += item.features.length;
|
||||
for (const item of _loaded.values()) count += item.features.size;
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -563,6 +671,8 @@ export function createGhostMapsLayer({
|
||||
enable() {
|
||||
_enabled = true;
|
||||
_lifetime = new AbortController();
|
||||
if (_expiryTimer === null)
|
||||
_expiryTimer = setInterval(expireEvents, 60_000);
|
||||
for (const item of _loaded.values()) item.dataSource.show = true;
|
||||
installClickHandler();
|
||||
if (!_unsubscribe && typeof source.subscribe === 'function')
|
||||
@@ -580,6 +690,8 @@ export function createGhostMapsLayer({
|
||||
|
||||
disable() {
|
||||
_enabled = false;
|
||||
clearInterval(_expiryTimer);
|
||||
_expiryTimer = null;
|
||||
_lifetime.abort();
|
||||
_unsubscribe?.();
|
||||
_unsubscribe = null;
|
||||
@@ -593,6 +705,7 @@ export function createGhostMapsLayer({
|
||||
|
||||
async update(_viewerArg, { signal } = {}) {
|
||||
if (!_enabled || signal?.aborted) return false;
|
||||
expireEvents();
|
||||
try {
|
||||
const catalog = await source.getCatalog({ signal });
|
||||
if (!_enabled) return false;
|
||||
@@ -637,6 +750,25 @@ export function createGhostMapsLayer({
|
||||
})
|
||||
: t('Connecting to the S2 GhostMaps repository…');
|
||||
return {
|
||||
segmented: {
|
||||
id: 'age',
|
||||
label: t('Event age'),
|
||||
options: GHOSTMAPS_AGE_OPTIONS.map((days) => ({
|
||||
id: `age-${days ?? 'all'}`,
|
||||
label:
|
||||
days === null
|
||||
? t('All', { context: 'event age filter' })
|
||||
: t('{days}d', { days: formatNumber(days) }),
|
||||
title:
|
||||
days === null
|
||||
? t('Show markers of every age')
|
||||
: t('Maximum event age: {days}d; undated markers are kept', {
|
||||
days: formatNumber(days),
|
||||
}),
|
||||
active: days === _ageDays,
|
||||
onClick: () => setAgeDays(days),
|
||||
})),
|
||||
},
|
||||
chips: products.map((product) => {
|
||||
const loaded = _loaded.get(product.id);
|
||||
const failed = _failed.get(product.id);
|
||||
@@ -651,11 +783,11 @@ export function createGhostMapsLayer({
|
||||
product.fileTitle,
|
||||
loaded
|
||||
? tn(
|
||||
loaded.features.length,
|
||||
loaded.features.size,
|
||||
'{count} placemark',
|
||||
'{count} placemarks',
|
||||
{
|
||||
count: formatNumber(loaded.features.length),
|
||||
count: formatNumber(loaded.features.size),
|
||||
},
|
||||
)
|
||||
: null,
|
||||
@@ -667,7 +799,7 @@ export function createGhostMapsLayer({
|
||||
};
|
||||
}),
|
||||
legend: [],
|
||||
info,
|
||||
info: `${_ageDays === null ? t('All markers') : t('Event age: {days}d · undated kept', { days: formatNumber(_ageDays) })} · ${info}`,
|
||||
infoTitle: t(
|
||||
'S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.',
|
||||
),
|
||||
@@ -677,6 +809,7 @@ export function createGhostMapsLayer({
|
||||
getStats() {
|
||||
return {
|
||||
count: featureCount(),
|
||||
ageDays: _ageDays,
|
||||
products: [..._loaded.keys()],
|
||||
commit: _catalog?.commit || null,
|
||||
lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null,
|
||||
@@ -693,7 +826,7 @@ export function createGhostMapsLayer({
|
||||
if (!_enabled) return [];
|
||||
const out = [];
|
||||
for (const [productId, item] of _loaded) {
|
||||
for (const feature of item.features) {
|
||||
for (const feature of item.features.values()) {
|
||||
if (out.length >= maxCount) return out;
|
||||
out.push({
|
||||
product: productId,
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createGhostMapsLayer } from './index.js';
|
||||
import { GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP } from './catalog.js';
|
||||
|
||||
function element(localName, children = [], value = '', attributes = {}) {
|
||||
const node = {
|
||||
@@ -22,7 +23,7 @@ function element(localName, children = [], value = '', attributes = {}) {
|
||||
return node;
|
||||
}
|
||||
|
||||
function documentFixture() {
|
||||
function documentFixture(reports = [{ name: 'Test report' }]) {
|
||||
return element('document', [
|
||||
element('kml', [
|
||||
element('Document', [
|
||||
@@ -36,11 +37,14 @@ function documentFixture() {
|
||||
'',
|
||||
{ id: 'pin' },
|
||||
),
|
||||
element('Placemark', [
|
||||
element('name', [], 'Test report'),
|
||||
element('styleUrl', [], '#pin'),
|
||||
element('Point', [element('coordinates', [], '12,34')]),
|
||||
]),
|
||||
...reports.map(({ name, description = '', geometry }) =>
|
||||
element('Placemark', [
|
||||
element('name', [], name),
|
||||
element('description', [], description),
|
||||
element('styleUrl', [], '#pin'),
|
||||
geometry || element('Point', [element('coordinates', [], '12,34')]),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
@@ -83,11 +87,21 @@ function fixtureKmz() {
|
||||
return new Uint8Array(Buffer.concat([...local, directory, end]));
|
||||
}
|
||||
|
||||
function fixture(t, { beforeFileReturns, onCreateUrl } = {}) {
|
||||
function fixture(
|
||||
t,
|
||||
{
|
||||
beforeFileReturns,
|
||||
onCreateUrl,
|
||||
reports,
|
||||
now,
|
||||
products,
|
||||
controls = {},
|
||||
} = {},
|
||||
) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser');
|
||||
globalThis.DOMParser = class {
|
||||
parseFromString() {
|
||||
return documentFixture();
|
||||
return documentFixture(reports);
|
||||
}
|
||||
};
|
||||
t.after(() => {
|
||||
@@ -119,9 +133,13 @@ function fixture(t, { beforeFileReturns, onCreateUrl } = {}) {
|
||||
};
|
||||
const layer = createGhostMapsLayer({
|
||||
storage: null,
|
||||
now,
|
||||
...controls,
|
||||
source: {
|
||||
getCatalog: async () => ({
|
||||
products: [{ id: 'cip', version: 'a'.repeat(40), defaultOn: true }],
|
||||
products: products ?? [
|
||||
{ id: 'cip', version: 'a'.repeat(40), defaultOn: true },
|
||||
],
|
||||
}),
|
||||
getFile: async () => {
|
||||
beforeFileReturns?.(layer);
|
||||
@@ -178,3 +196,416 @@ test('successful load keeps icons until the product is disabled', async (t) => {
|
||||
assert.deepEqual(revoked, created);
|
||||
assert.equal(mounted.size, 0);
|
||||
});
|
||||
|
||||
const dated = (name, value, label = 'Date') => ({
|
||||
name,
|
||||
description: `<table><tr><td>${label}</td><td>${value}</td></tr></table>`,
|
||||
});
|
||||
|
||||
const ageChip = (layer, days) =>
|
||||
layer
|
||||
.getRowControls()
|
||||
.segmented.options.find(({ id }) => id === `age-${days ?? 'all'}`);
|
||||
|
||||
test('age controls default to seven days and widening restores filtered and expired reports', async (t) => {
|
||||
let at = Date.UTC(2026, 10, 3, 12);
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => at,
|
||||
reports: [
|
||||
dated('Old', '10/1/2026'),
|
||||
dated('Ten days', '10/24/2026'),
|
||||
dated('Recent', '11/2/2026'),
|
||||
{ name: 'Undated' },
|
||||
],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().ageDays, 7);
|
||||
assert.equal(ageChip(layer, 7).active, true);
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
await ageChip(layer, 14).onClick();
|
||||
assert.equal(layer.getStats().count, 3);
|
||||
await ageChip(layer, null).onClick();
|
||||
assert.equal(layer.getStats().count, 4);
|
||||
at += 100 * 86400_000;
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 4, 'all markers never ages out');
|
||||
await ageChip(layer, 1).onClick();
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
assert.equal(layer.getAnalystRecords()[0].name, 'Undated');
|
||||
await ageChip(layer, null).onClick();
|
||||
assert.equal(layer.getStats().count, 4);
|
||||
assert.equal(mounted.size, 1, 'filter changes never duplicate data sources');
|
||||
const controls = layer.getRowControls();
|
||||
assert.equal(
|
||||
controls.segmented.options.filter(({ active }) => active).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
controls.chips.some(({ id }) => id.startsWith('age-')),
|
||||
false,
|
||||
'age options are one switch, not product chips',
|
||||
);
|
||||
assert.equal(ageChip(layer, null).active, true);
|
||||
});
|
||||
|
||||
test('age preferences persist independently of product choices and reject invalid storage', async (t) => {
|
||||
const values = new Map([['gev.ghostmaps.products', '["cip"]']]);
|
||||
const storage = {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
};
|
||||
await t.test('save All markers without changing products', async (t) => {
|
||||
const { layer, viewer } = fixture(t, { controls: { storage } });
|
||||
await layer.update(viewer);
|
||||
await ageChip(layer, null).onClick();
|
||||
assert.equal(values.get('gev.ghostmaps.ageDays'), 'null');
|
||||
assert.equal(values.get('gev.ghostmaps.products'), '["cip"]');
|
||||
});
|
||||
await t.test('restore All markers and save thirty days', async (t) => {
|
||||
const { layer } = fixture(t, { controls: { storage } });
|
||||
assert.equal(layer.getStats().ageDays, null);
|
||||
await ageChip(layer, 30).onClick();
|
||||
assert.equal(values.get('gev.ghostmaps.ageDays'), '30');
|
||||
});
|
||||
await t.test('restore thirty days', (t) => {
|
||||
const { layer } = fixture(t, { controls: { storage } });
|
||||
assert.equal(layer.getStats().ageDays, 30);
|
||||
});
|
||||
for (const value of ['garbage', '"7"', '-1', '0', '2', '{}']) {
|
||||
await t.test(`reject ${value}`, (t) => {
|
||||
values.set('gev.ghostmaps.ageDays', value);
|
||||
const { layer } = fixture(t, { controls: { storage } });
|
||||
assert.equal(layer.getStats().ageDays, 7);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('blocked storage still permits changing the age within the session', async (t) => {
|
||||
const storage = {
|
||||
getItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
setItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
const { layer, viewer } = fixture(t, { controls: { storage } });
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().ageDays, 7);
|
||||
await ageChip(layer, null).onClick();
|
||||
assert.equal(layer.getStats().ageDays, null);
|
||||
});
|
||||
|
||||
test('a filter changed during a pending load never installs the superseded filter', async (t) => {
|
||||
let changed = false;
|
||||
const { layer, viewer, mounted, revoked, created } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3),
|
||||
reports: [dated('Old', '10/1/2026')],
|
||||
beforeFileReturns: (layer) => {
|
||||
if (changed) return;
|
||||
changed = true;
|
||||
ageChip(layer, null).onClick();
|
||||
},
|
||||
});
|
||||
await layer.update(viewer);
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
assert.equal(layer.getStats().ageDays, null);
|
||||
assert.equal(mounted.size, 1);
|
||||
assert.equal(
|
||||
revoked.length,
|
||||
created.length - 1,
|
||||
'discarded load releases icons',
|
||||
);
|
||||
});
|
||||
|
||||
test('age filtering and All markers apply to every conflict-map product, including optional maps', async (t) => {
|
||||
for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) {
|
||||
await t.test(product.id, async (t) => {
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
products: [{ ...product, version: 'a'.repeat(40) }],
|
||||
reports: [
|
||||
dated(
|
||||
'Old event',
|
||||
'10/1/2026',
|
||||
product.id === 'cip' ? 'Date of Event' : 'Date',
|
||||
),
|
||||
dated('Recent event', '11/2/2026'),
|
||||
{ name: 'Undated reference feature' },
|
||||
],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
if (!product.defaultOn) {
|
||||
layer
|
||||
.getRowControls()
|
||||
.chips.find(({ id }) => id === product.id)
|
||||
.onClick();
|
||||
await layer.update(viewer);
|
||||
}
|
||||
assert.deepEqual(layer.getStats().products, [product.id]);
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
assert.deepEqual(
|
||||
layer.getAnalystRecords().map(({ name }) => name),
|
||||
['Recent event', 'Undated reference feature'],
|
||||
);
|
||||
await ageChip(layer, null).onClick();
|
||||
assert.equal(
|
||||
layer.getStats().count,
|
||||
3,
|
||||
"All markers restores this product's old events",
|
||||
);
|
||||
await ageChip(layer, 7).onClick();
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
assert.equal(mounted.size, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('default products filter old event dates while keeping recent, future and undated markers', async (t) => {
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
reports: [
|
||||
dated('Old event', '10/20/2026'),
|
||||
dated('Recent event', '11/2/2026'),
|
||||
dated('At cutoff', '10/27/2026 12:00:00 PM'),
|
||||
dated('Before cutoff', '10/27/2026 11:59:59 AM'),
|
||||
dated('Future event', '11/4/2026'),
|
||||
{ name: 'Undated site' },
|
||||
dated('Blank date', '<Null>'),
|
||||
dated('Invalid date', '2/30/2026'),
|
||||
dated('Only a report date', '10/1/2026', 'Date Of Report'),
|
||||
{
|
||||
name: 'Old event reported recently',
|
||||
description:
|
||||
'<table><tr><td>Date of Event</td><td>10/1/2026</td></tr><tr><td>Date Of Report</td><td>11/2/2026</td></tr></table>',
|
||||
},
|
||||
],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
assert.deepEqual(layer.getStats().products, ['cip']);
|
||||
assert.deepEqual(
|
||||
layer.getAnalystRecords().map((r) => r.name),
|
||||
[
|
||||
'Recent event',
|
||||
'At cutoff',
|
||||
'Future event',
|
||||
'Undated site',
|
||||
'Blank date',
|
||||
'Invalid date',
|
||||
'Only a report date',
|
||||
],
|
||||
);
|
||||
assert.equal(layer.getStats().count, 7);
|
||||
assert.equal([...mounted][0].entities.values.length, 7);
|
||||
// Filtering cannot shift pick ids onto a different report.
|
||||
assert.ok([...mounted][0].entities.getById('ghostmaps:cip:1:0'));
|
||||
assert.equal(
|
||||
[...mounted][0].entities.getById('ghostmaps:cip:0:0'),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('unchanged cached products age out on the timer, clear selected cards and keep surviving picks', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
let at = Date.UTC(2026, 10, 3, 12);
|
||||
let click;
|
||||
const cards = [];
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => at,
|
||||
reports: [
|
||||
dated('Expiring', '10/27/2026 12:00:00 PM'),
|
||||
dated('Surviving', '11/2/2026'),
|
||||
],
|
||||
controls: {
|
||||
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction: (fn) => {
|
||||
click = fn;
|
||||
},
|
||||
destroy() {},
|
||||
}),
|
||||
picking: { resolvePickId: (picked) => picked },
|
||||
},
|
||||
});
|
||||
await layer.update(viewer);
|
||||
const ds = [...mounted][0];
|
||||
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
|
||||
click({ position: { x: 0, y: 0 } });
|
||||
assert.equal(cards.at(-1).length, 1);
|
||||
at += 60_000;
|
||||
t.mock.timers.tick(60_000);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
assert.equal(ds.entities.values.length, 1);
|
||||
assert.equal(ds.entities.getById('ghostmaps:cip:0:0'), undefined);
|
||||
assert.ok(ds.entities.getById('ghostmaps:cip:1:0'));
|
||||
assert.deepEqual(cards.at(-1), []);
|
||||
await layer.update(viewer);
|
||||
assert.equal([...mounted][0], ds, 'same-version KMZ is not reloaded');
|
||||
viewer.scene.pick = () => 'ghostmaps:cip:1:0';
|
||||
click({ position: { x: 0, y: 0 } });
|
||||
assert.match(cards.at(-1)[0].title, /Surviving/);
|
||||
layer.disable();
|
||||
at += 14 * 86400_000;
|
||||
t.mock.timers.tick(60_000);
|
||||
assert.equal(mounted.size, 0);
|
||||
layer.enable();
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('expiry removes every geometry and polygon outline belonging to an old report', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
// 10/27 is a whole UTC day: at 7d it expires when 11/3 ends.
|
||||
let at = Date.UTC(2026, 10, 4) - 30_000;
|
||||
const polygon = element('Polygon', [
|
||||
element('outerBoundaryIs', [
|
||||
element('LinearRing', [
|
||||
element('coordinates', [], '12,34 13,34 13,35 12,34'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => at,
|
||||
reports: [
|
||||
{
|
||||
...dated('Multi-part', '10/27/2026'),
|
||||
geometry: element('MultiGeometry', [
|
||||
element('Point', [element('coordinates', [], '12,34')]),
|
||||
element('LineString', [element('coordinates', [], '12,34 13,35')]),
|
||||
polygon,
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
const ds = [...mounted][0];
|
||||
assert.equal(ds.entities.values.length, 4);
|
||||
at += 60_000;
|
||||
t.mock.timers.tick(60_000);
|
||||
assert.equal(ds.entities.values.length, 0);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('narrowing then returning to the previous window restores its reports without a duplicate source', async (t) => {
|
||||
const { layer, viewer, mounted } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
reports: [dated('Today', '11/3/2026'), dated('Three days', '10/31/2026')],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
await ageChip(layer, 1).onClick();
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
await ageChip(layer, 7).onClick();
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 2, '7d must show both reports again');
|
||||
assert.equal(mounted.size, 1);
|
||||
});
|
||||
|
||||
test('two quick clicks, 1d then 7d, restore the report', async (t) => {
|
||||
const at = Date.UTC(2026, 10, 3, 12);
|
||||
const { layer, viewer } = fixture(t, {
|
||||
now: () => at,
|
||||
reports: [dated('Today', '11/3/2026'), dated('Three days', '10/31/2026')],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
const a = ageChip(layer, 1).onClick();
|
||||
const b = ageChip(layer, 7).onClick();
|
||||
await a;
|
||||
await b;
|
||||
await layer.update(viewer);
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
});
|
||||
|
||||
test('narrowing the window keeps the open card of a surviving marker', async (t) => {
|
||||
let click;
|
||||
const cards = [];
|
||||
const { layer, viewer } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')],
|
||||
controls: {
|
||||
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction: (fn) => {
|
||||
click = fn;
|
||||
},
|
||||
destroy() {},
|
||||
}),
|
||||
picking: { resolvePickId: (picked) => picked },
|
||||
},
|
||||
});
|
||||
await layer.update(viewer);
|
||||
await ageChip(layer, 30).onClick();
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
|
||||
click({ position: { x: 0, y: 0 } });
|
||||
assert.equal(cards.at(-1).length, 1);
|
||||
await ageChip(layer, 3).onClick();
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
assert.equal(
|
||||
cards.at(-1).length,
|
||||
1,
|
||||
'card of the surviving marker stays open',
|
||||
);
|
||||
});
|
||||
|
||||
test('a report dated yesterday is shown by 1d one hour into today (UTC)', async (t) => {
|
||||
const { layer, viewer } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 6, 1),
|
||||
reports: [dated('Yesterday', '11/5/2026')],
|
||||
});
|
||||
await layer.update(viewer);
|
||||
await ageChip(layer, 1).onClick();
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
});
|
||||
|
||||
test('a widened reload that returns a different report at the selected index closes its card', async (t) => {
|
||||
let click;
|
||||
const cards = [];
|
||||
const reports = [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')];
|
||||
const { layer, viewer } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
reports,
|
||||
controls: {
|
||||
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction: (fn) => {
|
||||
click = fn;
|
||||
},
|
||||
destroy() {},
|
||||
}),
|
||||
picking: { resolvePickId: (picked) => picked },
|
||||
},
|
||||
});
|
||||
await layer.update(viewer);
|
||||
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
|
||||
click({ position: { x: 0, y: 0 } });
|
||||
assert.equal(cards.at(-1).length, 1);
|
||||
// The server answers the pinned version with a newer edition's bytes.
|
||||
reports[0] = dated('Replacement', '11/2/2026');
|
||||
await ageChip(layer, 30).onClick();
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
assert.equal(
|
||||
cards.at(-1).length,
|
||||
0,
|
||||
'a different report must not keep the old card',
|
||||
);
|
||||
});
|
||||
|
||||
test('narrowing to a window the loaded data covers clears a failed wider reload', async (t) => {
|
||||
let fail = false;
|
||||
const { layer, viewer } = fixture(t, {
|
||||
now: () => Date.UTC(2026, 10, 3, 12),
|
||||
reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')],
|
||||
beforeFileReturns: () => {
|
||||
if (fail) throw new Error('network down');
|
||||
},
|
||||
});
|
||||
await layer.update(viewer);
|
||||
fail = true;
|
||||
await ageChip(layer, 30).onClick();
|
||||
assert.ok(layer.getStats().error, 'the wider reload failed');
|
||||
await ageChip(layer, 3).onClick();
|
||||
assert.equal(layer.getStats().error, null);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
});
|
||||
+103
-11
@@ -1,4 +1,4 @@
|
||||
// Pure helpers for GhostMaps placemarks: description tables to plain fields,
|
||||
// Pure helpers for GhostMaps placemarks: report fields and event dates,
|
||||
// KML colors, and card text wrapping. Strings in, data out — description
|
||||
// markup is only ever read as text, never inserted into the page.
|
||||
|
||||
@@ -51,6 +51,106 @@ const MAX_FIELDS = 24;
|
||||
const MAX_VALUE = 600;
|
||||
const MAX_LINKS = 6;
|
||||
|
||||
// Use the same leaf rows for report cards and event dates, without the card's
|
||||
// field-count/value-length limits affecting the age filter.
|
||||
function* descriptionRows(source) {
|
||||
for (const row of source.matchAll(
|
||||
/<tr\b[^>]*>((?:(?!<tr\b)[\s\S])*?)<\/tr\s*>/gi,
|
||||
)) {
|
||||
const cells = [
|
||||
...row[1].matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]\s*>/gi),
|
||||
].map((cell) => cell[1]);
|
||||
if (cells.length === 2)
|
||||
yield {
|
||||
label: htmlToText(cells[0]),
|
||||
value: htmlToText(cells[1]),
|
||||
markup: cells[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const GHOSTMAPS_DEFAULT_AGE_DAYS = 7;
|
||||
export const GHOSTMAPS_AGE_OPTIONS = Object.freeze([1, 3, 7, 14, 30, null]);
|
||||
|
||||
/** ArcGIS US dates (optionally with a time) and ISO dates, interpreted in UTC
|
||||
* when no zone is supplied. A date without a time means the whole UTC day, so
|
||||
* it resolves to the day's last millisecond: "1d" still shows yesterday's
|
||||
* reports. Reject calendar rollovers instead of guessing. */
|
||||
export function parseGhostMapsEventDate(value) {
|
||||
const input = String(value ?? '').trim();
|
||||
const us =
|
||||
/^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM))?$/i.exec(
|
||||
input,
|
||||
);
|
||||
const iso =
|
||||
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})?)?$/.exec(
|
||||
input,
|
||||
);
|
||||
if (!us && !iso) return null;
|
||||
const year = Number(us ? us[3] : iso[1]);
|
||||
const month = Number(us ? us[1] : iso[2]);
|
||||
const day = Number(us ? us[2] : iso[3]);
|
||||
let hour = Number(us?.[4] ?? iso?.[4] ?? 0);
|
||||
const minute = Number(us?.[5] ?? iso?.[5] ?? 0);
|
||||
const second = Number(us?.[6] ?? iso?.[6] ?? 0);
|
||||
if (us?.[7]) {
|
||||
if (hour < 1 || hour > 12) return null;
|
||||
hour = (hour % 12) + (us[7].toUpperCase() === 'PM' ? 12 : 0);
|
||||
}
|
||||
const at = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||
const date = new Date(at);
|
||||
if (
|
||||
year < 1900 ||
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
date.getUTCDate() !== day ||
|
||||
hour > 23 ||
|
||||
minute > 59 ||
|
||||
second > 59
|
||||
)
|
||||
return null;
|
||||
if (iso?.[4]) {
|
||||
const zoned = /(?:Z|[+-]\d{2}:\d{2})$/.test(input) ? input : `${input}Z`;
|
||||
const parsed = Date.parse(zoned);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return us?.[4] ? at : at + 86400_000 - 1;
|
||||
}
|
||||
|
||||
/** The event's date, never its report/upload date or dates mentioned in prose. */
|
||||
export function ghostMapsEventDate(description) {
|
||||
const rows = [
|
||||
...descriptionRows(
|
||||
String(description ?? '').replace(
|
||||
/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi,
|
||||
' ',
|
||||
),
|
||||
),
|
||||
];
|
||||
for (const name of ['dateofevent', 'eventdate', 'date']) {
|
||||
for (const { label, value } of rows) {
|
||||
if (label.toLowerCase().replace(/[\s_]+/g, '') !== name) continue;
|
||||
const at = parseGhostMapsEventDate(value);
|
||||
if (at !== null) return at;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Unknown and future dates stay visible; only a confirmed old event expires. */
|
||||
export function isGhostMapsEventExpired(
|
||||
dateMs,
|
||||
now = Date.now(),
|
||||
ageDays = GHOSTMAPS_DEFAULT_AGE_DAYS,
|
||||
) {
|
||||
return (
|
||||
ageDays !== null &&
|
||||
Number.isFinite(dateMs) &&
|
||||
dateMs < now - ageDays * 86400_000
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields and links from a placemark description. ArcGIS "popup" exports are a
|
||||
* two-column table (label, value); anything else becomes one unlabelled field.
|
||||
@@ -73,18 +173,10 @@ export function parseDescriptionFields(html) {
|
||||
};
|
||||
// Innermost rows only: ArcGIS nests the field table inside an outer row,
|
||||
// and only leaf rows with exactly two cells are label/value pairs.
|
||||
for (const row of source.matchAll(
|
||||
/<tr\b[^>]*>((?:(?!<tr\b)[\s\S])*?)<\/tr\s*>/gi,
|
||||
)) {
|
||||
const cells = [
|
||||
...row[1].matchAll(/<t[dh]\b[^>]*>([\s\S]*?)<\/t[dh]\s*>/gi),
|
||||
].map((cell) => cell[1]);
|
||||
if (cells.length !== 2) continue;
|
||||
const label = htmlToText(cells[0]);
|
||||
const value = htmlToText(cells[1]);
|
||||
for (const { label, value, markup } of descriptionRows(source)) {
|
||||
if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue;
|
||||
if (EMPTY_VALUES.has(value.toLowerCase())) continue;
|
||||
addLinks(cells[1]);
|
||||
addLinks(markup);
|
||||
// A bare research link is carried by `links`; the card does not need
|
||||
// the URL twice.
|
||||
if (/^https?:\/\/\S+$/i.test(value)) continue;
|
||||
|
||||
@@ -7,8 +7,83 @@ import {
|
||||
kmlColor,
|
||||
parseDescriptionFields,
|
||||
wrapCardText,
|
||||
ghostMapsEventDate,
|
||||
isGhostMapsEventExpired,
|
||||
parseGhostMapsEventDate,
|
||||
} from './records.js';
|
||||
|
||||
test('event dates parse ArcGIS calendar dates and timestamps consistently in UTC', () => {
|
||||
for (const [value, iso] of [
|
||||
['10/28/2026', '2026-10-28T23:59:59.999Z'],
|
||||
['10/28/2026 6:12:00 PM', '2026-10-28T18:12:00Z'],
|
||||
['1/17/2026 9:46:41 AM', '2026-01-17T09:46:41Z'],
|
||||
['10/28/2026 12:00 AM', '2026-10-28T00:00:00Z'],
|
||||
['10/28/2026 12:00 PM', '2026-10-28T12:00:00Z'],
|
||||
['2026-10-28', '2026-10-28T23:59:59.999Z'],
|
||||
['2026-10-28T12:34:56', '2026-10-28T12:34:56Z'],
|
||||
['2026-10-28T12:34:56.123-04:00', '2026-10-28T16:34:56.123Z'],
|
||||
['2/29/2024', '2024-02-29T23:59:59.999Z'],
|
||||
])
|
||||
assert.equal(parseGhostMapsEventDate(value), Date.parse(iso), value);
|
||||
for (const value of [
|
||||
'',
|
||||
'<Null>',
|
||||
'N/A',
|
||||
'unknown',
|
||||
'2/29/2026',
|
||||
'9/31/2026',
|
||||
'2026-02-30',
|
||||
'13/1/2026',
|
||||
'1/0/2026',
|
||||
'10/28/2026 13:00 PM',
|
||||
'2026-10-28T24:00:00Z',
|
||||
'2026-10-28T12:60:00Z',
|
||||
]) {
|
||||
assert.equal(parseGhostMapsEventDate(value), null, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('event date fields take precedence over report dates and prose, even after long report tables', () => {
|
||||
const row = (label, value) => `<tr><td>${label}</td><td>${value}</td></tr>`;
|
||||
const html = `<table><tr><td><table>${
|
||||
row('Date Of Report', '11/2/2026') +
|
||||
row('Description', 'Incident mentioned on 11/1/2026') +
|
||||
Array.from({ length: 30 }, (_, i) => row(`Field${i}`, 'value')).join('') +
|
||||
row('Date', '10/30/2026') +
|
||||
row('Date of Event', '10/28/2026')
|
||||
}</table></td></tr></table>`;
|
||||
assert.equal(ghostMapsEventDate(html), Date.UTC(2026, 9, 29) - 1);
|
||||
assert.equal(
|
||||
ghostMapsEventDate(
|
||||
`<table>${row('Date', '<Null>')}${row('Date Of Report', '11/2/2026')}</table>`,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
ghostMapsEventDate(`<table>${row('Date_Of_Event', '10/28/2026')}</table>`),
|
||||
Date.UTC(2026, 9, 29) - 1,
|
||||
);
|
||||
assert.equal(ghostMapsEventDate('Occurred 10/28/2026'), null);
|
||||
assert.equal(
|
||||
ghostMapsEventDate(
|
||||
`<script><table>${row('Date', '1/1/2020')}</table></script>`,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('seven-day expiry keeps the exact boundary, future dates and unknown dates', () => {
|
||||
const at = Date.UTC(2026, 10, 3, 12);
|
||||
assert.equal(isGhostMapsEventExpired(Date.UTC(2026, 9, 27, 12), at), false);
|
||||
assert.equal(
|
||||
isGhostMapsEventExpired(Date.UTC(2026, 9, 27, 12) - 1, at),
|
||||
true,
|
||||
);
|
||||
assert.equal(isGhostMapsEventExpired(Date.UTC(2026, 10, 4), at), false);
|
||||
for (const value of [null, undefined, NaN])
|
||||
assert.equal(isGhostMapsEventExpired(value, at), false);
|
||||
});
|
||||
|
||||
// The shape ArcGIS Pro writes into every S2 placemark description.
|
||||
const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
|
||||
<META http-equiv="Content-Type" content="text/html"></head>
|
||||
@@ -27,6 +102,14 @@ const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
|
||||
function changeImage(a, b) { document.getElementById('x').innerHTML = b; }
|
||||
</script></html>`;
|
||||
|
||||
test('custom ages change the cutoff and all markers disables expiry', () => {
|
||||
const at = Date.UTC(2026, 10, 3, 12);
|
||||
const event = at - 10 * 86400_000;
|
||||
assert.equal(isGhostMapsEventExpired(event, at, 7), true);
|
||||
assert.equal(isGhostMapsEventExpired(event, at, 14), false);
|
||||
assert.equal(isGhostMapsEventExpired(event, at, null), false);
|
||||
});
|
||||
|
||||
test('ArcGIS popup tables become plain label/value fields and http links', () => {
|
||||
const { fields, links } = parseDescriptionFields(ARCGIS);
|
||||
assert.deepEqual(fields, [
|
||||
|
||||
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;
|
||||
|
||||
@@ -126,8 +126,17 @@ export function createStreetLevelLayer({
|
||||
});
|
||||
}
|
||||
|
||||
// One source at a time: the first provider that defaults on.
|
||||
const startsOn = defs.find((def) => def.defaultOn !== false);
|
||||
/** `def` if its key is ready, else the first keyless provider not refused. */
|
||||
function keylessInstead(def, refused = () => false) {
|
||||
if (def.keyReady?.() !== false) return def;
|
||||
return defs.find((d) => !d.requiresKeyId && !refused(d.id)) || def;
|
||||
}
|
||||
|
||||
// One source at a time: the first provider that defaults on. Without its
|
||||
// key, the first keyless source instead, so a fresh keyless install opens
|
||||
// on coverage rather than KEY REQUIRED.
|
||||
const preferred = defs.find((def) => def.defaultOn !== false);
|
||||
const startsOn = preferred && keylessInstead(preferred);
|
||||
for (const def of defs) {
|
||||
const entry = {
|
||||
def,
|
||||
@@ -585,7 +594,17 @@ export function createStreetLevelLayer({
|
||||
providerIds: state.providers.keys(),
|
||||
filter: state.filter,
|
||||
});
|
||||
const lit = litByParams(decoded.providers);
|
||||
// A restored "Mapillary on" (the codec default) would light a source a
|
||||
// keyless install cannot draw. Only redirect a provider the params switch
|
||||
// on: a filter-only update keeps whatever the user chose.
|
||||
const asked = litByParams(decoded.providers);
|
||||
const lit =
|
||||
asked && decoded.providers.get(asked) === true
|
||||
? keylessInstead(
|
||||
state.providers.get(asked).def,
|
||||
(id) => decoded.providers.get(id) === false,
|
||||
).id
|
||||
: asked;
|
||||
const before = litIds();
|
||||
for (const [id, entry] of state.providers)
|
||||
if (id !== lit) switchProvider(entry, false);
|
||||
|
||||
@@ -1127,3 +1127,59 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async (
|
||||
const view = presentStreetLevelPanel(layer.getUIState());
|
||||
assert.match(view.meta, /Google Street View needs the Google 3D map/);
|
||||
});
|
||||
|
||||
/** Mapillary (keyless unless `mapillary` says otherwise) plus KartaView. */
|
||||
function keylessLayer(t, mapillary = { hasToken: () => false }) {
|
||||
const layer = createStreetLevelLayer({
|
||||
providers: [
|
||||
createMapillaryProvider({
|
||||
source: fakeMapillarySource(mapillary),
|
||||
photoViewer: fakePhotoViewer(),
|
||||
}),
|
||||
createKartaViewProvider(),
|
||||
],
|
||||
});
|
||||
t.after(() => layer.destroy());
|
||||
return layer;
|
||||
}
|
||||
const litProviders = (layer) =>
|
||||
layer
|
||||
.getUIState()
|
||||
.providers.filter((provider) => provider.on)
|
||||
.map((provider) => provider.id);
|
||||
|
||||
test('without a Mapillary key the first keyless source is lit instead', (t) => {
|
||||
assert.deepEqual(litProviders(keylessLayer(t)), ['kartaview']);
|
||||
assert.deepEqual(litProviders(keylessLayer(t, {})), ['mapillary']);
|
||||
// A token-adding Graph proxy counts as a key.
|
||||
const proxied = keylessLayer(t, {
|
||||
hasToken: () => false,
|
||||
graphEndpoint: '/api/mapillary/graph',
|
||||
});
|
||||
assert.deepEqual(litProviders(proxied), ['mapillary']);
|
||||
});
|
||||
|
||||
test('restoring the default "Mapillary on" without its key keeps a keyless source', (t) => {
|
||||
const layer = keylessLayer(t);
|
||||
layer.setParams({ mapillary: true, providerSwitches: '' });
|
||||
assert.deepEqual(litProviders(layer), ['kartaview']);
|
||||
// A link that switches KartaView off keeps its word.
|
||||
layer.setParams({ mapillary: true, providerSwitches: 'kartaview-0' });
|
||||
assert.deepEqual(litProviders(layer), ['mapillary']);
|
||||
});
|
||||
|
||||
test('filter-only updates keep the lit source, even a chosen keyless Mapillary', (t) => {
|
||||
const layer = keylessLayer(t);
|
||||
layer.setParams({ pano: 'pano' });
|
||||
assert.deepEqual(litProviders(layer), ['kartaview']);
|
||||
// What the MAPILLARY chip sends, and what stored state restores.
|
||||
layer.setParams({ mapillary: true, providerSwitches: 'kartaview-0' });
|
||||
for (const params of [{ pano: 'pano' }, { sinceDays: 365 }, {}]) {
|
||||
layer.setParams(params);
|
||||
assert.deepEqual(
|
||||
litProviders(layer),
|
||||
['mapillary'],
|
||||
JSON.stringify(params),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -74,6 +74,9 @@ export function createMapillaryProvider({ source, photoViewer = null }) {
|
||||
name: MAPILLARY_NAME,
|
||||
label: MAPILLARY_LABEL,
|
||||
requiresKeyId: MAPILLARY_KEY_ID,
|
||||
/** A browser token, or a Graph proxy that adds one server-side. */
|
||||
keyReady: () =>
|
||||
source.hasToken?.() !== false || Boolean(source.graphEndpoint),
|
||||
pickPrefix: PICK_PREFIX.root,
|
||||
colors: COLORS,
|
||||
credit: Object.freeze({ html: MAPILLARY_CREDIT_HTML }),
|
||||
|
||||
@@ -20,6 +20,8 @@ export function filtersOf(provider) {
|
||||
* @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image.
|
||||
* @property {string} [requiresMapStack] Usable only on this map stack.
|
||||
* @property {boolean} [defaultOn] Default true; the first that defaults on is lit.
|
||||
* @property {() => boolean} [keyReady] False when its key is missing now; the
|
||||
* first keyless provider is lit instead of a default that cannot draw.
|
||||
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
|
||||
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
|
||||
* @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
|
||||
|
||||
@@ -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: [{}] },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1154,6 +1154,7 @@ export class StyleManager extends ShellFacade {
|
||||
_cctvSummary: this._cctvSummary,
|
||||
},
|
||||
cctv: cctvLayer,
|
||||
viewer: this.viewer,
|
||||
actions: {
|
||||
isEnabled: () => this._dataManager?.isEnabled('cctv'),
|
||||
setParams: (params, options) =>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useCctvStore } from './stores/cctv.js';
|
||||
import { useStatusStore } from './stores/status.js';
|
||||
import { applicationPinia } from './vueRuntime.js';
|
||||
import { onLocaleChange } from '../i18n/index.js';
|
||||
import { createQuebecCctvOverlay } from './quebecCctvOverlay.js';
|
||||
|
||||
/**
|
||||
* Own camera-panel interaction and presentation; receive the camera port and
|
||||
@@ -34,6 +35,7 @@ export class CctvControls {
|
||||
constructor({
|
||||
elements,
|
||||
cctv,
|
||||
viewer = null,
|
||||
actions,
|
||||
store = useCctvStore(applicationPinia()),
|
||||
statusStore = useStatusStore(applicationPinia()),
|
||||
@@ -42,11 +44,13 @@ export class CctvControls {
|
||||
this.store = store;
|
||||
this.statusStore = statusStore;
|
||||
this.cctv = cctv;
|
||||
this._quebecCctvOverlay = createQuebecCctvOverlay(viewer);
|
||||
this.actions = actions;
|
||||
this.destroyed = false;
|
||||
this.listeners = new AbortController();
|
||||
this._cctvUnsubscribe = null;
|
||||
this._cctvState = null;
|
||||
this._lastQuebecSelectionRevision = null;
|
||||
this._cctvSummaryTypingTimer = null;
|
||||
this._lastCctvSummaryText = '';
|
||||
this._lastSeenCctvActiveId = null;
|
||||
@@ -146,6 +150,7 @@ export class CctvControls {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this._offLocale();
|
||||
this._quebecCctvOverlay?.dispose();
|
||||
this._cctvVideoSurface?.stop();
|
||||
this._videoVisibilityObserver?.disconnect();
|
||||
this._actionGeneration++;
|
||||
|
||||
@@ -154,6 +154,20 @@ export function _renderCctvState(state) {
|
||||
const enabled = !!state?.enabled && !!this.actions.isEnabled();
|
||||
const activeId = state?.activeCameraId || '';
|
||||
const activeCamera = state?.activeCamera || null;
|
||||
const selectionRevision = Number(state?.activeCameraReselection);
|
||||
const reselected =
|
||||
this._lastQuebecSelectionRevision !== null &&
|
||||
Number.isFinite(selectionRevision) &&
|
||||
selectionRevision !== this._lastQuebecSelectionRevision;
|
||||
this._lastQuebecSelectionRevision = Number.isFinite(selectionRevision)
|
||||
? selectionRevision
|
||||
: null;
|
||||
const isQuebecCamera =
|
||||
enabled && activeCamera?.sourceKind?.startsWith('quebec-');
|
||||
this._quebecCctvOverlay?.setCamera(isQuebecCamera ? activeCamera : null, {
|
||||
reselected,
|
||||
});
|
||||
if (this._cctvFrameWrap) this._cctvFrameWrap.hidden = Boolean(isQuebecCamera);
|
||||
|
||||
// Auto-expand the panel when the active camera CHANGES to a new non-null
|
||||
// id while the layer is enabled. Covers click-on-globe, panel controls,
|
||||
@@ -241,7 +255,8 @@ export function _renderCctvState(state) {
|
||||
|
||||
this.store.publish(presentCctvPanel(state, enabled));
|
||||
|
||||
const liveIntent = enabled && !!activeCamera?.isVideo;
|
||||
const liveIntent =
|
||||
enabled && !!activeCamera?.isVideo && !activeCamera?.browserMediaUrl;
|
||||
this.store.setLive(liveIntent);
|
||||
if (this._cctvVideo) {
|
||||
const visible =
|
||||
@@ -261,8 +276,9 @@ export function _renderCctvState(state) {
|
||||
}
|
||||
|
||||
if (this._cctvFrame && !liveIntent) {
|
||||
const nextSrc = enabled ? activeCamera?.frameUrl : null;
|
||||
const nextCameraId = enabled ? activeCamera?.id || '' : '';
|
||||
const nextSrc = enabled && !isQuebecCamera ? activeCamera?.frameUrl : null;
|
||||
const nextCameraId =
|
||||
enabled && !isQuebecCamera ? activeCamera?.id || '' : '';
|
||||
const cameraChanged = this._frame.cameraId !== nextCameraId;
|
||||
const frameLoading = this._frame.loading;
|
||||
// A same-camera refresh waits for the current image to settle. Replacing
|
||||
|
||||
@@ -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
|
||||
|
||||
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user