mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge remote-tracking branch 'upstream/main' into fix/drone-airspace
# Conflicts: # src/i18n/locales/uk.json
This commit is contained in:
159 files changed
+18844
-1999
No files matched your search
@@ -47,6 +47,8 @@ export const prioritizeActiveCctvGeometryRecord =
|
||||
export const processGeometryBatch = layer.processGeometryBatch;
|
||||
export const hideCctvRecordVisuals = layer.hideCctvRecordVisuals;
|
||||
export const refreshCoverageStyles = layer.refreshCoverageStyles;
|
||||
export const refreshHorizonCulling = layer.refreshHorizonCulling;
|
||||
export const _viewRecordIdsForTest = layer._viewRecordIdsForTest;
|
||||
export const clearProbeClampOnDeactivation =
|
||||
layer.clearProbeClampOnDeactivation;
|
||||
export const cctvRecordNeedsActivation = layer.cctvRecordNeedsActivation;
|
||||
|
||||
@@ -1309,11 +1309,12 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
|
||||
assert.ok(renderer, '_renderCctvState is missing');
|
||||
assert.match(
|
||||
renderer[0],
|
||||
/else if \(!activeId\)[\s\S]*?selectedIndex = -1/,
|
||||
// Nothing active clears the pick; only the outside-view note may show.
|
||||
/else if \(!activeId\)[\s\S]*?selectedIndex =[^;]*\? 0 : -1;/,
|
||||
);
|
||||
assert.match(
|
||||
renderer[0],
|
||||
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*cameras\.length === 0\s*\|\|\s*!activeId/,
|
||||
/_cctvFocusBtn\.disabled\s*=\s*!enabled\s*\|\|\s*catalogCount === 0\s*\|\|\s*!activeId/,
|
||||
);
|
||||
} finally {
|
||||
cctvLayer.setParams({ autoHop: false });
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// src/data/cctvViewThinning.test.mjs — the CCTV view pass: only cameras on or
|
||||
// near the screen show, a zoomed-out view keeps one camera per world-anchored
|
||||
// cell, and the layer's UI state never rebuilds every camera for a listener
|
||||
// that does not read the list.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import cctvLayer, {
|
||||
_setCctvCoverageStateForTest,
|
||||
_viewRecordIdsForTest,
|
||||
refreshHorizonCulling,
|
||||
} from './cctv.js';
|
||||
|
||||
const WIDTH = 800;
|
||||
const HEIGHT = 600;
|
||||
|
||||
/**
|
||||
* Records at the given [lon, lat] degrees near the origin, each drawn at the
|
||||
* screen point `screenAt` gives it (default: the middle of the screen).
|
||||
*/
|
||||
function recordsAt(
|
||||
lonLats,
|
||||
screenAt = () => ({ x: WIDTH / 2, y: HEIGHT / 2 }),
|
||||
) {
|
||||
const screenByPosition = new Map();
|
||||
const records = lonLats.map(([lon, lat], index) => {
|
||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, 10);
|
||||
screenByPosition.set(position, screenAt(index));
|
||||
return {
|
||||
camera: {
|
||||
id: `cam-${index}`,
|
||||
name: `Camera ${index}`,
|
||||
city: 'Test',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: 0,
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
},
|
||||
position,
|
||||
billboard: { position, show: true },
|
||||
coverageEntities: [],
|
||||
projection: null,
|
||||
};
|
||||
});
|
||||
return { records, screenByPosition };
|
||||
}
|
||||
|
||||
function viewerAt(heightM, screenByPosition, shiftX = 0) {
|
||||
return {
|
||||
isDestroyed: () => false,
|
||||
camera: {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, heightM),
|
||||
positionCartographic: { latitude: 0, longitude: 0, height: heightM },
|
||||
frustum: { fovy: Math.PI / 3 },
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: WIDTH, clientHeight: HEIGHT },
|
||||
cartesianToCanvasCoordinates: (position) => {
|
||||
const screen = screenByPosition.get(position);
|
||||
return screen && { x: screen.x + shiftX, y: screen.y };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shownIds(records) {
|
||||
return records
|
||||
.filter((record) => record.billboard.show)
|
||||
.map((record) => record.camera.id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Three cameras within ~110 m, and one ~22 km away.
|
||||
const CLUSTER = [
|
||||
[0, 0],
|
||||
[0.001, 0],
|
||||
[0.0005, 0.0005],
|
||||
[0.2, 0],
|
||||
];
|
||||
|
||||
test('zoomed out, cameras sharing a ground cell show one icon', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
});
|
||||
assert.equal(
|
||||
refreshHorizonCulling(),
|
||||
true,
|
||||
'the first pass changes the view',
|
||||
);
|
||||
const shown = shownIds(records);
|
||||
assert.equal(shown.length, 2, 'one for the cluster, one for the far camera');
|
||||
assert.ok(shown.includes('cam-3'), 'a camera alone in its cell shows');
|
||||
assert.deepEqual(_viewRecordIdsForTest().sort(), shown);
|
||||
});
|
||||
|
||||
test('a pan keeps the same winners, and an unchanged view keeps its list', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER, (index) => ({
|
||||
x: 100 + index * 7,
|
||||
y: 300,
|
||||
}));
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
const before = shownIds(records);
|
||||
const list = _viewRecordIdsForTest();
|
||||
for (const shiftX of [3, 11, 20, 37]) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition, shiftX),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), before, `pan by ${shiftX} px`);
|
||||
}
|
||||
assert.equal(refreshHorizonCulling(), false, 'the same view changes nothing');
|
||||
assert.deepEqual(_viewRecordIdsForTest(), list);
|
||||
});
|
||||
|
||||
test('a small zoom within one cell size keeps the same winners', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
const shownAt = (heightM) => {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(heightM, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
return shownIds(records);
|
||||
};
|
||||
// Cells come in power-of-two metre steps: about 1.5 km and 1.6 km cells at
|
||||
// these heights fall in the same step, so the same cameras win.
|
||||
assert.deepEqual(shownAt(20_500), shownAt(20_000));
|
||||
});
|
||||
|
||||
test('a healthy camera wins its cell over an unknown one', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
|
||||
// Whichever camera the id order would pick, the healthy one must win.
|
||||
for (const id of ['cam-0', 'cam-1']) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
healthById: new Map([[id, { status: 'ok' }]]),
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), [id]);
|
||||
}
|
||||
});
|
||||
|
||||
test('the active camera always shows, even in a crowded cell', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 3));
|
||||
for (const active of records) {
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
activeCameraId: active.camera.id,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.ok(active.billboard.show, `${active.camera.id} shows while active`);
|
||||
}
|
||||
});
|
||||
|
||||
test('close up, every camera near the screen shows and far-off ones hide', () => {
|
||||
const { records, screenByPosition } = recordsAt(
|
||||
[
|
||||
[0, 0],
|
||||
[0.0001, 0],
|
||||
[0.0002, 0],
|
||||
],
|
||||
(index) =>
|
||||
index === 2 ? { x: WIDTH * 3, y: HEIGHT / 2 } : { x: 100, y: 100 },
|
||||
);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
assert.deepEqual(shownIds(records), ['cam-0', 'cam-1']);
|
||||
});
|
||||
|
||||
test('a disabled layer skips the view pass', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(20_000, screenByPosition),
|
||||
records,
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(refreshHorizonCulling(), false);
|
||||
assert.deepEqual(_viewRecordIdsForTest(), []);
|
||||
});
|
||||
|
||||
test('UI state builds the full camera list only when it is read', () => {
|
||||
const { records, screenByPosition } = recordsAt(CLUSTER.slice(0, 2));
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
let built = 0;
|
||||
for (const record of records) {
|
||||
const camera = record.camera;
|
||||
record.camera = new Proxy(camera, {
|
||||
get(target, key) {
|
||||
if (key === 'calibration') built += 1;
|
||||
return target[key];
|
||||
},
|
||||
});
|
||||
}
|
||||
const first = cctvLayer.getUIState();
|
||||
assert.equal(built, 0, 'listeners that skip `cameras` cost nothing');
|
||||
assert.deepEqual(
|
||||
first.cameraIndex.map((camera) => camera.id),
|
||||
['cam-0', 'cam-1'],
|
||||
);
|
||||
assert.equal(
|
||||
cctvLayer.getUIState().cameraIndex,
|
||||
first.cameraIndex,
|
||||
'the index keeps its identity while the view does',
|
||||
);
|
||||
assert.equal(first.cameras.length, 2);
|
||||
assert.ok(built > 0, 'reading `cameras` builds it');
|
||||
assert.equal(first.cameras, first.cameras, 'and builds it once');
|
||||
});
|
||||
|
||||
test('the camera picker lists the view, with the active camera first', () => {
|
||||
const { records, screenByPosition } = recordsAt(
|
||||
[
|
||||
[0, 0],
|
||||
[0.0001, 0],
|
||||
[0.0002, 0],
|
||||
],
|
||||
(index) => (index === 1 ? { x: WIDTH * 3, y: 0 } : { x: 100, y: 100 }),
|
||||
);
|
||||
_setCctvCoverageStateForTest({
|
||||
viewer: viewerAt(300, screenByPosition),
|
||||
records,
|
||||
activeCameraId: 'cam-1',
|
||||
});
|
||||
refreshHorizonCulling();
|
||||
const state = cctvLayer.getUIState();
|
||||
assert.deepEqual(
|
||||
state.cameraIndex.map((camera) => camera.id),
|
||||
['cam-1', 'cam-0', 'cam-2'],
|
||||
'the off-screen active camera leads, then what is in view',
|
||||
);
|
||||
assert.equal(state.cameras.length, 3, 'the full list is still every camera');
|
||||
});
|
||||
@@ -875,6 +875,13 @@ test('route numbers follow the page language and never break from their unit', (
|
||||
);
|
||||
const list = directionsStepList({ route });
|
||||
assert.equal(list.items[1].lead, '240\u00a0м');
|
||||
|
||||
// Japanese unit words follow the number: 1時間5分, but 1.5 km.
|
||||
setLocale('ja');
|
||||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||||
assert.equal(routeDurationText(39), '39秒');
|
||||
assert.equal(routeDurationText(3900), '1時間5分');
|
||||
assert.equal(routeDurationText(7200), '2時間');
|
||||
});
|
||||
|
||||
test('ramp and lane wording stays localized after choosing the maneuver form', (t) => {
|
||||
@@ -892,6 +899,12 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
|
||||
'Toma la salida bruscamente a la derecha',
|
||||
'Toma el carril izquierdo',
|
||||
],
|
||||
[
|
||||
'ja',
|
||||
'ランプを斜め左に進んでI-95に入る',
|
||||
'出口を急角度で右に出る',
|
||||
'左車線を通行',
|
||||
],
|
||||
[
|
||||
'zh-CN',
|
||||
'向左前方驶入匝道,进入 I-95',
|
||||
@@ -919,3 +932,19 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the reroute tooltip keeps German mode nouns capitalized', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
const title = (id) =>
|
||||
directionsRowControls(idle).chips.find((chip) => chip.id === id).title;
|
||||
assert.equal(title('mode-foot'), 'Walk — reroute for walk');
|
||||
setLocale('de');
|
||||
assert.equal(
|
||||
title('mode-foot'),
|
||||
'Zu Fuß – Route im Modus „Zu Fuß“ neu berechnen',
|
||||
);
|
||||
assert.equal(
|
||||
title('mode-bike'),
|
||||
'Fahrrad – Route im Modus „Fahrrad“ neu berechnen',
|
||||
);
|
||||
});
|
||||
@@ -20,7 +20,7 @@
|
||||
* jam keeps a size bump so queues read as fat beads.
|
||||
* - `crt` (retro): saturated primaries that survive 10-level posterization
|
||||
* plus an all-bucket size boost to out-shout the pixel grid.
|
||||
* - `normal` (normal/anime/snow/unknown): every lookup returns null/0 —
|
||||
* - `normal` (normal/anime/unknown): every lookup returns null/0 —
|
||||
* the shipped palette and sizing apply untouched.
|
||||
*
|
||||
* Sim/uncovered dots (bucket null/'sim') are NEVER restyled under any
|
||||
|
||||
@@ -13,7 +13,7 @@ const luma = ([r, g, b]) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
const BUCKETS = ['free', 'slow', 'jam'];
|
||||
const MONO_STYLES = ['surveillance', 'thermal', 'noir'];
|
||||
const NORMAL_STYLES = ['normal', 'anime', 'snow', 'does-not-exist', '', undefined, null];
|
||||
const NORMAL_STYLES = ['normal', 'anime', 'does-not-exist', '', undefined, null];
|
||||
|
||||
test('profile mapping: NVG/FLIR/noir are mono, retro is crt, everything else normal', () => {
|
||||
for (const s of MONO_STYLES) assert.equal(trafficStyleProfile(s), 'mono', s);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* black sprite with a light ring is what a hot object looks like there.
|
||||
* - `crt` (retro): saturated per-mode colours that survive posterisation,
|
||||
* a size boost to out-shout the pixel grid, and a thinner halo.
|
||||
* - `normal` (normal/anime/snow/unknown): every lookup returns the shipped
|
||||
* - `normal` (normal/anime/unknown): every lookup returns the shipped
|
||||
* value — mode colour, shipped size, hairline edge only.
|
||||
*
|
||||
* Brackets are a different matter. The detection canvas composites ABOVE the
|
||||
|
||||
@@ -17,7 +17,6 @@ test('profile mapping: NVG/FLIR/noir and cockpit nvg are mono, retro is crt, eve
|
||||
for (const plain of [
|
||||
'normal',
|
||||
'anime',
|
||||
'snow',
|
||||
'minecraft',
|
||||
'',
|
||||
null,
|
||||
|
||||
Reference in new issue
Block a user