mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
merge: refresh wind candidate onto current Transit and semantic-map main
This commit is contained in:
commit
a5dad07cc4
252 files changed
+48513
-4507
No files matched your search
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"roots": ["src", "server"]
|
||||
}
|
||||
@@ -675,5 +675,55 @@
|
||||
"src/search/nominatim.test.mjs",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/overpass.js",
|
||||
"src/sources/nominatim.js"
|
||||
"src/sources/nominatim.js",
|
||||
"server/providers/transit.js",
|
||||
"server/providers/transitHistory.js",
|
||||
"server/providers/transitHistory.test.mjs",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitFeeds.test.mjs",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/transitProxy.test.mjs",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/gtfsRealtime.test.mjs",
|
||||
"src/data/transit.js",
|
||||
"src/data/transit.test.mjs",
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/policy.js",
|
||||
"src/layers/transit/queries.js",
|
||||
"src/layers/transit/rendering.js",
|
||||
"src/layers/transit/selection.js",
|
||||
"src/layers/transit/state.js",
|
||||
"src/layers/transit/testing.js",
|
||||
"src/layers/transit/viewport.js",
|
||||
"src/layers/transit/lifecycle.test.mjs",
|
||||
"scripts/qa-transit.mjs",
|
||||
"src/tooling/transitProvider.test.mjs",
|
||||
"src/app/layers/transit.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/contactTrailRenderer.js",
|
||||
"src/data/contactTrailRenderer.test.mjs",
|
||||
"src/layers/transit/trails.js",
|
||||
"src/data/contactPlayback.test.mjs",
|
||||
"src/layers/transit/movement.js",
|
||||
"src/layers/transit/movement.test.mjs",
|
||||
"src/data/transitPresetStyle.js",
|
||||
"src/data/transitPresetStyle.test.mjs",
|
||||
"src/data/transitIcons.test.mjs",
|
||||
"src/overlays/worldOverlayTokens.test.mjs",
|
||||
"src/app/layers/transit.test.mjs",
|
||||
"src/layers/transit/qaMetrics.js",
|
||||
"src/layers/transit/qaMetrics.test.mjs",
|
||||
"scripts/qa-transit-scenes.mjs",
|
||||
"scripts/qa-transit-controls.mjs",
|
||||
"src/tooling/transitQa.test.mjs",
|
||||
"scripts/qa-transit-browser.mjs",
|
||||
"scripts/format-runtime.json",
|
||||
"src/tooling/transitService.test.mjs",
|
||||
"src/sources/overpassFeatures.test.mjs"
|
||||
]
|
||||
+56
-3
@@ -1,9 +1,58 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, writeFile, realpath, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import * as prettier from 'prettier';
|
||||
|
||||
/** Check or format the explicitly adopted files, without scanning other source. */
|
||||
/** Discover owned runtime code; tests and other files retain explicit adoption. */
|
||||
export async function discoverRuntimeFormatFiles(root) {
|
||||
const configFile = path.join(root, 'scripts/format-runtime.json');
|
||||
if (!existsSync(configFile)) return [];
|
||||
const { roots } = JSON.parse(await readFile(configFile, 'utf8'));
|
||||
if (
|
||||
!Array.isArray(roots) ||
|
||||
!roots.length ||
|
||||
new Set(roots).size !== roots.length ||
|
||||
roots.some(
|
||||
(name) =>
|
||||
typeof name !== 'string' ||
|
||||
!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(name),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
'Runtime formatting roots must be unique repository directory paths',
|
||||
);
|
||||
const candidates = execFileSync(
|
||||
'git',
|
||||
[
|
||||
'ls-files',
|
||||
'-z',
|
||||
'--cached',
|
||||
'--others',
|
||||
'--exclude-standard',
|
||||
'--',
|
||||
...roots,
|
||||
],
|
||||
{ cwd: root, encoding: 'utf8' },
|
||||
).split('\0');
|
||||
const files = [];
|
||||
for (const name of new Set(candidates)) {
|
||||
if (
|
||||
!/\.[cm]?js$/.test(name) ||
|
||||
/\.test\.[cm]?js$/.test(name) ||
|
||||
!existsSync(path.join(root, name))
|
||||
)
|
||||
continue;
|
||||
const info = await prettier.getFileInfo(path.join(root, name), {
|
||||
ignorePath: path.join(root, '.prettierignore'),
|
||||
});
|
||||
if (!info.ignored) files.push(name);
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
/** Check or format explicit adoption plus automatically discovered runtime code. */
|
||||
export async function formatAdoptedFiles(root, mode) {
|
||||
if (!['--check', '--write'].includes(mode)) {
|
||||
throw new Error('Usage: node scripts/format.mjs --check|--write');
|
||||
@@ -22,9 +71,13 @@ export async function formatAdoptedFiles(root, mode) {
|
||||
);
|
||||
}
|
||||
|
||||
const names = [
|
||||
...new Set([...scope, ...(await discoverRuntimeFormatFiles(root))]),
|
||||
];
|
||||
|
||||
// Validate the whole scope before any write, including resolved symlink paths.
|
||||
const files = [];
|
||||
for (const name of scope) {
|
||||
for (const name of names) {
|
||||
if (
|
||||
typeof name !== 'string' ||
|
||||
!name ||
|
||||
@@ -91,7 +144,7 @@ if (import.meta.url === invoked) {
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} adopted files.`,
|
||||
`${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} source files.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
+268
-12
@@ -68,7 +68,8 @@
|
||||
"scripts/google-server-key.mjs",
|
||||
"src/data/placeProviderPayloads.js",
|
||||
"src/data/routeSteps.js",
|
||||
"src/sources/httpBody.js"
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/rateLimit.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -205,7 +206,8 @@
|
||||
"src/data/placeProviderPayloads.js",
|
||||
"src/data/routeSteps.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/overpass.js"
|
||||
"src/sources/overpass.js",
|
||||
"src/sources/rateLimit.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -223,7 +225,8 @@
|
||||
"server/providers/overpass/constants.js",
|
||||
"server/providers/overpass/geometry.js",
|
||||
"server/providers/overpass/transport.js",
|
||||
"src/sources/httpBody.js"
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/rateLimit.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -244,7 +247,8 @@
|
||||
"server/providers/regional/weather.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/nominatimGeocode.js",
|
||||
"src/sources/httpBody.js"
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/rateLimit.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -266,7 +270,8 @@
|
||||
"src/hudSummaryResponse.js",
|
||||
"src/voice/voiceCost.js",
|
||||
"src/voice/actionSchemas.js",
|
||||
"server/providers/openai/toolDescriptions.js"
|
||||
"server/providers/openai/toolDescriptions.js",
|
||||
"src/sources/rateLimit.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -588,6 +593,7 @@
|
||||
"src/ui/styles/radio.css",
|
||||
"src/ui/styles/cctv.css",
|
||||
"src/ui/styles/scenes.css",
|
||||
"src/ui/styles/bhote-koshi.css",
|
||||
"src/ui/styles/recording.css",
|
||||
"src/ui/styles/responsive.css",
|
||||
"src/ui/styles/command-dock.css",
|
||||
@@ -603,6 +609,11 @@
|
||||
"scenes": {
|
||||
"exports": ["./scenes"],
|
||||
"modules": [
|
||||
"src/scenes/nepalEvidencePack.js",
|
||||
"src/data/bhoteKoshiIncidentPlaces.js",
|
||||
"src/data/bhoteKoshiShotPaths.js",
|
||||
"src/data/bhoteKoshiFloodPath.js",
|
||||
"src/maps/catalog.js",
|
||||
"src/scenes/director.js",
|
||||
"src/scenes/recipes.js",
|
||||
"src/scenes/scenePolicy.js",
|
||||
@@ -878,6 +889,7 @@
|
||||
"traffic-layer": {
|
||||
"exports": ["./layers/traffic"],
|
||||
"modules": [
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/flowMatch.js",
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/data/trafficBounds.js",
|
||||
@@ -899,7 +911,8 @@
|
||||
"src/layers/traffic/state.js",
|
||||
"src/layers/traffic/style.js",
|
||||
"src/layers/traffic/timing.js",
|
||||
"src/layers/traffic/viewport.js"
|
||||
"src/layers/traffic/viewport.js",
|
||||
"src/sources/overpassRoads.js"
|
||||
],
|
||||
"external": ["cesium", "pbf", "@mapbox/vector-tile"]
|
||||
},
|
||||
@@ -920,7 +933,8 @@
|
||||
"src/layers/bikeshare/state.js",
|
||||
"src/layers/bikeshare/testing.js",
|
||||
"src/layers/bikeshare/viewport.js",
|
||||
"src/data/inputOwnership.js"
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/cameraSensitivity.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1115,6 +1129,13 @@
|
||||
"./standalone/catalog"
|
||||
],
|
||||
"modules": [
|
||||
"src/scenes/nepalEvidencePack.js",
|
||||
"src/data/bhoteKoshiEvent.js",
|
||||
"src/data/bhoteKoshiEmbeddedMedia.js",
|
||||
"src/data/bhoteKoshiLocator.js",
|
||||
"src/data/bhoteKoshiIncidentPlaces.js",
|
||||
"src/data/bhoteKoshiFloodPath.js",
|
||||
"src/data/bhoteKoshiShotPaths.js",
|
||||
"src/annotations/annotationEngine.js",
|
||||
"src/annotations/annotationResolver.js",
|
||||
"src/annotations/hybridAnnotationRenderer.js",
|
||||
@@ -1644,7 +1665,39 @@
|
||||
"src/search/nominatim.js",
|
||||
"src/nominatimGeocode.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/nominatim.js"
|
||||
"src/sources/nominatim.js",
|
||||
"src/app/layers/transit.js",
|
||||
"src/data/transit.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/transitPresetStyle.js",
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/policy.js",
|
||||
"src/layers/transit/queries.js",
|
||||
"src/layers/transit/rendering.js",
|
||||
"src/layers/transit/selection.js",
|
||||
"src/layers/transit/state.js",
|
||||
"src/layers/transit/testing.js",
|
||||
"src/layers/transit/viewport.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/contactTrailRenderer.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/layers/transit/trails.js",
|
||||
"src/layers/transit/movement.js",
|
||||
"src/layers/transit/qaMetrics.js",
|
||||
"src/layers/transit/source.js",
|
||||
"src/sources/transitHistory.js",
|
||||
"src/sources/featureGeometry.js",
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js",
|
||||
"src/sources/overpassRoads.js"
|
||||
],
|
||||
"external": [
|
||||
"@mapbox/vector-tile",
|
||||
@@ -1663,6 +1716,12 @@
|
||||
"application-layer-construction": {
|
||||
"exports": ["./application/layers"],
|
||||
"modules": [
|
||||
"src/data/bhoteKoshiEvent.js",
|
||||
"src/data/bhoteKoshiEmbeddedMedia.js",
|
||||
"src/data/bhoteKoshiLocator.js",
|
||||
"src/data/bhoteKoshiIncidentPlaces.js",
|
||||
"src/data/bhoteKoshiFloodPath.js",
|
||||
"src/data/bhoteKoshiShotPaths.js",
|
||||
"src/annotations/annotationResolver.js",
|
||||
"src/annotations/resolver.js",
|
||||
"src/app/catalog.js",
|
||||
@@ -2004,7 +2063,38 @@
|
||||
"src/nominatimGeocode.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/nominatim.js"
|
||||
"src/sources/nominatim.js",
|
||||
"src/app/layers/transit.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/transitPresetStyle.js",
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/policy.js",
|
||||
"src/layers/transit/queries.js",
|
||||
"src/layers/transit/rendering.js",
|
||||
"src/layers/transit/selection.js",
|
||||
"src/layers/transit/state.js",
|
||||
"src/layers/transit/testing.js",
|
||||
"src/layers/transit/viewport.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/contactTrailRenderer.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/layers/transit/trails.js",
|
||||
"src/layers/transit/movement.js",
|
||||
"src/layers/transit/qaMetrics.js",
|
||||
"src/layers/transit/source.js",
|
||||
"src/sources/transitHistory.js",
|
||||
"src/sources/featureGeometry.js",
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js",
|
||||
"src/sources/overpassRoads.js"
|
||||
],
|
||||
"external": [
|
||||
"cesium",
|
||||
@@ -2053,7 +2143,11 @@
|
||||
"src/nominatimGeocode.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/nominatim.js"
|
||||
"src/sources/nominatim.js",
|
||||
"src/sources/featureGeometry.js",
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js"
|
||||
],
|
||||
"external": [
|
||||
"cesium",
|
||||
@@ -2254,7 +2348,10 @@
|
||||
},
|
||||
"installations-source": {
|
||||
"exports": ["./layers/installations/source"],
|
||||
"modules": ["src/layers/installations/source.js"],
|
||||
"modules": [
|
||||
"src/layers/installations/source.js",
|
||||
"src/data/militaryInstallationData.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"launches-source": {
|
||||
@@ -2278,7 +2375,8 @@
|
||||
"src/data/tomtomTiles.js",
|
||||
"src/layers/traffic/flowDecode.js",
|
||||
"src/layers/traffic/flowSource.js",
|
||||
"src/layers/traffic/source.js"
|
||||
"src/layers/traffic/source.js",
|
||||
"src/sources/overpassRoads.js"
|
||||
],
|
||||
"external": ["@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
@@ -2325,5 +2423,163 @@
|
||||
"src/sources/httpBody.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"transit-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/transit"],
|
||||
"modules": [
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/transit.js",
|
||||
"server/providers/transitHistory.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/transitHistory.js",
|
||||
"src/sources/transitService.js",
|
||||
"src/sources/transitHistoryStore.js",
|
||||
"src/sources/rateLimit.js"
|
||||
],
|
||||
"external": ["pbf"]
|
||||
},
|
||||
"transit-source": {
|
||||
"exports": ["./sources/transit"],
|
||||
"modules": [
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/sources/transitHistory.js"
|
||||
],
|
||||
"external": ["pbf"]
|
||||
},
|
||||
"transit-standalone": {
|
||||
"exports": ["./layers/transit/standalone"],
|
||||
"modules": [
|
||||
"src/app/sourceSlot.js",
|
||||
"src/celestialRing.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/contactTrailRenderer.js",
|
||||
"src/data/dataCredits.js",
|
||||
"src/data/detectionCohort.js",
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/data/geoid.js",
|
||||
"src/data/groundFloor.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/meshFloorSampler.js",
|
||||
"src/data/pickRegistry.js",
|
||||
"src/data/spriteOrder.js",
|
||||
"src/data/terrainHeights.js",
|
||||
"src/data/transit.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/transitPresetStyle.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/movement.js",
|
||||
"src/layers/transit/policy.js",
|
||||
"src/layers/transit/qaMetrics.js",
|
||||
"src/layers/transit/queries.js",
|
||||
"src/layers/transit/rendering.js",
|
||||
"src/layers/transit/selection.js",
|
||||
"src/layers/transit/state.js",
|
||||
"src/layers/transit/testing.js",
|
||||
"src/layers/transit/trails.js",
|
||||
"src/layers/transit/viewport.js",
|
||||
"src/overlays/worldOverlay.js",
|
||||
"src/overlays/worldOverlayDraw.js",
|
||||
"src/overlays/worldOverlayTokens.js",
|
||||
"src/renderGovernor.js",
|
||||
"src/services/application.js",
|
||||
"src/services/groundFloor.js",
|
||||
"src/services/meshFloorSampler.js",
|
||||
"src/services/requests.js",
|
||||
"src/services/terrainHeights.js",
|
||||
"src/sources/sourceSlot.js",
|
||||
"src/layers/transit/source.js",
|
||||
"src/sources/transitHistory.js",
|
||||
"src/sources/featureGeometry.js",
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js"
|
||||
],
|
||||
"external": ["cesium", "egm96-universal", "pbf"]
|
||||
},
|
||||
"transit-layer": {
|
||||
"exports": ["./layers/transit"],
|
||||
"modules": [
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/transitPresetStyle.js",
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/policy.js",
|
||||
"src/layers/transit/queries.js",
|
||||
"src/layers/transit/rendering.js",
|
||||
"src/layers/transit/selection.js",
|
||||
"src/layers/transit/state.js",
|
||||
"src/layers/transit/testing.js",
|
||||
"src/layers/transit/viewport.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/contactTrailRenderer.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/layers/transit/trails.js",
|
||||
"src/layers/transit/movement.js",
|
||||
"src/layers/transit/qaMetrics.js",
|
||||
"src/layers/transit/qaMetrics.test.mjs",
|
||||
"scripts/qa-transit-scenes.mjs",
|
||||
"scripts/qa-transit-controls.mjs",
|
||||
"scripts/qa-transit-browser.mjs",
|
||||
"src/layers/transit/source.js",
|
||||
"src/sources/transitHistory.js"
|
||||
],
|
||||
"external": ["cesium", "pbf"]
|
||||
},
|
||||
"transit-source-factory": {
|
||||
"exports": ["./layers/transit/source"],
|
||||
"modules": [
|
||||
"src/layers/transit/source.js",
|
||||
"src/sources/transitHistory.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"transit-service": {
|
||||
"exports": ["./sources/transit-service"],
|
||||
"modules": [
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/transitHistory.js",
|
||||
"src/sources/transitService.js",
|
||||
"src/sources/transitHistoryStore.js",
|
||||
"src/sources/rateLimit.js"
|
||||
],
|
||||
"external": ["pbf"]
|
||||
},
|
||||
"map-feature-source": {
|
||||
"exports": ["./sources/map-features"],
|
||||
"modules": [
|
||||
"src/sources/featureGeometry.js",
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js"
|
||||
],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
|
||||
const dm = gev.dataManager;
|
||||
await dm.setEnabled('traffic', true);
|
||||
const mod = dm.layers.get('traffic').module;
|
||||
const beforeUpdate = mod.getStats().lastUpdate;
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
// The app's intro flyTo animation clobbers a setView issued mid-flight —
|
||||
@@ -98,7 +99,9 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
|
||||
for (let i = 0; i < tS; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
if (s.count >= minC && !s.loading) break;
|
||||
// Retained roads from the previous city can satisfy count/loading before
|
||||
// the move debounce starts its request. Require the destination render.
|
||||
if (s.lastUpdate !== beforeUpdate && s.count >= minC && !s.loading) break;
|
||||
}
|
||||
return s;
|
||||
}, view, minCount, timeoutS);
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
/** Bound page promises as well as CDP itself; labels identify a stalled section. */
|
||||
export function boundPageEvaluations(page, defaultTimeoutMs = 30000) {
|
||||
const evaluate = page.evaluate.bind(page);
|
||||
page.evaluate = (fn, ...args) => {
|
||||
// The two long trace collectors pass their duration explicitly.
|
||||
const duration =
|
||||
args.find((v) => typeof v === 'number' && v >= 6000 && v <= 120000) || 0;
|
||||
const timeoutMs = Math.max(
|
||||
defaultTimeoutMs,
|
||||
duration ? duration + 5000 : 0,
|
||||
);
|
||||
const label = String(fn).slice(0, 120).replace(/\s+/g, ' ');
|
||||
return evaluate(
|
||||
async (source, values, timeoutMs, label) => {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
// Puppeteer callbacks are standalone functions, just as in evaluate.
|
||||
Promise.resolve().then(() => (0, eval)(`(${source})`)(...values)),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Transit QA page wait timed out after ${timeoutMs} ms: ${label}`,
|
||||
),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
String(fn),
|
||||
args,
|
||||
timeoutMs,
|
||||
label,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export async function reportTransitVisibility(page, label) {
|
||||
const result = await page.evaluate(() => {
|
||||
const app = window.__godsEyeView;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest();
|
||||
return {
|
||||
total: state._vehicles.size,
|
||||
visible: state._visible.size,
|
||||
moving: state._moving.size,
|
||||
held: state._renderHeld,
|
||||
requestRenderMode: app.viewer.scene.requestRenderMode,
|
||||
renderLoop: app.viewer.useDefaultRenderLoop,
|
||||
renderErrors: window.__transitRenderErrors || [],
|
||||
bounds: state._viewBounds,
|
||||
hidden: layer._transitVisibilityForTest().filter((e) => !e.shown),
|
||||
};
|
||||
});
|
||||
console.log(` ${label}: ${JSON.stringify(result)}`);
|
||||
if (result.renderLoop === false || result.renderErrors.length)
|
||||
throw new Error(
|
||||
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Intersect primed histories with the new snapshot; disappearance is availability. */
|
||||
export async function restoreRetainedTransit(retained) {
|
||||
if (!retained.length)
|
||||
return {
|
||||
error: 'No multi-fix history observed during the probe',
|
||||
unexercised: true,
|
||||
};
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest();
|
||||
// enable() schedules proximity discovery; an empty pre-discovery map is not
|
||||
// evidence that every primed vehicle departed. Wait for this feed's poll.
|
||||
const deadline = Date.now() + 12000;
|
||||
while (state._activeFeeds && !state._vehicles.size && Date.now() < deadline)
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await layer.update();
|
||||
if (
|
||||
retained[0]?.primedAt &&
|
||||
Math.floor(Date.now() / 60000) !== Math.floor(retained[0].primedAt / 60000)
|
||||
)
|
||||
return {
|
||||
error: 'Reload crossed the priming minute; same-minute proof unavailable',
|
||||
unexercised: true,
|
||||
};
|
||||
const history = retained.find((h) =>
|
||||
state._vehicles.has(`${h.feedId || 'mbta'}:${h.vehicleId}`),
|
||||
);
|
||||
if (!history)
|
||||
return {
|
||||
error: `All ${retained.length} primed vehicles left the current snapshot`,
|
||||
unexercised: true,
|
||||
};
|
||||
const entry = state._vehicles.get(
|
||||
`${history.feedId || 'mbta'}:${history.vehicleId}`,
|
||||
);
|
||||
const camera = app.viewer.camera,
|
||||
C = camera.positionCartographic.constructor;
|
||||
camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(entry.record.lon, entry.record.lat, 600),
|
||||
),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
layer._transitPartsForTest().selection.selectVehicle(entry.key);
|
||||
return { key: entry.key, oldest: history.oldestT };
|
||||
}
|
||||
|
||||
export async function sampleTransitPixels(page, palette) {
|
||||
return page.evaluate(async (palette) => {
|
||||
const app = window.__godsEyeView;
|
||||
const state = app.dataManager.layers
|
||||
.get('transit')
|
||||
.module._transitStateForTest();
|
||||
// Decode the actual billboard rasters before the paint being judged.
|
||||
const overlay = window.location
|
||||
? await import('/src/overlays/worldOverlay.js')
|
||||
: null;
|
||||
const rasters = new Map();
|
||||
await Promise.all(
|
||||
[
|
||||
...new Set(
|
||||
[...state._vehicles.values()]
|
||||
.map((e) => e.marker?.image)
|
||||
.filter(Boolean),
|
||||
),
|
||||
].map(async (url) => {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
await img.decode();
|
||||
const c = document.createElement('canvas');
|
||||
c.width = img.width;
|
||||
c.height = img.height;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
rasters.set(url, {
|
||||
width: c.width,
|
||||
height: c.height,
|
||||
data: ctx.getImageData(0, 0, c.width, c.height).data,
|
||||
});
|
||||
}),
|
||||
);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
remove();
|
||||
reject(
|
||||
new Error('Transit pixel readback: no postRender within 5000 ms'),
|
||||
);
|
||||
}, 5000);
|
||||
const remove =
|
||||
window.__godsEyeView.viewer.scene.postRender.addEventListener(() => {
|
||||
const app = window.__godsEyeView;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest();
|
||||
const scene = app.viewer.scene;
|
||||
const canvas = scene.canvas;
|
||||
const view = scene.camera.viewMatrix;
|
||||
const proj = scene.camera.frustum.projectionMatrix;
|
||||
const mul = (m, v) => [
|
||||
m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3],
|
||||
m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3],
|
||||
m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14] * v[3],
|
||||
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
|
||||
];
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
const sprites = [];
|
||||
const footprints = [];
|
||||
const intersects = (a, b) =>
|
||||
a.x < b.x + b.w &&
|
||||
a.x + a.w > b.x &&
|
||||
a.y < b.y + b.h &&
|
||||
a.y + a.h > b.y;
|
||||
const plates =
|
||||
app.styleManager
|
||||
?.getDetectionDiagnostics?.()
|
||||
?.calloutRects?.filter((r) => r.alpha > 0.01) || [];
|
||||
if (state._selectedKey) {
|
||||
const card = overlay?.getOverlayPaintRect(
|
||||
'transit-selected',
|
||||
state._selectedKey,
|
||||
);
|
||||
if (card) plates.push(card);
|
||||
}
|
||||
const panels = (overlay?.WORLD_OVERLAY_OCCLUDER_SELECTORS || [])
|
||||
.flatMap((selector) => [...document.querySelectorAll(selector)])
|
||||
.filter(
|
||||
(el) =>
|
||||
!el.hidden &&
|
||||
el.checkVisibility({
|
||||
checkOpacity: true,
|
||||
checkVisibilityCSS: true,
|
||||
}),
|
||||
)
|
||||
.map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.left, y: r.top, w: r.width, h: r.height };
|
||||
});
|
||||
const skipped = [];
|
||||
const style = state._stylePreset;
|
||||
const uniforms = app.styleManager?.stages?.[style]?.uniforms || {};
|
||||
const intensity = uniforms.intensity ?? 1;
|
||||
const pixelation =
|
||||
uniforms.pixelation ?? (style === 'surveillance' ? 2.5 : 1.5);
|
||||
const sensor = style === 'surveillance' || style === 'thermal';
|
||||
// Invert NVG's barrel transform; thermal only quantizes the source.
|
||||
const screenPoint = (sx, sy) => {
|
||||
let x = sx,
|
||||
y = sy;
|
||||
if (style === 'surveillance') {
|
||||
const tx = (sx / width) * 2 - 1,
|
||||
ty = (sy / height) * 2 - 1;
|
||||
let cx = tx,
|
||||
cy = ty;
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const r2 = cx * cx + cy * cy;
|
||||
const d =
|
||||
1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
|
||||
cx = tx / d;
|
||||
cy = ty / d;
|
||||
}
|
||||
x = ((cx + 1) * width) / 2;
|
||||
y = ((cy + 1) * height) / 2;
|
||||
}
|
||||
const offset = sensor
|
||||
? ((1 + (pixelation - 1) * intensity) * intensity) /
|
||||
2 /
|
||||
(canvas.width / width)
|
||||
: 0;
|
||||
return { x: x + offset, y: y - offset };
|
||||
};
|
||||
const uiBlocked = (x, y) => {
|
||||
const el = document.elementFromPoint?.(x, y);
|
||||
return (
|
||||
el &&
|
||||
el !== canvas &&
|
||||
!el.closest?.(
|
||||
'#world-overlay-root, #world-overlay-detection-surface',
|
||||
) &&
|
||||
el.tagName !== 'BODY' &&
|
||||
el.tagName !== 'HTML'
|
||||
);
|
||||
};
|
||||
for (const entry of state._vehicles.values()) {
|
||||
if (!entry.marker || entry.marker.show === false) continue;
|
||||
const p = entry.marker.position;
|
||||
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
|
||||
if (!(clip[3] > 0)) continue;
|
||||
const screen = entry.marker.computeScreenSpacePosition?.(scene) || {
|
||||
x: ((clip[0] / clip[3]) * 0.5 + 0.5) * width,
|
||||
y: (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) * height,
|
||||
};
|
||||
const rect = entry.marker.constructor.getScreenSpaceBoundingBox?.(
|
||||
entry.marker,
|
||||
screen,
|
||||
) || { x: screen.x - 16, y: screen.y - 16, width: 32, height: 32 };
|
||||
const { x, y } = screenPoint(screen.x, screen.y);
|
||||
const c = Math.abs(Math.cos(entry.marker.rotation || 0)),
|
||||
sn = Math.abs(Math.sin(entry.marker.rotation || 0));
|
||||
const w = rect.width * c + rect.height * sn,
|
||||
h = rect.height * c + rect.width * sn;
|
||||
const footprint = { x: x - w / 2, y: y - h / 2, w, h };
|
||||
footprints.push({ key: entry.key, ...footprint });
|
||||
let reason = null;
|
||||
if (
|
||||
rect.x < 0 ||
|
||||
rect.y < 0 ||
|
||||
rect.x + rect.width > width ||
|
||||
rect.y + rect.height > height
|
||||
)
|
||||
reason = 'billboard rectangle outside frame';
|
||||
if (
|
||||
entry.marker.color?.alpha < 0.95 ||
|
||||
entry.markerCollection?.show === false
|
||||
)
|
||||
reason = 'billboard not opaque/collection hidden';
|
||||
const raster = rasters.get(entry.marker.image);
|
||||
if (!raster) reason = 'billboard alpha unavailable';
|
||||
else if (
|
||||
raster.data[
|
||||
(Math.floor(raster.height / 2) * raster.width +
|
||||
Math.floor(raster.width / 2)) *
|
||||
4 +
|
||||
3
|
||||
] < 242
|
||||
)
|
||||
reason = 'billboard centre is transparent';
|
||||
if (
|
||||
sensor &&
|
||||
Math.hypot(
|
||||
(((x / width) * 2 - 1) * width) / height,
|
||||
(y / height) * 2 - 1,
|
||||
) > 0.6
|
||||
)
|
||||
reason = 'outside unmasked sensor lens';
|
||||
if (style === 'noir') {
|
||||
// Noir deliberately darkens the frame edges. Judge the core in
|
||||
// its unmasked field, independently of the measured brightness.
|
||||
const u = x / width,
|
||||
v = y / height;
|
||||
const transmission = Math.pow(
|
||||
16 * u * (1 - u) * v * (1 - v),
|
||||
0.3 + 0.4 * (uniforms.vignetteAmt ?? 0.5) * intensity,
|
||||
);
|
||||
if (1 - intensity + transmission * intensity < 0.9)
|
||||
reason = 'outside unmasked noir field';
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
[x, y],
|
||||
[rect.x, rect.y],
|
||||
[rect.x + rect.width, rect.y + rect.height],
|
||||
[rect.x + rect.width, rect.y],
|
||||
[rect.x, rect.y + rect.height],
|
||||
].some(([px, py]) => uiBlocked(px, py))
|
||||
)
|
||||
reason = 'UI panel overlaps billboard';
|
||||
if (panels.some((r) => intersects(footprint, r)))
|
||||
reason = 'UI panel rectangle overlaps billboard';
|
||||
if (plates.some((r) => intersects(footprint, r)))
|
||||
reason = 'DETECT label/card rectangle overlaps billboard';
|
||||
if (reason) {
|
||||
skipped.push({ key: entry.key, reason });
|
||||
continue;
|
||||
}
|
||||
if (x < 40 || y < 40 || x > width - 40 || y > height - 40) continue;
|
||||
sprites.push({
|
||||
key: entry.key,
|
||||
mode: entry.mode,
|
||||
x,
|
||||
y,
|
||||
selected: state._selectedKey === entry.key,
|
||||
screenX: screen.x,
|
||||
screenY: screen.y,
|
||||
rect,
|
||||
footprint,
|
||||
bracketX: entry.detectContact?._candidateScreenX ?? screen.x,
|
||||
bracketY: entry.detectContact?._candidateScreenY ?? screen.y,
|
||||
raster,
|
||||
rotation: entry.marker.rotation || 0,
|
||||
halfW: Number.isFinite(entry.marker.width)
|
||||
? Math.ceil(entry.marker.width / 2) + 2
|
||||
: 11,
|
||||
halfH: Number.isFinite(entry.marker.height)
|
||||
? Math.ceil(entry.marker.height / 2) + 2
|
||||
: 7,
|
||||
});
|
||||
}
|
||||
const isolated = sprites.filter(
|
||||
(a) =>
|
||||
!a.selected &&
|
||||
footprints.every(
|
||||
(b) => b.key === a.key || !intersects(a.footprint, b),
|
||||
),
|
||||
);
|
||||
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||
const scale = canvas.width / canvas.clientWidth;
|
||||
const luma = (r, g, b) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
const readLuma = (cx, cy) => {
|
||||
const px = Math.floor(cx * scale);
|
||||
const py = Math.floor(canvas.height - 1 - cy * scale);
|
||||
const buf = new Uint8Array(4);
|
||||
gl.readPixels(px, py, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf);
|
||||
return luma(buf[0], buf[1], buf[2]);
|
||||
};
|
||||
const surface = document.getElementById('world-overlay-canvas');
|
||||
const ctx = surface?.getContext('2d');
|
||||
// A hidden surface has no client size; nothing to read there.
|
||||
const sscale =
|
||||
surface && surface.clientWidth > 0
|
||||
? surface.width / surface.clientWidth
|
||||
: 0;
|
||||
const out = [];
|
||||
for (const s of isolated) {
|
||||
if (out.length >= 12) break;
|
||||
// The normal canvas includes DETECT plates and selected cards.
|
||||
// A plate over the body invalidates this contact, even if WebGL below is hot.
|
||||
if (ctx && sscale > 0) {
|
||||
const body = ctx.getImageData(
|
||||
Math.floor((s.x - 3) * sscale),
|
||||
Math.floor((s.y - 3) * sscale),
|
||||
Math.max(1, Math.ceil(6 * sscale)),
|
||||
Math.max(1, Math.ceil(6 * sscale)),
|
||||
).data;
|
||||
if (body.some((v, i) => i % 4 === 3 && v > 0)) {
|
||||
skipped.push({
|
||||
key: s.key,
|
||||
reason: 'DETECT label/card overlaps core',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Picking is independent of rendered brightness: an overlapping
|
||||
// label or sprite must not masquerade as a dim sensor signature.
|
||||
// Check the whole sampled core, not just its centre pick.
|
||||
let coreOwned = true;
|
||||
if (scene.pick) {
|
||||
const framebuffer = gl.getParameter?.(gl.FRAMEBUFFER_BINDING);
|
||||
for (let dx = -1; dx <= 1; dx++)
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
let u = (s.x + dx) / width,
|
||||
v = 1 - (s.y + dy) / height;
|
||||
if (style === 'surveillance') {
|
||||
const x = u * 2 - 1,
|
||||
y = v * 2 - 1,
|
||||
r2 = x * x + y * y;
|
||||
const d =
|
||||
1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
|
||||
u = (x * d + 1) / 2;
|
||||
v = (y * d + 1) / 2;
|
||||
}
|
||||
if (sensor) {
|
||||
const grid = 1 + (pixelation - 1) * intensity;
|
||||
u +=
|
||||
((Math.floor((u * canvas.width) / grid) * grid) /
|
||||
canvas.width -
|
||||
u) *
|
||||
intensity;
|
||||
v +=
|
||||
((Math.floor((v * canvas.height) / grid) * grid) /
|
||||
canvas.height -
|
||||
v) *
|
||||
intensity;
|
||||
}
|
||||
const picked = scene.pick(
|
||||
{ x: u * width, y: (1 - v) * height },
|
||||
1,
|
||||
1,
|
||||
);
|
||||
if (picked?.id !== s.key && picked?.primitive?.id !== s.key)
|
||||
coreOwned = false;
|
||||
}
|
||||
if (gl.bindFramebuffer)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
||||
}
|
||||
if (!coreOwned) {
|
||||
skipped.push({
|
||||
key: s.key,
|
||||
reason:
|
||||
'sampled core is partially obscured or outside the sprite',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let centre = 0;
|
||||
for (let dx = -1; dx <= 1; dx += 1)
|
||||
for (let dy = -1; dy <= 1; dy += 1)
|
||||
centre += readLuma(s.x + dx, s.y + dy) / 9;
|
||||
let ringMin = 1;
|
||||
let ringMax = 0;
|
||||
// Sample the actual rotated raster halo, not a fixed-radius circle
|
||||
// that can hit the hot body or terrain after a display-size change.
|
||||
const raster = s.raster;
|
||||
const cos = Math.cos(s.rotation),
|
||||
sin = Math.sin(s.rotation);
|
||||
for (let ry = 1; ry < raster.height - 1; ry++)
|
||||
for (let rx = 1; rx < raster.width - 1; rx++) {
|
||||
const i = (ry * raster.width + rx) * 4;
|
||||
if (
|
||||
raster.data[i + 3] < 200 ||
|
||||
raster.data[i] > 30 ||
|
||||
raster.data[i + 1] > 30 ||
|
||||
raster.data[i + 2] > 30
|
||||
)
|
||||
continue;
|
||||
const dx = ((rx + 0.5) / raster.width - 0.5) * s.rect.width;
|
||||
const dy = ((ry + 0.5) / raster.height - 0.5) * s.rect.height;
|
||||
const q = screenPoint(
|
||||
s.screenX + dx * cos + dy * sin,
|
||||
s.screenY - dx * sin + dy * cos,
|
||||
);
|
||||
const v = readLuma(q.x, q.y);
|
||||
ringMin = Math.min(ringMin, v);
|
||||
ringMax = Math.max(ringMax, v);
|
||||
}
|
||||
let bracket = null;
|
||||
if (ctx && sscale > 0) {
|
||||
// Transit brackets follow the padded sprite extent and snapped
|
||||
// stroke centres. Sample the middle of each straight segment,
|
||||
// never a colour histogram containing antialiased edges/callouts.
|
||||
const cx = Math.floor(s.bracketX) + 0.5,
|
||||
cy = Math.floor(s.bracketY) + 0.5;
|
||||
const hw = s.halfW,
|
||||
hh = s.halfH,
|
||||
mid = Math.max(4, Math.floor(Math.min(hw, hh) * 0.55)) / 2;
|
||||
const points = [
|
||||
[cx - hw + mid, cy - hh],
|
||||
[cx + hw - mid, cy - hh],
|
||||
[cx - hw + mid, cy + hh],
|
||||
[cx + hw - mid, cy + hh],
|
||||
[cx - hw, cy - hh + mid],
|
||||
[cx + hw, cy - hh + mid],
|
||||
[cx - hw, cy + hh - mid],
|
||||
[cx + hw, cy + hh - mid],
|
||||
];
|
||||
let bestAlpha = 0;
|
||||
for (const [index, [x, y]] of points.entries()) {
|
||||
if (
|
||||
uiBlocked(x, y) ||
|
||||
plates.some((r) =>
|
||||
intersects({ x: x - 1, y: y - 1, w: 2, h: 2 }, r),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
// A label plate fills both sides of a stroke. Require empty
|
||||
// pixels beyond its dark backing on the perpendicular axis.
|
||||
const normal = index < 4 ? [0, 3] : [3, 0];
|
||||
if (
|
||||
[-1, 1].some(
|
||||
(sign) =>
|
||||
ctx.getImageData(
|
||||
Math.floor((x + sign * normal[0]) * sscale),
|
||||
Math.floor((y + sign * normal[1]) * sscale),
|
||||
1,
|
||||
1,
|
||||
).data[3] > 0,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const data = ctx.getImageData(
|
||||
Math.floor(x * sscale),
|
||||
Math.floor(y * sscale),
|
||||
1,
|
||||
1,
|
||||
).data;
|
||||
if (data[3] > bestAlpha) {
|
||||
bestAlpha = data[3];
|
||||
bracket = [data[0], data[1], data[2]];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
key: s.key,
|
||||
mode: s.mode,
|
||||
verified: true,
|
||||
rectangle: s.rect,
|
||||
centre,
|
||||
background:
|
||||
[
|
||||
readLuma(s.x + 25, s.y),
|
||||
readLuma(s.x - 25, s.y),
|
||||
readLuma(s.x, s.y + 25),
|
||||
readLuma(s.x, s.y - 25),
|
||||
].reduce((a, b) => a + b, 0) / 4,
|
||||
ringMin,
|
||||
ringMax,
|
||||
bracket,
|
||||
bracketTier: `transit_${s.mode}`,
|
||||
bracketExpected: palette[s.mode]
|
||||
? [1, 3, 5].map((i) =>
|
||||
parseInt(palette[s.mode].slice(i, i + 2), 16),
|
||||
)
|
||||
: null,
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
});
|
||||
}
|
||||
clearTimeout(timer);
|
||||
remove();
|
||||
console.log(
|
||||
`TRANSIT_SAMPLER ${style}: ${out.length} verified; skipped=${JSON.stringify(skipped)}`,
|
||||
);
|
||||
resolve(out);
|
||||
});
|
||||
window.__godsEyeView.viewer.scene.requestRender();
|
||||
});
|
||||
}, palette);
|
||||
}
|
||||
|
||||
/** A fleet-to-fleet comparison must not inherit the city's selected marker. */
|
||||
export function readTransitFleetStyle({ key, resetSelection = false } = {}) {
|
||||
const layer = window.__godsEyeView.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest();
|
||||
if (resetSelection) layer._transitPartsForTest().selection.clearSelection();
|
||||
const entry = key
|
||||
? state._vehicles.get(key)
|
||||
: [...state._vehicles.values()].find((e) => e.marker?.show);
|
||||
return entry
|
||||
? {
|
||||
key: entry.key,
|
||||
selected: state._selectedKey === entry.key,
|
||||
color: entry.marker.color.toCssColorString(),
|
||||
width: entry.marker.width,
|
||||
image: entry.marker.image,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
export const BOSTON = {
|
||||
id: 'boston',
|
||||
label: 'Boston (MBTA)',
|
||||
lat: 42.3554,
|
||||
lon: -71.0605,
|
||||
heading: 25,
|
||||
zone: 'America/New_York',
|
||||
};
|
||||
const LIVE_CITIES = [
|
||||
BOSTON,
|
||||
{
|
||||
id: 'austin',
|
||||
label: 'Austin (CapMetro)',
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
heading: 340,
|
||||
zone: 'America/Chicago',
|
||||
},
|
||||
{
|
||||
id: 'minneapolis',
|
||||
label: 'Minneapolis (Metro Transit)',
|
||||
lat: 44.9778,
|
||||
lon: -93.265,
|
||||
heading: 0,
|
||||
zone: 'America/Chicago',
|
||||
},
|
||||
{
|
||||
id: 'helsinki',
|
||||
label: 'Helsinki (HSL)',
|
||||
lat: 60.1699,
|
||||
lon: 24.9384,
|
||||
heading: 0,
|
||||
zone: 'Europe/Helsinki',
|
||||
},
|
||||
{
|
||||
id: 'oslo',
|
||||
label: 'Oslo (Entur)',
|
||||
lat: 59.9139,
|
||||
lon: 10.7522,
|
||||
heading: 0,
|
||||
zone: 'Europe/Oslo',
|
||||
},
|
||||
];
|
||||
export function chooseLiveCities(now = new Date()) {
|
||||
return LIVE_CITIES.filter((city) => {
|
||||
const hour = Number(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: city.zone,
|
||||
hour: 'numeric',
|
||||
hourCycle: 'h23',
|
||||
}).format(now),
|
||||
);
|
||||
return hour >= 6 && hour < 22;
|
||||
}).slice(0, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the post-FX style the way a user does — the preset button — and
|
||||
* fall back to the style manager only when the tray is not clickable, saying
|
||||
* so. Then set any style parameters through their sliders.
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {string} name
|
||||
* @param {Record<string, number>} [params] aria-label → slider value
|
||||
* @returns {Promise<string>} how the style was set
|
||||
*/
|
||||
export async function selectStyle(page, name, params = {}, wait = pause) {
|
||||
let how = 'button';
|
||||
// The presets live in the bottom control panel, which auto-dismisses when
|
||||
// the pointer leaves it: pin it open the way a user does, once.
|
||||
await page.evaluate(() => {
|
||||
const panel = document.getElementById('control-panel');
|
||||
if (panel && !panel.classList.contains('dock-pinned')) {
|
||||
document
|
||||
.querySelector('.dock-pin-btn[data-pin-target="control-panel"]')
|
||||
?.click();
|
||||
}
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed?.(
|
||||
'control-panel',
|
||||
false,
|
||||
{ explicit: true },
|
||||
);
|
||||
});
|
||||
await wait(400);
|
||||
try {
|
||||
await page.$eval(`.style-btn[data-style="${name}"]`, (el) =>
|
||||
el.scrollIntoView({ block: 'center' }),
|
||||
);
|
||||
await page.click(`.style-btn[data-style="${name}"]`);
|
||||
} catch {
|
||||
how = 'styleManager.setStyle (button not clickable)';
|
||||
await page.evaluate(
|
||||
(n) => window.__godsEyeView.styleManager.setStyle(n),
|
||||
name,
|
||||
);
|
||||
}
|
||||
// A click can be swallowed by a loading cover or a collapsed tray without
|
||||
// Puppeteer throwing. Verify the active style before querying generated rows.
|
||||
if (
|
||||
!(await page.evaluate(
|
||||
(n) => document.documentElement.dataset.gevStyle === n,
|
||||
name,
|
||||
))
|
||||
) {
|
||||
how = 'styleManager.setStyle (button did not activate style)';
|
||||
await page.evaluate(
|
||||
(n) => window.__godsEyeView.styleManager.setStyle(n),
|
||||
name,
|
||||
);
|
||||
}
|
||||
await page.waitForFunction(
|
||||
(n) => document.documentElement.dataset.gevStyle === n,
|
||||
{ timeout: 10000 },
|
||||
name,
|
||||
);
|
||||
await wait(1_800);
|
||||
for (const [label, value] of Object.entries(params)) {
|
||||
const selector = `#param-sliders input.param-slider[aria-label="${label}"]`;
|
||||
await page.waitForSelector(selector, { timeout: 10000 });
|
||||
await page.$eval(
|
||||
selector,
|
||||
(el, v) => {
|
||||
el.value = String(v);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
},
|
||||
value,
|
||||
);
|
||||
await wait(700);
|
||||
}
|
||||
return how;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn DETECT on through its own button, cycling until the dense profile is
|
||||
* reported on the detection surface.
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @returns {Promise<string>} the profile reached
|
||||
*/
|
||||
export async function detectOn(page, wait = pause) {
|
||||
// The real button toggles OFF/restore. Density determines its on-profile.
|
||||
const mode = () =>
|
||||
page.evaluate(() => {
|
||||
const button = document.getElementById('detection-toggle');
|
||||
const label = button?.getAttribute('aria-label') || '';
|
||||
return {
|
||||
on: button?.getAttribute('aria-pressed') === 'true',
|
||||
mode: (label.split(':')[1] || 'off').trim().toUpperCase(),
|
||||
};
|
||||
});
|
||||
// The loading screen swallows the first click of a fresh page.
|
||||
await page
|
||||
.waitForFunction(() => !document.getElementById('loading-screen'), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.catch(() => {});
|
||||
// The DISPLAY rail starts collapsed on a first run: open it the way a user
|
||||
// does, then press DETECT until it reports on.
|
||||
await page.evaluate(() => {
|
||||
const rail = document.getElementById('pp-toggles');
|
||||
if (rail?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
|
||||
?.click();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('detection-toggle')?.getBoundingClientRect()
|
||||
.width > 0,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
if (!(await mode()).on) {
|
||||
await page.click('#detection-toggle');
|
||||
await wait(300);
|
||||
}
|
||||
await page.$eval('#detection-density-slider', (el) => {
|
||||
el.value = '100';
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.getElementById('detection-toggle')
|
||||
?.getAttribute('aria-label') === 'Detection overlay: dense',
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
// Prove the button's actual OFF/restore cycle preserves DENSE.
|
||||
await page.click('#detection-toggle');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.getElementById('detection-toggle')
|
||||
?.getAttribute('aria-pressed') === 'false',
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await page.click('#detection-toggle');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.getElementById('detection-toggle')
|
||||
?.getAttribute('aria-label') === 'Detection overlay: dense',
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
return (await mode()).mode;
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
import { reportTransitVisibility } from './qa-transit-browser.mjs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
reduceFleet,
|
||||
reduceMotion,
|
||||
reduceBackground,
|
||||
reduceSensorContrast,
|
||||
} from '../src/layers/transit/qaMetrics.js';
|
||||
|
||||
/** CDP includes objects collected during the sample, not just surviving objects. */
|
||||
export function allocationTotals(profile) {
|
||||
let frameBytes = 0,
|
||||
layerFrameBytes = 0,
|
||||
sharedOverlayBytes = 0,
|
||||
qaBytes = 0;
|
||||
const allocators = new Map();
|
||||
function visit(node, inFrame = false, inQa = false, inOverlay = false) {
|
||||
const frame = node.callFrame || {},
|
||||
name = frame.functionName || '',
|
||||
url = frame.url || '';
|
||||
const nested =
|
||||
inFrame ||
|
||||
name === 'onPreRender' ||
|
||||
/^render\d*$/.test(name) ||
|
||||
/^Scene\d*\.render$/.test(name);
|
||||
// QA wraps the real frame callback: re-enter production attribution at its
|
||||
// onPreRender boundary, but exclude QA's own Math/Cesium helper descendants.
|
||||
const qa =
|
||||
name === 'onPreRender'
|
||||
? false
|
||||
: inQa || /\/transit\/testing\.js(?:\?|$)/.test(url);
|
||||
const overlay = inOverlay || name === '_drawOverlay';
|
||||
const bytes = node.selfSize || 0;
|
||||
if (nested) {
|
||||
if (qa) qaBytes += bytes;
|
||||
else {
|
||||
frameBytes += bytes;
|
||||
const allocator = `${name || '(anonymous)'} ${url.split('?')[0]}:${(frame.lineNumber ?? -1) + 1}`;
|
||||
allocators.set(allocator, (allocators.get(allocator) || 0) + bytes);
|
||||
if (overlay) sharedOverlayBytes += bytes;
|
||||
if (
|
||||
/\/layers\/transit\/|\/data\/(?:contactPlayback|contactTrailRenderer|transit[^/]*)\.js/.test(
|
||||
url,
|
||||
)
|
||||
)
|
||||
layerFrameBytes += bytes;
|
||||
}
|
||||
}
|
||||
for (const child of node.children || []) visit(child, nested, qa, overlay);
|
||||
}
|
||||
visit(profile.head);
|
||||
return {
|
||||
frameBytes,
|
||||
layerFrameBytes,
|
||||
sharedOverlayBytes,
|
||||
qaBytes,
|
||||
topAllocators: [...allocators]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([allocator, bytes]) => ({ allocator, bytes })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runFleetBudgets({
|
||||
page,
|
||||
check,
|
||||
wait,
|
||||
shots,
|
||||
tag,
|
||||
detectOn,
|
||||
selectStyle,
|
||||
}) {
|
||||
console.log(
|
||||
'\n== moving fleet budgets: 1920×1080 CSS, warmed view, DETECT dense ==',
|
||||
);
|
||||
await selectStyle(page, 'normal');
|
||||
await detectOn(page);
|
||||
const cdp = await page.createCDPSession();
|
||||
await cdp.send('HeapProfiler.enable');
|
||||
for (const count of [800, 3000]) {
|
||||
await page.evaluate(async (count) => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
||||
const camera = app.viewer.camera,
|
||||
C = camera.positionCartographic.constructor;
|
||||
camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(-71.0605, 42.3554, count === 800 ? 600 : 30000),
|
||||
),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
layer._transitPartsForTest().ingestion.abortAllInFlight();
|
||||
layer._transitStateForTest()._activeFeeds.clear();
|
||||
for (const key of layer._transitStateForTest()._vehicles.keys())
|
||||
layer._transitPartsForTest().ingestion.removeVehicle(key);
|
||||
}, count);
|
||||
await wait(8000);
|
||||
// Camera proximity can finish a live poll during tile warm-up. Remove it
|
||||
// before the heap baseline, or its memory would discount the fixture fleet.
|
||||
await page.evaluate(() => {
|
||||
const layer =
|
||||
window.__godsEyeView.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest(),
|
||||
parts = layer._transitPartsForTest();
|
||||
parts.ingestion.abortAllInFlight();
|
||||
state._activeFeeds.clear();
|
||||
clearTimeout(state._cameraDebounceTimer);
|
||||
state._cameraDebounceTimer = null;
|
||||
for (const key of state._vehicles.keys())
|
||||
parts.ingestion.removeVehicle(key);
|
||||
parts.rendering.syncRenderHold();
|
||||
window.__godsEyeView.viewer.scene.requestRender();
|
||||
});
|
||||
await wait(500);
|
||||
await cdp.send('HeapProfiler.collectGarbage');
|
||||
const before = await cdp.send('Runtime.getHeapUsage');
|
||||
const loaded = await page.evaluate(
|
||||
(count) =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('transit')
|
||||
.module._loadTransitFleetForTest(
|
||||
count,
|
||||
{ lat: 42.3554, lon: -71.0605 },
|
||||
count === 800 ? 5 : 120,
|
||||
),
|
||||
count,
|
||||
);
|
||||
await reportTransitVisibility(
|
||||
page,
|
||||
`${count} budget visibility after load`,
|
||||
);
|
||||
await wait(2000);
|
||||
await cdp.send('HeapProfiler.collectGarbage');
|
||||
// The retained delta is conservative: it includes concurrent page growth.
|
||||
const retained = await cdp.send('Runtime.getHeapUsage');
|
||||
// Timing/upload acceptance runs without the allocation profiler attached.
|
||||
const trace = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('transit')
|
||||
.module._measureTransitFramesForTest(8000),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('transit')
|
||||
.module._resetTransitFixtureClockForTest(),
|
||||
);
|
||||
await wait(1500);
|
||||
await cdp.send('HeapProfiler.startSampling', {
|
||||
samplingInterval: 128,
|
||||
includeObjectsCollectedByMajorGC: true,
|
||||
includeObjectsCollectedByMinorGC: true,
|
||||
});
|
||||
await reportTransitVisibility(
|
||||
page,
|
||||
`${count} budget visibility before allocation trace`,
|
||||
);
|
||||
const allocationTrace = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('transit')
|
||||
.module._measureTransitFramesForTest(6000),
|
||||
);
|
||||
const { profile } = await cdp.send('HeapProfiler.stopSampling');
|
||||
const allocations = allocationTotals(profile);
|
||||
const retainedBytes = retained.usedSize - before.usedSize;
|
||||
const evidence = reduceFleet(
|
||||
trace.rows,
|
||||
count,
|
||||
allocations.layerFrameBytes / allocationTrace.rows.length,
|
||||
retainedBytes,
|
||||
loaded.fixBytes,
|
||||
);
|
||||
const details = {
|
||||
...evidence,
|
||||
...allocations,
|
||||
wholeFrameBytesPerFrame:
|
||||
allocations.frameBytes / allocationTrace.rows.length,
|
||||
topAllocatorsPerFrame: allocations.topAllocators.map((a) => ({
|
||||
...a,
|
||||
bytesPerFrame: a.bytes / allocationTrace.rows.length,
|
||||
})),
|
||||
allocationFrames: allocationTrace.rows.length,
|
||||
loaded,
|
||||
dpr: trace.dpr,
|
||||
viewport: [trace.width, trace.height],
|
||||
heapMethod:
|
||||
'GC-bracketed whole-page growth after fleet load (conservative)',
|
||||
allocationMethod:
|
||||
'CDP 128-byte sampling including collected objects; transit source self allocation gates, whole render stacks reported, QA scaffolding excluded',
|
||||
};
|
||||
await writeFile(
|
||||
`${shots}/${tag}-fleet-${count}.json`,
|
||||
JSON.stringify(details, null, 2),
|
||||
);
|
||||
await writeFile(
|
||||
`${shots}/${tag}-fleet-${count}-allocations.json`,
|
||||
JSON.stringify(profile),
|
||||
);
|
||||
check(
|
||||
`${count} moving vehicles meet CPU, frame interval, billboard upload, heap and allocation budgets`,
|
||||
evidence.pass && trace.width === 1920 && trace.height === 1080,
|
||||
JSON.stringify(details),
|
||||
);
|
||||
check(
|
||||
`${count}: transit-authored frame allocation <= 8 KiB`,
|
||||
allocationTrace.rows.length >= 100 &&
|
||||
reduceMotion(allocationTrace.rows).pass &&
|
||||
allocationTrace.rows.every((r) => r.moving === count) &&
|
||||
allocations.layerFrameBytes / allocationTrace.rows.length <= 8192,
|
||||
`${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `${shots}/${tag}-fleet-${count}.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 85,
|
||||
});
|
||||
const settled = await page.evaluate(async () => {
|
||||
const layer =
|
||||
window.__godsEyeView.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest();
|
||||
const { seek } = await import('/src/data/contactPlayback.js');
|
||||
const { updatePlayback } =
|
||||
await import('/src/layers/transit/movement.js');
|
||||
for (const entry of state._vehicles.values()) {
|
||||
entry.clocks.wallNowMs = Date.now();
|
||||
entry.clocks.monoNowMs = performance.now();
|
||||
seek(entry.track, entry.fixes.at(-1).t, entry.clocks);
|
||||
updatePlayback(entry, Date.now(), performance.now());
|
||||
layer._transitPartsForTest().rendering.schedulePlayback(entry);
|
||||
}
|
||||
layer._transitPartsForTest().rendering.syncRenderHold();
|
||||
return { moving: state._moving.size, held: state._renderHeld };
|
||||
});
|
||||
check(
|
||||
`${count}: settling releases the transit hold`,
|
||||
settled.moving === 0 && !settled.held,
|
||||
JSON.stringify(settled),
|
||||
);
|
||||
}
|
||||
await cdp.detach();
|
||||
}
|
||||
|
||||
export async function runBostonMatrix({
|
||||
page,
|
||||
check,
|
||||
wait,
|
||||
shots,
|
||||
tag,
|
||||
selectStyle,
|
||||
sampleRendered,
|
||||
detectOn,
|
||||
}) {
|
||||
console.log(
|
||||
'\n== Boston matrix: scripted Red, 741, 742, Green-E; actual map backgrounds ==',
|
||||
);
|
||||
await detectOn(page);
|
||||
const routes = ['Red', '741', '742', 'Green-E'];
|
||||
const looks = [
|
||||
{ name: 'normal', style: 'normal', params: {} },
|
||||
{ name: 'nvg', style: 'surveillance', params: {} },
|
||||
{ name: 'noir', style: 'noir', params: {} },
|
||||
{
|
||||
name: 'white-hot',
|
||||
style: 'thermal',
|
||||
params: { 'WHOT/BHOT': 0, Ironbow: 0 },
|
||||
},
|
||||
{
|
||||
name: 'black-hot',
|
||||
style: 'thermal',
|
||||
params: { 'WHOT/BHOT': 1, Ironbow: 0 },
|
||||
},
|
||||
{
|
||||
name: 'ironbow',
|
||||
style: 'thermal',
|
||||
params: { 'WHOT/BHOT': 0, Ironbow: 1 },
|
||||
},
|
||||
];
|
||||
// Actual Boston views. Acceptance checks the measured background; a tile/key
|
||||
// failure or an unsuitable view is explicitly unexercised, never assumed bright/dark.
|
||||
const backgrounds = [
|
||||
{
|
||||
name: 'bright',
|
||||
candidates: [
|
||||
{ lat: 42.3458, lon: -71.0464 },
|
||||
{ lat: 42.3659, lon: -71.018 },
|
||||
{ lat: 42.3526, lon: -71.0461 },
|
||||
{ lat: 42.3486, lon: -71.0825 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'dark',
|
||||
candidates: [
|
||||
{ lat: 42.3574, lon: -71.0375 },
|
||||
{ lat: 42.3554, lon: -71.0655 },
|
||||
{ lat: 42.3588, lon: -71.0738 },
|
||||
{ lat: 42.348, lon: -71.032 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const evidence = [];
|
||||
for (const group of backgrounds) {
|
||||
let background, backgroundResult;
|
||||
const attempts = [];
|
||||
await selectStyle(page, 'normal');
|
||||
for (const candidate of group.candidates) {
|
||||
background = { ...candidate, name: group.name };
|
||||
await page.evaluate((b) => {
|
||||
const camera = window.__godsEyeView.viewer.camera,
|
||||
C = camera.positionCartographic.constructor;
|
||||
camera.setView({
|
||||
destination: C.toCartesian(C.fromDegrees(b.lon, b.lat, 600)),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, background);
|
||||
await wait(8000);
|
||||
await page.evaluate(
|
||||
({ background, routes }) => {
|
||||
const layer =
|
||||
window.__godsEyeView.dataManager.layers.get('transit').module;
|
||||
layer._loadTransitFleetForTest(8, background, 45, routes);
|
||||
for (const entry of layer._transitStateForTest()._vehicles.values()) {
|
||||
entry.track.rate = 0;
|
||||
layer._transitPartsForTest().rendering.schedulePlayback(entry);
|
||||
}
|
||||
},
|
||||
{ background, routes },
|
||||
);
|
||||
await wait(1000);
|
||||
backgroundResult = reduceBackground(
|
||||
group.name,
|
||||
await sampleRendered(page),
|
||||
);
|
||||
attempts.push({ background, ...backgroundResult });
|
||||
if (backgroundResult.pass) break;
|
||||
}
|
||||
check(
|
||||
`Boston ${background.name}: all four routes over measured ${background.name} background`,
|
||||
backgroundResult.pass,
|
||||
JSON.stringify({ attempts, chosen: background }),
|
||||
{ unexercised: !backgroundResult.pass },
|
||||
);
|
||||
for (const look of looks) {
|
||||
await selectStyle(page, look.style, look.params);
|
||||
await wait(1200);
|
||||
await reportTransitVisibility(
|
||||
page,
|
||||
`Boston ${background.name}/${look.name} visibility`,
|
||||
);
|
||||
const pixels = await sampleRendered(page);
|
||||
const modes = await page.evaluate(() => {
|
||||
const layer =
|
||||
window.__godsEyeView.dataManager.layers.get('transit').module;
|
||||
return [...layer._transitStateForTest()._vehicles.values()].map(
|
||||
(e) => ({
|
||||
key: e.key,
|
||||
route: e.record.routeId,
|
||||
image: e.marker.image,
|
||||
width: e.marker.width,
|
||||
color: e.marker.color.toCssHexString(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
const contrast =
|
||||
look.name === 'normal'
|
||||
? null
|
||||
: reduceSensorContrast(
|
||||
pixels,
|
||||
look.name === 'black-hot' ? 'black' : 'white',
|
||||
look.style,
|
||||
);
|
||||
check(
|
||||
`Boston ${background.name}/${look.name}: all four silhouettes and mode brackets readable`,
|
||||
pixels.length >= 6 &&
|
||||
pixels.every(
|
||||
(p) =>
|
||||
p.bracket &&
|
||||
p.bracketExpected &&
|
||||
Math.hypot(...p.bracket.map((v, i) => v - p.bracketExpected[i])) <
|
||||
48,
|
||||
) &&
|
||||
(!contrast || contrast.pass),
|
||||
JSON.stringify({ pixels, contrast }),
|
||||
{ unexercised: pixels.length < 6 },
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 90,
|
||||
});
|
||||
for (const target of modes) {
|
||||
await page.evaluate(
|
||||
(key) =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('transit')
|
||||
.module._transitPartsForTest()
|
||||
.selection.selectVehicle(key),
|
||||
target.key,
|
||||
);
|
||||
await wait(500);
|
||||
await page.screenshot({
|
||||
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 90,
|
||||
});
|
||||
const restored = await page.evaluate((key) => {
|
||||
const layer =
|
||||
window.__godsEyeView.dataManager.layers.get('transit').module;
|
||||
layer._transitPartsForTest().selection.clearSelection();
|
||||
const e = layer._transitStateForTest()._vehicles.get(key);
|
||||
return {
|
||||
image: e.marker.image,
|
||||
width: e.marker.width,
|
||||
color: e.marker.color.toCssHexString(),
|
||||
};
|
||||
}, target.key);
|
||||
check(
|
||||
`Boston ${background.name}/${look.name}/${target.route}: deselect restores fleet styling`,
|
||||
restored.image === target.image &&
|
||||
restored.width === target.width &&
|
||||
restored.color === target.color,
|
||||
);
|
||||
}
|
||||
await wait(300);
|
||||
await page.screenshot({
|
||||
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 90,
|
||||
});
|
||||
evidence.push({
|
||||
background,
|
||||
look,
|
||||
pixels,
|
||||
routes: modes.map((m) => m.route),
|
||||
});
|
||||
}
|
||||
}
|
||||
await selectStyle(page, 'normal');
|
||||
await writeFile(
|
||||
`${shots}/${tag}-boston-matrix.json`,
|
||||
JSON.stringify(evidence, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
/** Real MBTA observations at oblique street views, separate from matrix fixtures. */
|
||||
export async function runBostonLive({
|
||||
page,
|
||||
check,
|
||||
wait,
|
||||
shots,
|
||||
tag,
|
||||
detectOn,
|
||||
selectStyle,
|
||||
sampleRendered,
|
||||
}) {
|
||||
console.log('\n== Boston live oblique streets ==');
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('transit', false, { source: 'qa' });
|
||||
const camera = app.viewer.camera,
|
||||
C = camera.positionCartographic.constructor;
|
||||
camera.setView({
|
||||
destination: C.toCartesian(C.fromDegrees(-71.0605, 42.3554, 600)),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 4, roll: 0 },
|
||||
});
|
||||
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
layer._setTransitFixtureFloorsForTest([], 0);
|
||||
await layer.update();
|
||||
});
|
||||
await wait(20000);
|
||||
await detectOn(page);
|
||||
const evidence = [];
|
||||
for (const view of [
|
||||
{
|
||||
name: 'white-hot',
|
||||
style: 'thermal',
|
||||
altitude: 120,
|
||||
heading: 0,
|
||||
pitch: -20,
|
||||
params: { 'WHOT/BHOT': 0, Ironbow: 0 },
|
||||
},
|
||||
{
|
||||
name: 'nvg',
|
||||
style: 'surveillance',
|
||||
altitude: 600,
|
||||
heading: 30,
|
||||
pitch: -45,
|
||||
params: {},
|
||||
},
|
||||
{
|
||||
name: 'noir',
|
||||
style: 'noir',
|
||||
altitude: 400,
|
||||
heading: 60,
|
||||
pitch: -35,
|
||||
params: {},
|
||||
},
|
||||
]) {
|
||||
await page.evaluate((view) => {
|
||||
const app = window.__godsEyeView,
|
||||
camera = app.viewer.camera,
|
||||
C = camera.positionCartographic.constructor;
|
||||
app.dataManager.layers
|
||||
.get('transit')
|
||||
.module._transitPartsForTest()
|
||||
.selection.clearSelection();
|
||||
camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(-71.0605, 42.3554, view.altitude),
|
||||
),
|
||||
orientation: {
|
||||
heading: (view.heading * Math.PI) / 180,
|
||||
pitch: (view.pitch * Math.PI) / 180,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
}, view);
|
||||
await selectStyle(page, view.style, view.params);
|
||||
await wait(8000);
|
||||
const fleet = await page.evaluate(() => {
|
||||
const state = window.__godsEyeView.dataManager.layers
|
||||
.get('transit')
|
||||
.module._transitStateForTest();
|
||||
const entries = [...state._vehicles.values()];
|
||||
return {
|
||||
total: entries.length,
|
||||
visible: entries.filter((e) => e.marker?.show).length,
|
||||
fixtures: entries.filter((e) => e.qaFixture).length,
|
||||
routes: [
|
||||
...new Set(
|
||||
entries.filter((e) => e.marker?.show).map((e) => e.record.routeId),
|
||||
),
|
||||
],
|
||||
};
|
||||
});
|
||||
check(
|
||||
`Boston live/${view.name}: observed vehicles in the oblique street view`,
|
||||
fleet.total > 0 && fleet.visible > 0 && fleet.fixtures === 0,
|
||||
JSON.stringify({ view, fleet }),
|
||||
{ unexercised: fleet.visible === 0 },
|
||||
);
|
||||
const pixels = await sampleRendered(page);
|
||||
const contrast = reduceSensorContrast(pixels, 'white', view.style);
|
||||
check(
|
||||
`Boston live/${view.name}: unobscured sensor contrast`,
|
||||
contrast.pass,
|
||||
JSON.stringify(contrast),
|
||||
{ unexercised: contrast.unexercised },
|
||||
);
|
||||
const target = pixels[0];
|
||||
let selection = null;
|
||||
if (target) {
|
||||
await page.mouse.click(target.x, target.y);
|
||||
await wait(250);
|
||||
selection = await page.evaluate((key) => {
|
||||
const state = window.__godsEyeView.dataManager.layers
|
||||
.get('transit')
|
||||
.module._transitStateForTest();
|
||||
return {
|
||||
expected: key,
|
||||
actual: state._selectedKey,
|
||||
pick: state._lastPickForTest ?? null,
|
||||
};
|
||||
}, target.key);
|
||||
}
|
||||
check(
|
||||
`Boston live/${view.name}: real mouse selects an observed vehicle`,
|
||||
selection?.actual === target?.key && !!target,
|
||||
JSON.stringify(selection),
|
||||
{ unexercised: !target },
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `${shots}/${tag}-boston-live-${view.name}.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 90,
|
||||
});
|
||||
evidence.push({ view, fleet, pixels, contrast, selection });
|
||||
}
|
||||
await writeFile(
|
||||
`${shots}/${tag}-boston-live.json`,
|
||||
JSON.stringify(evidence, null, 2),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+139
-32
@@ -2860,6 +2860,55 @@ async function main() {
|
||||
tick();
|
||||
setTimeout(() => { done = true; res(); }, ms);
|
||||
});
|
||||
// A timer can expire while the camera is new but the model/cache still
|
||||
// belongs to the old frame. Observe the first completed exit frame,
|
||||
// including a wrong result; never wait for visibility or height to pass.
|
||||
window.__dfObserveTrackedExit = async ({ scene, trackedEntity, trackedModel,
|
||||
getTrackedModel, getTrackedEntity, getTrackedId, getCameraHeight,
|
||||
exitHeight, read, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) => {
|
||||
const deadline = now() + 5000;
|
||||
let removePre, removePost, removeError, timer;
|
||||
let preFrame = null;
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
let finished = false;
|
||||
const finish = (result) => { if (!finished) { finished = true; resolve(result); } };
|
||||
const valid = () => {
|
||||
try {
|
||||
if (now() > deadline) return 'tracked exit observation exceeded its 5000 ms deadline';
|
||||
if (getTrackedEntity() !== trackedEntity || getTrackedId() !== 'aaa097') return 'tracked exit fixture identity changed';
|
||||
if (!trackedModel || getTrackedModel() !== trackedModel) return 'tracked exit model identity changed';
|
||||
if (!(getCameraHeight() > exitHeight)) return 'tracked exit camera left the exit regime';
|
||||
return null;
|
||||
} catch { return 'tracked exit fixture validation failed'; }
|
||||
};
|
||||
removePre = scene.preUpdate.addEventListener(() => {
|
||||
if (finished) return;
|
||||
const error = valid();
|
||||
if (error) { finish({ error }); return; }
|
||||
if (preFrame == null) preFrame = scene.frameState.frameNumber;
|
||||
});
|
||||
removePost = scene.postRender.addEventListener(() => {
|
||||
if (finished || preFrame == null) return;
|
||||
const error = valid();
|
||||
if (error) { finish({ error }); return; }
|
||||
if (scene.frameState.frameNumber === preFrame) {
|
||||
finish({ error: 'tracked exit observation has no new rendered frame' }); return;
|
||||
}
|
||||
try {
|
||||
const snapshot = read();
|
||||
finish(snapshot ? { ...snapshot, observationFrame: scene.frameState.frameNumber }
|
||||
: { error: 'tracked exit snapshot missing' });
|
||||
} catch { finish({ error: 'tracked exit snapshot failed' }); }
|
||||
});
|
||||
removeError = scene.renderError.addEventListener(() => finish({ error: 'tracked exit render failed' }));
|
||||
timer = setTimer(() => finish({ error: 'tracked exit update did not complete within 5000 ms' }), 5000);
|
||||
scene.requestRender();
|
||||
});
|
||||
} finally {
|
||||
clearTimer(timer); removePre?.(); removePost?.(); removeError?.();
|
||||
}
|
||||
};
|
||||
// Cesium Models in the scene, split by visibility (duck-typed the same
|
||||
// way the harness excludes them from height probes).
|
||||
//
|
||||
@@ -3378,10 +3427,30 @@ async function main() {
|
||||
// the billboard — which MUST be floored, or a tracked grounded contact
|
||||
// renders buried at any distance the operator pulls out to. This is the
|
||||
// coverage the toggle used to reach; the zoom regime reaches it now.
|
||||
const findOwnModel = () => {
|
||||
let found = null;
|
||||
const walk = (collection) => {
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
const item = collection.get(i);
|
||||
if (typeof item?.get === 'function' && typeof item.length === 'number') walk(item);
|
||||
else if (item?.id === 'aaa097' && item.activeAnimations !== undefined
|
||||
&& item.minimumPixelSize !== undefined) found = item;
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return found;
|
||||
};
|
||||
const trackedEntity = v.trackedEntity;
|
||||
const trackedModel = findOwnModel();
|
||||
const achievedOut = await window.__dfZoomAbove(window.__dfRegime.exit + 5000);
|
||||
await window.__dfSettle(1500);
|
||||
const zoomedOut = readTracked();
|
||||
if (!zoomedOut) return { error: 'tracked entity has no position (zoomed out)' };
|
||||
const zoomedOut = await window.__dfObserveTrackedExit({
|
||||
scene: v.scene, trackedEntity, trackedModel, getTrackedModel: findOwnModel,
|
||||
getTrackedEntity: () => v.trackedEntity,
|
||||
getTrackedId: () => fl.getTrackedInfo()?.icao24,
|
||||
getCameraHeight: window.__dfCamH, exitHeight: window.__dfRegime.exit,
|
||||
read: readTracked,
|
||||
});
|
||||
if (zoomedOut.error) return { error: zoomedOut.error };
|
||||
|
||||
const out = {
|
||||
rawH: d0.h,
|
||||
@@ -3643,11 +3712,10 @@ async function main() {
|
||||
}
|
||||
|
||||
// ---- F3: the LOADING window is billboard-owned, not model-owned ------
|
||||
// A fleet model is registered with Cesium's default show=true the instant
|
||||
// its glTF resolves, but the handoff waits for `ready`; and a tracked
|
||||
// model is null for the whole load even though the regime is already
|
||||
// active. Both windows leave the BILLBOARD as the visual, so both must
|
||||
// stay floored — ownership means actually rendering.
|
||||
// Stress the fleet ownership predicate with synthetic show=true/ready=false
|
||||
// flags. Production admission keeps the model hidden; these shadows do
|
||||
// not simulate Cesium's internal loading/draw state. The tracked check
|
||||
// below separately samples the no-rendering-model window.
|
||||
const dfLoading = await evalPage(async () => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js');
|
||||
@@ -3663,13 +3731,8 @@ async function main() {
|
||||
};
|
||||
const out = {};
|
||||
|
||||
// (a) FLEET: reach the real admitted-but-not-ready window. A fleet model
|
||||
// is registered in `_models` with Cesium's default show=true the moment
|
||||
// its glTF resolves, while the billboard handoff waits for `ready` — so
|
||||
// for a tick the model claims the visual it is not drawing. That window
|
||||
// is ~one fleet tick wide, so hold the state open by shadowing the two
|
||||
// flags on the instance (the same defineProperty trick the ground-3d
|
||||
// group uses on `tilesLoaded`).
|
||||
// (a) FLEET: retain the adversarial flags until this contact has passed
|
||||
// through its installed fleet update and that frame has completed.
|
||||
const findFleetModel = (id) => {
|
||||
let found = null;
|
||||
const walk = (coll) => {
|
||||
@@ -3698,8 +3761,26 @@ async function main() {
|
||||
// assigns `model.show = false` for a not-ready model, and assigning to a
|
||||
// non-writable own property throws in strict mode — which lands inside
|
||||
// Cesium's render loop and stops rendering for the rest of the run.
|
||||
const priorReady = Object.getOwnPropertyDescriptor(fleetModel, 'ready');
|
||||
const priorShow = Object.getOwnPropertyDescriptor(fleetModel, 'show');
|
||||
let armed = false;
|
||||
let processedFrame = null;
|
||||
let sample = null;
|
||||
let removePostRender = null;
|
||||
try {
|
||||
Object.defineProperty(fleetModel, 'ready', { get: () => false, set: () => {}, configurable: true });
|
||||
Object.defineProperty(fleetModel, 'show', { get: () => true, set: () => {}, configurable: true });
|
||||
Object.defineProperty(fleetModel, 'show', {
|
||||
get: () => true,
|
||||
set: (value) => {
|
||||
// Both the not-ready handoff and horizon cull clear show AFTER
|
||||
// computing this contact's display floor. Neither a timer nor an
|
||||
// unrelated rendered frame proves that this aircraft was processed.
|
||||
if (armed && value === false && processedFrame == null) {
|
||||
processedFrame = v.scene.frameState.frameNumber;
|
||||
}
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
const bb = window.__dfFindBB('aaa097');
|
||||
if (!bb) return { error: 'aaa097 billboard missing' };
|
||||
bb.show = true; // the handoff would not have hidden it: the model is not ready
|
||||
@@ -3713,21 +3794,45 @@ async function main() {
|
||||
fl._clearDisplayFloorStateForTest();
|
||||
const seededFleet = d0.h + 45;
|
||||
floorAround(d0, seededFleet);
|
||||
// reportMeshFloorCell is a direct test seam and does not schedule a
|
||||
// Cesium frame. Force the CallbackProperty to re-evaluate before the
|
||||
// assertion, matching the request-render fix in the retained-model
|
||||
// scenario above.
|
||||
v.scene.requestRender();
|
||||
await window.__dfSettle(900);
|
||||
const bbAfter = window.__dfFindBB('aaa097');
|
||||
out.fleetModelReady = fleetModel.ready;
|
||||
out.fleetModelShow = fleetModel.show;
|
||||
out.fleetBillboardVisible = !!bbAfter?.show;
|
||||
out.fleetSeeded = seededFleet;
|
||||
out.fleetH = bbAfter ? window.__dfCarto(bbAfter.position).h : null;
|
||||
delete fleetModel.ready;
|
||||
delete fleetModel.show;
|
||||
fl.setParams({ models3d: false });
|
||||
const tickDeadline = Date.now() + 5000;
|
||||
removePostRender = v.scene.postRender.addEventListener(() => {
|
||||
if (sample || processedFrame == null || v.scene.frameState.frameNumber < processedFrame) return;
|
||||
if (Date.now() > tickDeadline) {
|
||||
sample = { error: 'fleet loading fixture completed after its 5000 ms deadline' };
|
||||
return;
|
||||
}
|
||||
const currentBb = window.__dfFindBB('aaa097');
|
||||
const currentModel = findFleetModel('aaa097');
|
||||
sample = {
|
||||
fleetTickCompleted: true,
|
||||
fleetFrame: v.scene.frameState.frameNumber,
|
||||
fleetModelReady: fleetModel.ready,
|
||||
fleetModelShow: fleetModel.show,
|
||||
fleetBillboardVisible: !!currentBb?.show,
|
||||
fleetSeeded: seededFleet,
|
||||
fleetH: currentBb ? window.__dfCarto(currentBb.position).h : null,
|
||||
};
|
||||
if (currentBb !== bb || currentModel !== fleetModel || fleetModel.isDestroyed()
|
||||
|| fl.getTrackedInfo()) sample.error = 'fleet loading fixture changed before its completed frame';
|
||||
});
|
||||
armed = true;
|
||||
// This is a setup-completion deadline, not a visual latency budget.
|
||||
// Take the FIRST completed fleet result, even when its height is wrong;
|
||||
// never poll the height until the assertion happens to pass.
|
||||
do {
|
||||
await window.__dfSettle(50);
|
||||
} while (!sample && Date.now() < tickDeadline);
|
||||
if (!sample) return { error: 'fleet loading fixture did not complete an aircraft update within 5000 ms' };
|
||||
Object.assign(out, sample);
|
||||
} finally {
|
||||
armed = false;
|
||||
removePostRender?.();
|
||||
if (priorReady) Object.defineProperty(fleetModel, 'ready', priorReady);
|
||||
else delete fleetModel.ready;
|
||||
if (priorShow) Object.defineProperty(fleetModel, 'show', priorShow);
|
||||
else delete fleetModel.show;
|
||||
fl.setParams({ models3d: false });
|
||||
}
|
||||
await window.__dfSettle(400);
|
||||
|
||||
// (b) TRACKED, regime ACTIVE but no model yet. Turning 3D on while
|
||||
@@ -3771,7 +3876,8 @@ async function main() {
|
||||
skip('display-floor/loading: fleet model loading window', dfLoading.skipped);
|
||||
} else {
|
||||
record('display-floor/loading: a fleet model that is shown-but-not-ready does not own the visual',
|
||||
!dfLoading.error && dfLoading.fleetModelShow === true && dfLoading.fleetModelReady === false
|
||||
!dfLoading.error && dfLoading.fleetTickCompleted === true
|
||||
&& dfLoading.fleetModelShow === true && dfLoading.fleetModelReady === false
|
||||
&& dfLoading.fleetBillboardVisible === true
|
||||
&& Number.isFinite(dfLoading.fleetH)
|
||||
&& dfLoading.fleetH >= dfLoading.fleetSeeded + DISPLAY_FLOOR_LIFT_M - 0.5,
|
||||
@@ -3955,6 +4061,7 @@ async function main() {
|
||||
delete window.__dfPriorTilesLoadedDescriptor;
|
||||
delete window.__dfSkinSamples;
|
||||
delete window.__dfSkinM;
|
||||
delete window.__dfObserveTrackedExit;
|
||||
await fl.update(v);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user