mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 12:23:59 +00:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c62435910 | ||
|
|
c9f40a837c | ||
|
|
910e63a56a | ||
|
|
3d6423c6f8 | ||
|
|
15fdf65a73 | ||
|
|
39d47a99ab | ||
|
|
3f152a71a1 | ||
|
|
39ff131d60 | ||
|
|
141181f83a | ||
|
|
c058ecf21b | ||
|
|
e073e4dbe4 | ||
|
|
a69d0492d0 | ||
|
|
0dbbd9047e | ||
|
|
acc4e63ec4 | ||
|
|
5d8f2b878f | ||
|
|
1015b16664 | ||
|
|
d7856109c7 | ||
|
|
bd8ef09042 | ||
|
|
47ea4aff1d | ||
|
|
067383fda8 | ||
|
|
da00b096d2 | ||
|
|
e2ca8d9a27 | ||
|
|
bc65c274da | ||
|
|
e4fda9d4e6 | ||
|
|
ee3466dff9 | ||
|
|
092e7e71a8 | ||
|
|
652f3c8ea1 | ||
|
|
1f0a8b5f16 | ||
|
|
2c936ae131 | ||
|
|
b68cd25e9e | ||
|
|
66a5c483c2 | ||
|
|
04d626a4b1 | ||
|
|
4318f51de4 | ||
|
|
52603f0afe | ||
|
|
7e765a3f93 | ||
|
|
3c26636ab4 | ||
|
|
5b5d942d95 | ||
|
|
2e0d855cf8 | ||
|
|
4ce28c83c0 | ||
|
|
239ba14005 | ||
|
|
cd83fc62fe | ||
|
|
6292b13d75 | ||
|
|
ed1982ab96 | ||
|
|
a9f16fecd3 | ||
|
|
bba786d215 | ||
|
|
295d089377 | ||
|
|
6cf641967b | ||
|
|
7ee51c093a | ||
|
|
621b87c52c | ||
|
|
a953c9a545 | ||
|
|
aa745bcee5 | ||
|
|
81f49406b8 |
No files matched your search
+1
-1
@@ -217,7 +217,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
||||
|
||||
| Dataset | Folder | License | Commercial use? | Attribution |
|
||||
| --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| **Datacenters** (4,351) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
|
||||
| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
|
||||
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map |
|
||||
| **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation |
|
||||
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
|
||||
|
||||
@@ -473,7 +473,7 @@ In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Ev
|
||||
|
||||
_The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×._
|
||||
|
||||
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712).
|
||||
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,352), Dams (704), and Submarine Cables (712).
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
- A vessel whose ambient card is on the map no longer gets a Detection callout
|
||||
showing through the card and covering its speed and course. A vessel whose
|
||||
card the overlay did not paint keeps its callout (German, @gekh, #1577).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Keep keyless place search on a nearby exact match for names written in
|
||||
non-Latin scripts instead of discarding the match and retrying without its
|
||||
proximity ranking (mertkayacs, #1585).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Reduce dead-reckoning work for flights already hidden behind the horizon.
|
||||
Hidden contacts update on a staggered one-in-13-ticks rotation (about once a
|
||||
second) while visible contacts and contacts with fleet models retain the full
|
||||
update path (Wilmer Alzate, #870).
|
||||
@@ -0,0 +1,2 @@
|
||||
- Add Troll Housing to the Datacenters layer (4,352 features) by refreshing
|
||||
Norway from OpenStreetMap data of 2026-10-05 (trygvehhh, #953).
|
||||
@@ -5350,7 +5350,7 @@ silently demoting every later lookup for the session.
|
||||
|
||||
### Motion & Symbology Correctness (June 10, 2026)
|
||||
|
||||
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The whole fleet dead-reckons at ~12Hz (1m² write gating); aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
|
||||
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
|
||||
- **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder.
|
||||
- **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them.
|
||||
- **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
|
||||
@@ -5483,7 +5483,7 @@ silently demoting every later lookup for the session.
|
||||
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
|
||||
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
|
||||
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
|
||||
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
|
||||
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`; a vessel's callout fades by the painted opacity of its own ambient card, so a card dropped by budget, collisions or UI chrome, or faded at the keyhole rim, leaves the callout readable), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
|
||||
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request.
|
||||
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
|
||||
|
||||
@@ -5881,8 +5881,11 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total
|
||||
deadline, with Photon requests capped at six seconds each. Caller/application
|
||||
cancellation stops retries and late cache writes. Replacing a location search
|
||||
cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five
|
||||
candidates, and an unbiased retry for name mismatches. A "place, region" query
|
||||
prefers a candidate named exactly for the place whose city, county, state,
|
||||
candidates, and an unbiased retry for name mismatches. Name comparison folds
|
||||
Latin accents while preserving letters and marks in other scripts, so a
|
||||
non-Latin name spelled exactly, marks included, keeps its proximity ranking. A
|
||||
leading "the" counts: "The Hague" does not take a nearby "Hague". A "place, region" query prefers a
|
||||
candidate named exactly for the place whose city, county, state,
|
||||
country or country code (or USA/UK) contains every word of the region, and a
|
||||
settlement or administrative area over a POI when both fit. Nearby results that
|
||||
fail that check are retried unbiased. After the region checks of both passes, a
|
||||
|
||||
@@ -57,6 +57,6 @@ Consumers use the package exports, which do not import standalone application
|
||||
globals.
|
||||
|
||||
Dataset files and source/license notices remain under `src/data/local_data/`.
|
||||
Their input counts are 4,351 datacenter features and 704 dam features. Cesium may
|
||||
Their input counts are 4,352 datacenter features and 704 dam features. Cesium may
|
||||
expand multipart geometries into multiple entities; entity and stem counts are
|
||||
different measurements. This change does not refresh or relicense the datasets.
|
||||
@@ -347,6 +347,7 @@
|
||||
"src/layers/flights/tracking.js",
|
||||
"src/layers/flights/rendering.js",
|
||||
"src/layers/flights/testing.js",
|
||||
"src/layers/flights/hiddenContactRotation.test.mjs",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/queries.js",
|
||||
|
||||
@@ -572,6 +572,7 @@
|
||||
"src/search/placeSearch.js",
|
||||
"src/search/google.js",
|
||||
"src/keylessGeocoder.js",
|
||||
"src/data/placeNames.js",
|
||||
"src/search/geospatial.js",
|
||||
"src/search/http.js",
|
||||
"src/search/defaults.js",
|
||||
|
||||
@@ -1786,3 +1786,42 @@ test('vessel focus wire skips hidden sprites at rest while a target is tracked',
|
||||
assert.equal(result.activeCount, 1);
|
||||
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
|
||||
});
|
||||
|
||||
test('detection objects name the vessel card their callout gives way to', () => {
|
||||
const vessel = (mmsi, name) => ({
|
||||
name,
|
||||
mmsi,
|
||||
type: 'Cargo',
|
||||
destination: '',
|
||||
speed: 9.5,
|
||||
course: 80,
|
||||
heading: 80,
|
||||
lastPositionUtc: '',
|
||||
missedRefreshes: 0,
|
||||
position: POS,
|
||||
billboard: null,
|
||||
});
|
||||
const keyed = vessel('200', 'GOLDEN BEAR');
|
||||
const unkeyed = { ...vessel('', 'NO MMSI'), lat: 37.8, lon: -122.4 };
|
||||
_setVesselStateForTest({
|
||||
viewer: {},
|
||||
records: [keyed, unkeyed],
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
});
|
||||
try {
|
||||
const objects = Object.fromEntries(
|
||||
aisLiveVesselsLayer
|
||||
.getDetectableObjects()
|
||||
.map((object) => [object.id, object]),
|
||||
);
|
||||
assert.equal(objects['GOLDEN BEAR'].cardSourceId, 'ais-live-vessels');
|
||||
assert.equal(objects['GOLDEN BEAR'].cardEntryId, 'vessel:200');
|
||||
assert.equal(
|
||||
objects['NO MMSI'].cardEntryId,
|
||||
'vessel:unkeyed:NO MMSI:37.80000:-122.40000',
|
||||
'the same identity the vessel card is published under',
|
||||
);
|
||||
} finally {
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
}
|
||||
});
|
||||
+24
-3
@@ -21,7 +21,10 @@ import {
|
||||
getKeyholeFadeTuning,
|
||||
keyholeLabelAlphaFromGeometry,
|
||||
} from '../celestialRing.js';
|
||||
import { registerWorldOverlayPaintLane } from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
getQueuedOverlayEntryAlpha,
|
||||
registerWorldOverlayPaintLane,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
DETECTION_STYLE,
|
||||
DETECTION_THEME_MAP,
|
||||
@@ -107,6 +110,8 @@ const THEME_MAP = DETECTION_THEME_MAP;
|
||||
const SPARSE_ZONE_FRACTION = 0.5;
|
||||
/** Solve cadence for membership/collision; accepted identities still reproject every frame. */
|
||||
const LABEL_SOLVE_INTERVAL_MS = 125;
|
||||
/** Own-card opacity that fully hides a callout; below it the callout fades in. */
|
||||
const CARD_ALPHA_HIDING_CALLOUT = 0.5;
|
||||
/** Alpha bands retain bracket batching while approximating a continuous radial fade. */
|
||||
const BRACKET_ALPHA_STEPS = 4;
|
||||
/** Finer bands for Global Context echo brackets, whose fade is animated. */
|
||||
@@ -576,7 +581,8 @@ export function getDetectionDiagnostics() {
|
||||
// sprites/strokes. Nothing is copied on the production paint path.
|
||||
result.calloutRects = _calloutPool
|
||||
.slice(0, _calloutCount)
|
||||
.map(({ x, y, w, h, alpha }) => ({ x, y, w, h, alpha }));
|
||||
.filter((row) => row.paintAlpha > 0.001)
|
||||
.map(({ x, y, w, h, paintAlpha }) => ({ x, y, w, h, alpha: paintAlpha }));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1077,6 +1083,9 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
font: FONT,
|
||||
microFont: MICRO_FONT,
|
||||
alpha: 1,
|
||||
cardSourceId: '',
|
||||
cardEntryId: '',
|
||||
paintAlpha: 1,
|
||||
};
|
||||
_calloutPool[_calloutCount] = row;
|
||||
}
|
||||
@@ -1103,6 +1112,8 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
row.font = FONT;
|
||||
row.microFont = MICRO_FONT;
|
||||
row.alpha = alpha;
|
||||
row.cardSourceId = candidate.cardSourceId;
|
||||
row.cardEntryId = candidate.cardEntryId;
|
||||
_calloutCount++;
|
||||
}
|
||||
|
||||
@@ -1134,7 +1145,15 @@ function _paintCalloutLane(frame) {
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
for (let i = 0; i < _calloutCount; i++) {
|
||||
const row = _calloutPool[i];
|
||||
paintDetectionCallout(ctx, row, row.alpha);
|
||||
// The callout would show through its contact's own card: fade it by the
|
||||
// opacity that card paints with this frame.
|
||||
const cardAlpha =
|
||||
row.cardEntryId === ''
|
||||
? 0
|
||||
: getQueuedOverlayEntryAlpha(row.cardSourceId, row.cardEntryId);
|
||||
row.paintAlpha =
|
||||
row.alpha * Math.max(0, 1 - cardAlpha / CARD_ALPHA_HIDING_CALLOUT);
|
||||
if (row.paintAlpha > 0.001) paintDetectionCallout(ctx, row, row.paintAlpha);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
@@ -1211,6 +1230,8 @@ function _materializeCandidate(
|
||||
primary,
|
||||
micro,
|
||||
hasMicro: card.hasMicro,
|
||||
cardSourceId: obj.cardSourceId ? String(obj.cardSourceId) : '',
|
||||
cardEntryId: obj.cardEntryId ? String(obj.cardEntryId) : '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,16 @@ import {
|
||||
} from './detection.js';
|
||||
import {
|
||||
destroyWorldOverlay,
|
||||
getOverlayPaintRect,
|
||||
initWorldOverlay,
|
||||
setOverlayEntries,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import { applyVesselOverlayPolicy } from './vesselLabels.js';
|
||||
import {
|
||||
getKeyholeFadeTuning,
|
||||
getKeyholeGeometry,
|
||||
keyholeLabelAlphaFromGeometry,
|
||||
} from '../celestialRing.js';
|
||||
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
|
||||
import {
|
||||
clearAwarenessEchoField,
|
||||
@@ -579,6 +586,178 @@ test('every style paints its OWN plate token through the production callout path
|
||||
}
|
||||
});
|
||||
|
||||
/** A vessel detection contact that names its ambient card, like the AIS layer does. */
|
||||
function shipWithCard(mmsi, position) {
|
||||
return {
|
||||
position,
|
||||
sourceId: mmsi,
|
||||
id: `SHIP-${mmsi}`,
|
||||
metric: '6 kn',
|
||||
type: 'SEA',
|
||||
cardSourceId: 'ais-live-vessels',
|
||||
cardEntryId: `vessel:${mmsi}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The production ambient vessel card (not protected: it goes through the arbiter). */
|
||||
function ambientVesselCard(mmsi, position) {
|
||||
return applyVesselOverlayPolicy({
|
||||
id: `vessel:${mmsi}`,
|
||||
position,
|
||||
title: `CARD-${mmsi}`,
|
||||
details: ['CARGO · 6KT · 80°'],
|
||||
gapPx: 10,
|
||||
});
|
||||
}
|
||||
|
||||
test('a callout stays readable when its own card is faded out at the keyhole rim', () => {
|
||||
// Default keyhole tuning: 7% feather, 1% outside opacity.
|
||||
assert.deepEqual(getKeyholeFadeTuning(), { fadeRatio: 0.07, outsideOpacity: 0.01 });
|
||||
const width = 1600;
|
||||
const height = 900;
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
const env = installEnvironment({ width, height, dpr: 1 });
|
||||
// Just outside the keyhole's left rim: the card sits above or below the
|
||||
// anchor (outside, 1% alpha), the callout to its right (inside the feather).
|
||||
const position = new Cesium.Cartesian3(270 / (width / 2) - 1, 1 - 460 / (height / 2), 6_356_752);
|
||||
const ship = shipWithCard('200', position);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const [alone] = getDetectionDiagnostics().calloutRects;
|
||||
assert.ok(alone && alone.alpha >= 0.5, 'without a card the callout is clearly visible');
|
||||
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', position)]);
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const rect = getOverlayPaintRect('ais-live-vessels', 'vessel:200');
|
||||
assert.ok(rect, 'the host paints the card (at the outside opacity)');
|
||||
const cardAlpha = keyholeLabelAlphaFromGeometry(rect.x + rect.w / 2, rect.y + rect.h / 2, keyhole);
|
||||
assert.ok(cardAlpha <= 0.05, `the card is invisible at the rim (keyhole alpha ${cardAlpha})`);
|
||||
|
||||
const callouts = getDetectionDiagnostics().calloutRects;
|
||||
assert.equal(callouts.length, 1, 'the callout keeps painting while its card is faded out');
|
||||
assert.ok(callouts[0].alpha >= 0.4, 'and stays readable');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a callout stays hidden while its card fades out after leaving the published cohort', () => {
|
||||
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
|
||||
const at = (x, y) => new Cesium.Cartesian3(x, y, 6_356_752);
|
||||
const ship = shipWithCard('200', at(0.1, 0.1));
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
const frame = (ms) => {
|
||||
env.ctx.calls.length = 0;
|
||||
env.advance(ms);
|
||||
env.postRender.raise();
|
||||
};
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
|
||||
for (let i = 0; i < 6; i++) frame(250);
|
||||
frame(16);
|
||||
assert.ok(painted('CARD-200') && !painted('SHIP-200'), 'steady state: card only');
|
||||
|
||||
// The vessel layer republishes its screen-grid cohort after a camera move:
|
||||
// this vessel's card is replaced by another one, the vessel stays detectable.
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('300', at(-0.5, -0.4))]);
|
||||
frame(16);
|
||||
assert.ok(getOverlayPaintRect('ais-live-vessels', 'vessel:200'), 'the dropped card is still fading out');
|
||||
assert.ok(painted('CARD-200'), 'and its text still paints');
|
||||
assert.ok(!painted('SHIP-200'), 'no full callout under the fading card');
|
||||
|
||||
for (let i = 0; i < 4; i++) frame(250);
|
||||
assert.ok(!painted('CARD-200'), 'the card has faded out');
|
||||
assert.ok(painted('SHIP-200'), 'and the callout is back');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a vessel whose ambient card loses the collision keeps its callout', () => {
|
||||
const env = installEnvironment({ width: 900, height: 600, dpr: 1 });
|
||||
// Four vessels a few pixels apart: their cards collide (a card goes only
|
||||
// above or below its anchor), so the arbiter paints only some of them.
|
||||
const ships = [0, 1, 2, 3].map((i) =>
|
||||
shipWithCard(String(100 + i), new Cesium.Cartesian3(-0.2 + i * 0.005, i * 0.005, 6_356_752)),
|
||||
);
|
||||
const painted = (text) => env.ctx.calls.some(([name, value]) => name === 'fillText' && value === text);
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => ships }], () => {});
|
||||
setOverlayEntries(
|
||||
'ais-live-vessels',
|
||||
ships.map((ship) => ambientVesselCard(ship.sourceId, ship.position)),
|
||||
);
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
env.ctx.calls.length = 0;
|
||||
settleFrame(env);
|
||||
|
||||
const winners = ships.filter((ship) => getOverlayPaintRect('ais-live-vessels', ship.cardEntryId));
|
||||
assert.ok(
|
||||
winners.length >= 1 && winners.length < ships.length,
|
||||
`the colliding cards leave some unpainted (${winners.length} of ${ships.length} painted)`,
|
||||
);
|
||||
for (const ship of ships) {
|
||||
const carded = winners.includes(ship);
|
||||
assert.equal(
|
||||
painted(ship.id),
|
||||
!carded,
|
||||
carded ? `${ship.id} draws no callout under its card` : `${ship.id} keeps its callout`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
getDetectionDiagnostics().calloutRects.length,
|
||||
ships.length - winners.length,
|
||||
'only the callouts that painted are reported',
|
||||
);
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a callout fades out while its card fades in instead of vanishing at once', () => {
|
||||
const env = installEnvironment({ width: 1600, height: 900, dpr: 1 });
|
||||
const ship = shipWithCard('200', new Cesium.Cartesian3(0.1, 0.1, 6_356_752));
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
initDetection(env.viewer, [{ id: 'ais-live-vessels', getDetectableObjects: () => [ship] }], () => {});
|
||||
setMode('DENSE');
|
||||
for (let i = 0; i < 4; i++) settleFrame(env);
|
||||
const [alone] = getDetectionDiagnostics().calloutRects;
|
||||
assert.ok(alone.alpha >= 0.9, 'the callout starts fully visible');
|
||||
|
||||
setOverlayEntries('ais-live-vessels', [ambientVesselCard('200', ship.position)]);
|
||||
const alphas = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
env.advance(16);
|
||||
env.postRender.raise();
|
||||
alphas.push(getDetectionDiagnostics().calloutRects[0]?.alpha ?? 0);
|
||||
}
|
||||
assert.ok(
|
||||
alphas.some((alpha) => alpha > 0.1 && alpha < alone.alpha - 0.1),
|
||||
`the callout passes through partial opacity: ${alphas.map((a) => a.toFixed(2)).join(' ')}`,
|
||||
);
|
||||
assert.equal(alphas.at(-1), 0, 'and is gone once the card is fully in');
|
||||
} finally {
|
||||
destroyDetection();
|
||||
destroyWorldOverlay();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('space-tier callouts take the heavier plate while air contacts keep the light one', () => {
|
||||
// Satellites sit over the high-albedo lit Earth disc far more often than
|
||||
// aircraft do, so the space tier carries the heavier backing. Losing the
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Test fixtures
|
||||
|
||||
- `photon-moscow-search.json` - Photon responses for `Москва`, captured
|
||||
2026-11-05 with and without a proximity bias near the Tajikistan namesake.
|
||||
The fixture is trimmed to the fields the keyless geocoder consumes and is
|
||||
used only by offline search tests. Data from OpenStreetMap contributors,
|
||||
ODbL 1.0 (https://www.openstreetmap.org/copyright).
|
||||
- `tomtom-flow-austin-12-935-1686.pbf` — one real TomTom traffic-flow vector
|
||||
tile (Mapbox Vector Tile protobuf, layer `"Traffic flow"`), downtown Austin
|
||||
z12 x935 y1686, captured 2026-07-16 from
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"biased": {
|
||||
"features": [
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [71.8339931, 38.948412]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Тоҷикистон"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.6063916, 55.625578]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.6174782, 55.7505412]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.0536908, 55.7098009]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [32.163334, 56.917778]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"unbiased": {
|
||||
"features": [
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.6063916, 55.625578]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.6174782, 55.7505412]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [37.0536908, 55.7098009]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [32.163334, 56.917778]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
},
|
||||
{
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [71.8339931, 38.948412]
|
||||
},
|
||||
"properties": {
|
||||
"name": "Москва",
|
||||
"country": "Тоҷикистон"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ test('infrastructure factory preserves identity and creates independent state wi
|
||||
|
||||
test('dataset URLs still name the complete bundled sources', () => {
|
||||
for (const [file, count] of [
|
||||
['datacenters', 4351],
|
||||
['datacenters', 4352],
|
||||
['dams', 704],
|
||||
]) {
|
||||
const lines = readFileSync(
|
||||
|
||||
@@ -5,7 +5,7 @@ layer.
|
||||
|
||||
- Source: OpenStreetMap contributors
|
||||
- License: Open Database License (ODbL) 1.0
|
||||
- Feature count: 4,351
|
||||
- Feature count: 4,352
|
||||
- Runtime file: `datacenters.geojsonl`
|
||||
|
||||
The public-release snapshot removes contact-oriented tags such as email, phone,
|
||||
@@ -16,3 +16,12 @@ ODbL 1.0 with the required OpenStreetMap attribution.
|
||||
|
||||
The original extraction date and query were not recorded alongside this
|
||||
snapshot. Future refreshes should record both before replacing the file.
|
||||
|
||||
## Norway refresh
|
||||
|
||||
Features inside Geofabrik's Norway boundary (`norway.poly`, with Svalbard) come
|
||||
from a newer extract than the rest of the file. Contact tags are dropped as above.
|
||||
|
||||
- Extract: Geofabrik `norway-261005.osm.pbf`, OSM data
|
||||
2026-10-05T20:21:35Z, MD5 `1d2cfdeb98980fce11b8891d0f9146c0`
|
||||
- Filter: `osmium tags-filter nwr/telecom=data_center nwr/building=data_center`
|
||||
@@ -3770,15 +3770,15 @@
|
||||
{"id":939340882,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021364183,52.315373019],[21.02232433,52.315435883],[21.022363641,52.315209656],[21.021403578,52.315146875],[21.021364183,52.315373019]]]},"properties":{"id":11407,"tags":{"name":"T-Mobile Warszawa-Szlachecka","telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":939340882}}
|
||||
{"id":1005973518,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021325375,52.315595978],[21.022285857,52.315658423],[21.02232433,52.315435883],[21.021364183,52.315373019],[21.021325375,52.315595978]]]},"properties":{"id":11976,"tags":{"telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":1005973518}}
|
||||
{"id":1156823776,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.115130857,52.235385942],[21.115189866,52.236349777],[21.115232027,52.236667954],[21.115818174,52.237174556],[21.116175578,52.237468677],[21.117262124,52.236985628],[21.11728157,52.236976576],[21.117262795,52.236960985],[21.117340495,52.236926033],[21.117360276,52.2369433],[21.117447532,52.236904491],[21.11804097,52.236654459],[21.118030996,52.236645742],[21.118041054,52.236641719],[21.116603055,52.235624072],[21.116518984,52.235676375],[21.115986231,52.235696659],[21.115954966,52.235361551],[21.115130857,52.235385942]]]},"properties":{"id":15342,"tags":{"name":"Atman Data Center Warsaw-1","telecom":"data_center","website":"https://www.atman.pl"},"type":"data_center","osm_id":1156823776}}
|
||||
{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.598350034,58.860111293]},"properties":{"id":24849,"tags":{"name":"Maan Group","telecom":"data_center","operator":"Privat"},"type":"data_center","osm_id":7405586142}}
|
||||
{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.5983501,58.8601113]},"properties":{"id":24849,"tags":{"name":"Maan Group","opening_hours":"24/7","operator":"Privat","telecom":"data_center"},"type":"data_center","osm_id":7405586142}}
|
||||
{"id":481615983,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[9.572510149,56.481777951],[9.574069938,56.484921751],[9.57465357,56.48483349],[9.574639572,56.484805243],[9.574700676,56.484795939],[9.574714925,56.484824857],[9.575298557,56.484736595],[9.57454888,56.483225422],[9.574609229,56.483216369],[9.574510155,56.483016629],[9.574449722,56.483025849],[9.573738769,56.481592795],[9.573155137,56.481680972],[9.573170643,56.481712321],[9.573109539,56.481721625],[9.573093781,56.48168969],[9.572510149,56.481777951]]]},"properties":{"id":7881,"tags":{"telecom":"data_center","building":"data_center","operator":"Apple","wikidata":"Q19721053","wikipedia":"en:Foulum Data Center","description":"Apple Datacenter"},"type":"data_center","osm_id":481615983}}
|
||||
{"id":618756435,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.500360092,57.318668738],[10.500399236,57.318671672],[10.500382891,57.318734871],[10.500552038,57.318744175],[10.500545249,57.318787677],[10.500501495,57.318785666],[10.500458999,57.318957746],[10.500970295,57.31899647],[10.501009187,57.318847189],[10.501084959,57.318852888],[10.50111966,57.318719197],[10.500843896,57.318698159],[10.500865521,57.318615178],[10.500859235,57.318614759],[10.500869461,57.318575699],[10.500954537,57.318582153],[10.501022347,57.31832223],[10.500736691,57.318300437],[10.500669133,57.318559689],[10.500674162,57.318560025],[10.500664272,57.318598498],[10.500383981,57.318577124],[10.500360092,57.318668738]]]},"properties":{"id":8842,"tags":{"ref":"AAL01-01","name":"Prime Data Centers - Saeby Campus AAL01","telecom":"data_center","website":"https://primedatacenters.com/locations/saeby/","building":"industrial","operator":"Prime Data Centers"},"type":"data_center","osm_id":618756435}}
|
||||
{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002243225,59.633471864],[11.003903261,59.633446551],[11.007046726,59.633399696],[11.006983443,59.632420941],[11.006982772,59.63227057],[11.006911023,59.632265289],[11.006852936,59.632253973],[11.006765848,59.632228576],[11.00671782,59.632205275],[11.006119687,59.632213573],[11.006119352,59.632202592],[11.006118933,59.63219245],[11.005930424,59.632194378],[11.005923635,59.632120869],[11.005980799,59.632120031],[11.005979542,59.632104524],[11.005977027,59.63206865],[11.00597225,59.631998242],[11.005412171,59.632035541],[11.005286945,59.632043839],[11.00527848,59.632044342],[11.003707962,59.632147272],[11.003107483,59.63219178],[11.002741864,59.632235533],[11.002729375,59.63223788],[11.002345987,59.632309294],[11.002308352,59.632316251],[11.002302066,59.632477351],[11.002277088,59.632913881],[11.002243225,59.633471864]]]},"properties":{"id":107,"tags":{"name":"STACK Infrastructure OSL04","type":"multipolygon","telecom":"data_center","website":"https://www.stackinfra.com","operator":"Stack Infrastructure","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":-14367602}}
|
||||
{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.0022433,59.6334719],[11.0022771,59.6329139],[11.0023021,59.6324774],[11.0023084,59.6323163],[11.002346,59.6323093],[11.0027294,59.6322379],[11.0027419,59.6322356],[11.0031075,59.6321918],[11.003708,59.6321473],[11.0052785,59.6320444],[11.005287,59.6320439],[11.0054122,59.6320356],[11.0059723,59.6319983],[11.0059771,59.6320687],[11.0059796,59.6321046],[11.0059808,59.6321201],[11.0059237,59.6321209],[11.0059305,59.6321944],[11.006119,59.6321925],[11.0061194,59.6322026],[11.0061197,59.6322136],[11.0067179,59.6322053],[11.0067659,59.6322286],[11.006853,59.632254],[11.0069111,59.6322653],[11.0069828,59.6322706],[11.0069835,59.632421],[11.0070468,59.6333997],[11.0039033,59.6334466],[11.0022433,59.6334719]]]},"properties":{"id":107,"tags":{"landuse":"industrial","name":"STACK Infrastructure OSL04","operator":"Stack Infrastructure","operator:wikidata":"Q115477173","telecom":"data_center","website":"https://www.stackinfra.com","type":"multipolygon"},"type":"data_center","osm_id":-14367602}}
|
||||
{"id":1028687251,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.004111886,59.632578437],[11.004142983,59.633037262],[11.004275166,59.633034999],[11.004274998,59.63303173],[11.00505133,59.633019157],[11.005056443,59.633099372],[11.005224416,59.63309669],[11.005190637,59.632562344],[11.005060885,59.632564439],[11.005060969,59.632567457],[11.004587978,59.632575419],[11.00458538,59.632536527],[11.004459819,59.632538623],[11.004462418,59.632576844],[11.004242476,59.6325807],[11.004242141,59.632576174],[11.004111886,59.632578437]]]},"properties":{"id":12226,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1028687251}}
|
||||
{"id":1164840308,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002600126,59.632599392],[11.002631223,59.633058217],[11.00276349,59.633055954],[11.002763238,59.633052685],[11.00353957,59.633040196],[11.003544683,59.633120327],[11.00371274,59.633117645],[11.003678961,59.632583382],[11.003549042,59.632585478],[11.003549293,59.632588495],[11.003076135,59.632596458],[11.00307362,59.632557566],[11.002948143,59.632559661],[11.002950657,59.632597883],[11.002730716,59.632601571],[11.002730465,59.632597129],[11.002600126,59.632599392]]]},"properties":{"id":15459,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840308}}
|
||||
{"id":1164840309,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.005613672,59.632723444],[11.005644769,59.633182269],[11.005776951,59.633179922],[11.005776784,59.633176737],[11.006553199,59.633164164],[11.006558229,59.633244463],[11.006726286,59.633241781],[11.006692423,59.632707351],[11.006562671,59.632709446],[11.006562839,59.632712464],[11.006089764,59.632720426],[11.006087166,59.632681534],[11.005961772,59.632683797],[11.005964287,59.632721851],[11.005744262,59.632725623],[11.005743927,59.632721181],[11.005613672,59.632723444]]]},"properties":{"id":15460,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840309}}
|
||||
{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.991467366,59.759024056],[10.992780978,59.759406942],[10.992773937,59.759423957],[10.992948197,59.759474248],[10.993073842,59.759363859],[10.993022293,59.759348939],[10.993291688,59.759112653],[10.993128827,59.759065547],[10.993181885,59.759019027],[10.993083062,59.758990445],[10.992976947,59.759083484],[10.992845519,59.759045346],[10.992813584,59.759073342],[10.9927876,59.759065798],[10.993409789,59.758524243],[10.99263002,59.758297094],[10.991938848,59.758898663],[10.991693594,59.758827165],[10.991467366,59.759024056]]]},"properties":{"id":11377,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"warehouse","ref:bygningsnr":"300752944"},"type":"data_center","osm_id":935092287}}
|
||||
{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.992826324,59.757448761],[10.994530281,59.757601396],[10.994634888,59.757303168],[10.992928332,59.757152545],[10.992826324,59.757448761]]]},"properties":{"id":15426,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"industrial"},"type":"data_center","osm_id":1161764012}}
|
||||
{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9914674,59.7590241],[10.9916936,59.7588272],[10.9919389,59.7588987],[10.9926301,59.7582971],[10.9934098,59.7585243],[10.9927876,59.7590658],[10.9928136,59.7590734],[10.9928456,59.7590454],[10.992977,59.7590835],[10.9930831,59.7589905],[10.9931819,59.7590191],[10.9931289,59.7590656],[10.9932917,59.7591127],[10.9930223,59.759349],[10.9930739,59.7593639],[10.9929482,59.7594743],[10.992774,59.759424],[10.992781,59.759407],[10.9914674,59.7590241]]]},"properties":{"id":11377,"tags":{"building":"warehouse","name":"Green Mountain Ytre Enebakk","ref:bygningsnr":"300752944","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":935092287}}
|
||||
{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9928264,59.7574488],[10.9929284,59.7571526],[10.9946349,59.7573032],[10.9945303,59.7576014],[10.9928264,59.7574488]]]},"properties":{"id":15426,"tags":{"building":"industrial","name":"Green Mountain Ytre Enebakk","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":1161764012}}
|
||||
{"id":30724580,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.808790995,59.924984985],[10.808893925,59.925031923],[10.80893793,59.92500594],[10.808982941,59.925025972],[10.809042955,59.92501298],[10.809083943,59.925031923],[10.809012948,59.925077856],[10.80926583,59.925189755],[10.80955115,59.925025972],[10.809676543,59.925081293],[10.81032899,59.924701928],[10.809890365,59.924508138],[10.80982155,59.924515347],[10.809691631,59.924457847],[10.808820667,59.924557592],[10.809099365,59.924699581],[10.808806418,59.924865291],[10.808918149,59.924914828],[10.808875988,59.924937962],[10.808790995,59.924984985]]]},"properties":{"id":261,"tags":{"name":"Stack Infrastructure OSLO1A","telecom":"data_center","building":"office","operator":"Stack Infrastructure","ref:bygningsnr":"80877293","building:levels":"4","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":30724580}}
|
||||
{"id":80871917,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.83408892,59.938588981],[10.834305927,59.938720996],[10.834575992,59.938720996],[10.834574987,59.938736],[10.834581943,59.938736922],[10.834579932,59.938771958],[10.834736925,59.938771958],[10.834734997,59.93893792],[10.836717988,59.938944961],[10.836717988,59.938983937],[10.836817984,59.938983937],[10.83681899,59.938944961],[10.836844974,59.938944961],[10.836846985,59.938774976],[10.836841956,59.938774976],[10.836842962,59.938744968],[10.836849919,59.938744968],[10.836851931,59.938653941],[10.836683957,59.938652935],[10.836684125,59.938640278],[10.836686975,59.938414973],[10.83490297,59.938406926],[10.834899953,59.938646984],[10.834812949,59.938645978],[10.83481496,59.938551933],[10.834579932,59.938550927],[10.834580938,59.938564925],[10.834567946,59.938564925],[10.834567946,59.938551933],[10.834089926,59.938549922],[10.83408892,59.938588981]]]},"properties":{"id":1464,"tags":{"name":"Bulk Data Centers Oslo Internet Exchange","telecom":"data_center","website":"https://bulkinfrastructure.com/data-centers/locations/os-ix","building":"industrial","ref:bygningsnr":"80709943"},"type":"data_center","osm_id":80871917}}
|
||||
{"id":12158409495,"type":"Feature","geometry":{"type":"Point","coordinates":[9.689363435,62.59439936]},"properties":{"id":28723,"tags":{"name":"VisBook AS","telecom":"data_center","website":"https://visbook.com/"},"type":"data_center","osm_id":12158409495}}
|
||||
@@ -4349,3 +4349,4 @@
|
||||
{"id":540895898,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.71747909,56.303855963],[101.71782015,56.304420065],[101.721113819,56.303806929],[101.720772759,56.303242994],[101.71747909,56.303855963]]]},"properties":{"id":8271,"tags":{"name":"Битривер Рус","name:en":"Bitriver Rus","name:ru":"Битривер Рус","telecom":"data_center","website":"https://bitriver.farm/","building":"data_center"},"type":"data_center","osm_id":540895898}}
|
||||
{"id":1082803064,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[123.948021193,47.340264299],[123.948354625,47.340268239],[123.948490663,47.340269831],[123.948488568,47.340349292],[123.948809762,47.340353064],[123.948811942,47.340267485],[123.948918643,47.340268658],[123.948922918,47.340137817],[123.948023959,47.340126082],[123.948021193,47.340264299]]]},"properties":{"id":13377,"tags":{"name":"联通云数据中心·齐齐哈尔","name:en":"Unicom Data Center of Qiqihar","name:zh":"联通云数据中心·齐齐哈尔","telecom":"data_center","building":"yes","name:zh-Hans":"联通云数据中心·齐齐哈尔","name:zh-Hant":"聯通雲數據中心·齊齊哈爾"},"type":"data_center","osm_id":1082803064}}
|
||||
{"id":442479059,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[142.76019226,46.910096898],[142.760192427,46.910325724],[142.760447656,46.910328155],[142.760460397,46.910102263],[142.76019226,46.910096898]]]},"properties":{"id":7471,"tags":{"telecom":"data_center","building":"yes"},"type":"data_center","osm_id":442479059}}
|
||||
{"id":1551029155,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[7.1069147,62.8627625],[7.1071207,62.8621617],[7.1075917,62.8616616],[7.1088647,62.8620476],[7.1106308,62.862645],[7.1098655,62.8632827],[7.1089604,62.8630042],[7.1086734,62.8632156],[7.1069147,62.8627625]]]},"properties":{"id":29471,"tags":{"layer":"-1","loc_name":"Troll Housing Klempertåsen","name":"Troll Housing","operator":"Troll Housing AS","telecom":"data_center","website":"https://www.trollhousing.no/"},"type":"data_center","osm_id":1551029155}}
|
||||
+19
-9
@@ -11,17 +11,16 @@ const FOLD = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Comparable form of a place name, shared by the bundled packs, the
|
||||
* OpenFreeMap street and grounds matchers and the Nominatim server: case
|
||||
* folded, accents removed from Latin letters only, punctuation and symbols
|
||||
* collapsed to single spaces, and a leading "the" dropped. Other scripts keep
|
||||
* their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while "São Paulo",
|
||||
* "sao paulo" and "SÃO PAULO!" meet.
|
||||
* Script-aware fold of a place name: case folded, accents removed from Latin
|
||||
* letters only, punctuation and symbols collapsed to single spaces. Other
|
||||
* scripts keep their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while
|
||||
* "São Paulo", "sao paulo" and "SÃO PAULO!" meet. Every word is kept: "The
|
||||
* Hague" is not "Hague".
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function foldPlaceName(value) {
|
||||
export function foldPlaceText(value) {
|
||||
return String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[ßøæłđıœþ]/g, (c) => FOLD[c])
|
||||
@@ -29,8 +28,19 @@ export function foldPlaceName(value) {
|
||||
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
|
||||
.normalize('NFKC')
|
||||
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
|
||||
.trim()
|
||||
.replace(/^the /, '');
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparable form of a place name, shared by the bundled packs, the
|
||||
* OpenFreeMap street and grounds matchers and the Nominatim server:
|
||||
* `foldPlaceText` with a leading "the" dropped.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function foldPlaceName(value) {
|
||||
return foldPlaceText(value).replace(/^the /, '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { foldPlaceText } from './data/placeNames.js';
|
||||
|
||||
/** Photon/OpenStreetMap place-search adapter. */
|
||||
const PHOTON_ENDPOINT = 'https://photon.komoot.io/api/';
|
||||
|
||||
@@ -233,19 +235,15 @@ export function photonSearchUrl(
|
||||
}
|
||||
|
||||
/**
|
||||
* Place name reduced to comparable form: unaccented, lowercased, punctuation
|
||||
* collapsed to single spaces. "Huế" and "Hue" have to compare equal, because a
|
||||
* user typing either means the same city.
|
||||
* Place name reduced to comparable form: lowercased, Latin accents removed,
|
||||
* other scripts preserved, and punctuation collapsed to single spaces. "Huế"
|
||||
* and "Hue" have to compare equal, because a user typing either means the same
|
||||
* city.
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeToponym(text) {
|
||||
return String(text ?? '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
return foldPlaceText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,12 @@ import {
|
||||
import { geocodeNavigationMode } from './locations.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const MOSCOW_RESPONSES = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(ROOT, 'src/data/fixtures/photon-moscow-search.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
|
||||
/** Photon's real answer for "Hanoi", trimmed to the fields we consume. */
|
||||
const HANOI = {
|
||||
@@ -249,6 +255,58 @@ test('a biased near-miss is refused, and the unbiased answer is taken instead',
|
||||
assert.equal(fetchImpl.urls.length, 2, 'the refused bias costs exactly one extra request');
|
||||
});
|
||||
|
||||
test('a non-Latin exact match keeps the nearby namesake', async () => {
|
||||
const fetchImpl = photonPair(
|
||||
MOSCOW_RESPONSES.biased.features,
|
||||
MOSCOW_RESPONSES.unbiased.features,
|
||||
);
|
||||
|
||||
const result = await geocodeKeyless('Москва', {
|
||||
bias: '38.90,71.80|39.00,71.90',
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
assert.equal(result.label, 'Москва, Тоҷикистон');
|
||||
assert.equal(fetchImpl.urls.length, 1);
|
||||
});
|
||||
|
||||
test('toponym folding preserves non-Latin scripts', () => {
|
||||
const cases = [
|
||||
['Huế', 'hue'],
|
||||
['Москва', 'москва'],
|
||||
['Αθήνα', 'αθήνα'],
|
||||
['القاهرة', 'القاهرة'],
|
||||
['東京', '東京'],
|
||||
['São 東京', 'sao 東京'],
|
||||
['Straße', 'strasse'],
|
||||
['Tromsø', 'tromso'],
|
||||
['Łódź', 'lodz'],
|
||||
['The Hague', 'the hague'],
|
||||
];
|
||||
|
||||
for (const [input, expected] of cases) {
|
||||
assert.equal(normalizeToponym(input), expected, input);
|
||||
}
|
||||
});
|
||||
|
||||
test('a leading "the" is part of the name a nearby match must equal', async () => {
|
||||
// "The Hague" over upstate New York means the Dutch city, not Hague, NY.
|
||||
const hagueNy = {
|
||||
geometry: { type: 'Point', coordinates: [-73.5004, 43.6967] },
|
||||
properties: { osm_key: 'place', osm_value: 'town', type: 'city', name: 'Hague', country: 'United States' },
|
||||
};
|
||||
const theHague = {
|
||||
geometry: { type: 'Point', coordinates: [4.3113, 52.0799] },
|
||||
properties: { osm_key: 'place', osm_value: 'city', type: 'city', name: 'The Hague', country: 'Nederland' },
|
||||
};
|
||||
const fetchImpl = photonPair([hagueNy], [theHague]);
|
||||
|
||||
const result = await geocodeKeyless('The Hague', { bias: '43.65,-73.55|43.75,-73.45', fetchImpl });
|
||||
|
||||
assert.equal(Math.round(result.lat), 52, `got ${result.label}`);
|
||||
assert.equal(fetchImpl.urls.length, 2);
|
||||
});
|
||||
|
||||
test('a biased match that leads with the name is taken without a second request', async () => {
|
||||
// The other half of the rule, and the reason bias exists at all: "Sixth
|
||||
// Street" over Austin must stay Austin's.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import {
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
HIDDEN_CONTACT_DR_INTERVAL_MS,
|
||||
HIDDEN_CONTACT_DR_TICKS,
|
||||
hiddenContactDrPhase,
|
||||
} from './policy.js';
|
||||
|
||||
// Contacts hidden behind the horizon re-reckon on one fleet tick per rotation
|
||||
// instead of every tick. Their positions still feed hidden-inclusive proximity
|
||||
// queries, so the rotation must reach every contact about once a second, and
|
||||
// it must spread them across ticks so no single tick carries the whole fleet.
|
||||
|
||||
const icao = (n) => n.toString(16).padStart(6, '0');
|
||||
|
||||
test('the rotation revisits each hidden contact about once a second', () => {
|
||||
const periodMs = HIDDEN_CONTACT_DR_TICKS * FLEET_DR_INTERVAL_MS;
|
||||
assert.ok(HIDDEN_CONTACT_DR_TICKS > 1);
|
||||
assert.ok(
|
||||
Math.abs(periodMs - HIDDEN_CONTACT_DR_INTERVAL_MS) <= FLEET_DR_INTERVAL_MS,
|
||||
);
|
||||
});
|
||||
|
||||
test('a contact keeps the same slot, inside the rotation', () => {
|
||||
for (let n = 0; n < 500; n++) {
|
||||
const phase = hiddenContactDrPhase(icao(n * 7919));
|
||||
assert.ok(Number.isInteger(phase));
|
||||
assert.ok(phase >= 0 && phase < HIDDEN_CONTACT_DR_TICKS);
|
||||
assert.equal(hiddenContactDrPhase(icao(n * 7919)), phase);
|
||||
}
|
||||
});
|
||||
|
||||
test('a worldwide fleet is spread evenly enough that no tick carries a burst', () => {
|
||||
const fleet = 13000;
|
||||
const perSlot = new Array(HIDDEN_CONTACT_DR_TICKS).fill(0);
|
||||
// Real ICAO24 addresses cluster by national block; sample several blocks.
|
||||
for (let n = 0; n < fleet; n++)
|
||||
perSlot[hiddenContactDrPhase(icao(0x0a0000 + n * 53))]++;
|
||||
const fair = fleet / HIDDEN_CONTACT_DR_TICKS;
|
||||
for (const count of perSlot) assert.ok(count < fair * 1.5, `${count} > 1.5×`);
|
||||
});
|
||||
|
||||
/** Real fleet tick and horizon culling, with motion and presentation injected. */
|
||||
function fleetFixture() {
|
||||
let now = 1000;
|
||||
let reckonings = 0;
|
||||
const services = {
|
||||
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
|
||||
militaryRegistry: { isMilitaryIcao: () => false },
|
||||
aircraftPresentation: { isTr3b: () => false },
|
||||
camera: {},
|
||||
focus: {
|
||||
focusNowMs: () => now,
|
||||
getFocusTarget: () => null,
|
||||
nearFarScalarValueAtDistance: () => 1,
|
||||
advanceProjectedSpriteFocus: () => ({ factor: 1 }),
|
||||
},
|
||||
recession: {
|
||||
applyAircraftBillboardTreatment: () => ({
|
||||
factors: { scale: 1 },
|
||||
alpha: 1,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const flightState = createFlightState({ services });
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100),
|
||||
positionCartographic: { height: 100 },
|
||||
heading: 0,
|
||||
pitch: 0,
|
||||
roll: 0,
|
||||
frustum: {
|
||||
computeCullingVolume: () => ({
|
||||
computeVisibility: () => Cesium.Intersect.INSIDE,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const id = 'abc123';
|
||||
// 111 km away at ground level is beyond a 100 m camera's horizon, but
|
||||
// inside the model keep radius. No WebGL scene is needed for the culler.
|
||||
const billboard = {
|
||||
show: false,
|
||||
position: Cesium.Cartesian3.fromDegrees(1, 0, 0),
|
||||
};
|
||||
flightState._viewer = { camera, scene: {} };
|
||||
flightState._billboardCollection = { show: true };
|
||||
flightState._billboards.set(id, billboard);
|
||||
flightState.records.data.set(id, {});
|
||||
flightState._models3dEnabled = false;
|
||||
// Far cockpit contacts avoid glyph rasterization, keeping the fixture DOM-free.
|
||||
flightState._cockpitContactMode = true;
|
||||
const parts = {
|
||||
tracking: { _refreshCockpitNearContacts() {} },
|
||||
motion: {
|
||||
_deadReckon() {
|
||||
reckonings++;
|
||||
return billboard.position;
|
||||
},
|
||||
_floorGroundedDisplayPosition(id, info, position) {
|
||||
return position;
|
||||
},
|
||||
},
|
||||
};
|
||||
const rendering = createRendering({ flightState, services, parts });
|
||||
return {
|
||||
flightState,
|
||||
camera,
|
||||
billboard,
|
||||
id,
|
||||
reckonings: () => reckonings,
|
||||
tick() {
|
||||
now += FLEET_DR_INTERVAL_MS;
|
||||
rendering._fleetTick();
|
||||
},
|
||||
offTurn() {
|
||||
flightState._fleetTickSeq =
|
||||
(hiddenContactDrPhase(id) + 1) % HIDDEN_CONTACT_DR_TICKS;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('the fleet tick dead-reckons a hidden contact once per complete rotation', () => {
|
||||
const f = fleetFixture();
|
||||
for (let i = 0; i < HIDDEN_CONTACT_DR_TICKS; i++) f.tick();
|
||||
assert.equal(f.reckonings(), 1);
|
||||
assert.equal(f.billboard.show, false);
|
||||
});
|
||||
|
||||
test('a camera reveal bypasses the hidden rotation on the same fleet tick', () => {
|
||||
const f = fleetFixture();
|
||||
f.offTurn();
|
||||
f.tick();
|
||||
assert.equal(f.reckonings(), 0);
|
||||
f.camera.positionWC = Cesium.Cartesian3.fromDegrees(1, 0, 100);
|
||||
f.offTurn();
|
||||
f.tick();
|
||||
assert.equal(f.reckonings(), 1);
|
||||
assert.equal(f.billboard.show, true);
|
||||
});
|
||||
|
||||
test('a hidden contact with a retained fleet model is never rotation-skipped', () => {
|
||||
const f = fleetFixture();
|
||||
f.flightState._models3dEnabled = true;
|
||||
const model = { ready: true, show: true };
|
||||
f.flightState._models.set(f.id, model);
|
||||
f.offTurn();
|
||||
f.tick();
|
||||
assert.equal(f.reckonings(), 1);
|
||||
assert.equal(
|
||||
f.flightState._models.get(f.id),
|
||||
model,
|
||||
'model remains in keep range',
|
||||
);
|
||||
assert.equal(
|
||||
model.show,
|
||||
false,
|
||||
'the full pass hides its model beyond the horizon',
|
||||
);
|
||||
});
|
||||
@@ -198,6 +198,37 @@ export const FLEET_DR_INTERVAL_MS = 80;
|
||||
|
||||
export const ROTATION_REFRESH_MS = 1000;
|
||||
|
||||
/**
|
||||
* @constant {number} Dead-reckoning cadence (ms) for contacts hidden behind the
|
||||
* horizon. The worldwide feed carries ~13k contacts and a zoomed-in view shows
|
||||
* well under 1% of them; the rest still need positions for hidden-inclusive
|
||||
* proximity queries, but ~1 s old (≈250 m for an airliner) is well inside the
|
||||
* 30 s poll's own uncertainty. Each hidden contact is re-reckoned on one tick in
|
||||
* every HIDDEN_CONTACT_DR_TICKS, staggered by id so no tick carries them all.
|
||||
*/
|
||||
|
||||
export const HIDDEN_CONTACT_DR_INTERVAL_MS = 1000;
|
||||
|
||||
export const HIDDEN_CONTACT_DR_TICKS = Math.max(
|
||||
1,
|
||||
Math.round(HIDDEN_CONTACT_DR_INTERVAL_MS / FLEET_DR_INTERVAL_MS),
|
||||
);
|
||||
|
||||
/**
|
||||
* Stable slot in [0, HIDDEN_CONTACT_DR_TICKS) for a contact id, so each hidden
|
||||
* contact re-reckons on the same tick of every rotation.
|
||||
* @param {string} id Contact id (ICAO24).
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
export function hiddenContactDrPhase(id) {
|
||||
const text = String(id);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++)
|
||||
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
|
||||
return hash % HIDDEN_CONTACT_DR_TICKS;
|
||||
}
|
||||
|
||||
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
|
||||
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
ROTATION_REFRESH_MS,
|
||||
COURSE_MAX_DPS,
|
||||
HIDDEN_CONTACT_DR_TICKS,
|
||||
hiddenContactDrPhase,
|
||||
} from './policy.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||
|
||||
@@ -839,6 +841,12 @@ export function createRendering({
|
||||
}
|
||||
}
|
||||
|
||||
/** The contact's hidden-rotation slot, cached on its billboard. */
|
||||
function _hiddenDrPhase(bb, icao24) {
|
||||
bb._gevHiddenDrPhase ??= hiddenContactDrPhase(icao24);
|
||||
return bb._gevHiddenDrPhase;
|
||||
}
|
||||
|
||||
function _fleetTick() {
|
||||
if (
|
||||
!flightState._viewer ||
|
||||
@@ -965,9 +973,25 @@ export function createRendering({
|
||||
for (const icao of toRelease) _releaseModel(icao);
|
||||
}
|
||||
|
||||
const hiddenDrPhase = flightState._fleetTickSeq++ % HIDDEN_CONTACT_DR_TICKS;
|
||||
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
|
||||
|
||||
// A contact still behind the horizon at its last position would only be
|
||||
// re-reckoned to be hidden again, so off its turn it keeps that position
|
||||
// (see HIDDEN_CONTACT_DR_TICKS). Anything the stale position puts in view,
|
||||
// and any contact with a fleet model, takes the full pass below at once.
|
||||
if (
|
||||
!bb.show &&
|
||||
_hiddenDrPhase(bb, icao24) !== hiddenDrPhase &&
|
||||
!flightState._models.has(icao24) &&
|
||||
!occluder.isPointVisible(
|
||||
flightState._cullPositions.get(icao24) || bb.position,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
|
||||
const info = flightState.records.data.get(icao24);
|
||||
|
||||
const dr = parts.motion._deadReckon(
|
||||
|
||||
@@ -204,6 +204,10 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._lastFleetTickMs = 0;
|
||||
|
||||
/** @type {number} Fleet ticks run; selects which hidden contacts re-reckon */
|
||||
|
||||
flightState._fleetTickSeq = 0;
|
||||
|
||||
/** @type {string} Camera pose signature at the last rotation pass */
|
||||
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
REFRESH_MS,
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
} from './policy.js';
|
||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||
|
||||
export function createQueries({
|
||||
vesselState,
|
||||
@@ -469,7 +470,7 @@ export function createQueries({
|
||||
* @param {Object} [options={}] - Options from the detection system.
|
||||
* @param {number} [options.maxCount] - Maximum objects to return (defaults to all).
|
||||
* @param {number} [options.seed] - Seed offset for stride sampling.
|
||||
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
|
||||
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean, cardSourceId: string, cardEntryId: string}>}
|
||||
*/
|
||||
getDetectableObjects(options = {}) {
|
||||
if (
|
||||
@@ -504,6 +505,9 @@ export function createQueries({
|
||||
id: record.name || record.mmsi || 'VESSEL',
|
||||
type: 'SEA',
|
||||
skipLabel: record === selected,
|
||||
// Detection hides the callout while this card paints.
|
||||
cardSourceId: VESSEL_OVERLAY_SOURCE_ID,
|
||||
cardEntryId: components.cards.vesselOverlayEntryId(record),
|
||||
klass: record.type
|
||||
? String(record.type).toUpperCase().slice(0, 14)
|
||||
: undefined,
|
||||
|
||||
@@ -212,6 +212,10 @@ const _paintQueue = [];
|
||||
const _paintItemPool = [];
|
||||
const _paintRectPool = [];
|
||||
const _paintRectByKey = new Map();
|
||||
/** @type {Map<string, Map<string, object>>} This frame's paint items by source and entry id. */
|
||||
const _queuedItemBySource = new Map();
|
||||
/** Index rows that stop painting are dropped this many frames apart. */
|
||||
const QUEUED_ITEM_PRUNE_FRAMES = 600;
|
||||
const _hitRects = [];
|
||||
const _paintedBySource = Object.create(null);
|
||||
const _paintedSourceKeys = [];
|
||||
@@ -1042,6 +1046,30 @@ export function getOverlayPaintRect(sourceId, entryId) {
|
||||
return rect && rect.key === key && rect.stamp === _frameStamp ? rect : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opacity an entry paints with this frame, or 0 when it is not queued. Custom
|
||||
* lanes may call it before later lanes publish paint rects. Leaves out the
|
||||
* Cyber sonar fade and yield-collision hiding.
|
||||
* @param {string} sourceId
|
||||
* @param {string} entryId
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getQueuedOverlayEntryAlpha(sourceId, entryId) {
|
||||
if (_destroyed) return 0;
|
||||
const item = _queuedItemBySource.get(sourceId)?.get(entryId);
|
||||
// Pooled items are reassigned every frame: check the frame and the entry.
|
||||
if (!item || item.stamp !== _frameStamp) return 0;
|
||||
const record = item.record;
|
||||
if (record.entry.id !== entryId || record.entry.source !== sourceId) return 0;
|
||||
return (
|
||||
record.sourceAlpha *
|
||||
item.temporalAlpha *
|
||||
record.distanceAlpha *
|
||||
record.altitudeAlpha *
|
||||
(_keyhole ? entryKeyholeAlpha(record.entry, item.placement, _keyhole) : 1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the topmost interactive world-overlay entry at CSS-pixel coords.
|
||||
* @param {number} x
|
||||
@@ -2223,7 +2251,34 @@ function addPaintItem(record, placement, temporalAlpha, selected) {
|
||||
item.zIndex = record.entry.zIndex;
|
||||
item.key = record.key;
|
||||
item.collisionHidden = false;
|
||||
item.stamp = _frameStamp;
|
||||
_paintQueue[_paintCount++] = item;
|
||||
// Keyed by source and id, not `record.key`, so a lookup builds no string.
|
||||
// Also holds cards fading out after they left `_records`.
|
||||
let bySource = _queuedItemBySource.get(record.entry.source);
|
||||
if (!bySource) {
|
||||
bySource = new Map();
|
||||
_queuedItemBySource.set(record.entry.source, bySource);
|
||||
}
|
||||
bySource.set(record.entry.id, item);
|
||||
}
|
||||
|
||||
/** Drop index rows for entries that stopped painting (bounded, not per frame). */
|
||||
function pruneQueuedItemIndex() {
|
||||
for (const [sourceId, bySource] of _queuedItemBySource) {
|
||||
for (const [entryId, item] of bySource) {
|
||||
// A pooled item that now paints another entry keeps a fresh stamp.
|
||||
const entry = item.record.entry;
|
||||
if (
|
||||
item.stamp !== _frameStamp ||
|
||||
entry.id !== entryId ||
|
||||
entry.source !== sourceId
|
||||
) {
|
||||
bySource.delete(entryId);
|
||||
}
|
||||
}
|
||||
if (bySource.size === 0) _queuedItemBySource.delete(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
function sourceCanPaint(sourceId) {
|
||||
@@ -2295,6 +2350,7 @@ function solveDomains(timestamp) {
|
||||
solveRevision += domain.arbiter.solveRevision;
|
||||
}
|
||||
_paintQueue.length = _paintCount;
|
||||
if (_frameStamp % QUEUED_ITEM_PRUNE_FRAMES === 0) pruneQueuedItemIndex();
|
||||
_solveDirty = false;
|
||||
_diagnostics.solveMs = solveMs;
|
||||
_diagnostics.selectedCount = selectedCount;
|
||||
@@ -2423,24 +2479,26 @@ function paintCustomLane(lane) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Keyhole edge fade of one placed entry (1 for entries that do not fade). */
|
||||
function entryKeyholeAlpha(entry, placement, keyhole) {
|
||||
if (entry.edgeFade !== 'keyhole') return 1;
|
||||
const keyholeX = placement.centerX - keyhole.centerX;
|
||||
const keyholeY = placement.centerY - keyhole.centerY;
|
||||
return keyholeX * keyholeX + keyholeY * keyholeY <=
|
||||
keyhole.radius * keyhole.radius
|
||||
? 1
|
||||
: keyholeLabelAlphaFromGeometry(
|
||||
placement.centerX,
|
||||
placement.centerY,
|
||||
keyhole,
|
||||
);
|
||||
}
|
||||
|
||||
function paintEntryItem(item, keyhole, sonar) {
|
||||
if (item.collisionHidden) return;
|
||||
const { record, placement } = item;
|
||||
const entry = record.entry;
|
||||
let keyholeAlpha = 1;
|
||||
if (entry.edgeFade === 'keyhole') {
|
||||
const keyholeX = placement.centerX - keyhole.centerX;
|
||||
const keyholeY = placement.centerY - keyhole.centerY;
|
||||
keyholeAlpha =
|
||||
keyholeX * keyholeX + keyholeY * keyholeY <=
|
||||
keyhole.radius * keyhole.radius
|
||||
? 1
|
||||
: keyholeLabelAlphaFromGeometry(
|
||||
placement.centerX,
|
||||
placement.centerY,
|
||||
keyhole,
|
||||
);
|
||||
}
|
||||
const keyholeAlpha = entryKeyholeAlpha(entry, placement, keyhole);
|
||||
// All five channels are normalized at their source. Keep the multiply on
|
||||
// the hot paint path so its intermediate doubles remain unboxed; the pure
|
||||
// `combinedOverlayAlpha` export still specifies/tests the same binding.
|
||||
@@ -2829,6 +2887,7 @@ export function destroyWorldOverlay() {
|
||||
_accessibleActivatorByKey.clear();
|
||||
_accessibilitySignature = '';
|
||||
_paintRectByKey.clear();
|
||||
_queuedItemBySource.clear();
|
||||
_paintCount = 0;
|
||||
_paintRectCount = 0;
|
||||
_hitRectCount = 0;
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
activateWorldOverlayAction,
|
||||
initWorldOverlay,
|
||||
subscribeWorldOverlayActions,
|
||||
getQueuedOverlayEntryAlpha,
|
||||
isOverlayPointVisible,
|
||||
normalizeOverlayEntry,
|
||||
paintLaneForOverlayEntry,
|
||||
@@ -1222,6 +1223,42 @@ test('custom detection lane receives the shared host frame and paints below ordi
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
test('a detection lane sees which cards this frame will paint before they publish paint rects', () => {
|
||||
// The detection lane paints before the cards publish this frame's paint
|
||||
// rects, so it reads the queued alpha instead.
|
||||
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
|
||||
initWorldOverlay(env.viewer);
|
||||
const seen = [];
|
||||
registerWorldOverlayPaintLane('detection', () => {
|
||||
seen.push({
|
||||
shown: getQueuedOverlayEntryAlpha('cards', 'SHOWN'),
|
||||
hidden: getQueuedOverlayEntryAlpha('hidden-cards', 'HIDDEN'),
|
||||
unknown: getQueuedOverlayEntryAlpha('cards', 'NOT-PUBLISHED'),
|
||||
rect: getOverlayPaintRect('cards', 'SHOWN'),
|
||||
});
|
||||
}, { id: 'detection', active: true, target: 'detection' });
|
||||
setOverlayEntries('cards', [selectedEntry('SHOWN', {
|
||||
position: positionAtScreen(200, 150),
|
||||
})]);
|
||||
setOverlayEntries('hidden-cards', [selectedEntry('HIDDEN', {
|
||||
position: positionAtScreen(100, 100),
|
||||
})]);
|
||||
setOverlaySourceVisible('hidden-cards', false);
|
||||
|
||||
env.postRender.raise();
|
||||
assert.deepEqual(seen[0], { shown: 1, hidden: 0, unknown: 0, rect: null },
|
||||
'the card is queued while its paint rect is still unpublished');
|
||||
assert.ok(getOverlayPaintRect('cards', 'SHOWN'), 'the card did paint after the lane');
|
||||
assert.equal(getOverlayPaintRect('hidden-cards', 'HIDDEN'), null);
|
||||
|
||||
clearOverlaySource('cards');
|
||||
env.postRender.raise();
|
||||
assert.equal(seen[1].shown, 0, 'a card cleared since the last frame is no longer queued');
|
||||
destroyWorldOverlay();
|
||||
assert.equal(getQueuedOverlayEntryAlpha('cards', 'SHOWN'), 0);
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
test('custom-lane painter state is bracketed by host save/restore', () => {
|
||||
// A painter that leaks a clip or alpha must not corrupt later frames: the
|
||||
// host wraps every custom-lane painter in save/try/finally-restore. Pin the
|
||||
@@ -2489,3 +2526,49 @@ test('promoting an ambient entry to selected paints it once with no fading dupli
|
||||
assert.equal(paint(), 1, 'deselect restores the ambient card without re-entry cooldown');
|
||||
} finally { env.cleanup(); }
|
||||
});
|
||||
|
||||
test('the queued-card index stays bounded while card identities churn', () => {
|
||||
// The index is internal: capture it through Map.prototype.set, like the
|
||||
// projection-record test.
|
||||
const originalSet = Map.prototype.set;
|
||||
let queuedIndex = null;
|
||||
Map.prototype.set = function captureQueuedIndex(key, value) {
|
||||
if (typeof key === 'string' && key.startsWith('churn-card-')
|
||||
&& value && typeof value === 'object' && 'temporalAlpha' in value
|
||||
&& 'placement' in value && 'stamp' in value) {
|
||||
queuedIndex = this;
|
||||
}
|
||||
return originalSet.call(this, key, value);
|
||||
};
|
||||
const env = installMockEnvironment({ width: 400, height: 300, dpr: 1 });
|
||||
try {
|
||||
initWorldOverlay(env.viewer);
|
||||
const FRAMES = 1300;
|
||||
for (let frame = 0; frame < FRAMES; frame++) {
|
||||
// One card whose id changes every frame reuses one pooled paint item,
|
||||
// like a vessel cohort republished as the camera moves.
|
||||
setOverlayEntries('cards', [selectedEntry(`churn-card-${frame}`, {
|
||||
position: positionAtScreen(200, 150),
|
||||
})]);
|
||||
env.advanceTime(16);
|
||||
env.postRender.raise();
|
||||
assert.equal(getQueuedOverlayEntryAlpha('cards', `churn-card-${frame}`), 1,
|
||||
'the live card is queued in the frame just painted');
|
||||
if (frame > 0) {
|
||||
assert.equal(getQueuedOverlayEntryAlpha('cards', `churn-card-${frame - 1}`), 0,
|
||||
'a stale row never reports the previous card as queued');
|
||||
}
|
||||
}
|
||||
assert.ok(queuedIndex, 'the queued-card index was never populated');
|
||||
// Rows are pruned every 600 painted frames, so with one new id per frame
|
||||
// the index holds at most one prune window of ids plus the live card.
|
||||
const bound = 600 + 1;
|
||||
assert.ok(
|
||||
queuedIndex.size <= bound,
|
||||
`queued-card index holds ${queuedIndex.size} rows after ${FRAMES} identities (bound ${bound})`,
|
||||
);
|
||||
} finally {
|
||||
Map.prototype.set = originalSet;
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1217,6 +1217,9 @@ function buildPhase6DetectionWorkload(count) {
|
||||
: `FL${String(180 + (index % 220)).padStart(3, '0')}`,
|
||||
type: ['AIR', 'SAT', 'SEA', 'VEH'][index % 4],
|
||||
tier: index % 17 === 0 ? 'military' : undefined,
|
||||
// Vessels name their ambient card, so each callout asks the host for it.
|
||||
cardSourceId: index % 4 === 2 ? 'ais-live-vessels' : undefined,
|
||||
cardEntryId: index % 4 === 2 ? `vessel:${index}` : undefined,
|
||||
});
|
||||
}
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user