refactor: own application request operations and surface caches

This commit is contained in:
Sameh Khamis committed 2026-09-14 14:49:04 -07:00
1 parent 310809e8f1
commit ac977bd8bc
53 files changed
+4388 -3317

No files matched your search

+2
View File
@@ -91,6 +91,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
- Give application request services, terrain/floor caches and annotation lookup state explicit owners and cancellation; share them across controls, layers and voice.
- Construct application layers from explicit sources, with standalone provider selection and catalog-owned aircraft classification; controls and voice queries use those instances.
- Let application data and controls receive the same explicit layer catalog; keep standalone defaults and registration-before-restoration ordering.
+18
View File
@@ -430,3 +430,21 @@ exports. Browser regression probes use the registered instance's testing surface
to avoid accidentally inspecting an unused compatibility instance. Existing source
setters apply only to compatibility instances; the normal application supplies
its sources at construction.
### Application operations
`application/operations` accepts request-service instances and an application
lifetime. It constructs terrain resolution, coarse floor/mesh caches and an
annotation resolver without selecting upstream providers. Its checked graph is
separate from standalone setup. Scene construction returns these operations; the
catalog and controls use the same surface owner. Layers still own their individual
ground-snap caches and model resources.
Terrain cancellation rejects late replies before caching, clears floor queues and
removes the map-stack listener. Annotation lookup caches are instance-owned and
cleared on cancellation. Geometry selection and floor policies are unchanged.
HUD and weather controllers accept their respective service; regional lookup and
location framing use the supplied operations. Voice shares the same boundary and
floor services, with analyst memory scoped to the runner. Direct compatibility
entrypoints retain default services; normal assembly does not configure their
source slots.
+9
View File
@@ -1,5 +1,14 @@
# God's Eye View Current State
Application scene setup owns request services, terrain/floor caches and annotation
lookup state. Controls, layers and voice share those owners, and cancellation
clears caches and removes map-stack listeners. Standalone setup supplies the
existing service clients. HUD summaries, regional brief, cockpit weather, location
framing and annotation boundaries receive their service instances explicitly.
Compatibility entrypoints retain defaults for direct callers; normal startup does
not change global service slots. Analyst follow-up memory belongs to its voice runner.
Application startup constructs fresh layer instances from explicit source objects.
Standalone composition selects the existing providers. Controls, launch orbit
lookups, Contacts voice answers and ISS pass queries use the registered catalog.
+2 -1
View File
@@ -194,6 +194,7 @@
},
"./application/catalog": "./src/app/catalog.js",
"./standalone/catalog": "./src/standalone/catalog.js",
"./application/layers": "./src/app/constructCatalog.js"
"./application/layers": "./src/app/constructCatalog.js",
"./application/operations": "./src/app/operations.js"
}
}
+10 -1
View File
@@ -577,5 +577,14 @@
"src/app/layers/aisLiveVessels.js",
"src/app/constructCatalog.js",
"src/standalone/layerSources.js",
"src/app/constructCatalog.test.mjs"
"src/app/constructCatalog.test.mjs",
"src/app/surfaceServices.js",
"src/app/operations.js",
"src/data/surfaceServices.js",
"src/annotations/resolver.js",
"src/services/terrainHeights.js",
"src/services/groundFloor.js",
"src/services/meshFloorSampler.js",
"src/services/groundSnap.js",
"src/app/operations.test.mjs"
]
+51 -6
View File
@@ -1521,7 +1521,15 @@
"src/app/layers/overlayHost.js",
"src/app/layers/aisLiveVessels.js",
"src/app/constructCatalog.js",
"src/standalone/layerSources.js"
"src/standalone/layerSources.js",
"src/app/surfaceServices.js",
"src/app/operations.js",
"src/data/surfaceServices.js",
"src/annotations/resolver.js",
"src/services/terrainHeights.js",
"src/services/groundFloor.js",
"src/services/meshFloorSampler.js",
"src/services/groundSnap.js"
],
"external": [
"@mapbox/vector-tile",
@@ -1541,6 +1549,7 @@
"exports": ["./application/layers"],
"modules": [
"src/annotations/annotationResolver.js",
"src/annotations/resolver.js",
"src/app/catalog.js",
"src/app/constructCatalog.js",
"src/app/layers/aisLiveVessels.js",
@@ -1581,14 +1590,11 @@
"src/data/detectionCohort.js",
"src/data/detectionDraw.js",
"src/data/detectionPolicy.js",
"src/data/fireAnchors.js",
"src/data/firmsAdapt.js",
"src/data/firmsLabels.js",
"src/data/flowMatch.js",
"src/data/focusDeemphasis.js",
"src/data/geoid.js",
"src/data/groundFloor.js",
"src/data/groundSnap.js",
"src/data/iconOrientation.js",
"src/data/infrastructure.js",
"src/data/installationFeedback.js",
@@ -1601,7 +1607,6 @@
"src/data/local_data/natural_earth/marine.json",
"src/data/local_data/natural_earth/regions.json",
"src/data/local_data/neighborhoods/san-francisco.json",
"src/data/meshFloorSampler.js",
"src/data/militaryAwarenessEngine.js",
"src/data/militaryInstallationData.js",
"src/data/modelVisualAnchor.js",
@@ -1616,7 +1621,6 @@
"src/data/satelliteClass.js",
"src/data/scenePick.js",
"src/data/spriteOrder.js",
"src/data/terrainHeights.js",
"src/data/tomtomTiles.js",
"src/data/tr3bRegistry.js",
"src/data/trackedCamera.js",
@@ -1859,5 +1863,46 @@
"pbf",
"mgrs"
]
},
"application-operations": {
"exports": ["./application/operations"],
"modules": [
"src/annotations/annotationResolver.js",
"src/annotations/resolver.js",
"src/app/operations.js",
"src/app/sourceSlot.js",
"src/app/surfaceServices.js",
"src/data/dataCredits.js",
"src/data/geoid.js",
"src/data/local_data/natural_earth/marine.json",
"src/data/local_data/natural_earth/regions.json",
"src/data/local_data/neighborhoods/san-francisco.json",
"src/data/naturalEarthRegions.js",
"src/data/neighborhoodPolygons.js",
"src/data/retryableLoad.js",
"src/data/scenePick.js",
"src/keylessGeocoder.js",
"src/layers/firms/anchors.js",
"src/locations.js",
"src/search/defaults.js",
"src/search/geospatial.js",
"src/search/google.js",
"src/search/http.js",
"src/search/placeSearch.js",
"src/services/application.js",
"src/services/groundFloor.js",
"src/services/groundSnap.js",
"src/services/meshFloorSampler.js",
"src/services/requests.js",
"src/services/terrainHeights.js"
],
"external": [
"cesium",
"egm96-universal",
"satellite.js",
"@mapbox/vector-tile",
"pbf",
"mgrs"
]
}
}
+5 -11
View File
@@ -2986,17 +2986,12 @@ async function main() {
}
return window.__dfCountModels(icao);
};
// Vite serves an edited source file as `…/groundFloor.js?t=<hmr stamp>`;
// importing the PLAIN path then hands back a second, unrelated module
// instance whose cells the app never reads. Offer the URL the app itself
// loaded first, then the plain path (clean, never-hot-reloaded server).
const seen = performance.getEntriesByType('resource')
.map((e) => e.name)
.filter((n) => /\/src\/data\/groundFloor\.js(\?|$)/.test(n));
window.__dfCandidates = [...new Set([...seen.reverse(), `${window.__gevQaSourceBase || '/src'}/data/groundFloor.js`])];
// Read the application's surface owner, then prove it is the one
// used by the actual poll/render path with the unchanged floor seed.
window.__dfCandidates = window.__godsEyeView.surfaceServices?.groundFloor ? ['application surface'] : [];
return { candidates: window.__dfCandidates.length };
});
record('display-floor: groundFloor module URL candidates found', dfSetup.candidates > 0,
record('display-floor: ground-floor service owner found', dfSetup.candidates > 0,
JSON.stringify(dfSetup));
// Identity probe. Seed a contact's FIX cell BEFORE its first fix arrives,
@@ -3012,8 +3007,7 @@ async function main() {
const tried = [];
for (let i = 0; i < window.__dfCandidates.length; i++) {
const url = window.__dfCandidates[i];
let gf;
try { gf = await import(/* @vite-ignore */ url); } catch { tried.push({ url, h: null }); continue; }
const gf = window.__godsEyeView.surfaceServices.groundFloor;
if (typeof gf.reportMeshFloorCell !== 'function') { tried.push({ url, h: null }); continue; }
gf.setMeshFloorPreferred(true);
gf._clearMeshFloorCellsForTest();
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -10,13 +10,13 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
* and voice tool wiring are identical to the other two branches.
*/
export function initAnnotations({ viewer, tileset = null, placeSearch }) {
export function initAnnotations({ viewer, tileset = null, placeSearch, resolver }) {
// World-space footprint draping; clamped marks can use the photoreal tiles.
if (tileset) {
try { tileset.enableCollision = true; } catch { /* older tileset */ }
}
const renderer = createHybridAnnotationRenderer(viewer);
const engine = createAnnotationEngine({ viewer, renderer, placeSearch });
const engine = createAnnotationEngine({ viewer, renderer, placeSearch, resolveTarget: resolver?.resolveAnnotationTarget });
window.__gevAnnotations = engine;
return engine;
}
File diff suppressed because it is too large. Load diff
+12 -4
View File
@@ -47,6 +47,7 @@ const SOURCE_METHODS = Object.freeze({
* The manager owns layer destruction, while abort releases classification even if startup fails.
*/
export function createApplicationCatalog({
surface,
sources,
signal,
metadata = LAYER_STATE_REGISTRY,
@@ -56,6 +57,9 @@ export function createApplicationCatalog({
if (!signal?.addEventListener)
throw new TypeError('An application lifetime signal is required');
signal.throwIfAborted();
if (!surface?.groundFloor || !surface?.terrain)
throw new TypeError('Application surface services are required');
for (const [name, methods] of Object.entries(SOURCE_METHODS)) {
if (
methods.some((method) => typeof sources?.[name]?.[method] !== 'function')
@@ -71,11 +75,13 @@ export function createApplicationCatalog({
try {
militaryRegistry.configureSource(sources.military, { signal });
const flights = createApplicationFlights({
surface,
source: sources.flights,
militaryRegistry,
resolveAsset,
});
const military = createApplicationMilitary({
surface,
source: sources.military,
militaryRegistry,
resolveAsset,
@@ -85,6 +91,7 @@ export function createApplicationCatalog({
options: vesselOptions,
});
const installations = createApplicationInstallations({
surface,
source: sources.installations,
});
const satellites = createApplicationSatellites({
@@ -95,12 +102,12 @@ export function createApplicationCatalog({
flights,
military,
createApplicationEarthquakes({ source: sources.earthquakes }),
createApplicationAlpr({ source: sources.alpr }),
createApplicationAlpr({ surface, source: sources.alpr }),
satellites,
createApplicationLaunches({ source: sources.launches, satellites }),
createApplicationTraffic({ source: sources.traffic }),
createApplicationCctv({ source: sources.cctv }),
createApplicationRadio({ source: sources.radio }),
createApplicationCctv({ surface, source: sources.cctv }),
createApplicationRadio({ surface, source: sources.radio }),
createApplicationBikeshare({ source: sources.bikeshare }),
vessels,
installations,
@@ -113,6 +120,7 @@ export function createApplicationCatalog({
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
createApplicationFirms({
surface,
id: 'local-firms',
name: 'FIRMS Active Fires',
icon: '▲',
@@ -122,7 +130,7 @@ export function createApplicationCatalog({
],
metadata,
);
return Object.freeze({ ...catalog, militaryRegistry });
return Object.freeze({ ...catalog, militaryRegistry, surface });
} catch (error) {
dispose();
throw error;
+17 -1
View File
@@ -1,3 +1,4 @@
import { createSurfaceServices } from './surfaceServices.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApplicationCatalog } from './constructCatalog.js';
@@ -30,10 +31,12 @@ test('catalogs construct distinct layers and classification from their supplied
const first = createApplicationCatalog({
sources: fixtureSources(['abc123'], callsA),
signal: a.signal,
surface: fixtureSurface(a.signal),
});
const second = createApplicationCatalog({
sources: fixtureSources(['def456'], callsB),
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 17);
assert.deepEqual(
@@ -62,7 +65,12 @@ test('catalogs construct distinct layers and classification from their supplied
test('invalid or already cancelled construction fails before classification can acquire', () => {
const lifetime = new AbortController();
assert.throws(
() => createApplicationCatalog({ sources: {}, signal: lifetime.signal }),
() =>
createApplicationCatalog({
sources: {},
signal: lifetime.signal,
surface: fixtureSurface(lifetime.signal),
}),
/catalog source/,
);
lifetime.abort();
@@ -75,3 +83,11 @@ test('invalid or already cancelled construction fails before classification can
{ name: 'AbortError' },
);
});
function fixtureSurface(signal) {
return createSurfaceServices({
terrainSource: { getHeights: async () => [] },
signal,
eventTarget: null,
});
}
+13 -3
View File
@@ -5,7 +5,7 @@ import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
/** Construct the existing controls and camera presentation. */
export function createApplicationControls({
scene: { viewer, mapStackController },
scene: { viewer, mapStackController, operations },
loaderStatus,
Controls = StyleManager,
services,
@@ -15,7 +15,15 @@ export function createApplicationControls({
}) {
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new Controls(viewer, {
services: { ...services, ...catalogControlServices(catalog) },
services: {
...services,
...operations.surface.controlServices,
searchAndFlyTo: operations.searchAndFlyTo,
fetchRegionalBrief: (...args) =>
operations.requests.regional.getBrief(...args),
...catalogControlServices(catalog),
},
requestServices: operations.requests,
mapStackController,
placeSearch,
});
@@ -26,7 +34,9 @@ export function createApplicationControls({
// clouds use a separate, capped low-resolution GPU pass that never attaches
// Cesium fog or post-process stages and is fully stopped in map mode.
const weatherEffects = null;
const cockpitCloudEffects = initCockpitCloudEffects(viewer);
const cockpitCloudEffects = initCockpitCloudEffects(viewer, {
weatherService: operations.requests.weather,
});
defer(() => cockpitCloudEffects?.destroy());
// If no share link state, do default fly-to Austin
+2 -2
View File
@@ -2,10 +2,10 @@ import { createAlprCamerasLayer } from '../../layers/alpr/index.js';
import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js';
import * as picking from '../../data/pickRegistry.js';
import * as groundFloor from '../../data/groundFloor.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationAlpr({ source }) {
export function createApplicationAlpr({ surface, source }) {
const { groundFloor } = surface;
return createAlprCamerasLayer({
source,
services: { render, context, picking, groundFloor },
+2 -4
View File
@@ -4,14 +4,12 @@ import * as activation from '../../cctvFocusRequest.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as locations from '../../locations.js';
import * as picking from '../../data/pickRegistry.js';
import * as terrain from '../../data/terrainHeights.js';
import * as ground from '../../data/groundFloor.js';
import * as mesh from '../../data/meshFloorSampler.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationCctv({ source }) {
export function createApplicationCctv({ surface, source }) {
const { terrain, groundFloor: ground, meshFloor: mesh } = surface;
return createCctvLayer({
source,
services: {
+2 -4
View File
@@ -4,7 +4,6 @@ import * as render from '../../renderGovernor.js';
import * as sprites from '../../data/spriteOrder.js';
import * as context from '../../data/contextStore.js';
import * as picking from '../../data/pickRegistry.js';
import * as anchors from '../../data/fireAnchors.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as focus from '../../worldFocus.js';
export const firmsServices = {
@@ -12,19 +11,18 @@ export const firmsServices = {
sprites,
context,
picking,
anchors,
overlays,
focus,
};
/** Bind a supplied fire feed to the application scene service owners. */
export function createApplicationFirms(options) {
export function createApplicationFirms({ surface, ...options }) {
return createLayer({
...options,
icon: options.icon ?? '▲',
source: options.source ?? 'NASA FIRMS',
feed: options.feed,
services: firmsServices,
services: { ...firmsServices, anchors: surface.anchors },
overlayHost: options.overlayHost ?? {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
+2 -3
View File
@@ -5,23 +5,22 @@ import * as trails from '../../data/trailRenderer.js';
import * as aircraftPresentation from '../../data/tr3bRegistry.js';
import * as camera from '../../data/trackedCamera.js';
import * as labels from '../../data/detectionDraw.js';
import * as groundFloor from '../../data/groundFloor.js';
import * as meshFloor from '../../data/meshFloorSampler.js';
import * as geoid from '../../data/geoid.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as readout from '../../data/trackedReadout.js';
import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as groundSnap from '../../data/groundSnap.js';
import * as recession from '../../data/aircraftRecession.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationFlights({
surface,
source,
militaryRegistry,
resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
}) {
const { groundFloor, meshFloor, groundSnap } = surface;
return createCivilFlightLayer({
source,
resolveAsset,
+2 -3
View File
@@ -5,23 +5,22 @@ import * as trails from '../../data/trailRenderer.js';
import * as aircraftPresentation from '../../data/tr3bRegistry.js';
import * as camera from '../../data/trackedCamera.js';
import * as labels from '../../data/detectionDraw.js';
import * as groundFloor from '../../data/groundFloor.js';
import * as meshFloor from '../../data/meshFloorSampler.js';
import * as geoid from '../../data/geoid.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as readout from '../../data/trackedReadout.js';
import * as context from '../../data/contextStore.js';
import * as render from '../../renderGovernor.js';
import * as groundSnap from '../../data/groundSnap.js';
import * as recession from '../../data/aircraftRecession.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationMilitary({
surface,
source,
militaryRegistry,
resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
}) {
const { groundFloor, meshFloor, groundSnap } = surface;
return createMilitaryFlightLayer({
source,
resolveAsset,
+2 -3
View File
@@ -1,12 +1,11 @@
import { createInstallationsLayer } from '../../layers/installations/index.js';
import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js';
import * as ground from '../../data/groundFloor.js';
import * as anchors from '../../data/fireAnchors.js';
import * as picking from '../../data/pickRegistry.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationInstallations({ source }) {
export function createApplicationInstallations({ surface, source }) {
const { groundFloor: ground, anchors } = surface;
return createInstallationsLayer({
source,
services: { render, context, ground, anchors, picking },
+2 -2
View File
@@ -1,12 +1,12 @@
import { createRadioLayer } from '../../layers/radio/index.js';
import * as ground from '../../data/groundFloor.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as globe from '../../celestialRing.js';
import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationRadio({ source }) {
export function createApplicationRadio({ surface, source }) {
const { groundFloor: ground } = surface;
return createRadioLayer({
source,
services: { ground, picking, overlays, globe, render },
+37
View File
@@ -0,0 +1,37 @@
import { createSurfaceServices } from './surfaceServices.js';
import { createAnnotationResolver } from '../annotations/resolver.js';
import { searchAndFlyTo } from '../locations.js';
/** Assemble application operations from the caller's request services. */
export function createApplicationOperations({ requests, signal, eventTarget }) {
for (const [name, method] of Object.entries({
boundaries: 'query',
terrain: 'getHeights',
regional: 'getBrief',
weather: 'getConditions',
summary: 'summarize',
})) {
if (typeof requests?.[name]?.[method] !== 'function')
throw new TypeError(`Missing application request service: ${name}`);
}
const surface = createSurfaceServices({
terrainSource: requests.terrain,
signal,
eventTarget,
});
const annotationResolver = createAnnotationResolver({
boundarySource: requests.boundaries,
signal,
});
return Object.freeze({
requests,
surface,
annotationResolver,
searchAndFlyTo: (viewer, query, options = {}) =>
searchAndFlyTo(viewer, query, {
...options,
boundaries: requests.boundaries,
recoverNearView: annotationResolver.placesNearViewRecovery,
}),
});
}
+165
View File
@@ -0,0 +1,165 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApplicationOperations } from './operations.js';
function requests(terrain, boundaries = { query: async () => [] }) {
return {
terrain,
boundaries,
regional: { getBrief: async () => ({}) },
weather: { getConditions: async () => ({}) },
summary: { summarize: async () => ({}) },
};
}
test('surface cache and stack listeners belong to the supplied application lifetime', async (t) => {
const a = new AbortController();
const b = new AbortController();
t.after(() => {
a.abort();
b.abort();
});
const target = new EventTarget();
let calls = 0;
const first = createApplicationOperations({
signal: a.signal,
eventTarget: target,
requests: requests({
async getHeights(points) {
calls++;
return points.map(() => ({ ellipsoid: 123 }));
},
}),
});
const second = createApplicationOperations({
signal: b.signal,
eventTarget: target,
requests: requests({
async getHeights(points) {
return points.map(() => ({ ellipsoid: 456 }));
},
}),
});
const point = { lat: 30, lon: -97 };
await first.surface.terrain.resolveEllipsoidalGround([point]);
await first.surface.terrain.resolveEllipsoidalGround([point]);
await second.surface.terrain.resolveEllipsoidalGround([point]);
assert.equal(calls, 1);
assert.equal(first.surface.groundFloor.cachedGroundFloor(30, -97), 123);
assert.equal(second.surface.groundFloor.cachedGroundFloor(30, -97), 456);
target.dispatchEvent(
new CustomEvent('gev:map-stack-changed', {
detail: { activeId: 'esri-imagery' },
}),
);
assert.equal(first.surface.groundFloor.meshFloorPreferred(), false);
a.abort();
assert.equal(first.surface.terrain.cachedEllipsoidalGround(30, -97), null);
target.dispatchEvent(
new CustomEvent('gev:map-stack-changed', {
detail: { activeId: 'photoreal' },
}),
);
assert.equal(
first.surface.groundFloor.meshFloorPreferred(),
false,
'disposed listener cannot update the old owner',
);
assert.equal(second.surface.groundFloor.meshFloorPreferred(), true);
});
test('a terrain reply after application abort cannot refill the cache or become a fallback', async () => {
const lifetime = new AbortController();
let respond;
let receivedSignal;
const operations = createApplicationOperations({
signal: lifetime.signal,
eventTarget: null,
requests: requests({
getHeights(_points, { signal }) {
receivedSignal = signal;
return new Promise((resolve) => {
respond = resolve;
});
},
}),
});
const pending = operations.surface.terrain.resolveEllipsoidalGround([
{ lat: 30, lon: -97 },
]);
lifetime.abort();
respond([{ ellipsoid: 123 }]);
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(receivedSignal.aborted, true);
assert.equal(
operations.surface.terrain.cachedEllipsoidalGround(30, -97),
null,
);
});
test('annotation lookup and cached outlines use the supplied source independently', async (t) => {
const priorWindow = globalThis.window;
globalThis.window = { setTimeout, clearTimeout };
const a = new AbortController();
const b = new AbortController();
t.after(() => {
a.abort();
b.abort();
if (priorWindow === undefined) delete globalThis.window;
else globalThis.window = priorWindow;
});
const calls = [0, 0];
const make = (signal, index) =>
createApplicationOperations({
signal,
eventTarget: null,
requests: requests(
{ getHeights: async () => [] },
{
query: async () => {
calls[index]++;
const delta = index * 0.0001;
return [
{
type: 'way',
id: index + 1,
tags: { building: 'yes', name: 'Source fixture hall' },
geometry: [
{ lat: 30 - 0.0001, lon: -97 - 0.0001 + delta },
{ lat: 30 - 0.0001, lon: -97 + 0.0001 + delta },
{ lat: 30 + 0.0001, lon: -97 + 0.0001 + delta },
{ lat: 30 + 0.0001, lon: -97 - 0.0001 + delta },
{ lat: 30 - 0.0001, lon: -97 - 0.0001 + delta },
],
},
];
},
},
),
});
const first = make(a.signal, 0);
const second = make(b.signal, 1);
const query = {
viewer: {},
target: 'Source fixture hall',
latitude: 30,
longitude: -97,
footprint: true,
entityKind: 'building',
};
const resultA = await first.annotationResolver.resolveAnnotationTarget(query);
const resultB =
await second.annotationResolver.resolveAnnotationTarget(query);
assert.ok(resultA.ring?.length >= 4);
assert.ok(resultB.ring?.length >= 4);
assert.notDeepEqual(resultA.ring, resultB.ring);
const before = calls[0];
const cached = await first.annotationResolver.resolveAnnotationTarget(query);
assert.deepEqual(cached.ring, resultA.ring);
assert.equal(calls[0], before);
a.abort();
await assert.rejects(
first.annotationResolver.resolveAnnotationTarget(query),
{ name: 'AbortError' },
);
});
+7 -1
View File
@@ -1,3 +1,4 @@
import { createApplicationOperations } from './operations.js';
import * as Cesium from 'cesium';
import { createApplicationViewer } from '../app/viewer.js';
import { registerDataCredits } from '../data/dataCredits.js';
@@ -13,6 +14,7 @@ import { describeError } from './errors.js';
/** Construct the application globe using the caller's local configuration. */
export async function createApplicationScene({
requestServices,
googleApiKey,
cesiumToken,
credits,
@@ -22,6 +24,10 @@ export async function createApplicationScene({
signal,
defer,
}) {
const operations = createApplicationOperations({
requests: requestServices,
signal,
});
defer(initLogoGaze());
const previousKey = window.__GOOGLE_MAPS_API_KEY__;
if (googleApiKey) {
@@ -109,5 +115,5 @@ export async function createApplicationScene({
});
signal.throwIfAborted();
return { viewer, tileset, mapStackController };
return { viewer, tileset, mapStackController, operations };
}
+37
View File
@@ -0,0 +1,37 @@
import { createTerrainHeights } from '../services/terrainHeights.js';
import { createGroundFloor } from '../services/groundFloor.js';
import { createMeshFloorSampler } from '../services/meshFloorSampler.js';
import { createGroundSnap } from '../services/groundSnap.js';
import { createFireAnchors } from '../layers/firms/anchors.js';
/** Own the DEM, coarse floors and mesh samples used by one application. */
export function createSurfaceServices({
terrainSource,
signal,
eventTarget = globalThis.window,
}) {
const terrain = createTerrainHeights({ source: terrainSource, signal });
const groundFloor = createGroundFloor({ terrain, signal });
const meshFloor = createMeshFloorSampler({
groundFloor,
signal,
eventTarget,
});
return Object.freeze({
terrain,
groundFloor,
meshFloor,
groundSnap: Object.freeze({
createGroundSnap: () => createGroundSnap({ groundFloor }),
}),
anchors: createFireAnchors(groundFloor),
controlServices: Object.freeze({
cachedGroundFloor: groundFloor.cachedGroundFloor,
cachedMeshFloor: groundFloor.cachedMeshFloor,
GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M,
meshFloorPreferred: groundFloor.meshFloorPreferred,
warmGroundFloor: groundFloor.warmGroundFloor,
sampleMeshFloorCells: meshFloor.sampleMeshFloorCells,
}),
});
}
+11 -2
View File
@@ -23,13 +23,18 @@ export function createApplicationTools({
signal,
defer,
}) {
const { viewer, tileset, mapStackController } = scene;
const { viewer, tileset, mapStackController, operations } = scene;
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data;
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager);
defer(() => sceneDirector.destroy());
onSceneDirector?.(sceneDirector);
const annotations = initAnnotations({ viewer, tileset, placeSearch });
const annotations = initAnnotations({
viewer,
tileset,
placeSearch,
resolver: operations.annotationResolver,
});
defer(() => {
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
annotations.destroy();
@@ -95,6 +100,7 @@ export function createApplicationTools({
weatherEffects,
cockpitCloudEffects,
getRenderGovernorDiagnostics,
surfaceServices: operations.surface,
requestRender: governorRequestRender,
};
const debug = window.__godsEyeView;
@@ -103,6 +109,9 @@ export function createApplicationTools({
});
const voiceCommands = initGevVoiceCommands({
...voice,
floorServices: operations.surface.groundFloor,
annotationResolver: operations.annotationResolver,
searchNavigation: operations.searchAndFlyTo,
signal,
placeSearch,
viewer,
+1 -1
View File
@@ -241,7 +241,7 @@ test('validated voice camera destinations share the UI navigation authority faca
// The corridor warm is injected the same way the floor READ is — the dolly
// never reaches into the data layer itself, and the voice dispatch is the one
// place that binds both.
assert.match(voice, /\(lat, lon\) => cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => warmGroundFloor\(cells\),/);
assert.match(voice, /\(lat, lon\) => floorServices\.cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => floorServices\.warmGroundFloor\(cells\),/);
});
test('deferred search releases only after its final authority check', () => {
+5 -4
View File
@@ -166,7 +166,8 @@ export function cockpitWeatherEnabledFromStoredValue(value) {
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
*/
export class CockpitCloudEffectsController {
constructor(viewer) {
constructor(viewer, { weatherService = applicationServices.weather } = {}) {
this.weatherService = weatherService;
this.viewer = viewer;
this.canvas = document.createElement('canvas');
this.canvas.id = 'cockpit-cloud-effects';
@@ -369,7 +370,7 @@ export class CockpitCloudEffectsController {
this.abort?.abort();
this.abort = new AbortController();
const pending = applicationServices.weather.getConditions(point.latitude, point.longitude, { signal: this.abort.signal });
const pending = this.weatherService.getConditions(point.latitude, point.longitude, { signal: this.abort.signal });
const request = this.abort;
this.pending = pending.then(async (payload) => {
if (this.abort !== request || request.signal.aborted) return null;
@@ -522,6 +523,6 @@ export class CockpitCloudEffectsController {
}
}
export function initCockpitCloudEffects(viewer) {
return new CockpitCloudEffectsController(viewer);
export function initCockpitCloudEffects(viewer, options) {
return new CockpitCloudEffectsController(viewer, options);
}
+2
View File
@@ -1,3 +1,4 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationAlpr } from '../app/layers/alprCameras.js';
import { createSourceSlot } from '../app/sourceSlot.js';
import { createOverpassAlprSource } from '../layers/alpr/index.js';
@@ -9,5 +10,6 @@ const slot = createSourceSlot(
);
export const configureAlprSource = slot.configure;
export default createApplicationAlpr({
surface: defaultSurface,
source: slot.source,
});
+2
View File
@@ -1,3 +1,4 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationCctv } from '../app/layers/cctv.js';
import { createSourceSlot } from '../app/sourceSlot.js';
import { createCctvSource } from '../layers/cctv/source.js';
@@ -9,6 +10,7 @@ const sourceSlot = createSourceSlot(
);
export const configureCctvSource = sourceSlot.configure;
const layer = createApplicationCctv({
surface: defaultSurface,
source: sourceSlot.source,
});
export const calibrationPatchMovesAnchor = layer.calibrationPatchMovesAnchor;
+5 -1
View File
@@ -1,3 +1,4 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationFirms, firmsServices } from '../app/layers/firms.js';
import { createSourceSlot } from '../app/sourceSlot.js';
import {
@@ -10,7 +11,9 @@ const sourceSlot = createSourceSlot(
'Fire source',
);
export const configureFirmsSource = sourceSlot.configure;
const helpers = createFirmsHelpers({ services: firmsServices });
const helpers = createFirmsHelpers({
services: { ...firmsServices, anchors: defaultSurface.anchors },
});
export const mapAnalystRecord = helpers.mapAnalystRecord;
export const fireCullPosition = helpers.fireCullPosition;
export const applyHorizonCull = helpers.applyHorizonCull;
@@ -22,6 +25,7 @@ export const applyFirmsOverlayPolicy = helpers.applyFirmsOverlayPolicy;
/** Retain the existing default feed and helper exports for direct callers. */
export function createFirmsHeatmapLayer(options) {
return createApplicationFirms({
surface: defaultSurface,
...options,
feed: options.feed ?? sourceSlot.source,
});
+2
View File
@@ -1,9 +1,11 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationFlights } from '../app/layers/flights.js';
import { createOpenSkySource } from '../sources/live/standalone.js';
import * as militaryRegistry from './militaryRegistry.js';
const flightsLayer = createApplicationFlights({
surface: defaultSurface,
source: createOpenSkySource(),
militaryRegistry,
});
+4 -580
View File
@@ -1,580 +1,4 @@
// src/data/groundFloor.js — coarse ground-floor clamp for entity render
// heights (field-test round 2026-07-06).
//
// Two owner findings drove this module:
// - RS46 (military H60): baro-only low-altitude contacts near steep terrain
// render INSIDE the hillside (no alt_geom → baro+N is off by more than the
// local relief).
// - WAKE01 (military B212): trail waypoints flagged 'ground' rendered at a
// fixed 50 m ellipsoidal sentinel — ~1.5 km underground at Kirtland AFB
// (field elevation ~1590 m ellipsoidal).
//
// The fix everywhere is the same: never render below the local ellipsoidal
// surface. This module provides the shared floor: a COARSE (3-decimal,
// ~111 m) grid over Task 3's terrainHeights resolver/cache, so that moving
// aircraft and multi-hundred-point trails collapse onto a small set of cells
// with heavy cache reuse (the exact 5-decimal grid would mint a new cache key
// every fix — the taxiing-cache lesson from the adversarial review).
// A ~111 m cell means the floor can be off by the relief within the cell in
// steep terrain; the clamp is a visual floor, not a survey — lifting an icon
// a few metres short of a ridge line still beats burying it.
//
// Zero-network on the render path: reads are synchronous cache lookups;
// `warmGroundFloor` batches the misses through resolveEllipsoidalGround
// (single-flight, fire-and-forget) exactly like flights.js's grounded-surface
// warm — results land for later polls/appends.
import { cachedRealEllipsoidalGround, resolveEllipsoidalGround } from './terrainHeights.js';
/** @constant {number} Metres added above the resolved floor so a clamped
* billboard/waypoint sits ON the surface instead of z-fighting it. */
export const GROUND_FLOOR_LIFT_M = 1.5;
/** Accepted rendered-mesh window around a real ellipsoidal DEM prior. */
export const MESH_FLOOR_BELOW_PRIOR_M = 15;
export const MESH_FLOOR_ABOVE_PRIOR_M = 80;
/**
* Snaps a coordinate to the coarse (3-decimal, ~111 m) floor grid.
* @param {number} lat
* @param {number} lon
* @returns {{lat: number, lon: number}} Grid-cell coordinate.
*/
export function coarseFloorCoord(lat, lon) {
return { lat: Number(lat.toFixed(3)), lon: Number(lon.toFixed(3)) };
}
/**
* Pure floor clamp.
* - finite alt + finite ground → max(alt, ground + lift)
* - finite alt + unknown ground → alt (no data, no clamp)
* - null alt + finite ground → ground + lift (the 'ground'-waypoint case:
* the point IS on the surface; put it there)
* - null alt + unknown ground → null (caller applies its own fallback)
* @param {number|null|undefined} altM - Proposed ellipsoidal render height.
* @param {number|null|undefined} groundM - Local ellipsoidal ground, if known.
* @param {number} [liftM] - Clearance above the floor.
* @returns {number|null}
*/
export function floorAltitudeM(altM, groundM, liftM = GROUND_FLOOR_LIFT_M) {
const hasAlt = Number.isFinite(altM);
const hasGround = Number.isFinite(groundM);
if (hasAlt && hasGround) return Math.max(altM, groundM + liftM);
if (hasAlt) return altM;
if (hasGround) return groundM + liftM;
return null;
}
/**
* Floor decision at the point of DISPLAY (2026-08-19).
*
* `floorAltitudeM` above answers "what height should this FIX render at" and
* runs once per poll. But what the user sees is the DEAD-RECKONED position:
* a Cartesian interpolation between two fixes for the whole segment, and a
* velocity extrapolation of the newest fix while a contact coasts through its
* stale-feed grace. Both drift the rendered point horizontally away from the
* fix whose cell supplied the height — across a graded apron (KAUS spans
* ~119–140 m ellipsoidal) a taxiing sprite therefore renders under the mesh it
* has drifted over (measured −15.5 m; `scripts/qa-floor-verify.mjs`).
*
* This is the same "never below the visible surface" rule applied at the
* displayed coordinate instead of the fix coordinate. It returns `null` rather
* than the unchanged height whenever nothing should move, so the per-frame
* caller can skip rebuilding a Cartesian entirely — the common case by far.
*
* @param {number|null|undefined} displayHeightM - Ellipsoidal height the
* dead-reckoned display position currently carries.
* @param {number|null|undefined} floorM - Ellipsoidal floor at the DISPLAYED
* coordinate (`cachedGroundFloor`), or null when that cell is not warm.
* @param {number} [liftM] - Clearance above the floor.
* @returns {number|null} The height to render at, or null for "leave it alone".
*/
export function displayFloorHeightM(displayHeightM, floorM, liftM = GROUND_FLOOR_LIFT_M) {
if (!Number.isFinite(displayHeightM) || !Number.isFinite(floorM)) return null;
const lifted = floorM + liftM;
return displayHeightM < lifted ? lifted : null;
}
/** @constant {number} Pitch of the coarse floor grid in degrees — the spacing
* `coarseFloorCoord` rounds to, and therefore the step between adjacent cells. */
export const FLOOR_CELL_DEG = 0.001;
/** @constant {number} Resolved neighbours required before one may be borrowed.
* A single reading cannot be checked against anything: a hangar roof and an
* apron are both "a warm cell 111 m away", and there is no way to tell them
* apart from one number. Two readings at least agree or disagree. */
export const NEIGHBOR_FLOOR_MIN_SAMPLES = 2;
/**
* A floor for `cell` borrowed from the eight cells ADJACENT to it.
*
* Used only as a stand-in while `cell` itself is unresolved (2026-08-21: the
* Re:Earth proxy timed out four times in a row and a grounded contact standing
* on a cold cell rendered at the geoid — tens of metres under the mesh it was
* parked on). A neighbour is ~111 m away; on the aprons and taxiways where
* grounded contacts live that is the same surface, and it is a MEASURED height
* rather than an invented one.
*
* Leans to the LOWEST resolved neighbour, and refuses to answer at all from
* fewer than NEIGHBOR_FLOOR_MIN_SAMPLES of them.
*
* An earlier cut leaned HIGH, reasoning from the locked "never below the
* visible surface" principle. That principle is about a contact's OWN measured
* ground; applied to a BORROWED cell it inverts, and an owner playtest found
* why — planes floating in midair at terminal gates. The two errors are not
* symmetric:
* - Too LOW is inert. `displayFloorHeightM` only ever RAISES a position, so a
* floor under the contact simply does not lift it — the same "no clamp" the
* cold-cell case already has, bounded by one cell of grade (metres).
* - Too HIGH actively invents a position. It is bounded by BUILDING HEIGHT,
* not by grade, and a parked contact holds it: measured on the A/B rig,
* a lone roof neighbour put a contact 29.5 m up and left it there
* permanently when its own cell never warmed, and 6.3 s of visible
* stair-stepping when it did.
* A plane at a gate is on the apron, never on the terminal roof, so on a
* structure edge the LOW neighbour is the answer and the high one is an
* artefact of the photogrammetric mesh.
*
* The honest residual: on a genuine SLOPE the lowest neighbour under-reads by
* the grade across ~111 m, so the clamp lifts a little less than it could. That
* shows up as no lift rather than as a wrong one, and the contact's own cell
* corrects it as soon as it warms.
*
* @param {{lat: number, lon: number}|null|undefined} cell - Coarse cell.
* @returns {number|null} Ellipsoidal floor, or null when too few neighbours are
* warm to borrow from.
*/
export function neighborFloorM(cell) {
if (!Number.isFinite(cell?.lat) || !Number.isFinite(cell?.lon)) return null;
let lowest = null;
let resolved = 0;
for (let dLat = -1; dLat <= 1; dLat += 1) {
for (let dLon = -1; dLon <= 1; dLon += 1) {
if (dLat === 0 && dLon === 0) continue;
const h = cachedGroundFloor(cell.lat + dLat * FLOOR_CELL_DEG, cell.lon + dLon * FLOOR_CELL_DEG);
if (h == null) continue;
resolved += 1;
if (lowest == null || h < lowest) lowest = h;
}
}
return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;
}
/** @constant {number} Cap on cells one corridor may contribute — a fast rollout
* or a spliced fix must not turn one contact into an unbounded warm batch.
* Sized for the worst realistic ground segment: a 27 m/s rollout coasts ~810 m
* (≈7 cells) in one poll interval, leaving headroom plus the endpoint slot. */
export const CORRIDOR_MAX_CELLS = 12;
/** @constant {number} Step along a corridor leg when collecting cells (~14 m,
* an eighth of a cell). Half-cell steps were only cell-complete on LONG legs:
* a short leg rounds to a single step, so the walk degenerates to testing the
* leg's endpoints and a cell clipped between them is skipped. At this step any
* cell the path occupies for ~14 m of ground or more contains a sample — the
* guarantee this walk actually makes. The loop still exits the moment the cell
* budget fills, so a finer step costs iterations only on short corridors. */
export const CORRIDOR_WALK_STEP_DEG = 0.000125;
/** @constant {number} Degrees added around a cell before a contact is allowed
* to adopt the next one (~22 m). A dead-reckoned position that jitters across
* a 0.001° edge would otherwise alternate floors at fleet-tick rate and pop
* the sprite between two heights. */
export const CELL_HYSTERESIS_DEG = 0.0002;
/**
* The floor cell a contact should read, with boundary hysteresis.
*
* Cells are 0.001° squares, so a display position sitting on an edge (parked
* GPS jitter, the tracked reconciliation's decaying correction) flips between
* two cells frame to frame — and their floors can differ by the local grade or,
* next to a building, several metres. The contact keeps its previous cell until
* it is CELL_HYSTERESIS_DEG clear of that cell's bounds, which costs two
* comparisons and no allocation on the sticky path.
*
* @param {number} lat @param {number} lon - Displayed coordinate.
* @param {{lat: number, lon: number}|null|undefined} previousCell - The cell
* this contact read last tick, if any.
* @returns {{lat: number, lon: number}} The cell to read.
*/
export function stickyFloorCell(lat, lon, previousCell) {
if (previousCell
&& Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG
&& Math.abs(lon - previousCell.lon) <= 0.0005 + CELL_HYSTERESIS_DEG) {
return previousCell;
}
return coarseFloorCoord(lat, lon);
}
/**
* The coarse cells a contact's DISPLAY is about to walk through.
*
* The dead-reckoned display runs one render delay behind and advances toward
* the newest fix, so the segment [display → newest fix] is exactly the ground
* it will render over during the next poll or two. Warming only the cell it is
* standing on warms the cell it is LEAVING: at 10 m/s a contact crosses a
* ~111 m cell every ~11 s while the warm batch runs once per 30 s poll, so it
* stays permanently ahead of its own floor data and the display clamp has
* nothing to read (KAUS: sprites 2–4 m under the mesh with a cold cell for the
* whole observation).
*
* Walks in half-cell steps so no cell between the ends is skipped. The step
* COUNT follows the segment length (never a fixed cap — a capped count over a
* long segment samples sparsely and leaves holes in the middle of ground the
* contact does cross). Length is bounded instead, and honestly: the walk emits
* a GAP-FREE PREFIX from `from`, and the destination cell always takes the last
* slot. A corridor longer than CORRIDOR_MAX_CELLS is therefore truncated —
* contiguous near ground plus the far endpoint — rather than thinned; the
* ground in between is picked up on a later poll as the display advances into
* it. Loop work stays bounded because the walk exits as soon as the cell budget
* fills, whatever the segment length.
*
* Takes the display PATH as a polyline, not two endpoints: while the contact
* is extrapolating, that path is an arc (the dead-reckon integrates a
* constant-rate turn), and walking a straight chord between its ends would
* warm ground the contact never crosses while its actual arc stayed cold.
*
* @param {Array<{lat: number, lon: number}>} points - Display path, current
* position first, destination last.
* @returns {Array<{lat: number, lon: number}>} Deduped coarse cells, start
* first, destination last.
*/
export function corridorFloorCells(points) {
if (!Array.isArray(points) || !points.length) return [];
const first = points[0];
if (!Number.isFinite(first?.lat) || !Number.isFinite(first?.lon)) return [];
const start = coarseFloorCoord(first.lat, first.lon);
const out = [start];
const seen = new Set([`${start.lat},${start.lon}`]);
let destination = null;
for (let i = points.length - 1; i >= 1; i--) {
if (Number.isFinite(points[i]?.lat) && Number.isFinite(points[i]?.lon)) {
destination = coarseFloorCoord(points[i].lat, points[i].lon);
break;
}
}
if (!destination) return out;
const destKey = `${destination.lat},${destination.lon}`;
// One slot is reserved for the destination, appended after the prefix.
const prefixLimit = CORRIDOR_MAX_CELLS - 1;
walk:
for (let s = 0; s < points.length - 1; s++) {
const a = points[s];
const b = points[s + 1];
if (![a?.lat, a?.lon, b?.lat, b?.lon].every(Number.isFinite)) continue;
const steps = Math.ceil(
Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lon - a.lon)) / CORRIDOR_WALK_STEP_DEG,
);
for (let i = 1; i <= steps; i++) {
if (out.length >= prefixLimit) break walk;
const t = i / steps;
const cell = coarseFloorCoord(a.lat + (b.lat - a.lat) * t, a.lon + (b.lon - a.lon) * t);
const key = `${cell.lat},${cell.lon}`;
if (seen.has(key) || key === destKey) continue;
seen.add(key);
out.push(cell);
}
}
if (!seen.has(destKey)) out.push(destination);
return out;
}
/**
* Shares one poll's corridor-cell budget across the contacts that need it.
*
* The first cut walked contacts in Map insertion order and decremented the
* budget BEFORE deduping, so parked contacts burned slots on cells the poll had
* already collected and the contacts actually outrunning their floor data
* starved. Three rules fix that:
* 1. Cells already collected are FREE — a parked contact, whose corridor is
* its own fix cell, never competes for budget at all.
* 2. Candidates are ordered by how many of their cells are genuinely cold,
* then by speed (whoever leaves their floor behind soonest).
* 3. Allocation runs in widening rounds — one cell each, then `fairShare`
* each, then the remainder — so no contact is left with NOTHING while
* another takes seconds. The nearest cell is the one a contact needs first
* anyway; the far end of its corridor can wait a poll.
*
* @param {Array<{cells: Array<{lat: number, lon: number}>, cold: number, speedMps: number}>} candidates
* @param {Set<string>} seen - Keys ("lat,lon") already collected; MUTATED with
* everything this call allocates, so the caller's dedupe stays consistent.
* Ranking alone still starves a TIE longer than the budget: 80 equally needy
* movers means the same stable prefix wins every poll and the tail never moves.
* The `epoch` rotates each run of equal-ranked candidates, so a tie cycles
* round-robin across polls while a genuinely needier contact is never demoted
* below a less needy one (rotation happens WITHIN a rank, never across ranks).
*
* `seen` alone is NOT a sufficient free-list: in production it holds only the
* CURRENT poll's batch, so a cell that warmed on an earlier poll looked new and
* spent budget again, letting a contact's near cells consume its whole share
* poll after poll while the ground ahead of it stayed cold. Budget is therefore
* charged only for cells with no floor yet (`isWarm`) — but warm cells are
* still EMITTED, because the mesh sampler must see a cell again after its DEM
* prior lands or it never latches a rendered-surface height there.
*
* That gives a service bound derivable from the policy rather than a magic
* number: the first round charges at most one cell per candidate, so at least
* `min(candidates, budget)` candidates are served per poll; a candidate that
* misses out keeps its cold count while every served candidate's drops, so it
* outranks them next poll. Every candidate is therefore served within
* `ceil(candidates / budget)` polls once the warms land.
*
* @param {number} budget - Max cold cells to charge for.
* @param {number} fairShare - Per-candidate cap for the middle round.
* @param {number} [epoch] - Poll counter; rotates equal-ranked runs.
* @param {(cell: {lat: number, lon: number}) => boolean} [isWarm] - Whether a
* cell already has a floor (defaults to the shared cache).
* @returns {Array<{lat: number, lon: number}>} Cells to add, allocation order.
*/
export function allocateCorridorCells(
candidates, seen, budget, fairShare, epoch = 0,
isWarm = (cell) => cachedGroundFloor(cell.lat, cell.lon) != null,
) {
const out = [];
if (!Array.isArray(candidates) || !candidates.length || !(budget > 0)) return out;
const ranked = [...candidates].sort((a, b) => (b.cold - a.cold) || (b.speedMps - a.speedMps));
if (Number.isFinite(epoch) && epoch !== 0) {
for (let i = 0; i < ranked.length;) {
let j = i + 1;
while (j < ranked.length
&& ranked[j].cold === ranked[i].cold
&& ranked[j].speedMps === ranked[i].speedMps) j += 1;
const runLength = j - i;
if (runLength > 1) {
const shift = ((epoch % runLength) + runLength) % runLength;
const run = ranked.slice(i, j);
for (let m = 0; m < runLength; m += 1) ranked[i + m] = run[(m + shift) % runLength];
}
i = j;
}
}
// Cursor per candidate, carried ACROSS rounds: a later round resumes where
// the previous one stopped instead of re-walking cells already handled.
const cursor = new Array(ranked.length).fill(0);
let left = budget;
const round = (limit) => {
for (let i = 0; i < ranked.length; i++) {
const cells = ranked[i].cells || [];
while (cursor[i] < cells.length && cursor[i] < limit) {
const cell = cells[cursor[i]];
const key = `${cell.lat},${cell.lon}`;
if (seen.has(key)) { cursor[i] += 1; continue; } // already in this batch
if (isWarm(cell)) {
// Free: it has a floor already. Still emitted, so the mesh sampler
// gets another look now that its DEM prior exists.
cursor[i] += 1;
seen.add(key);
out.push(cell);
continue;
}
if (left <= 0) return;
cursor[i] += 1;
seen.add(key);
out.push(cell);
left -= 1;
}
}
};
round(1); // nobody goes home empty-handed
round(fairShare); // then a fair slice each
round(CORRIDOR_MAX_CELLS); // then the remainder, neediest first
return out;
}
// --- Mesh-floor cells (round 4, owner-approved design) ---------------------
// The Re:Earth DEM is BARE EARTH; the visible world in the google-3d regime
// is the photogrammetric MESH, which sits above it (measured ~17 m at the
// Austin airport apron). DEM-flooring therefore still buried sprites/trails
// inside the mesh. These cells hold the RENDERED surface height, sampled
// one-shot per coarse cell near the viewer (scene.sampleHeight — the same
// source groundSnap uses, which is why 3D models always looked right) and
// reported here by meshFloorSampler.js. Design principle (owner): NEVER
// below the visible surface; slightly above is always fine. The DEM stays
// the instant global prior/fallback and the sanity gate for samples.
/** @type {Map<string, number>} coarse cell key -> rendered-surface height (m, ellipsoidal). */
const _meshCells = new Map();
/** @type {boolean} Whether mesh cells apply — true only in the google-3d
* regime (photoreal stack). On globe stacks the rendered surface IS the
* terrain provider (Re:Earth on keyless), so the DEM is already correct
* there and mesh heights would float. Kept current by meshFloorSampler's
* 'gev:map-stack-changed' listener. Photoreal is the boot default. */
let _meshPreferred = true;
/** @param {boolean} preferred - google-3d regime active. */
export function setMeshFloorPreferred(preferred) {
_meshPreferred = !!preferred;
}
/** @returns {boolean} Whether mesh-floor cells currently apply. */
export function meshFloorPreferred() {
return _meshPreferred;
}
/**
* Records a sampled rendered-surface height for the cell containing the
* coordinate (one-shot latch — first accepted sample wins).
* @param {number} lat @param {number} lon @param {number} heightM
*/
export function reportMeshFloorCell(lat, lon, heightM) {
if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(heightM)) return;
const c = coarseFloorCoord(lat, lon);
const key = `${c.lat},${c.lon}`;
if (!_meshCells.has(key)) _meshCells.set(key, heightM);
}
/** Return whether a rendered-mesh sample is plausible against a real DEM prior. */
export function meshFloorSampleWithinPrior(heightM, priorM) {
return Number.isFinite(heightM)
&& Number.isFinite(priorM)
&& heightM >= priorM - MESH_FLOOR_BELOW_PRIOR_M
&& heightM <= priorM + MESH_FLOOR_ABOVE_PRIOR_M;
}
/**
* Validate and record a rendered-mesh floor through the single shared gate.
* Callers must not bypass this with raw `reportMeshFloorCell`: a coarse-LOD,
* rooftop, or aircraft hit would otherwise win the session-long first-write
* latch and poison every floor consumer in the cell.
* @returns {boolean} Whether the sample passed and was reported.
*/
export function reportValidatedMeshFloorCell(lat, lon, heightM) {
if (![lat, lon, heightM].every(Number.isFinite)) return false;
const cell = coarseFloorCoord(lat, lon);
const prior = cachedRealEllipsoidalGround(cell.lat, cell.lon);
if (!meshFloorSampleWithinPrior(heightM, prior)) return false;
reportMeshFloorCell(cell.lat, cell.lon, heightM);
return true;
}
/**
* Synchronous mesh-cell read — null when the cell is unsampled OR the
* google-3d regime is inactive (globe stacks must floor on the DEM).
* @param {number} lat @param {number} lon
* @returns {number|null}
*/
export function cachedMeshFloor(lat, lon) {
if (!_meshPreferred) return null;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const c = coarseFloorCoord(lat, lon);
const h = _meshCells.get(`${c.lat},${c.lon}`);
return h != null ? h : null;
}
/** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */
export function _clearMeshFloorCellsForTest() {
_meshCells.clear();
}
/**
* Synchronous coarse-cell ground read (warm cache only — never a fetch).
* Prefers the RENDERED-surface mesh cell (google-3d regime) and falls back
* to the Re:Earth DEM cell — the single choke point every ground-adjacent
* consumer (fleet clamp, grounded surface chain, trail floors) reads, so
* they all agree on one surface.
* @param {number} lat
* @param {number} lon
* @returns {number|null} Ellipsoidal floor of the cell, or null if not warm.
*/
export function cachedGroundFloor(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const mesh = cachedMeshFloor(lat, lon);
if (mesh != null) return mesh;
const c = coarseFloorCoord(lat, lon);
return cachedRealEllipsoidalGround(c.lat, c.lon);
}
/**
* Awaitable variant for one-shot consumers (trail backfills): dedupes the
* points to unique coarse cells and resolves them, so `cachedGroundFloor`
* reads immediately after are warm. The underlying resolver chunks (≤200/req)
* and falls back per-point, so any trace length is safe. Never throws.
* @param {Array<{lat: number, lon: number}>} points
* @returns {Promise<void>}
*/
/** @constant {number} Max ms a one-shot consumer (trail backfill) waits for
* the floor resolve before painting with whatever cells are warm. Owner
* field test 2026-07-06 round 2: an UNBOUNDED await here held trail paints
* hostage to cold Re:Earth lookups spanning a whole flight path (seconds to
* the 30 s proxy timeout) — trails showed up late, short, or not at all.
* The resolve keeps running past the deadline and lands in the cache for
* the next paint/select. */
export const FLOOR_RESOLVE_DEADLINE_MS = 1200;
/**
* Bounded-latency variant of resolveGroundFloorCells: waits at most
* FLOOR_RESOLVE_DEADLINE_MS, then returns so the caller can paint with the
* cells that ARE warm. The underlying resolve continues in the background
* and fills the cache for later reads. Never throws.
* @param {Array<{lat: number, lon: number}>} points
* @returns {Promise<void>}
*/
export function resolveGroundFloorCellsBounded(points) {
return Promise.race([
resolveGroundFloorCells(points),
new Promise((resolve) => setTimeout(resolve, FLOOR_RESOLVE_DEADLINE_MS)),
]);
}
export async function resolveGroundFloorCells(points) {
if (!Array.isArray(points) || !points.length) return;
const cells = new Map();
for (const p of points) {
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
const c = coarseFloorCoord(p.lat, p.lon);
const key = `${c.lat},${c.lon}`;
if (cells.has(key)) continue;
if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue;
cells.set(key, c);
}
if (!cells.size) return;
try {
await resolveEllipsoidalGround([...cells.values()]);
} catch { /* best-effort — unresolved cells simply don't clamp */ }
}
/** @type {boolean} Single-flight guard for the warm batch. */
let _floorBatchInFlight = false;
/** @type {Map<string, {lat: number, lon: number}>} Cells requested while a
* batch was in flight — flushed as the NEXT batch the moment it completes.
* Round 6: the old guard silently DROPPED contending calls (flights every
* 30 s, military every 15 s, trail backfills ad hoc), so arriving at a new
* airport could take minutes of lost polls before a given cell resolved —
* the owner's "you can't just go to a place and start inspecting it". */
const _pendingFloorCells = new Map();
/** Kicks the resolver for a cell map, chaining any cells queued meanwhile. */
function _resolveFloorCells(cells) {
_floorBatchInFlight = true;
resolveEllipsoidalGround([...cells.values()])
.catch(() => { /* best-effort — unclamped this tick, floor lands later */ })
.finally(() => {
_floorBatchInFlight = false;
if (_pendingFloorCells.size) {
const next = new Map(_pendingFloorCells);
_pendingFloorCells.clear();
_resolveFloorCells(next);
}
});
}
/**
* Fire-and-forget batch warm of the coarse floor cells for the given points.
* Dedupes to unique cells, skips already-warm ones, and never blocks or
* throws into the caller. Contending calls QUEUE (never drop): their cells
* ride the next batch as soon as the in-flight one completes. Results are
* picked up by later `cachedGroundFloor` reads.
* @param {Array<{lat: number, lon: number}>} points
*/
export function warmGroundFloor(points) {
if (!Array.isArray(points) || !points.length) return;
const cells = new Map();
for (const p of points) {
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
const c = coarseFloorCoord(p.lat, p.lon);
const key = `${c.lat},${c.lon}`;
if (cells.has(key) || _pendingFloorCells.has(key)) continue;
if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue; // already warm
cells.set(key, c);
}
if (!cells.size) return;
if (_floorBatchInFlight) {
for (const [key, c] of cells) _pendingFloorCells.set(key, c);
return;
}
_resolveFloorCells(cells);
}
import { createGroundFloor } from '../services/groundFloor.js';
import * as terrain from './terrainHeights.js';
const groundFloor = createGroundFloor({ terrain });
export const { GROUND_FLOOR_LIFT_M, MESH_FLOOR_BELOW_PRIOR_M, MESH_FLOOR_ABOVE_PRIOR_M, coarseFloorCoord, floorAltitudeM, displayFloorHeightM, FLOOR_CELL_DEG, NEIGHBOR_FLOOR_MIN_SAMPLES, neighborFloorM, CORRIDOR_MAX_CELLS, CORRIDOR_WALK_STEP_DEG, CELL_HYSTERESIS_DEG, stickyFloorCell, corridorFloorCells, allocateCorridorCells, setMeshFloorPreferred, meshFloorPreferred, reportMeshFloorCell, meshFloorSampleWithinPrior, reportValidatedMeshFloorCell, cachedMeshFloor, _clearMeshFloorCellsForTest, cachedGroundFloor, FLOOR_RESOLVE_DEADLINE_MS, resolveGroundFloorCellsBounded, resolveGroundFloorCells, warmGroundFloor } = groundFloor;
+4 -352
View File
@@ -1,352 +1,4 @@
/**
* groundSnap.js — one-shot cached ground-height snap for MODELED grounded aircraft.
*
* Why: a parked/taxiing plane's feed altitude is last-known baro or 0 m — nowhere
* near the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland
* airports like AUS, floating ~30 m at sea-level fields like SFO, where the geoid
* sits below the ellipsoid). A 3D model placed at the meta altitude therefore
* renders inside the tiles or hovering above them. Billboards dodge this with
* disableDepthTestDistance; a depth-tested glTF model cannot.
*
* Discipline (CCTV-B9b style): scene.sampleHeight forces a synchronous offscreen
* pick render, so it is NEVER called per frame. Each grounded modeled plane gets
* ONE successful sample, cached per icao; the cache stops answering directly when
* the plane moves >MOVE_INVALIDATE_M from the sampled spot (taxiing). Misses (tiles
* still streaming, OSM fallback with no tileset skin, sample outside loaded
* geometry) are a retry-later signal with per-icao exponential backoff. A
* per-window budget stops a freshly modeled airport cluster from firing dozens of
* pick renders in one fleet tick.
*
* What a caller gets while a sample is outstanding depends on whether this
* contact has ever HAD one, and the difference is the difference between two
* kinds of wrong:
* - COLD (nothing ever resolved here) — null. There is no evidence of where the
* ground is, and the only honest answer for a depth-tested model is not to
* place it at all; the caller keeps the depth-test-free billboard.
* - WARM (a snap resolved, then taxi invalidated it and the resample has not
* landed) — the last measurement, held within HELD_SNAP_MAX_DRIFT_M. Hiding
* the model here would pop a taxiing aircraft from 3D back to 2D for a whole
* retry backoff, and the ground it was measured on a few dozen metres back is
* real evidence, not a guess. Same shape as the billboard chain's
* `_heldDisplayFloorM` (flights.js): hold the last thing that resolved,
* bounded by how far the contact can have travelled from it.
*
* A hold is a MEMORY, and it is ranked accordingly: held evidence never outranks
* fresh evidence that contradicts it. Distance travelled is only a proxy for
* "does this still describe the ground here", and it is a loose one — a contact
* can taxi 200 m onto a different surface well inside the bound, and because a
* miss preserves the hold, nothing in the retry path would ever correct that.
* So where the mesh-floor cells have actually MEASURED the surface under the
* contact's current position, and that surface is ABOVE the held value by more
* than HELD_SNAP_CONTRADICTION_M, the measurement wins: the hold is dropped and
* the contact is COLD again. The fallback is the floored 2D billboard, which is
* the honest picture — a model buried in the apron is not a better one.
*
* Shared by flights.js and militaryFlights.js (one instance each — the caches
* are per-layer, keyed by that layer's icao space).
*/
import * as Cesium from 'cesium';
import { cachedMeshFloor, meshFloorPreferred, reportValidatedMeshFloorCell } from './groundFloor.js';
/** Taxi threshold: a cached snap answers directly for moves up to this far from
* the sampled spot (m). Past it the value is demoted to a held last-known and a
* resample runs. */
const MOVE_INVALIDATE_M = 50;
/** @constant {number} How far a contact may travel from the spot its snap was
* measured at before that measurement stops describing the ground under it, and
* the hold is dropped rather than stretched.
*
* Sized to two things at once. It has to outlast a resample outage, or it buys
* nothing: RETRY_MAX_MS is 30 s of backoff, and ordinary taxi is ~10 m/s, so a
* hold that expires inside ~25 s of rolling would hand back the pop it exists to
* prevent. And it has to stay inside the grade error an airfield can hide: the
* KAUS spread this file's header cites is ~21 m across a ~4 km field, ~0.5%, so
* 250 m of taxi is ~1.3 m of vertical error — the order of the belly offsets
* already being added, and two orders under the ~100+ m burial the snap exists
* to prevent.
*
* Deliberately a quarter of the billboard chain's HELD_FLOOR_MAX_DRIFT_KM. That
* hold only ever RAISES a position (`displayFloorHeightM` never lowers), so an
* under-read there is inert; here the held value IS the placement, so the error
* shows in both directions and the bound has to be tighter. */
const HELD_SNAP_MAX_DRIFT_M = 250;
/** @constant {number} How far a held measurement may disagree with FRESH
* measured evidence at the contact's current position before it is dropped
* rather than trusted.
*
* The drift bound above asks "how far has the contact travelled?", which is a
* proxy for "does this value still describe the ground?" — and a proxy is all
* it is. 202 m of taxi onto a different surface is inside the bound and still
* wrong, and because a miss preserves the hold, repeated misses preserve the
* error for as long as they last. So the bound is not the only guard: where
* something has actually MEASURED the surface the contact is standing on now,
* that measurement outranks the memory, and a memory that contradicts it is
* discarded rather than stretched to cover the gap.
*
* 5 m sits between the two scales this has to separate:
* - ABOVE the honest disagreement between two measurements of the SAME
* surface. A floor cell is ~111 m across, so cell grade is ~0.6 m at the
* ~0.5 % KAUS spread this file's header cites and ~3.3 m even at a steep
* 3 %; the hold's own 250 m allowance is ~1.3 m at that grade. None of
* those should cost a taxiing aircraft its model.
* - BELOW the scale at which a placement reads as wrong. The belly offsets
* this snap adds are ~2–5 m and an airliner fuselage is ~4 m across, so a
* model displaced more than ~5 m is visibly off its gear rather than
* imprecisely on it — and it is two orders under the ~100+ m burial the
* snap exists to prevent.
*
* Applied in ONE direction: the hold is released only when fresh evidence sits
* ABOVE it, which is the BURIAL case — the model sunk into the surface, which
* is the entire reason this gate exists. The other sign is deliberately left
* alone, and the asymmetry is not tidiness:
*
* - A cell reading LOW against a real sample is the EXPECTED error here, not a
* contradiction. Cells are a one-shot first-write latch over ~111 m, the
* borrowed-neighbour path leans to the LOWEST on purpose (`neighborFloorM`),
* and `displayFloorHeightM` only ever RAISES — the whole floor chain is
* built to under-read rather than to invent. Releasing on that sign would
* discard good measurements wherever the two disagree downward. Measured on
* the track-regression rig: a planted cell sat 66.7 m BELOW a real sample at
* the SAME spot, and a two-sided rule dropped a hold that was correct.
* - The residual is stated rather than hidden, and stated WITH its condition.
* A contact taxiing DOWNHILL keeps a held value above its new surface and
* its model floats. The correction is a SUCCESSFUL resample, and nothing on
* this path guarantees one: on the OSM fallback sampleHeight misses forever,
* so the retries never land and the float persists for as long as the
* contact stays inside HELD_SNAP_MAX_DRIFT_M. There is no timer beside it
* and no vertical cap of its own — only whatever the ground drops within
* that radius. The other releases (a ground flip via forget(), travelling
* past the bound, a mesh cell measuring ABOVE the hold) are not ones a
* downhill taxi is guaranteed to hit either. It is accepted over the
* alternative because it errs UPWARD and stays visible, where a buried model
* errs into the terrain and cannot be seen at all. */
const HELD_SNAP_CONTRADICTION_M = 5;
/** First retry delay after a miss / tiles-not-ready (ms); doubles per consecutive miss. */
const RETRY_BASE_MS = 2000;
/** Backoff cap (ms) — on the OSM fallback (no tile skin) sampleHeight misses forever. */
const RETRY_MAX_MS = 30000;
/** Max sampleHeight calls per window across the layer (airport-cluster burst guard). */
const SAMPLE_BUDGET_PER_WINDOW = 4;
/** Budget window length (ms). */
const SAMPLE_WINDOW_MS = 250;
/**
* True when the active photoreal tileset has finished streaming the tiles in
* view (cctv.js's projectionTilesReady pattern: first Cesium3DTileset found in
* scene.primitives, duck-typed via its boolean tilesLoaded). Sampling before
* that both blows the frame budget on forced synchronous loads and produces
* misses that would burn retry backoff for nothing. With NO tileset present
* (OSM fallback) this returns true so the sample path is not permanently
* blocked — the sample just misses and backs off.
* @param {Cesium.Viewer} viewer
* @returns {boolean}
*/
function _tilesReady(viewer) {
const prims = viewer?.scene?.primitives;
if (!prims) return false;
for (let i = 0; i < prims.length; i++) {
let p = null;
try { p = prims.get(i); } catch { continue; }
if (p && p.show !== false && typeof p.tilesLoaded === 'boolean') {
return p.tilesLoaded === true;
}
}
return true;
}
/**
* Create a per-layer ground-snap cache.
* @returns {{
* heightFor: (viewer: Cesium.Viewer, icao: string, pos: Cesium.Cartesian3,
* getExclusions?: () => Array<unknown>) => number|null,
* forget: (icao: string) => void,
* clear: () => void,
* }}
*/
export function createGroundSnap() {
/** @type {Map<string, {h: number|null, samplePos: Cesium.Cartesian3|null,
* held: boolean, nextRetryMs: number, misses: number}>} Per-icao snap state:
* the sampled height and the surface point it was measured at, whether that
* pair is still the fresh answer or has been demoted to a bounded last-known
* (`held`), and the retry backoff a miss earned. */
const entries = new Map();
let windowStartMs = 0;
let windowCount = 0;
const scratchCarto = new Cesium.Cartographic();
const scratchSurfacePos = new Cesium.Cartesian3();
/** Separate from `scratchCarto`: the held-evidence check runs on paths that
* return BEFORE the sample writes `scratchCarto`, and on the sampling path it
* must not disturb the cartographic that `reportValidatedMeshFloorCell` reads
* back after the sample lands. */
const scratchHeldCarto = new Cesium.Cartographic();
/**
* The last measurement for a contact whose fresh snap has been demoted — it
* taxied past MOVE_INVALIDATE_M and the resample has not landed yet.
*
* Answers only while the contact is still within HELD_SNAP_MAX_DRIFT_M of the
* spot the value was measured at; past that the memory is DROPPED rather than
* stretched, and the contact is back to no evidence (null, model hidden). The
* bound is purely spatial on purpose: the ground under a contact that has not
* moved does not change with time, so what invalidates the value is the
* contact MOVING, which is exactly what this measures.
*
* @param {{h: number|null, samplePos: Cesium.Cartesian3|null, held: boolean}|undefined} entry
* @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid.
* @returns {number|null}
*/
function heldSnapM(entry, surfacePos) {
if (!entry || !entry.held || entry.h == null || !entry.samplePos) return null;
if (Cesium.Cartesian3.distanceSquared(surfacePos, entry.samplePos)
> HELD_SNAP_MAX_DRIFT_M * HELD_SNAP_MAX_DRIFT_M) {
return dropHold(entry);
}
// Held memory never outranks fresh contradicting evidence. Inside the drift
// bound the contact can still have taxied onto a different surface — an
// apron edge, a ramp, the far side of a rise — and since a miss PRESERVES
// the hold, nothing else would ever correct it: the model would stay buried
// (or floating) for as long as the resample kept missing.
const fresh = freshMeasuredFloorAt(surfacePos);
if (fresh != null && fresh - entry.h > HELD_SNAP_CONTRADICTION_M) {
return dropHold(entry);
}
return entry.h;
}
/** Forget a demoted measurement: it no longer describes anywhere this contact
* is, so the contact is COLD again (model hidden, floored billboard). */
function dropHold(entry) {
entry.h = null;
entry.samplePos = null;
entry.held = false;
return null;
}
/**
* Independently measured surface height at the contact's CURRENT position, or
* null where nothing has measured there yet.
*
* MESH cells only, deliberately — not `cachedGroundFloor`. A snap is a
* `sampleHeight` of the RENDERED skin, and the mesh cells are that same
* measurement through the same validation gate (written by this module's own
* successful samples and by `meshFloorSampler.js`). `cachedGroundFloor` falls
* back to the Re:Earth DEM, which is a DIFFERENT surface: the spread between
* skin and DEM is precisely what `MESH_FLOOR_BELOW/ABOVE_PRIOR_M` budgets for
* (15 m / 80 m), so testing a snap against a DEM cell would throw away good
* holds over a disagreement that says nothing about where the model sits.
*
* The contact's OWN cell only. A neighbour is ~111 m away and may be a
* terminal roof — that is why `neighborFloorM` leans to the lowest and refuses
* to answer from a single sample — and borrowing one to DISCARD a real
* measurement would let a roof cell hide a correctly placed model.
*
* @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid.
* @returns {number|null}
*/
function freshMeasuredFloorAt(surfacePos) {
const carto = Cesium.Cartographic.fromCartesian(
surfacePos, Cesium.Ellipsoid.WGS84, scratchHeldCarto,
);
if (!carto) return null;
return cachedMeshFloor(
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
);
}
/**
* Cached tile-skin height (m, ellipsoid) at the aircraft's lat/lon, or null
* when this contact has NO ground evidence at all (caller keeps the model
* hidden and calls again on a later tick — cheap map hit until a retry is due).
* A contact that HAS resolved once keeps that answer through a resample outage;
* see `heldSnapM`.
*
* @param {Cesium.Viewer} viewer - Live viewer (sampling + tiles-ready check).
* @param {string} icao - Cache key (per-layer icao space).
* @param {Cesium.Cartesian3} pos - Current display position. Cache movement
* is measured after projecting this input to the WGS84 ellipsoid, so a
* poll-time altitude/datum change cannot invalidate a stationary snap.
* @param {(() => Array<unknown>)|undefined} getExclusions - Lazily builds the
* scene.sampleHeight objectsToExclude list (own billboards/models/entities —
* the vertical pick ray at a plane's OWN lat/lon lands on its icon/model
* otherwise). Only invoked when a sample actually fires.
* @returns {number|null}
*/
function heightFor(viewer, icao, pos, getExclusions) {
const surfacePos = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(pos, scratchSurfacePos);
if (!surfacePos) return null;
const cached = entries.get(icao);
if (cached && cached.h != null && cached.samplePos && !cached.held) {
if (Cesium.Cartesian3.distanceSquared(surfacePos, cached.samplePos) <= MOVE_INVALIDATE_M * MOVE_INVALIDATE_M) {
return cached.h;
}
// Taxied away from the sampled spot. The measurement is DEMOTED to a
// bounded last-known rather than deleted, and the resample runs below: a
// miss now holds the aircraft on the ground it just left instead of
// hiding a model that was already up.
cached.held = true;
cached.nextRetryMs = 0;
cached.misses = 0;
}
const now = Date.now();
const entry = entries.get(icao);
if (entry && now < entry.nextRetryMs) return heldSnapM(entry, surfacePos); // backoff in force
// Per-window budget: deny WITHOUT a retry stamp so the overflow simply
// tries again next tick instead of waiting out a backoff it didn't earn.
if (now - windowStartMs > SAMPLE_WINDOW_MS) { windowStartMs = now; windowCount = 0; }
if (windowCount >= SAMPLE_BUDGET_PER_WINDOW) return heldSnapM(entry, surfacePos);
const misses = entry ? entry.misses : 0;
const miss = () => {
// A held measurement survives the miss — only the retry schedule moves.
const held = !!(entry && entry.held && entry.h != null && entry.samplePos);
const next = {
h: held ? entry.h : null,
samplePos: held ? entry.samplePos : null,
held,
nextRetryMs: now + Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** misses),
misses: misses + 1,
};
entries.set(icao, next);
return heldSnapM(next, surfacePos);
};
if (!_tilesReady(viewer)) return miss();
windowCount += 1;
let sampled;
try {
const carto = Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84, scratchCarto);
// sampleHeight throws when unsupported (no depth textures) — that's a miss.
sampled = viewer.scene.sampleHeight(carto, getExclusions ? getExclusions() : undefined);
} catch {
sampled = undefined;
}
// Same sanity floor as cctv.js's sampleGroundHeight: a hit far below the
// ellipsoid is pick garbage, not ground.
if (!Number.isFinite(sampled) || sampled < -150) return miss();
if (meshFloorPreferred()) {
reportValidatedMeshFloorCell(
Cesium.Math.toDegrees(scratchCarto.latitude),
Cesium.Math.toDegrees(scratchCarto.longitude),
sampled,
);
}
// A fresh sample RELEASES any hold: this is a measurement of where the
// contact is now, and it outranks a memory of where it was.
entries.set(icao, {
h: sampled,
samplePos: Cesium.Cartesian3.clone(surfacePos),
held: false,
nextRetryMs: 0,
misses: 0,
});
return sampled;
}
/** Drop one aircraft's snap (eviction / ground-flag flip / suppression). */
function forget(icao) { entries.delete(icao); }
/** Drop everything (layer destroy). */
function clear() { entries.clear(); }
return { heightFor, forget, clear };
}
import { createGroundSnap as createSnap } from '../services/groundSnap.js';
import * as groundFloor from './groundFloor.js';
/** Compatibility constructor using the default floor cache. */
export function createGroundSnap() { return createSnap({ groundFloor }); }
+4 -165
View File
@@ -1,165 +1,4 @@
// src/data/meshFloorSampler.js — rendered-surface (mesh) floor sampling
// (field-test round 4, 2026-07-06).
//
// The visible world in the google-3d regime is the PHOTOGRAMMETRIC MESH,
// which sits above the Re:Earth bare-earth DEM (measured ~17 m at the Austin
// airport apron) — so DEM-floored sprites/trails still buried themselves in
// the mesh while 3D models (which groundSnap against the mesh) looked right.
// This module samples the mesh height ONE-SHOT per coarse cell and reports it
// to groundFloor.js, whose cachedGroundFloor() then answers mesh-first.
//
// Rationing (the CCTV loading-jitter lesson): scene.sampleHeight forces
// tile-load prioritization at the probe point, so sampling is (a) gated to
// cells NEAR the viewer — where tiles are streamed and where an error is
// visible at all, (b) capped per call, (c) one-shot per cell for the session,
// (d) skipped entirely outside the google-3d regime. A returned-undefined
// probe (tiles not streamed yet) is NOT latched — it retries on a later call.
//
// Sample acceptance is sanity-gated against the DEM prior when warm (a
// vertical probe can hit a rooftop or another aircraft instead of pavement):
// the mesh legitimately sits ABOVE bare earth, so the window is asymmetric.
import * as Cesium from 'cesium';
import {
coarseFloorCoord, cachedMeshFloor, reportValidatedMeshFloorCell,
setMeshFloorPreferred, meshFloorPreferred, cachedGroundFloor,
} from './groundFloor.js';
/** @constant {number} Max scene samples per call (one call per layer poll). */
const MAX_SAMPLES_PER_CALL = 40;
/** @constant {number} Only cells within this range of the viewer subpoint are
* sampled. Round-5 hardening: tightened 40 → 15 km — beyond the streamed
* fine-LOD area a probe returns COARSE-tile heights (not undefined), and the
* one-shot latch made those permanent (owner round 5: grounded planes stuck
* at a uniform wrong height). */
const MAX_SAMPLE_DIST_KM = 15;
/** @constant {number} No sampling when the camera is above this height — the
* streamed LOD under a high camera is coarse everywhere, so every probe
* would latch junk. */
const MAX_CAMERA_HEIGHT_M = 25_000;
/** @type {Cesium.Cartographic} Scratch for probe coordinates. */
const _scratchProbe = new Cesium.Cartographic();
// Regime tracking: mesh cells only apply while the photoreal (google-3d)
// stack renders. main.js re-dispatches MapStackController.onChange as this
// CustomEvent; the boot default is photoreal, matching groundFloor's initial
// preference. Module-scope listener: the module only loads in the browser
// bundle, and the subscription is idempotent for the app's lifetime.
if (typeof window !== 'undefined') {
window.addEventListener('gev:map-stack-changed', (event) => {
const activeId = event?.detail?.activeId;
if (activeId) setMeshFloorPreferred(activeId === 'photoreal');
});
}
/**
* True when a VISIBLE 3D tileset in the scene reports its streaming queue
* drained (tilesLoaded) — the mirror of cctv.js's projectionTilesReady.
* Walks top-level primitives only (a handful; once per poll).
* @param {Cesium.Scene} scene
* @returns {boolean}
*/
function _visibleTilesetLoaded(scene) {
try {
const prims = scene.primitives;
for (let i = 0; i < prims.length; i++) {
const p = prims.get(i);
if (p instanceof Cesium.Cesium3DTileset && p.show) {
return !!p.tilesLoaded;
}
}
} catch { /* mid-teardown */ }
return false;
}
/**
* Equirectangular distance (km) — same approximation the flights clamp uses.
*/
function _approxKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
const dLon = (lon2 - lon1) * 111.32 * Math.cos(((lat1 + lat2) / 2) * Math.PI / 180);
return Math.hypot(dLat, dLon);
}
/**
* Samples the rendered mesh height for the coarse cells containing `points`,
* one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per
* layer poll (never per contact, never per frame). Synchronous — Cesium's
* sampleHeight is a CPU-side query against loaded tiles.
*
* @param {Cesium.Scene|undefined} scene - The scene (skipped when absent/torn down).
* @param {Array<{lat: number, lon: number}>} points - Contact/waypoint coords.
* @param {object} [options]
* @param {Array<object>} [options.excludeObjects] - Own billboards/models to
* exclude from the probe (Cesium matches instances/.primitive/.id — pass
* Billboard and Model instances, NOT collections).
* @param {number} [options.viewerLat] @param {number} [options.viewerLon]
* Viewer subpoint (computed once by the caller's poll).
*/
export function sampleMeshFloorCells(scene, points, { excludeObjects = [], viewerLat, viewerLon } = {}) {
if (!meshFloorPreferred()) return;
if (!scene || typeof scene.sampleHeight !== 'function') return;
if (!Array.isArray(points) || !points.length) return;
// Round-5 hardening (the CCTV projectionTilesReady lesson, ported late):
// while tiles are MID-STREAM a probe returns coarse-LOD heights — real
// numbers, wildly wrong — and the one-shot latch made them permanent.
// Only sample when the visible Google tileset reports tilesLoaded AND the
// camera is low enough that the streamed LOD near it is fine-grained.
const camH = scene.camera?.positionCartographic?.height;
if (!Number.isFinite(camH) || camH > MAX_CAMERA_HEIGHT_M) return;
if (!_visibleTilesetLoaded(scene)) return;
let sampled = 0;
const attempted = new Set();
for (const p of points) {
if (sampled >= MAX_SAMPLES_PER_CALL) break;
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
const cell = coarseFloorCoord(p.lat, p.lon);
const key = `${cell.lat},${cell.lon}`;
if (attempted.has(key)) continue;
attempted.add(key);
if (cachedMeshFloor(cell.lat, cell.lon) != null) continue; // one-shot latch
if (Number.isFinite(viewerLat) && Number.isFinite(viewerLon) &&
_approxKm(viewerLat, viewerLon, cell.lat, cell.lon) > MAX_SAMPLE_DIST_KM) {
continue; // too far: tiles not streamed there, probe would be a guaranteed miss
}
let height;
try {
const carto = Cesium.Cartographic.fromDegrees(cell.lon, cell.lat, 0, _scratchProbe);
height = scene.sampleHeight(carto, excludeObjects);
sampled += 1;
} catch {
continue; // scene mid-teardown — try again next poll
}
if (!Number.isFinite(height)) continue; // tiles not loaded yet — no negative latch
// Round 5: a REAL Re:Earth prior is REQUIRED before any sample latches.
// - A geoid-fallback prior (cached while the proxy was failing) is a
// sea-level poison that rejected legitimate samples at elevated
// fields (AUS: mesh 138 m vs poisoned −27 m prior → dropped forever).
// - No prior at all means no way to tell a real surface from a
// coarse-LOD/rooftop/aircraft mis-hit — the old sanity-only branch
// latched a 37 m "surface" at Austin permanently. The DEM lands
// within a poll (the same warm batch that queued this cell), so
// waiting costs one poll, not correctness.
// 2026-08-21: a "provisional" tier that KEPT the reading when no prior
// existed was built here and then removed, because it was measured failing.
// Run twice against the real GPU at the same Austin apron with the proxy
// down, it recorded 122.1 m once and 20.6 m the next time — the second is a
// coarse-LOD read of ground that is really ~122 m, and it left the contact
// ~100 m under the mesh. The DEM requirement above is not a formality.
//
// Before reaching for the obvious gate: `tilesLoaded` / `allTilesLoaded` is
// NOT a resolution signal. It reports that the streaming queue has drained
// for the CURRENT camera's screen-space-error target, and a distant or high
// camera is entirely satisfied by coarse tiles — which is exactly the state
// that reported "loaded" during the measurement above. Gating harder on it
// buys nothing. A correct gate has to ask about the specific tile covering
// the sample point: walk the tileset tree to the tile containing the
// coordinate and require its `geometricError` to be below a threshold tied
// to the accuracy the caller needs, before trusting the sample. That is
// feasible and deliberately deferred — see the P2 in
// the project roadmap, which bundles it with the fly_route probe so
// one post-launch pass settles rendered-mesh sampling everywhere.
reportValidatedMeshFloorCell(cell.lat, cell.lon, height);
}
}
export { cachedGroundFloor }; // re-export for callers that want one import site
import { createMeshFloorSampler } from '../services/meshFloorSampler.js';
import * as groundFloor from './groundFloor.js';
const meshFloorSampler = createMeshFloorSampler({ groundFloor, eventTarget: typeof window === 'undefined' ? null : window });
export const { sampleMeshFloorCells, cachedGroundFloor } = meshFloorSampler;
+2
View File
@@ -1,9 +1,11 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationMilitary } from '../app/layers/militaryFlights.js';
import { createAdsbLolSource } from '../sources/live/standalone.js';
import * as militaryRegistry from './militaryRegistry.js';
const militaryFlightsLayer = createApplicationMilitary({
surface: defaultSurface,
source: createAdsbLolSource(),
militaryRegistry,
});
+2
View File
@@ -1,3 +1,4 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationInstallations } from '../app/layers/militaryInstallations.js';
import { createSourceSlot } from '../app/sourceSlot.js';
import { createInstallationSource } from '../layers/installations/index.js';
@@ -9,6 +10,7 @@ const sourceSlot = createSourceSlot(
);
export const configureInstallationSource = sourceSlot.configure;
const layer = createApplicationInstallations({
surface: defaultSurface,
source: sourceSlot.source,
});
export const approximateSurfaceDistanceM = layer.approximateSurfaceDistanceM;
+2
View File
@@ -1,3 +1,4 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationRadio } from '../app/layers/radio.js';
import { createSourceSlot } from '../app/sourceSlot.js';
import { createRadioSource } from '../layers/radio/source.js';
@@ -9,6 +10,7 @@ const sourceSlot = createSourceSlot(
);
export const configureRadioSource = sourceSlot.configure;
const layer = createApplicationRadio({
surface: defaultSurface,
source: sourceSlot.source,
});
export const radioGlobeLabel = layer.radioGlobeLabel;
+13
View File
@@ -0,0 +1,13 @@
import * as terrain from './terrainHeights.js';
import * as groundFloor from './groundFloor.js';
import * as meshFloor from './meshFloorSampler.js';
import * as groundSnap from './groundSnap.js';
import * as anchors from './fireAnchors.js';
/** Default scene services for direct data-module compatibility callers. */
export const defaultSurface = Object.freeze({
terrain,
groundFloor,
meshFloor,
groundSnap,
anchors,
});
+3 -242
View File
@@ -1,243 +1,4 @@
import { createTerrainHeights } from '../services/terrainHeights.js';
import { applicationServices } from '../services/application.js';
// src/data/terrainHeights.js — batched, cached client terrain-height resolver
// (docs/plans/2026-07-05-entity-height-datum-fix.md Task 3).
//
// Resolves ELLIPSOIDAL ground height per (lat, lon) via the server-side
// `/api/terrain/heights` proxy (Task 2 — Re:Earth `heights.json`, disk-cached,
// serve-stale). When the proxy is unreachable (or errors) AND the point isn't
// already warm in this module's in-memory cache, falls back to bundled-geoid
// math (Task 1, `src/data/geoid.js`):
//
// sourceOrthometricM (if finite) + geoidHeight(lat, lon) → source:'geoid-fallback'
// else geoidHeight(lat, lon) alone (H≈0 "at the geoid/coast" prior) → source:'geoid-fallback'
//
// Two things learned building the Task 2 proxy (see its report, and the
// ledger's T2 entry) that this module builds in:
//
// 1. The proxy's GET has a Node http header-size ceiling — empirically a
// single request much past ~700-1500 points (depending on coordinate
// precision) risks a raw socket-level 431 *before* the proxy's own
// request handler runs. So every network request this module issues is
// chunked at CHUNK_SIZE (64 points), well under that ceiling, and
// chunks are sent SEQUENTIALLY (not in parallel) to keep this client
// well-behaved against a single dev-server proxy.
// 2. The proxy's disk cache is keyed by the raw `points` query string, with
// no eviction — so a client that varies coordinate precision or point
// order defeats the warm cache and grows the cache file unbounded. This
// module rounds every coordinate to 5 decimal places (~1.1 m of
// precision at the equator — comfortably tighter than any of this app's
// placement needs) BOTH for its own in-memory cache key AND for the
// points string sent to the proxy, so repeated calls (including calls
// from a fresh page load, a different camera batch, etc.) consistently
// hit the same warm proxy cache entry.
import { ensureGeoidReady, geoidHeight } from './geoid.js';
/** Max points per outgoing request to `/api/terrain/heights` (see file header, point 1). */
// Match the server's upstream batch so sequential upstream work also fits
// within this client's 30-second request deadline when Re:Earth slows.
const CHUNK_SIZE = 64;
/** Avoid repeatedly hitting a known-failing proxy from warm fallback reads. */
const GEOID_FALLBACK_COOLDOWN_MS = 60_000;
/**
* In-memory cache: `"lat.toFixed(5),lon.toFixed(5)"` -> `{ellipsoid, source}`.
* Module-scoped (not exported) — the only reads are through
* `cachedEllipsoidalGround` and the internal lookup in
* `resolveEllipsoidalGround`.
* @type {Map<string, {ellipsoid: number, source: 'reearth'|'geoid-fallback', retryAt?: number}>}
*/
const cache = new Map();
/**
* Builds the rounded cache key shared between the in-memory cache and the
* `points` string sent to the proxy (see file header, point 2).
* @param {number} lat
* @param {number} lon
* @returns {string}
*/
function cacheKey(lat, lon) {
return `${lat.toFixed(5)},${lon.toFixed(5)}`;
}
/**
* Synchronous read of a previously resolved ellipsoidal ground height.
* Returns null if this exact (rounded) coordinate hasn't been resolved yet
* by either the proxy path or the geoid-fallback path.
* @param {number} lat
* @param {number} lon
* @returns {number|null}
*/
export function cachedEllipsoidalGround(lat, lon) {
const entry = cache.get(cacheKey(lat, lon));
return entry ? entry.ellipsoid : null;
}
/**
* Field-test round 5 (2026-07-06, the "sea-level poison"): like
* cachedEllipsoidalGround but returns null for geoid-FALLBACK entries —
* only a real Re:Earth value counts. A fallback (cached when the proxy
* failed mid-burst) is the geoid surface, which at Austin sits ~165 m below
* the airport: floors built on it sank every sprite/trail, and the mesh
* sampler's sanity gate rejected REAL surface samples against it. Floor
* consumers read THIS; the plain read stays for display-only consumers.
* @param {number} lat
* @param {number} lon
* @returns {number|null}
*/
export function cachedRealEllipsoidalGround(lat, lon) {
const entry = cache.get(cacheKey(lat, lon));
return entry && entry.source === 'reearth' ? entry.ellipsoid : null;
}
/**
* Fetches one chunk (<=CHUNK_SIZE points) from the `/api/terrain/heights`
* proxy. Returns a Map keyed by the same rounded cache key so callers can
* look results up positionally-independent of upstream response ordering.
* Throws on any failure (non-ok response, network error, malformed body) —
* callers decide the fallback behavior per point.
* @param {Array<{key: string, lat: number, lon: number}>} chunk
* @returns {Promise<Map<string, number>>} key -> ellipsoid height (m)
*/
async function fetchChunk(chunk) {
// lon,lat order (matches the proxy's documented `points=lon,lat;…` contract
// and Task 2's implementation).
const results = await applicationServices.terrain.getHeights(chunk, { signal: AbortSignal.timeout(30000) });
const body = { results };
if (!Array.isArray(body?.results)) throw new Error('malformed terrain heights response (no results array)');
if (body.results.length !== chunk.length) {
throw new Error(
`terrain heights response length mismatch (expected ${chunk.length}, got ${body.results.length})`
);
}
const out = new Map();
// The proxy is documented to preserve input order (Task 2 report verifies
// this against the live upstream), so map positionally rather than trust
// the response's own lon/lat fields to re-match — those are also present
// for callers who want them, but this module doesn't need them.
for (let i = 0; i < chunk.length; i += 1) {
const ellipsoid = body.results[i]?.ellipsoid;
if (!Number.isFinite(ellipsoid)) throw new Error(`non-finite ellipsoid height at index ${i}`);
out.set(chunk[i].key, ellipsoid);
}
return out;
}
/**
* Computes the geoid-fallback ellipsoidal height for one point per the
* brief's fallback chain: `sourceOrthometricM + geoidHeight` when a finite
* orthometric source height is available, else `geoidHeight` alone (treats
* the point as if it were at the geoid, i.e. H≈0 — a coast-level prior).
* Assumes `ensureGeoidReady()` has already resolved.
* @param {number} lat
* @param {number} lon
* @param {number} [sourceOrthometricM]
* @returns {number}
*/
function geoidFallback(lat, lon, sourceOrthometricM) {
const n = geoidHeight(lat, lon);
return Number.isFinite(sourceOrthometricM) ? sourceOrthometricM + n : n;
}
/**
* Resolves ellipsoidal ground height for a batch of coordinates, in order.
*
* - Results already warm in the in-memory cache are returned without any
* network call.
* - Remaining (deduplicated) coordinates are sent to `/api/terrain/heights`
* in sequential chunks of <=64 points.
* - If a chunk request fails (network error, non-ok HTTP, malformed body),
* every point in THAT chunk falls back to geoid math
* (`sourceOrthometricM + geoidHeight` or `geoidHeight` alone) rather than
* failing the whole batch — a transient proxy outage in the middle of a
* large batch shouldn't blank out entities whose chunk happened to land
* earlier or later.
* - Every resolved value (proxy or fallback) is written back into the
* in-memory cache before this function returns, so a later call — even
* with a real proxy round-trip pending — sees a warm hit.
*
* @param {Array<{lat:number, lon:number, sourceOrthometricM?:number}>} coords
* @returns {Promise<Array<{ellipsoid:number, source:'reearth'|'geoid-fallback'}>>}
* Same length and order as `coords`.
*/
export async function resolveEllipsoidalGround(coords) {
if (!Array.isArray(coords) || coords.length === 0) return [];
// Build the per-input work list (key + original index) up front so the
// final assembly can map back positionally regardless of how many inputs
// shared a cache key or a chunk.
const work = coords.map((c, index) => ({
index,
lat: c.lat,
lon: c.lon,
sourceOrthometricM: c.sourceOrthometricM,
key: cacheKey(c.lat, c.lon),
}));
// Points not yet warm in the cache, deduplicated by key (multiple inputs —
// even within the same call — naming the same rounded coordinate should
// only ever hit the network once). Round 5: a geoid-FALLBACK entry does
// NOT count as permanently warm — it was cached when the proxy failed
// mid-burst and (at elevated terrain) is a sea-level poison that sank every
// floor built on it. Give failed keys a short cooldown before re-requesting
// so warm consumers do not hammer a failing proxy; after it expires, a
// successful response replaces the fallback entry and clears the path.
const uncached = [];
const seenKeys = new Set();
const now = Date.now();
for (const item of work) {
const entry = cache.get(item.key);
const fallbackCooling = entry?.source === 'geoid-fallback'
&& Number.isFinite(entry.retryAt)
&& now < entry.retryAt;
if ((entry && entry.source === 'reearth') || fallbackCooling || seenKeys.has(item.key)) continue;
seenKeys.add(item.key);
uncached.push(item);
}
// Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's
// failure is isolated to that chunk's points (geoid fallback), so a single
// bad chunk doesn't lose results for the rest of a large batch.
for (let i = 0; i < uncached.length; i += CHUNK_SIZE) {
const chunk = uncached.slice(i, i + CHUNK_SIZE);
try {
const resolved = await fetchChunk(chunk);
for (const item of chunk) {
const ellipsoid = resolved.get(item.key);
// Round 6: only a FINITE value may be cached as 'reearth'. A point
// the upstream response omitted used to cache {ellipsoid: undefined,
// source: 'reearth'} — permanently "warm" yet empty, so its floor
// read null forever and every later warm skipped it (ATL verify:
// one contact frozen at the geoid while its neighbors resolved).
// An omitted point now caches nothing and retries on the next warm.
if (Number.isFinite(ellipsoid)) {
cache.set(item.key, { ellipsoid, source: 'reearth' });
}
}
} catch {
// Proxy down (or cold cache had nothing to serve-stale) — fall back to
// geoid math for every point in this chunk. `ensureGeoidReady()` is
// awaited lazily, only on the fallback path, so the common (proxy
// healthy) case never pays for the geoid grid's dynamic import.
await ensureGeoidReady();
for (const item of chunk) {
const ellipsoid = geoidFallback(item.lat, item.lon, item.sourceOrthometricM);
cache.set(item.key, {
ellipsoid,
source: 'geoid-fallback',
retryAt: Date.now() + GEOID_FALLBACK_COOLDOWN_MS,
});
}
}
}
// Assemble the output in the original input order from the cache. A point
// the upstream omitted has NO entry (round 6 — deliberately uncached so it
// retries later): report it unresolved instead of throwing.
return work.map((item) => {
const entry = cache.get(item.key);
return entry
? { ellipsoid: entry.ellipsoid, source: entry.source }
: { ellipsoid: null, source: 'unresolved' };
});
}
const terrainHeights = createTerrainHeights({ source: applicationServices.terrain });
export const { cachedEllipsoidalGround, cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrainHeights;
+3 -2
View File
@@ -66,7 +66,8 @@ export class IntelHUD {
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for
* camera telemetry and coordinate derivation.
*/
constructor(viewer, { placeSearch, summaryPolicy = {}, basemapContext = {} } = {}) {
constructor(viewer, { placeSearch, summaryPolicy = {}, basemapContext = {}, summaryService = applicationServices.summary } = {}) {
this.summaryService = summaryService;
this.summaryPolicy = summaryPolicy;
this.basemapContext = basemapContext;
this.placeSearch = placeSearch;
@@ -664,7 +665,7 @@ export class IntelHUD {
this._summaryRequest = controller;
try {
this.summaryPolicy.onRequest?.();
const response = await applicationServices.summary.summarize(context, { signal: controller.signal });
const response = await this.summaryService.summarize(context, { signal: controller.signal });
const data = response.data;
if (revision !== this._summaryRevision) return;
if (isHudSummaryUnconfigured(response.status, data)) {
+8 -2
View File
@@ -310,7 +310,7 @@ test('accents and punctuation do not change what a name compares as', () => {
test('forward geocoding is composed once and consumers do not call a source directly', () => {
for (const file of ['locations.js', 'annotations/annotationResolver.js', 'voice/gevActions.js']) {
const source = fs.readFileSync(path.join(ROOT, 'src', file), 'utf8');
const source = readGeocodingConsumer(file);
assert.match(source, /placeSearch\.geocode\(/);
assert.doesNotMatch(source, /geocodeKeyless|geocode\/json\?address/);
}
@@ -325,7 +325,7 @@ test('a missing key is never a thrown error on the client', () => {
// It was one, in the Radio layer: a keyless install surfaced "play radio near
// Hanoi" as a failed voice turn rather than as a station it could not place.
for (const file of ['locations.js', 'annotations/annotationResolver.js', 'voice/gevActions.js']) {
const body = fs.readFileSync(path.join(ROOT, 'src', file), 'utf8');
const body = readGeocodingConsumer(file);
assert.doesNotMatch(body, /throw new Error\([^)]*No Google Maps API key/, `${file} throws on a missing key`);
}
});
@@ -446,3 +446,9 @@ test('a biased pass that never answered leaves the whole lookup unanswered', asy
assert.deepEqual(outcome, { place: null, answered: false });
assert.equal(urls.length, 2);
});
function readGeocodingConsumer(file) {
const files = file === 'annotations/annotationResolver.js'
? [file, 'annotations/resolver.js'] : [file];
return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n');
}
+4 -4
View File
@@ -794,7 +794,7 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
let viewport = result?.viewport || null;
// Nearby landmark recovery retains precedence over a fallback geocoder hit.
const recovered = await placesNearViewRecovery(
const recovered = await (options.recoverNearView || placesNearViewRecovery)(
viewer,
query,
result && !outcome.fallbackUsed ? { lat, lon: lng } : null,
@@ -885,7 +885,7 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
const shouldResolveBuilding = navigationMode === 'precise-place';
const buildingBounds = shouldResolveBuilding
? await resolveBuildingBounds(lat, lng, query)
? await resolveBuildingBounds(lat, lng, query, options.boundaries)
: null;
const range = requestedRange || defaultRangeForNavigationMode(navigationMode);
if (!mayFly()) return CANCELLED_SEARCH;
@@ -1330,7 +1330,7 @@ function buildingPitch(bounds) {
return -32;
}
async function resolveBuildingBounds(lat, lon, query) {
async function resolveBuildingBounds(lat, lon, query, boundaries = applicationServices.boundaries) {
const overpassQuery = `
[out:json][timeout:10];
(
@@ -1346,7 +1346,7 @@ async function resolveBuildingBounds(lat, lon, query) {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 6000);
try {
const elements = await applicationServices.boundaries.query(overpassQuery, { signal: controller.signal });
const elements = await boundaries.query(overpassQuery, { signal: controller.signal });
return selectBuildingBounds(Array.isArray(elements) ? elements : [], lat, lon, query);
} catch {
return null;
+661
View File
@@ -0,0 +1,661 @@
/** Construct an instance-owned groundFloor service with explicit dependencies. */
export function createGroundFloor({ terrain, signal }) {
const { cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrain;
signal?.throwIfAborted();
// src/data/groundFloor.js — coarse ground-floor clamp for entity render
// heights (field-test round 2026-07-06).
//
// Two owner findings drove this module:
// - RS46 (military H60): baro-only low-altitude contacts near steep terrain
// render INSIDE the hillside (no alt_geom → baro+N is off by more than the
// local relief).
// - WAKE01 (military B212): trail waypoints flagged 'ground' rendered at a
// fixed 50 m ellipsoidal sentinel — ~1.5 km underground at Kirtland AFB
// (field elevation ~1590 m ellipsoidal).
//
// The fix everywhere is the same: never render below the local ellipsoidal
// surface. This module provides the shared floor: a COARSE (3-decimal,
// ~111 m) grid over Task 3's terrainHeights resolver/cache, so that moving
// aircraft and multi-hundred-point trails collapse onto a small set of cells
// with heavy cache reuse (the exact 5-decimal grid would mint a new cache key
// every fix — the taxiing-cache lesson from the adversarial review).
// A ~111 m cell means the floor can be off by the relief within the cell in
// steep terrain; the clamp is a visual floor, not a survey — lifting an icon
// a few metres short of a ridge line still beats burying it.
//
// Zero-network on the render path: reads are synchronous cache lookups;
// `warmGroundFloor` batches the misses through resolveEllipsoidalGround
// (single-flight, fire-and-forget) exactly like flights.js's grounded-surface
// warm — results land for later polls/appends.
/** @constant {number} Metres added above the resolved floor so a clamped
* billboard/waypoint sits ON the surface instead of z-fighting it. */
const GROUND_FLOOR_LIFT_M = 1.5;
/** Accepted rendered-mesh window around a real ellipsoidal DEM prior. */
const MESH_FLOOR_BELOW_PRIOR_M = 15;
const MESH_FLOOR_ABOVE_PRIOR_M = 80;
/**
* Snaps a coordinate to the coarse (3-decimal, ~111 m) floor grid.
* @param {number} lat
* @param {number} lon
* @returns {{lat: number, lon: number}} Grid-cell coordinate.
*/
function coarseFloorCoord(lat, lon) {
return { lat: Number(lat.toFixed(3)), lon: Number(lon.toFixed(3)) };
}
/**
* Pure floor clamp.
* - finite alt + finite ground → max(alt, ground + lift)
* - finite alt + unknown ground → alt (no data, no clamp)
* - null alt + finite ground → ground + lift (the 'ground'-waypoint case:
* the point IS on the surface; put it there)
* - null alt + unknown ground → null (caller applies its own fallback)
* @param {number|null|undefined} altM - Proposed ellipsoidal render height.
* @param {number|null|undefined} groundM - Local ellipsoidal ground, if known.
* @param {number} [liftM] - Clearance above the floor.
* @returns {number|null}
*/
function floorAltitudeM(altM, groundM, liftM = GROUND_FLOOR_LIFT_M) {
const hasAlt = Number.isFinite(altM);
const hasGround = Number.isFinite(groundM);
if (hasAlt && hasGround) return Math.max(altM, groundM + liftM);
if (hasAlt) return altM;
if (hasGround) return groundM + liftM;
return null;
}
/**
* Floor decision at the point of DISPLAY (2026-08-19).
*
* `floorAltitudeM` above answers "what height should this FIX render at" and
* runs once per poll. But what the user sees is the DEAD-RECKONED position:
* a Cartesian interpolation between two fixes for the whole segment, and a
* velocity extrapolation of the newest fix while a contact coasts through its
* stale-feed grace. Both drift the rendered point horizontally away from the
* fix whose cell supplied the height — across a graded apron (KAUS spans
* ~119–140 m ellipsoidal) a taxiing sprite therefore renders under the mesh it
* has drifted over (measured −15.5 m; `scripts/qa-floor-verify.mjs`).
*
* This is the same "never below the visible surface" rule applied at the
* displayed coordinate instead of the fix coordinate. It returns `null` rather
* than the unchanged height whenever nothing should move, so the per-frame
* caller can skip rebuilding a Cartesian entirely — the common case by far.
*
* @param {number|null|undefined} displayHeightM - Ellipsoidal height the
* dead-reckoned display position currently carries.
* @param {number|null|undefined} floorM - Ellipsoidal floor at the DISPLAYED
* coordinate (`cachedGroundFloor`), or null when that cell is not warm.
* @param {number} [liftM] - Clearance above the floor.
* @returns {number|null} The height to render at, or null for "leave it alone".
*/
function displayFloorHeightM(
displayHeightM,
floorM,
liftM = GROUND_FLOOR_LIFT_M,
) {
if (!Number.isFinite(displayHeightM) || !Number.isFinite(floorM))
return null;
const lifted = floorM + liftM;
return displayHeightM < lifted ? lifted : null;
}
/** @constant {number} Pitch of the coarse floor grid in degrees — the spacing
* `coarseFloorCoord` rounds to, and therefore the step between adjacent cells. */
const FLOOR_CELL_DEG = 0.001;
/** @constant {number} Resolved neighbours required before one may be borrowed.
* A single reading cannot be checked against anything: a hangar roof and an
* apron are both "a warm cell 111 m away", and there is no way to tell them
* apart from one number. Two readings at least agree or disagree. */
const NEIGHBOR_FLOOR_MIN_SAMPLES = 2;
/**
* A floor for `cell` borrowed from the eight cells ADJACENT to it.
*
* Used only as a stand-in while `cell` itself is unresolved (2026-08-21: the
* Re:Earth proxy timed out four times in a row and a grounded contact standing
* on a cold cell rendered at the geoid — tens of metres under the mesh it was
* parked on). A neighbour is ~111 m away; on the aprons and taxiways where
* grounded contacts live that is the same surface, and it is a MEASURED height
* rather than an invented one.
*
* Leans to the LOWEST resolved neighbour, and refuses to answer at all from
* fewer than NEIGHBOR_FLOOR_MIN_SAMPLES of them.
*
* An earlier cut leaned HIGH, reasoning from the locked "never below the
* visible surface" principle. That principle is about a contact's OWN measured
* ground; applied to a BORROWED cell it inverts, and an owner playtest found
* why — planes floating in midair at terminal gates. The two errors are not
* symmetric:
* - Too LOW is inert. `displayFloorHeightM` only ever RAISES a position, so a
* floor under the contact simply does not lift it — the same "no clamp" the
* cold-cell case already has, bounded by one cell of grade (metres).
* - Too HIGH actively invents a position. It is bounded by BUILDING HEIGHT,
* not by grade, and a parked contact holds it: measured on the A/B rig,
* a lone roof neighbour put a contact 29.5 m up and left it there
* permanently when its own cell never warmed, and 6.3 s of visible
* stair-stepping when it did.
* A plane at a gate is on the apron, never on the terminal roof, so on a
* structure edge the LOW neighbour is the answer and the high one is an
* artefact of the photogrammetric mesh.
*
* The honest residual: on a genuine SLOPE the lowest neighbour under-reads by
* the grade across ~111 m, so the clamp lifts a little less than it could. That
* shows up as no lift rather than as a wrong one, and the contact's own cell
* corrects it as soon as it warms.
*
* @param {{lat: number, lon: number}|null|undefined} cell - Coarse cell.
* @returns {number|null} Ellipsoidal floor, or null when too few neighbours are
* warm to borrow from.
*/
function neighborFloorM(cell) {
if (!Number.isFinite(cell?.lat) || !Number.isFinite(cell?.lon)) return null;
let lowest = null;
let resolved = 0;
for (let dLat = -1; dLat <= 1; dLat += 1) {
for (let dLon = -1; dLon <= 1; dLon += 1) {
if (dLat === 0 && dLon === 0) continue;
const h = cachedGroundFloor(
cell.lat + dLat * FLOOR_CELL_DEG,
cell.lon + dLon * FLOOR_CELL_DEG,
);
if (h == null) continue;
resolved += 1;
if (lowest == null || h < lowest) lowest = h;
}
}
return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;
}
/** @constant {number} Cap on cells one corridor may contribute — a fast rollout
* or a spliced fix must not turn one contact into an unbounded warm batch.
* Sized for the worst realistic ground segment: a 27 m/s rollout coasts ~810 m
* (≈7 cells) in one poll interval, leaving headroom plus the endpoint slot. */
const CORRIDOR_MAX_CELLS = 12;
/** @constant {number} Step along a corridor leg when collecting cells (~14 m,
* an eighth of a cell). Half-cell steps were only cell-complete on LONG legs:
* a short leg rounds to a single step, so the walk degenerates to testing the
* leg's endpoints and a cell clipped between them is skipped. At this step any
* cell the path occupies for ~14 m of ground or more contains a sample — the
* guarantee this walk actually makes. The loop still exits the moment the cell
* budget fills, so a finer step costs iterations only on short corridors. */
const CORRIDOR_WALK_STEP_DEG = 0.000125;
/** @constant {number} Degrees added around a cell before a contact is allowed
* to adopt the next one (~22 m). A dead-reckoned position that jitters across
* a 0.001° edge would otherwise alternate floors at fleet-tick rate and pop
* the sprite between two heights. */
const CELL_HYSTERESIS_DEG = 0.0002;
/**
* The floor cell a contact should read, with boundary hysteresis.
*
* Cells are 0.001° squares, so a display position sitting on an edge (parked
* GPS jitter, the tracked reconciliation's decaying correction) flips between
* two cells frame to frame — and their floors can differ by the local grade or,
* next to a building, several metres. The contact keeps its previous cell until
* it is CELL_HYSTERESIS_DEG clear of that cell's bounds, which costs two
* comparisons and no allocation on the sticky path.
*
* @param {number} lat @param {number} lon - Displayed coordinate.
* @param {{lat: number, lon: number}|null|undefined} previousCell - The cell
* this contact read last tick, if any.
* @returns {{lat: number, lon: number}} The cell to read.
*/
function stickyFloorCell(lat, lon, previousCell) {
if (
previousCell &&
Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG &&
Math.abs(lon - previousCell.lon) <= 0.0005 + CELL_HYSTERESIS_DEG
) {
return previousCell;
}
return coarseFloorCoord(lat, lon);
}
/**
* The coarse cells a contact's DISPLAY is about to walk through.
*
* The dead-reckoned display runs one render delay behind and advances toward
* the newest fix, so the segment [display → newest fix] is exactly the ground
* it will render over during the next poll or two. Warming only the cell it is
* standing on warms the cell it is LEAVING: at 10 m/s a contact crosses a
* ~111 m cell every ~11 s while the warm batch runs once per 30 s poll, so it
* stays permanently ahead of its own floor data and the display clamp has
* nothing to read (KAUS: sprites 2–4 m under the mesh with a cold cell for the
* whole observation).
*
* Walks in half-cell steps so no cell between the ends is skipped. The step
* COUNT follows the segment length (never a fixed cap — a capped count over a
* long segment samples sparsely and leaves holes in the middle of ground the
* contact does cross). Length is bounded instead, and honestly: the walk emits
* a GAP-FREE PREFIX from `from`, and the destination cell always takes the last
* slot. A corridor longer than CORRIDOR_MAX_CELLS is therefore truncated —
* contiguous near ground plus the far endpoint — rather than thinned; the
* ground in between is picked up on a later poll as the display advances into
* it. Loop work stays bounded because the walk exits as soon as the cell budget
* fills, whatever the segment length.
*
* Takes the display PATH as a polyline, not two endpoints: while the contact
* is extrapolating, that path is an arc (the dead-reckon integrates a
* constant-rate turn), and walking a straight chord between its ends would
* warm ground the contact never crosses while its actual arc stayed cold.
*
* @param {Array<{lat: number, lon: number}>} points - Display path, current
* position first, destination last.
* @returns {Array<{lat: number, lon: number}>} Deduped coarse cells, start
* first, destination last.
*/
function corridorFloorCells(points) {
if (!Array.isArray(points) || !points.length) return [];
const first = points[0];
if (!Number.isFinite(first?.lat) || !Number.isFinite(first?.lon)) return [];
const start = coarseFloorCoord(first.lat, first.lon);
const out = [start];
const seen = new Set([`${start.lat},${start.lon}`]);
let destination = null;
for (let i = points.length - 1; i >= 1; i--) {
if (Number.isFinite(points[i]?.lat) && Number.isFinite(points[i]?.lon)) {
destination = coarseFloorCoord(points[i].lat, points[i].lon);
break;
}
}
if (!destination) return out;
const destKey = `${destination.lat},${destination.lon}`;
// One slot is reserved for the destination, appended after the prefix.
const prefixLimit = CORRIDOR_MAX_CELLS - 1;
walk: for (let s = 0; s < points.length - 1; s++) {
const a = points[s];
const b = points[s + 1];
if (![a?.lat, a?.lon, b?.lat, b?.lon].every(Number.isFinite)) continue;
const steps = Math.ceil(
Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lon - a.lon)) /
CORRIDOR_WALK_STEP_DEG,
);
for (let i = 1; i <= steps; i++) {
if (out.length >= prefixLimit) break walk;
const t = i / steps;
const cell = coarseFloorCoord(
a.lat + (b.lat - a.lat) * t,
a.lon + (b.lon - a.lon) * t,
);
const key = `${cell.lat},${cell.lon}`;
if (seen.has(key) || key === destKey) continue;
seen.add(key);
out.push(cell);
}
}
if (!seen.has(destKey)) out.push(destination);
return out;
}
/**
* Shares one poll's corridor-cell budget across the contacts that need it.
*
* The first cut walked contacts in Map insertion order and decremented the
* budget BEFORE deduping, so parked contacts burned slots on cells the poll had
* already collected and the contacts actually outrunning their floor data
* starved. Three rules fix that:
* 1. Cells already collected are FREE — a parked contact, whose corridor is
* its own fix cell, never competes for budget at all.
* 2. Candidates are ordered by how many of their cells are genuinely cold,
* then by speed (whoever leaves their floor behind soonest).
* 3. Allocation runs in widening rounds — one cell each, then `fairShare`
* each, then the remainder — so no contact is left with NOTHING while
* another takes seconds. The nearest cell is the one a contact needs first
* anyway; the far end of its corridor can wait a poll.
*
* @param {Array<{cells: Array<{lat: number, lon: number}>, cold: number, speedMps: number}>} candidates
* @param {Set<string>} seen - Keys ("lat,lon") already collected; MUTATED with
* everything this call allocates, so the caller's dedupe stays consistent.
* Ranking alone still starves a TIE longer than the budget: 80 equally needy
* movers means the same stable prefix wins every poll and the tail never moves.
* The `epoch` rotates each run of equal-ranked candidates, so a tie cycles
* round-robin across polls while a genuinely needier contact is never demoted
* below a less needy one (rotation happens WITHIN a rank, never across ranks).
*
* `seen` alone is NOT a sufficient free-list: in production it holds only the
* CURRENT poll's batch, so a cell that warmed on an earlier poll looked new and
* spent budget again, letting a contact's near cells consume its whole share
* poll after poll while the ground ahead of it stayed cold. Budget is therefore
* charged only for cells with no floor yet (`isWarm`) — but warm cells are
* still EMITTED, because the mesh sampler must see a cell again after its DEM
* prior lands or it never latches a rendered-surface height there.
*
* That gives a service bound derivable from the policy rather than a magic
* number: the first round charges at most one cell per candidate, so at least
* `min(candidates, budget)` candidates are served per poll; a candidate that
* misses out keeps its cold count while every served candidate's drops, so it
* outranks them next poll. Every candidate is therefore served within
* `ceil(candidates / budget)` polls once the warms land.
*
* @param {number} budget - Max cold cells to charge for.
* @param {number} fairShare - Per-candidate cap for the middle round.
* @param {number} [epoch] - Poll counter; rotates equal-ranked runs.
* @param {(cell: {lat: number, lon: number}) => boolean} [isWarm] - Whether a
* cell already has a floor (defaults to the shared cache).
* @returns {Array<{lat: number, lon: number}>} Cells to add, allocation order.
*/
function allocateCorridorCells(
candidates,
seen,
budget,
fairShare,
epoch = 0,
isWarm = (cell) => cachedGroundFloor(cell.lat, cell.lon) != null,
) {
const out = [];
if (!Array.isArray(candidates) || !candidates.length || !(budget > 0))
return out;
const ranked = [...candidates].sort(
(a, b) => b.cold - a.cold || b.speedMps - a.speedMps,
);
if (Number.isFinite(epoch) && epoch !== 0) {
for (let i = 0; i < ranked.length;) {
let j = i + 1;
while (
j < ranked.length &&
ranked[j].cold === ranked[i].cold &&
ranked[j].speedMps === ranked[i].speedMps
)
j += 1;
const runLength = j - i;
if (runLength > 1) {
const shift = ((epoch % runLength) + runLength) % runLength;
const run = ranked.slice(i, j);
for (let m = 0; m < runLength; m += 1)
ranked[i + m] = run[(m + shift) % runLength];
}
i = j;
}
}
// Cursor per candidate, carried ACROSS rounds: a later round resumes where
// the previous one stopped instead of re-walking cells already handled.
const cursor = new Array(ranked.length).fill(0);
let left = budget;
const round = (limit) => {
for (let i = 0; i < ranked.length; i++) {
const cells = ranked[i].cells || [];
while (cursor[i] < cells.length && cursor[i] < limit) {
const cell = cells[cursor[i]];
const key = `${cell.lat},${cell.lon}`;
if (seen.has(key)) {
cursor[i] += 1;
continue;
} // already in this batch
if (isWarm(cell)) {
// Free: it has a floor already. Still emitted, so the mesh sampler
// gets another look now that its DEM prior exists.
cursor[i] += 1;
seen.add(key);
out.push(cell);
continue;
}
if (left <= 0) return;
cursor[i] += 1;
seen.add(key);
out.push(cell);
left -= 1;
}
}
};
round(1); // nobody goes home empty-handed
round(fairShare); // then a fair slice each
round(CORRIDOR_MAX_CELLS); // then the remainder, neediest first
return out;
}
// --- Mesh-floor cells (round 4, owner-approved design) ---------------------
// The Re:Earth DEM is BARE EARTH; the visible world in the google-3d regime
// is the photogrammetric MESH, which sits above it (measured ~17 m at the
// Austin airport apron). DEM-flooring therefore still buried sprites/trails
// inside the mesh. These cells hold the RENDERED surface height, sampled
// one-shot per coarse cell near the viewer (scene.sampleHeight — the same
// source groundSnap uses, which is why 3D models always looked right) and
// reported here by meshFloorSampler.js. Design principle (owner): NEVER
// below the visible surface; slightly above is always fine. The DEM stays
// the instant global prior/fallback and the sanity gate for samples.
/** @type {Map<string, number>} coarse cell key -> rendered-surface height (m, ellipsoidal). */
const _meshCells = new Map();
/** @type {boolean} Whether mesh cells apply — true only in the google-3d
* regime (photoreal stack). On globe stacks the rendered surface IS the
* terrain provider (Re:Earth on keyless), so the DEM is already correct
* there and mesh heights would float. Kept current by meshFloorSampler's
* 'gev:map-stack-changed' listener. Photoreal is the boot default. */
let _meshPreferred = true;
/** @param {boolean} preferred - google-3d regime active. */
function setMeshFloorPreferred(preferred) {
_meshPreferred = !!preferred;
}
/** @returns {boolean} Whether mesh-floor cells currently apply. */
function meshFloorPreferred() {
return _meshPreferred;
}
/**
* Records a sampled rendered-surface height for the cell containing the
* coordinate (one-shot latch — first accepted sample wins).
* @param {number} lat @param {number} lon @param {number} heightM
*/
function reportMeshFloorCell(lat, lon, heightM) {
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
!Number.isFinite(heightM)
)
return;
const c = coarseFloorCoord(lat, lon);
const key = `${c.lat},${c.lon}`;
if (!_meshCells.has(key)) _meshCells.set(key, heightM);
}
/** Return whether a rendered-mesh sample is plausible against a real DEM prior. */
function meshFloorSampleWithinPrior(heightM, priorM) {
return (
Number.isFinite(heightM) &&
Number.isFinite(priorM) &&
heightM >= priorM - MESH_FLOOR_BELOW_PRIOR_M &&
heightM <= priorM + MESH_FLOOR_ABOVE_PRIOR_M
);
}
/**
* Validate and record a rendered-mesh floor through the single shared gate.
* Callers must not bypass this with raw `reportMeshFloorCell`: a coarse-LOD,
* rooftop, or aircraft hit would otherwise win the session-long first-write
* latch and poison every floor consumer in the cell.
* @returns {boolean} Whether the sample passed and was reported.
*/
function reportValidatedMeshFloorCell(lat, lon, heightM) {
if (![lat, lon, heightM].every(Number.isFinite)) return false;
const cell = coarseFloorCoord(lat, lon);
const prior = cachedRealEllipsoidalGround(cell.lat, cell.lon);
if (!meshFloorSampleWithinPrior(heightM, prior)) return false;
reportMeshFloorCell(cell.lat, cell.lon, heightM);
return true;
}
/**
* Synchronous mesh-cell read — null when the cell is unsampled OR the
* google-3d regime is inactive (globe stacks must floor on the DEM).
* @param {number} lat @param {number} lon
* @returns {number|null}
*/
function cachedMeshFloor(lat, lon) {
if (!_meshPreferred) return null;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const c = coarseFloorCoord(lat, lon);
const h = _meshCells.get(`${c.lat},${c.lon}`);
return h != null ? h : null;
}
/** Test hook: clears mesh cells (never called at runtime — cells are valid all session). */
function _clearMeshFloorCellsForTest() {
_meshCells.clear();
}
/**
* Synchronous coarse-cell ground read (warm cache only — never a fetch).
* Prefers the RENDERED-surface mesh cell (google-3d regime) and falls back
* to the Re:Earth DEM cell — the single choke point every ground-adjacent
* consumer (fleet clamp, grounded surface chain, trail floors) reads, so
* they all agree on one surface.
* @param {number} lat
* @param {number} lon
* @returns {number|null} Ellipsoidal floor of the cell, or null if not warm.
*/
function cachedGroundFloor(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const mesh = cachedMeshFloor(lat, lon);
if (mesh != null) return mesh;
const c = coarseFloorCoord(lat, lon);
return cachedRealEllipsoidalGround(c.lat, c.lon);
}
/**
* Awaitable variant for one-shot consumers (trail backfills): dedupes the
* points to unique coarse cells and resolves them, so `cachedGroundFloor`
* reads immediately after are warm. The underlying resolver chunks (≤200/req)
* and falls back per-point, so any trace length is safe. Never throws.
* @param {Array<{lat: number, lon: number}>} points
* @returns {Promise<void>}
*/
/** @constant {number} Max ms a one-shot consumer (trail backfill) waits for
* the floor resolve before painting with whatever cells are warm. Owner
* field test 2026-07-06 round 2: an UNBOUNDED await here held trail paints
* hostage to cold Re:Earth lookups spanning a whole flight path (seconds to
* the 30 s proxy timeout) — trails showed up late, short, or not at all.
* The resolve keeps running past the deadline and lands in the cache for
* the next paint/select. */
const FLOOR_RESOLVE_DEADLINE_MS = 1200;
/**
* Bounded-latency variant of resolveGroundFloorCells: waits at most
* FLOOR_RESOLVE_DEADLINE_MS, then returns so the caller can paint with the
* cells that ARE warm. The underlying resolve continues in the background
* and fills the cache for later reads. Never throws.
* @param {Array<{lat: number, lon: number}>} points
* @returns {Promise<void>}
*/
function resolveGroundFloorCellsBounded(points) {
return Promise.race([
resolveGroundFloorCells(points),
new Promise((resolve) => setTimeout(resolve, FLOOR_RESOLVE_DEADLINE_MS)),
]);
}
async function resolveGroundFloorCells(points) {
if (!Array.isArray(points) || !points.length) return;
const cells = new Map();
for (const p of points) {
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
const c = coarseFloorCoord(p.lat, p.lon);
const key = `${c.lat},${c.lon}`;
if (cells.has(key)) continue;
if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue;
cells.set(key, c);
}
if (!cells.size) return;
try {
await resolveEllipsoidalGround([...cells.values()]);
} catch {
/* best-effort — unresolved cells simply don't clamp */
}
}
/** @type {boolean} Single-flight guard for the warm batch. */
let _floorBatchInFlight = false;
/** @type {Map<string, {lat: number, lon: number}>} Cells requested while a
* batch was in flight — flushed as the NEXT batch the moment it completes.
* Round 6: the old guard silently DROPPED contending calls (flights every
* 30 s, military every 15 s, trail backfills ad hoc), so arriving at a new
* airport could take minutes of lost polls before a given cell resolved —
* the owner's "you can't just go to a place and start inspecting it". */
const _pendingFloorCells = new Map();
/** Kicks the resolver for a cell map, chaining any cells queued meanwhile. */
function _resolveFloorCells(cells) {
_floorBatchInFlight = true;
resolveEllipsoidalGround([...cells.values()])
.catch(() => {
/* best-effort — unclamped this tick, floor lands later */
})
.finally(() => {
_floorBatchInFlight = false;
if (!signal?.aborted && _pendingFloorCells.size) {
const next = new Map(_pendingFloorCells);
_pendingFloorCells.clear();
_resolveFloorCells(next);
}
});
}
/**
* Fire-and-forget batch warm of the coarse floor cells for the given points.
* Dedupes to unique cells, skips already-warm ones, and never blocks or
* throws into the caller. Contending calls QUEUE (never drop): their cells
* ride the next batch as soon as the in-flight one completes. Results are
* picked up by later `cachedGroundFloor` reads.
* @param {Array<{lat: number, lon: number}>} points
*/
function warmGroundFloor(points) {
if (signal?.aborted) return;
if (!Array.isArray(points) || !points.length) return;
const cells = new Map();
for (const p of points) {
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
const c = coarseFloorCoord(p.lat, p.lon);
const key = `${c.lat},${c.lon}`;
if (cells.has(key) || _pendingFloorCells.has(key)) continue;
if (cachedRealEllipsoidalGround(c.lat, c.lon) != null) continue; // already warm
cells.set(key, c);
}
if (!cells.size) return;
if (_floorBatchInFlight) {
for (const [key, c] of cells) _pendingFloorCells.set(key, c);
return;
}
_resolveFloorCells(cells);
}
signal?.addEventListener(
'abort',
() => {
_meshCells.clear();
_pendingFloorCells.clear();
},
{ once: true },
);
return {
GROUND_FLOOR_LIFT_M,
MESH_FLOOR_BELOW_PRIOR_M,
MESH_FLOOR_ABOVE_PRIOR_M,
coarseFloorCoord,
floorAltitudeM,
displayFloorHeightM,
FLOOR_CELL_DEG,
NEIGHBOR_FLOOR_MIN_SAMPLES,
neighborFloorM,
CORRIDOR_MAX_CELLS,
CORRIDOR_WALK_STEP_DEG,
CELL_HYSTERESIS_DEG,
stickyFloorCell,
corridorFloorCells,
allocateCorridorCells,
setMeshFloorPreferred,
meshFloorPreferred,
reportMeshFloorCell,
meshFloorSampleWithinPrior,
reportValidatedMeshFloorCell,
cachedMeshFloor,
_clearMeshFloorCellsForTest,
cachedGroundFloor,
FLOOR_RESOLVE_DEADLINE_MS,
resolveGroundFloorCellsBounded,
resolveGroundFloorCells,
warmGroundFloor,
};
}
+388
View File
@@ -0,0 +1,388 @@
/**
* groundSnap.js — one-shot cached ground-height snap for MODELED grounded aircraft.
*
* Why: a parked/taxiing plane's feed altitude is last-known baro or 0 m — nowhere
* near the photoreal tile skin in ellipsoid heights (buried ~100+ m at inland
* airports like AUS, floating ~30 m at sea-level fields like SFO, where the geoid
* sits below the ellipsoid). A 3D model placed at the meta altitude therefore
* renders inside the tiles or hovering above them. Billboards dodge this with
* disableDepthTestDistance; a depth-tested glTF model cannot.
*
* Discipline (CCTV-B9b style): scene.sampleHeight forces a synchronous offscreen
* pick render, so it is NEVER called per frame. Each grounded modeled plane gets
* ONE successful sample, cached per icao; the cache stops answering directly when
* the plane moves >MOVE_INVALIDATE_M from the sampled spot (taxiing). Misses (tiles
* still streaming, OSM fallback with no tileset skin, sample outside loaded
* geometry) are a retry-later signal with per-icao exponential backoff. A
* per-window budget stops a freshly modeled airport cluster from firing dozens of
* pick renders in one fleet tick.
*
* What a caller gets while a sample is outstanding depends on whether this
* contact has ever HAD one, and the difference is the difference between two
* kinds of wrong:
* - COLD (nothing ever resolved here) — null. There is no evidence of where the
* ground is, and the only honest answer for a depth-tested model is not to
* place it at all; the caller keeps the depth-test-free billboard.
* - WARM (a snap resolved, then taxi invalidated it and the resample has not
* landed) — the last measurement, held within HELD_SNAP_MAX_DRIFT_M. Hiding
* the model here would pop a taxiing aircraft from 3D back to 2D for a whole
* retry backoff, and the ground it was measured on a few dozen metres back is
* real evidence, not a guess. Same shape as the billboard chain's
* `_heldDisplayFloorM` (flights.js): hold the last thing that resolved,
* bounded by how far the contact can have travelled from it.
*
* A hold is a MEMORY, and it is ranked accordingly: held evidence never outranks
* fresh evidence that contradicts it. Distance travelled is only a proxy for
* "does this still describe the ground here", and it is a loose one — a contact
* can taxi 200 m onto a different surface well inside the bound, and because a
* miss preserves the hold, nothing in the retry path would ever correct that.
* So where the mesh-floor cells have actually MEASURED the surface under the
* contact's current position, and that surface is ABOVE the held value by more
* than HELD_SNAP_CONTRADICTION_M, the measurement wins: the hold is dropped and
* the contact is COLD again. The fallback is the floored 2D billboard, which is
* the honest picture — a model buried in the apron is not a better one.
*
* Shared by flights.js and militaryFlights.js (one instance each — the caches
* are per-layer, keyed by that layer's icao space).
*/
import * as Cesium from 'cesium';
/** Taxi threshold: a cached snap answers directly for moves up to this far from
* the sampled spot (m). Past it the value is demoted to a held last-known and a
* resample runs. */
const MOVE_INVALIDATE_M = 50;
/** @constant {number} How far a contact may travel from the spot its snap was
* measured at before that measurement stops describing the ground under it, and
* the hold is dropped rather than stretched.
*
* Sized to two things at once. It has to outlast a resample outage, or it buys
* nothing: RETRY_MAX_MS is 30 s of backoff, and ordinary taxi is ~10 m/s, so a
* hold that expires inside ~25 s of rolling would hand back the pop it exists to
* prevent. And it has to stay inside the grade error an airfield can hide: the
* KAUS spread this file's header cites is ~21 m across a ~4 km field, ~0.5%, so
* 250 m of taxi is ~1.3 m of vertical error — the order of the belly offsets
* already being added, and two orders under the ~100+ m burial the snap exists
* to prevent.
*
* Deliberately a quarter of the billboard chain's HELD_FLOOR_MAX_DRIFT_KM. That
* hold only ever RAISES a position (`displayFloorHeightM` never lowers), so an
* under-read there is inert; here the held value IS the placement, so the error
* shows in both directions and the bound has to be tighter. */
const HELD_SNAP_MAX_DRIFT_M = 250;
/** @constant {number} How far a held measurement may disagree with FRESH
* measured evidence at the contact's current position before it is dropped
* rather than trusted.
*
* The drift bound above asks "how far has the contact travelled?", which is a
* proxy for "does this value still describe the ground?" — and a proxy is all
* it is. 202 m of taxi onto a different surface is inside the bound and still
* wrong, and because a miss preserves the hold, repeated misses preserve the
* error for as long as they last. So the bound is not the only guard: where
* something has actually MEASURED the surface the contact is standing on now,
* that measurement outranks the memory, and a memory that contradicts it is
* discarded rather than stretched to cover the gap.
*
* 5 m sits between the two scales this has to separate:
* - ABOVE the honest disagreement between two measurements of the SAME
* surface. A floor cell is ~111 m across, so cell grade is ~0.6 m at the
* ~0.5 % KAUS spread this file's header cites and ~3.3 m even at a steep
* 3 %; the hold's own 250 m allowance is ~1.3 m at that grade. None of
* those should cost a taxiing aircraft its model.
* - BELOW the scale at which a placement reads as wrong. The belly offsets
* this snap adds are ~2–5 m and an airliner fuselage is ~4 m across, so a
* model displaced more than ~5 m is visibly off its gear rather than
* imprecisely on it — and it is two orders under the ~100+ m burial the
* snap exists to prevent.
*
* Applied in ONE direction: the hold is released only when fresh evidence sits
* ABOVE it, which is the BURIAL case — the model sunk into the surface, which
* is the entire reason this gate exists. The other sign is deliberately left
* alone, and the asymmetry is not tidiness:
*
* - A cell reading LOW against a real sample is the EXPECTED error here, not a
* contradiction. Cells are a one-shot first-write latch over ~111 m, the
* borrowed-neighbour path leans to the LOWEST on purpose (`neighborFloorM`),
* and `displayFloorHeightM` only ever RAISES — the whole floor chain is
* built to under-read rather than to invent. Releasing on that sign would
* discard good measurements wherever the two disagree downward. Measured on
* the track-regression rig: a planted cell sat 66.7 m BELOW a real sample at
* the SAME spot, and a two-sided rule dropped a hold that was correct.
* - The residual is stated rather than hidden, and stated WITH its condition.
* A contact taxiing DOWNHILL keeps a held value above its new surface and
* its model floats. The correction is a SUCCESSFUL resample, and nothing on
* this path guarantees one: on the OSM fallback sampleHeight misses forever,
* so the retries never land and the float persists for as long as the
* contact stays inside HELD_SNAP_MAX_DRIFT_M. There is no timer beside it
* and no vertical cap of its own — only whatever the ground drops within
* that radius. The other releases (a ground flip via forget(), travelling
* past the bound, a mesh cell measuring ABOVE the hold) are not ones a
* downhill taxi is guaranteed to hit either. It is accepted over the
* alternative because it errs UPWARD and stays visible, where a buried model
* errs into the terrain and cannot be seen at all. */
const HELD_SNAP_CONTRADICTION_M = 5;
/** First retry delay after a miss / tiles-not-ready (ms); doubles per consecutive miss. */
const RETRY_BASE_MS = 2000;
/** Backoff cap (ms) — on the OSM fallback (no tile skin) sampleHeight misses forever. */
const RETRY_MAX_MS = 30000;
/** Max sampleHeight calls per window across the layer (airport-cluster burst guard). */
const SAMPLE_BUDGET_PER_WINDOW = 4;
/** Budget window length (ms). */
const SAMPLE_WINDOW_MS = 250;
/**
* True when the active photoreal tileset has finished streaming the tiles in
* view (cctv.js's projectionTilesReady pattern: first Cesium3DTileset found in
* scene.primitives, duck-typed via its boolean tilesLoaded). Sampling before
* that both blows the frame budget on forced synchronous loads and produces
* misses that would burn retry backoff for nothing. With NO tileset present
* (OSM fallback) this returns true so the sample path is not permanently
* blocked — the sample just misses and backs off.
* @param {Cesium.Viewer} viewer
* @returns {boolean}
*/
function _tilesReady(viewer) {
const prims = viewer?.scene?.primitives;
if (!prims) return false;
for (let i = 0; i < prims.length; i++) {
let p = null;
try {
p = prims.get(i);
} catch {
continue;
}
if (p && p.show !== false && typeof p.tilesLoaded === 'boolean') {
return p.tilesLoaded === true;
}
}
return true;
}
/**
* Create a per-layer ground-snap cache.
* @returns {{
* heightFor: (viewer: Cesium.Viewer, icao: string, pos: Cesium.Cartesian3,
* getExclusions?: () => Array<unknown>) => number|null,
* forget: (icao: string) => void,
* clear: () => void,
* }}
*/
export function createGroundSnap({ groundFloor }) {
const { cachedMeshFloor, meshFloorPreferred, reportValidatedMeshFloorCell } =
groundFloor;
/** @type {Map<string, {h: number|null, samplePos: Cesium.Cartesian3|null,
* held: boolean, nextRetryMs: number, misses: number}>} Per-icao snap state:
* the sampled height and the surface point it was measured at, whether that
* pair is still the fresh answer or has been demoted to a bounded last-known
* (`held`), and the retry backoff a miss earned. */
const entries = new Map();
let windowStartMs = 0;
let windowCount = 0;
const scratchCarto = new Cesium.Cartographic();
const scratchSurfacePos = new Cesium.Cartesian3();
/** Separate from `scratchCarto`: the held-evidence check runs on paths that
* return BEFORE the sample writes `scratchCarto`, and on the sampling path it
* must not disturb the cartographic that `reportValidatedMeshFloorCell` reads
* back after the sample lands. */
const scratchHeldCarto = new Cesium.Cartographic();
/**
* The last measurement for a contact whose fresh snap has been demoted — it
* taxied past MOVE_INVALIDATE_M and the resample has not landed yet.
*
* Answers only while the contact is still within HELD_SNAP_MAX_DRIFT_M of the
* spot the value was measured at; past that the memory is DROPPED rather than
* stretched, and the contact is back to no evidence (null, model hidden). The
* bound is purely spatial on purpose: the ground under a contact that has not
* moved does not change with time, so what invalidates the value is the
* contact MOVING, which is exactly what this measures.
*
* @param {{h: number|null, samplePos: Cesium.Cartesian3|null, held: boolean}|undefined} entry
* @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid.
* @returns {number|null}
*/
function heldSnapM(entry, surfacePos) {
if (!entry || !entry.held || entry.h == null || !entry.samplePos)
return null;
if (
Cesium.Cartesian3.distanceSquared(surfacePos, entry.samplePos) >
HELD_SNAP_MAX_DRIFT_M * HELD_SNAP_MAX_DRIFT_M
) {
return dropHold(entry);
}
// Held memory never outranks fresh contradicting evidence. Inside the drift
// bound the contact can still have taxied onto a different surface — an
// apron edge, a ramp, the far side of a rise — and since a miss PRESERVES
// the hold, nothing else would ever correct it: the model would stay buried
// (or floating) for as long as the resample kept missing.
const fresh = freshMeasuredFloorAt(surfacePos);
if (fresh != null && fresh - entry.h > HELD_SNAP_CONTRADICTION_M) {
return dropHold(entry);
}
return entry.h;
}
/** Forget a demoted measurement: it no longer describes anywhere this contact
* is, so the contact is COLD again (model hidden, floored billboard). */
function dropHold(entry) {
entry.h = null;
entry.samplePos = null;
entry.held = false;
return null;
}
/**
* Independently measured surface height at the contact's CURRENT position, or
* null where nothing has measured there yet.
*
* MESH cells only, deliberately — not `cachedGroundFloor`. A snap is a
* `sampleHeight` of the RENDERED skin, and the mesh cells are that same
* measurement through the same validation gate (written by this module's own
* successful samples and by `meshFloorSampler.js`). `cachedGroundFloor` falls
* back to the Re:Earth DEM, which is a DIFFERENT surface: the spread between
* skin and DEM is precisely what `MESH_FLOOR_BELOW/ABOVE_PRIOR_M` budgets for
* (15 m / 80 m), so testing a snap against a DEM cell would throw away good
* holds over a disagreement that says nothing about where the model sits.
*
* The contact's OWN cell only. A neighbour is ~111 m away and may be a
* terminal roof — that is why `neighborFloorM` leans to the lowest and refuses
* to answer from a single sample — and borrowing one to DISCARD a real
* measurement would let a roof cell hide a correctly placed model.
*
* @param {Cesium.Cartesian3} surfacePos - Contact's position on the ellipsoid.
* @returns {number|null}
*/
function freshMeasuredFloorAt(surfacePos) {
const carto = Cesium.Cartographic.fromCartesian(
surfacePos,
Cesium.Ellipsoid.WGS84,
scratchHeldCarto,
);
if (!carto) return null;
return cachedMeshFloor(
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
);
}
/**
* Cached tile-skin height (m, ellipsoid) at the aircraft's lat/lon, or null
* when this contact has NO ground evidence at all (caller keeps the model
* hidden and calls again on a later tick — cheap map hit until a retry is due).
* A contact that HAS resolved once keeps that answer through a resample outage;
* see `heldSnapM`.
*
* @param {Cesium.Viewer} viewer - Live viewer (sampling + tiles-ready check).
* @param {string} icao - Cache key (per-layer icao space).
* @param {Cesium.Cartesian3} pos - Current display position. Cache movement
* is measured after projecting this input to the WGS84 ellipsoid, so a
* poll-time altitude/datum change cannot invalidate a stationary snap.
* @param {(() => Array<unknown>)|undefined} getExclusions - Lazily builds the
* scene.sampleHeight objectsToExclude list (own billboards/models/entities —
* the vertical pick ray at a plane's OWN lat/lon lands on its icon/model
* otherwise). Only invoked when a sample actually fires.
* @returns {number|null}
*/
function heightFor(viewer, icao, pos, getExclusions) {
const surfacePos = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(
pos,
scratchSurfacePos,
);
if (!surfacePos) return null;
const cached = entries.get(icao);
if (cached && cached.h != null && cached.samplePos && !cached.held) {
if (
Cesium.Cartesian3.distanceSquared(surfacePos, cached.samplePos) <=
MOVE_INVALIDATE_M * MOVE_INVALIDATE_M
) {
return cached.h;
}
// Taxied away from the sampled spot. The measurement is DEMOTED to a
// bounded last-known rather than deleted, and the resample runs below: a
// miss now holds the aircraft on the ground it just left instead of
// hiding a model that was already up.
cached.held = true;
cached.nextRetryMs = 0;
cached.misses = 0;
}
const now = Date.now();
const entry = entries.get(icao);
if (entry && now < entry.nextRetryMs) return heldSnapM(entry, surfacePos); // backoff in force
// Per-window budget: deny WITHOUT a retry stamp so the overflow simply
// tries again next tick instead of waiting out a backoff it didn't earn.
if (now - windowStartMs > SAMPLE_WINDOW_MS) {
windowStartMs = now;
windowCount = 0;
}
if (windowCount >= SAMPLE_BUDGET_PER_WINDOW)
return heldSnapM(entry, surfacePos);
const misses = entry ? entry.misses : 0;
const miss = () => {
// A held measurement survives the miss — only the retry schedule moves.
const held = !!(
entry &&
entry.held &&
entry.h != null &&
entry.samplePos
);
const next = {
h: held ? entry.h : null,
samplePos: held ? entry.samplePos : null,
held,
nextRetryMs: now + Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** misses),
misses: misses + 1,
};
entries.set(icao, next);
return heldSnapM(next, surfacePos);
};
if (!_tilesReady(viewer)) return miss();
windowCount += 1;
let sampled;
try {
const carto = Cesium.Cartographic.fromCartesian(
pos,
Cesium.Ellipsoid.WGS84,
scratchCarto,
);
// sampleHeight throws when unsupported (no depth textures) — that's a miss.
sampled = viewer.scene.sampleHeight(
carto,
getExclusions ? getExclusions() : undefined,
);
} catch {
sampled = undefined;
}
// Same sanity floor as cctv.js's sampleGroundHeight: a hit far below the
// ellipsoid is pick garbage, not ground.
if (!Number.isFinite(sampled) || sampled < -150) return miss();
if (meshFloorPreferred()) {
reportValidatedMeshFloorCell(
Cesium.Math.toDegrees(scratchCarto.latitude),
Cesium.Math.toDegrees(scratchCarto.longitude),
sampled,
);
}
// A fresh sample RELEASES any hold: this is a measurement of where the
// contact is now, and it outranks a memory of where it was.
entries.set(icao, {
h: sampled,
samplePos: Cesium.Cartesian3.clone(surfacePos),
held: false,
nextRetryMs: 0,
misses: 0,
});
return sampled;
}
/** Drop one aircraft's snap (eviction / ground-flag flip / suppression). */
function forget(icao) {
entries.delete(icao);
}
/** Drop everything (layer destroy). */
function clear() {
entries.clear();
}
return { heightFor, forget, clear };
}
+199
View File
@@ -0,0 +1,199 @@
import * as Cesium from 'cesium';
/** Construct an instance-owned meshFloorSampler service with explicit dependencies. */
export function createMeshFloorSampler({
groundFloor,
signal,
eventTarget = null,
}) {
const {
coarseFloorCoord,
cachedMeshFloor,
reportValidatedMeshFloorCell,
setMeshFloorPreferred,
meshFloorPreferred,
cachedGroundFloor,
} = groundFloor;
signal?.throwIfAborted();
// src/data/meshFloorSampler.js — rendered-surface (mesh) floor sampling
// (field-test round 4, 2026-07-06).
//
// The visible world in the google-3d regime is the PHOTOGRAMMETRIC MESH,
// which sits above the Re:Earth bare-earth DEM (measured ~17 m at the Austin
// airport apron) — so DEM-floored sprites/trails still buried themselves in
// the mesh while 3D models (which groundSnap against the mesh) looked right.
// This module samples the mesh height ONE-SHOT per coarse cell and reports it
// to groundFloor.js, whose cachedGroundFloor() then answers mesh-first.
//
// Rationing (the CCTV loading-jitter lesson): scene.sampleHeight forces
// tile-load prioritization at the probe point, so sampling is (a) gated to
// cells NEAR the viewer — where tiles are streamed and where an error is
// visible at all, (b) capped per call, (c) one-shot per cell for the session,
// (d) skipped entirely outside the google-3d regime. A returned-undefined
// probe (tiles not streamed yet) is NOT latched — it retries on a later call.
//
// Sample acceptance is sanity-gated against the DEM prior when warm (a
// vertical probe can hit a rooftop or another aircraft instead of pavement):
// the mesh legitimately sits ABOVE bare earth, so the window is asymmetric.
/** @constant {number} Max scene samples per call (one call per layer poll). */
const MAX_SAMPLES_PER_CALL = 40;
/** @constant {number} Only cells within this range of the viewer subpoint are
* sampled. Round-5 hardening: tightened 40 → 15 km — beyond the streamed
* fine-LOD area a probe returns COARSE-tile heights (not undefined), and the
* one-shot latch made those permanent (owner round 5: grounded planes stuck
* at a uniform wrong height). */
const MAX_SAMPLE_DIST_KM = 15;
/** @constant {number} No sampling when the camera is above this height — the
* streamed LOD under a high camera is coarse everywhere, so every probe
* would latch junk. */
const MAX_CAMERA_HEIGHT_M = 25_000;
/** @type {Cesium.Cartographic} Scratch for probe coordinates. */
const _scratchProbe = new Cesium.Cartographic();
// Regime tracking: mesh cells only apply while the photoreal (google-3d)
// stack renders. main.js re-dispatches MapStackController.onChange as this
// CustomEvent; the boot default is photoreal, matching groundFloor's initial
// preference. Module-scope listener: the module only loads in the browser
// bundle, and the subscription is idempotent for the app's lifetime.
/**
* True when a VISIBLE 3D tileset in the scene reports its streaming queue
* drained (tilesLoaded) — the mirror of cctv.js's projectionTilesReady.
* Walks top-level primitives only (a handful; once per poll).
* @param {Cesium.Scene} scene
* @returns {boolean}
*/
function _visibleTilesetLoaded(scene) {
try {
const prims = scene.primitives;
for (let i = 0; i < prims.length; i++) {
const p = prims.get(i);
if (p instanceof Cesium.Cesium3DTileset && p.show) {
return !!p.tilesLoaded;
}
}
} catch {
/* mid-teardown */
}
return false;
}
/**
* Equirectangular distance (km) — same approximation the flights clamp uses.
*/
function _approxKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
const dLon =
(lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
/**
* Samples the rendered mesh height for the coarse cells containing `points`,
* one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per
* layer poll (never per contact, never per frame). Synchronous — Cesium's
* sampleHeight is a CPU-side query against loaded tiles.
*
* @param {Cesium.Scene|undefined} scene - The scene (skipped when absent/torn down).
* @param {Array<{lat: number, lon: number}>} points - Contact/waypoint coords.
* @param {object} [options]
* @param {Array<object>} [options.excludeObjects] - Own billboards/models to
* exclude from the probe (Cesium matches instances/.primitive/.id — pass
* Billboard and Model instances, NOT collections).
* @param {number} [options.viewerLat] @param {number} [options.viewerLon]
* Viewer subpoint (computed once by the caller's poll).
*/
function sampleMeshFloorCells(
scene,
points,
{ excludeObjects = [], viewerLat, viewerLon } = {},
) {
if (!meshFloorPreferred()) return;
if (!scene || typeof scene.sampleHeight !== 'function') return;
if (!Array.isArray(points) || !points.length) return;
// Round-5 hardening (the CCTV projectionTilesReady lesson, ported late):
// while tiles are MID-STREAM a probe returns coarse-LOD heights — real
// numbers, wildly wrong — and the one-shot latch made them permanent.
// Only sample when the visible Google tileset reports tilesLoaded AND the
// camera is low enough that the streamed LOD near it is fine-grained.
const camH = scene.camera?.positionCartographic?.height;
if (!Number.isFinite(camH) || camH > MAX_CAMERA_HEIGHT_M) return;
if (!_visibleTilesetLoaded(scene)) return;
let sampled = 0;
const attempted = new Set();
for (const p of points) {
if (sampled >= MAX_SAMPLES_PER_CALL) break;
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) continue;
const cell = coarseFloorCoord(p.lat, p.lon);
const key = `${cell.lat},${cell.lon}`;
if (attempted.has(key)) continue;
attempted.add(key);
if (cachedMeshFloor(cell.lat, cell.lon) != null) continue; // one-shot latch
if (
Number.isFinite(viewerLat) &&
Number.isFinite(viewerLon) &&
_approxKm(viewerLat, viewerLon, cell.lat, cell.lon) > MAX_SAMPLE_DIST_KM
) {
continue; // too far: tiles not streamed there, probe would be a guaranteed miss
}
let height;
try {
const carto = Cesium.Cartographic.fromDegrees(
cell.lon,
cell.lat,
0,
_scratchProbe,
);
height = scene.sampleHeight(carto, excludeObjects);
sampled += 1;
} catch {
continue; // scene mid-teardown — try again next poll
}
if (!Number.isFinite(height)) continue; // tiles not loaded yet — no negative latch
// Round 5: a REAL Re:Earth prior is REQUIRED before any sample latches.
// - A geoid-fallback prior (cached while the proxy was failing) is a
// sea-level poison that rejected legitimate samples at elevated
// fields (AUS: mesh 138 m vs poisoned −27 m prior → dropped forever).
// - No prior at all means no way to tell a real surface from a
// coarse-LOD/rooftop/aircraft mis-hit — the old sanity-only branch
// latched a 37 m "surface" at Austin permanently. The DEM lands
// within a poll (the same warm batch that queued this cell), so
// waiting costs one poll, not correctness.
// 2026-08-21: a "provisional" tier that KEPT the reading when no prior
// existed was built here and then removed, because it was measured failing.
// Run twice against the real GPU at the same Austin apron with the proxy
// down, it recorded 122.1 m once and 20.6 m the next time — the second is a
// coarse-LOD read of ground that is really ~122 m, and it left the contact
// ~100 m under the mesh. The DEM requirement above is not a formality.
//
// Before reaching for the obvious gate: `tilesLoaded` / `allTilesLoaded` is
// NOT a resolution signal. It reports that the streaming queue has drained
// for the CURRENT camera's screen-space-error target, and a distant or high
// camera is entirely satisfied by coarse tiles — which is exactly the state
// that reported "loaded" during the measurement above. Gating harder on it
// buys nothing. A correct gate has to ask about the specific tile covering
// the sample point: walk the tileset tree to the tile containing the
// coordinate and require its `geometricError` to be below a threshold tied
// to the accuracy the caller needs, before trusting the sample. That is
// feasible and deliberately deferred — see the P2 in
// the project roadmap, which bundles it with the fly_route probe so
// one post-launch pass settles rendered-mesh sampling everywhere.
reportValidatedMeshFloorCell(cell.lat, cell.lon, height);
}
}
// re-export for callers that want one import site
const onStack = (event) => {
const id = event?.detail?.activeId;
if (id) setMeshFloorPreferred(id === 'photoreal');
};
eventTarget?.addEventListener('gev:map-stack-changed', onStack);
signal?.addEventListener(
'abort',
() => eventTarget?.removeEventListener('gev:map-stack-changed', onStack),
{ once: true },
);
return { sampleMeshFloorCells, cachedGroundFloor };
}
+276
View File
@@ -0,0 +1,276 @@
import { ensureGeoidReady, geoidHeight } from '../data/geoid.js';
/** Construct an instance-owned terrainHeights service with explicit dependencies. */
export function createTerrainHeights({ source, signal }) {
if (typeof source?.getHeights !== 'function')
throw new TypeError('Terrain heights require a source');
signal?.throwIfAborted();
// src/data/terrainHeights.js — batched, cached client terrain-height resolver
// (docs/plans/2026-07-05-entity-height-datum-fix.md Task 3).
//
// Resolves ELLIPSOIDAL ground height per (lat, lon) via the server-side
// `/api/terrain/heights` proxy (Task 2 — Re:Earth `heights.json`, disk-cached,
// serve-stale). When the proxy is unreachable (or errors) AND the point isn't
// already warm in this module's in-memory cache, falls back to bundled-geoid
// math (Task 1, `src/data/geoid.js`):
//
// sourceOrthometricM (if finite) + geoidHeight(lat, lon) → source:'geoid-fallback'
// else geoidHeight(lat, lon) alone (H≈0 "at the geoid/coast" prior) → source:'geoid-fallback'
//
// Two things learned building the Task 2 proxy (see its report, and the
// ledger's T2 entry) that this module builds in:
//
// 1. The proxy's GET has a Node http header-size ceiling — empirically a
// single request much past ~700-1500 points (depending on coordinate
// precision) risks a raw socket-level 431 *before* the proxy's own
// request handler runs. So every network request this module issues is
// chunked at CHUNK_SIZE (64 points), well under that ceiling, and
// chunks are sent SEQUENTIALLY (not in parallel) to keep this client
// well-behaved against a single dev-server proxy.
// 2. The proxy's disk cache is keyed by the raw `points` query string, with
// no eviction — so a client that varies coordinate precision or point
// order defeats the warm cache and grows the cache file unbounded. This
// module rounds every coordinate to 5 decimal places (~1.1 m of
// precision at the equator — comfortably tighter than any of this app's
// placement needs) BOTH for its own in-memory cache key AND for the
// points string sent to the proxy, so repeated calls (including calls
// from a fresh page load, a different camera batch, etc.) consistently
// hit the same warm proxy cache entry.
/** Max points per outgoing request to `/api/terrain/heights` (see file header, point 1). */
// Match the server's upstream batch so sequential upstream work also fits
// within this client's 30-second request deadline when Re:Earth slows.
const CHUNK_SIZE = 64;
/** Avoid repeatedly hitting a known-failing proxy from warm fallback reads. */
const GEOID_FALLBACK_COOLDOWN_MS = 60_000;
/**
* In-memory cache: `"lat.toFixed(5),lon.toFixed(5)"` -> `{ellipsoid, source}`.
* Module-scoped (not exported) — the only reads are through
* `cachedEllipsoidalGround` and the internal lookup in
* `resolveEllipsoidalGround`.
* @type {Map<string, {ellipsoid: number, source: 'reearth'|'geoid-fallback', retryAt?: number}>}
*/
const cache = new Map();
/**
* Builds the rounded cache key shared between the in-memory cache and the
* `points` string sent to the proxy (see file header, point 2).
* @param {number} lat
* @param {number} lon
* @returns {string}
*/
function cacheKey(lat, lon) {
return `${lat.toFixed(5)},${lon.toFixed(5)}`;
}
/**
* Synchronous read of a previously resolved ellipsoidal ground height.
* Returns null if this exact (rounded) coordinate hasn't been resolved yet
* by either the proxy path or the geoid-fallback path.
* @param {number} lat
* @param {number} lon
* @returns {number|null}
*/
function cachedEllipsoidalGround(lat, lon) {
const entry = cache.get(cacheKey(lat, lon));
return entry ? entry.ellipsoid : null;
}
/**
* Field-test round 5 (2026-07-06, the "sea-level poison"): like
* cachedEllipsoidalGround but returns null for geoid-FALLBACK entries —
* only a real Re:Earth value counts. A fallback (cached when the proxy
* failed mid-burst) is the geoid surface, which at Austin sits ~165 m below
* the airport: floors built on it sank every sprite/trail, and the mesh
* sampler's sanity gate rejected REAL surface samples against it. Floor
* consumers read THIS; the plain read stays for display-only consumers.
* @param {number} lat
* @param {number} lon
* @returns {number|null}
*/
function cachedRealEllipsoidalGround(lat, lon) {
const entry = cache.get(cacheKey(lat, lon));
return entry && entry.source === 'reearth' ? entry.ellipsoid : null;
}
/**
* Fetches one chunk (<=CHUNK_SIZE points) from the `/api/terrain/heights`
* proxy. Returns a Map keyed by the same rounded cache key so callers can
* look results up positionally-independent of upstream response ordering.
* Throws on any failure (non-ok response, network error, malformed body) —
* callers decide the fallback behavior per point.
* @param {Array<{key: string, lat: number, lon: number}>} chunk
* @returns {Promise<Map<string, number>>} key -> ellipsoid height (m)
*/
async function fetchChunk(chunk) {
// lon,lat order (matches the proxy's documented `points=lon,lat;…` contract
// and Task 2's implementation).
const results = await source.getHeights(chunk, {
signal: signal
? AbortSignal.any([signal, AbortSignal.timeout(30000)])
: AbortSignal.timeout(30000),
});
const body = { results };
if (!Array.isArray(body?.results))
throw new Error('malformed terrain heights response (no results array)');
if (body.results.length !== chunk.length) {
throw new Error(
`terrain heights response length mismatch (expected ${chunk.length}, got ${body.results.length})`,
);
}
const out = new Map();
// The proxy is documented to preserve input order (Task 2 report verifies
// this against the live upstream), so map positionally rather than trust
// the response's own lon/lat fields to re-match — those are also present
// for callers who want them, but this module doesn't need them.
for (let i = 0; i < chunk.length; i += 1) {
const ellipsoid = body.results[i]?.ellipsoid;
if (!Number.isFinite(ellipsoid))
throw new Error(`non-finite ellipsoid height at index ${i}`);
out.set(chunk[i].key, ellipsoid);
}
return out;
}
/**
* Computes the geoid-fallback ellipsoidal height for one point per the
* brief's fallback chain: `sourceOrthometricM + geoidHeight` when a finite
* orthometric source height is available, else `geoidHeight` alone (treats
* the point as if it were at the geoid, i.e. H≈0 — a coast-level prior).
* Assumes `ensureGeoidReady()` has already resolved.
* @param {number} lat
* @param {number} lon
* @param {number} [sourceOrthometricM]
* @returns {number}
*/
function geoidFallback(lat, lon, sourceOrthometricM) {
const n = geoidHeight(lat, lon);
return Number.isFinite(sourceOrthometricM) ? sourceOrthometricM + n : n;
}
/**
* Resolves ellipsoidal ground height for a batch of coordinates, in order.
*
* - Results already warm in the in-memory cache are returned without any
* network call.
* - Remaining (deduplicated) coordinates are sent to `/api/terrain/heights`
* in sequential chunks of <=64 points.
* - If a chunk request fails (network error, non-ok HTTP, malformed body),
* every point in THAT chunk falls back to geoid math
* (`sourceOrthometricM + geoidHeight` or `geoidHeight` alone) rather than
* failing the whole batch — a transient proxy outage in the middle of a
* large batch shouldn't blank out entities whose chunk happened to land
* earlier or later.
* - Every resolved value (proxy or fallback) is written back into the
* in-memory cache before this function returns, so a later call — even
* with a real proxy round-trip pending — sees a warm hit.
*
* @param {Array<{lat:number, lon:number, sourceOrthometricM?:number}>} coords
* @returns {Promise<Array<{ellipsoid:number, source:'reearth'|'geoid-fallback'}>>}
* Same length and order as `coords`.
*/
async function resolveEllipsoidalGround(coords) {
signal?.throwIfAborted();
if (!Array.isArray(coords) || coords.length === 0) return [];
// Build the per-input work list (key + original index) up front so the
// final assembly can map back positionally regardless of how many inputs
// shared a cache key or a chunk.
const work = coords.map((c, index) => ({
index,
lat: c.lat,
lon: c.lon,
sourceOrthometricM: c.sourceOrthometricM,
key: cacheKey(c.lat, c.lon),
}));
// Points not yet warm in the cache, deduplicated by key (multiple inputs —
// even within the same call — naming the same rounded coordinate should
// only ever hit the network once). Round 5: a geoid-FALLBACK entry does
// NOT count as permanently warm — it was cached when the proxy failed
// mid-burst and (at elevated terrain) is a sea-level poison that sank every
// floor built on it. Give failed keys a short cooldown before re-requesting
// so warm consumers do not hammer a failing proxy; after it expires, a
// successful response replaces the fallback entry and clears the path.
const uncached = [];
const seenKeys = new Set();
const now = Date.now();
for (const item of work) {
const entry = cache.get(item.key);
const fallbackCooling =
entry?.source === 'geoid-fallback' &&
Number.isFinite(entry.retryAt) &&
now < entry.retryAt;
if (
(entry && entry.source === 'reearth') ||
fallbackCooling ||
seenKeys.has(item.key)
)
continue;
seenKeys.add(item.key);
uncached.push(item);
}
// Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's
// failure is isolated to that chunk's points (geoid fallback), so a single
// bad chunk doesn't lose results for the rest of a large batch.
for (let i = 0; i < uncached.length; i += CHUNK_SIZE) {
const chunk = uncached.slice(i, i + CHUNK_SIZE);
try {
const resolved = await fetchChunk(chunk);
signal?.throwIfAborted();
for (const item of chunk) {
const ellipsoid = resolved.get(item.key);
// Round 6: only a FINITE value may be cached as 'reearth'. A point
// the upstream response omitted used to cache {ellipsoid: undefined,
// source: 'reearth'} — permanently "warm" yet empty, so its floor
// read null forever and every later warm skipped it (ATL verify:
// one contact frozen at the geoid while its neighbors resolved).
// An omitted point now caches nothing and retries on the next warm.
if (Number.isFinite(ellipsoid)) {
cache.set(item.key, { ellipsoid, source: 'reearth' });
}
}
} catch {
signal?.throwIfAborted();
// Proxy down (or cold cache had nothing to serve-stale) — fall back to
// geoid math for every point in this chunk. `ensureGeoidReady()` is
// awaited lazily, only on the fallback path, so the common (proxy
// healthy) case never pays for the geoid grid's dynamic import.
await ensureGeoidReady();
signal?.throwIfAborted();
for (const item of chunk) {
const ellipsoid = geoidFallback(
item.lat,
item.lon,
item.sourceOrthometricM,
);
cache.set(item.key, {
ellipsoid,
source: 'geoid-fallback',
retryAt: Date.now() + GEOID_FALLBACK_COOLDOWN_MS,
});
}
}
}
// Assemble the output in the original input order from the cache. A point
// the upstream omitted has NO entry (round 6 — deliberately uncached so it
// retries later): report it unresolved instead of throwing.
return work.map((item) => {
const entry = cache.get(item.key);
return entry
? { ellipsoid: entry.ellipsoid, source: entry.source }
: { ellipsoid: null, source: 'unresolved' };
});
}
signal?.addEventListener('abort', () => cache.clear(), { once: true });
return {
cachedEllipsoidalGround,
cachedRealEllipsoidalGround,
resolveEllipsoidalGround,
};
}
+7 -3
View File
@@ -25,19 +25,23 @@ export function createStandaloneApplication({
let placeSearch;
let catalog;
return createApplication({
createScene: (context) => {
catalog = createStandaloneCatalog({ signal: context.signal });
createScene: async (context) => {
placeSearch = createStandalonePlaceSearch({
...geospatial,
resolveApiKey: () => googleApiKey,
signal: context.signal,
});
return createStandaloneScene({
const scene = await createStandaloneScene({
...context,
googleApiKey,
cesiumToken,
loaderStatus,
});
catalog = createStandaloneCatalog({
signal: context.signal,
surface: scene.operations.surface,
});
return scene;
},
createControls: (context) =>
createStandaloneControls({
+7
View File
@@ -1,3 +1,5 @@
import { createSurfaceServices } from '../app/surfaceServices.js';
import { createApplicationRequestServices } from '../services/requests.js';
import { createApplicationCatalog } from '../app/constructCatalog.js';
import { createStandaloneLayerSources } from './layerSources.js';
export { createStandaloneReferenceSources } from './layerSources.js';
@@ -5,8 +7,13 @@ export { createStandaloneReferenceSources } from './layerSources.js';
/** Create fresh layer instances using the existing standalone source choices. */
export function createStandaloneCatalog({
signal = new AbortController().signal,
surface = createSurfaceServices({
terrainSource: createApplicationRequestServices().terrain,
signal,
}),
} = {}) {
return createApplicationCatalog({
surface,
sources: createStandaloneLayerSources(),
signal,
vesselOptions: {
+9 -1
View File
@@ -1 +1,9 @@
export { createApplicationScene as createStandaloneScene } from '../app/scene.js';
import { createApplicationScene } from '../app/scene.js';
import { createApplicationRequestServices } from '../services/requests.js';
/** Supply standalone request services when directly constructing a scene. */
export function createStandaloneScene(options) {
return createApplicationScene({
requestServices: createApplicationRequestServices(),
...options,
});
}
+1
View File
@@ -8,6 +8,7 @@ export class StyleManager extends ApplicationControls {
...options,
services: {
...catalogControlServices(getStandaloneCatalog()),
...getStandaloneCatalog().surface.controlServices,
...options.services,
},
});
+5 -2
View File
@@ -148,7 +148,7 @@ export class StyleManager {
*/
constructor(
viewer,
{ mapStackController = null, placeSearch, services } = {},
{ mapStackController = null, placeSearch, services, requestServices } = {},
) {
const {
IntelHUD,
@@ -293,7 +293,10 @@ export class StyleManager {
this._orbitIndicator = null;
// Intel HUD
this.hud = new IntelHUD(viewer, { placeSearch });
this.hud = new IntelHUD(viewer, {
placeSearch,
summaryService: requestServices?.summary,
});
this._recording.hud = this.hud;
this._cockpitVisionMode = 'optical';
this._cockpitVisionRestore = null;
-14
View File
@@ -41,14 +41,6 @@ import {
setDetectionTuning,
} from '../data/detection.js';
import { isTr3b, toggleTr3b } from '../data/tr3bRegistry.js';
import {
cachedGroundFloor,
cachedMeshFloor,
GROUND_FLOOR_LIFT_M,
meshFloorPreferred,
warmGroundFloor,
} from '../data/groundFloor.js';
import { sampleMeshFloorCells } from '../data/meshFloorSampler.js';
import {
holdContinuousRender,
releaseContinuousRender,
@@ -105,12 +97,6 @@ export class StyleManager extends ApplicationShell {
setDetectionTuning,
isTr3b,
toggleTr3b,
cachedGroundFloor,
cachedMeshFloor,
GROUND_FLOOR_LIFT_M,
meshFloorPreferred,
warmGroundFloor,
sampleMeshFloorCells,
holdContinuousRender,
releaseContinuousRender,
governorRequestRender,
+19 -18
View File
@@ -13,10 +13,10 @@ import { contextModeWord } from '../contextModePolicy.js';
import { createAnalystEngine } from '../data/analystEngine.js';
import { layerFeedState } from '../data/manager.js';
import { initCameraVerbs, moveCamera, flyRoute, interruptCameraMotion, adjustOrbitRange } from '../cameraVerbs.js';
import { cachedGroundFloor, warmGroundFloor } from '../data/groundFloor.js';
import * as defaultFloorServices from '../data/groundFloor.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { resolveRegionRingForQuery } from '../annotations/annotationResolver.js';
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
@@ -263,7 +263,11 @@ const BASEMAP_CONTEXT_WAIT_MS = 1500;
const viewTargetCache = new WeakMap();
/** Create application actions over the supplied scene and services. */
export function createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null, placeSearch = unavailablePlaceSearch }) {
export function createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null, placeSearch = unavailablePlaceSearch, floorServices = defaultFloorServices, annotationResolver = defaultAnnotationResolver, searchNavigation = searchAndFlyTo }) {
// Voice enable times and analyst follow-up memory belong to this runner.
const _layerEnabledAt = new Map();
let analystEngine;
const resolveRegionRing = (name) => annotationResolver.resolveRegionRingForQuery(name, undefined, placeSearch);
installViewTargetPrewarm(viewer);
initCameraVerbs(viewer, getViewTargetCartesian);
return async function runGevAction(name, rawArgs = {}, runOptions = {}) {
@@ -486,7 +490,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
};
const nearest = await createAnalystEngine(analystProviders(viewer, dataManager, {
recordLimitByLayer: { [layerId]: Number.MAX_SAFE_INTEGER }, placeSearch,
recordLimitByLayer: { [layerId]: Number.MAX_SAFE_INTEGER }, placeSearch, resolveRegionRing,
})).query({
layers: [layerId],
scope: { kind: 'view' },
@@ -745,7 +749,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
if (name === 'fly_to_location') {
return flyToRequestedLocation(viewer, args, {
placeSearch, signal: runOptions.signal,
placeSearch, searchNavigation, signal: runOptions.signal,
runImmediate: typeof styleManager?.runImmediateLocationNavigation === 'function'
? (navigate) => styleManager.runImmediateLocationNavigation(navigate)
: null,
@@ -791,7 +795,8 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
}
if (name === 'analyst_query') {
return runAnalystQuery(viewer, dataManager, args, placeSearch);
analystEngine ||= createAnalystEngine(analystProviders(viewer, dataManager, { placeSearch, resolveRegionRing }));
return runAnalystQuery(analystEngine, dataManager, args, _layerEnabledAt);
}
if (name === 'move_camera') {
@@ -804,9 +809,9 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
return flyRoute(
annotations?.list?.() || [],
args,
(lat, lon) => cachedGroundFloor(lat, lon),
(lat, lon) => floorServices.cachedGroundFloor(lat, lon),
(navigate) => runManagedVoiceNavigation(styleManager, 'route', 'fly_route', navigate),
(cells) => warmGroundFloor(cells),
(cells) => floorServices.warmGroundFloor(cells),
);
}
@@ -2166,7 +2171,7 @@ function normalizeStyle(value) {
}
async function flyToRequestedLocation(viewer, args, {
placeSearch = unavailablePlaceSearch, signal,
placeSearch = unavailablePlaceSearch, searchNavigation = searchAndFlyTo, signal,
onStart = null,
runImmediate = null,
beginDeferred = null,
@@ -2281,7 +2286,7 @@ async function flyToRequestedLocation(viewer, args, {
const generation = typeof beginDeferred === 'function' ? beginDeferred() : null;
if (generation === false) return cancelled(query);
const managedDeferred = typeof reassertDeferred === 'function';
const destination = await searchAndFlyTo(viewer, query, {
const destination = await searchNavigation(viewer, query, {
placeSearch, signal,
...(rangeM ? { range: rangeM } : {}),
forceClose: args.viewMode === 'close',
@@ -3218,9 +3223,6 @@ function clampNumber(value, min, max, fallback) {
* the voice payload. One engine per runner keeps follow-up memory
* ("which of those is closest?") scoped to the session.
*/
/** layerId → epoch ms of last voice-driven enable (analyst warm-up honesty). */
const _layerEnabledAt = new Map();
let _analystEngine = null;
/** Layers whose loaded set follows the camera, so a loaded count is not a world count. */
const VIEWPORT_LOADED_LAYERS = new Set(['flights']);
@@ -3239,7 +3241,7 @@ function activeContactsWindow(dataManager) {
}
}
function analystProviders(viewer, dataManager, { recordLimitByLayer = null, placeSearch = unavailablePlaceSearch } = {}) {
function analystProviders(viewer, dataManager, { recordLimitByLayer = null, placeSearch = unavailablePlaceSearch, resolveRegionRing = (name) => defaultAnnotationResolver.resolveRegionRingForQuery(name, undefined, placeSearch) } = {}) {
return {
getRecords(layerKey) {
const layer = dataManager.layers.get(layerKey);
@@ -3251,7 +3253,7 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null, plac
? (mod.getAnalystRecords(requestedLimit) || [])
: (mod.getAnalystRecords() || []);
},
resolveRegionRing: (name) => resolveRegionRingForQuery(name, undefined, placeSearch),
resolveRegionRing,
/**
* The active Contacts subject, when there is one — the centre the operator
* is reasoning about while Contacts is up. Null whenever Contacts is off,
@@ -3285,9 +3287,8 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null, plac
};
}
async function runAnalystQuery(viewer, dataManager, args = {}, placeSearch = unavailablePlaceSearch) {
if (!_analystEngine) _analystEngine = createAnalystEngine(analystProviders(viewer, dataManager, { placeSearch }));
const result = await _analystEngine.query({
async function runAnalystQuery(analystEngine, dataManager, args = {}, _layerEnabledAt) {
const result = await analystEngine.query({
layers: Array.isArray(args.layers) ? args.layers : undefined,
scope: args.scope,
filters: Array.isArray(args.filters) ? args.filters : [],