Break Contacts distance ties by identity so navigation reaches the same ID in any feed order

This commit is contained in:
German Khokhlov committed 2026-11-03 19:50:25 +04:00
1 parent c96d4ecfb1
commit df73c5b21a
3 files changed
+195 -3

No files matched your search

+25 -2
View File
@@ -14,6 +14,21 @@ export const AWARENESS_RELATIONSHIP = Object.freeze({
UNKNOWN: 'UNKNOWN',
});
/** The identity a nearby item is navigated by. */
function awarenessItemId(item) {
return String(item?.icao24 || item?.mmsi || item?.id || '');
}
/**
* Break exact distance ties by identity, so the same contacts come back in the
* same order whatever order a feed (or a rebuilt scene) listed them in.
*/
export function compareAwarenessIds(a, b) {
const left = String(a ?? '');
const right = String(b ?? '');
return left < right ? -1 : left > right ? 1 : 0;
}
/**
* Search an expanding area synchronously, doubling the radius after each miss.
* The maximum covers the farthest possible Earth-surface chord while keeping
@@ -110,7 +125,11 @@ export function summarizeAwarenessCohort(
(item.distanceM ?? item.distance) >= 0,
)
.map((item) => ({ ...item, distanceM: item.distanceM ?? item.distance }))
.sort((a, b) => a.distanceM - b.distanceM);
.sort(
(a, b) =>
a.distanceM - b.distanceM ||
compareAwarenessIds(awarenessItemId(a), awarenessItemId(b)),
);
return {
relationship: normalized.length
? AWARENESS_RELATIONSHIP.NEARBY
@@ -177,7 +196,11 @@ export function getAwarenessNavigationTargets(
Number(a.layerId !== subject?.layerId) -
Number(b.layerId !== subject?.layerId);
if (subjectOrder) return subjectOrder;
return (a.item.distanceM ?? Infinity) - (b.item.distanceM ?? Infinity);
return (
(a.item.distanceM ?? Infinity) - (b.item.distanceM ?? Infinity) ||
compareAwarenessIds(a.layerId, b.layerId) ||
compareAwarenessIds(a.id, b.id)
);
});
return targets;
}
+166
View File
@@ -0,0 +1,166 @@
// Edge cases for military-awareness from docs/migration/planet-separation/layers.md:
// with no scene the same nearest contacts come back, and NEXT/PREV, a type
// filter and an exhausted list lead to the same ID whatever scene is attached
// and whatever order the feeds list equally distant contacts in.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../geo/index.js';
import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js';
import { createState } from './state.js';
import { createNavigation } from './navigation.js';
import { createHistory } from './history.js';
import { createFocus } from './focus.js';
const at = (lon) => geo.cartesian3.fromDegrees(lon, 30, 3_000);
// Two pairs of exact distance ties: f2/f3 inside one layer, f1/m1 across layers.
const CONTACTS = {
flights: [
{ icao24: 'f1', distanceM: 1_500, klass: 'widebody', position: at(0.01) },
{ icao24: 'f2', distanceM: 4_000, klass: 'helicopter', position: at(0.02) },
{ icao24: 'f3', distanceM: 4_000, klass: 'helicopter', position: at(0.03) },
],
military: [
{ icao24: 'm1', distanceM: 1_500, klass: 'fighter', position: at(0.04) },
{ icao24: 'm2', distanceM: 9_000, klass: 'helicopter', position: at(0.05) },
],
'ais-live-vessels': [
{ mmsi: '366000001', distanceM: 800, position: at(0.06) },
],
};
const SUBJECT = { layerId: 'military', id: 'm0', label: 'M0', position: at(0) };
/** The real navigation, history and focus parts over one Contacts state. */
function contacts({ reversed = false, display = null } = {}) {
const order = (items) => (reversed ? [...items].reverse() : [...items]);
const state = createState({ services: {} });
state.enabled = true;
state.display = display;
const parts = {};
const select = (layerId, id) => {
const item = CONTACTS[layerId].find(
(entry) => String(entry.icao24 || entry.mmsi) === String(id),
);
if (!item) return false;
parts.subject.selectSubject({
layerId,
id: String(id),
label: String(id),
position: item.position,
});
return true;
};
const aircraftLayer = (layerId) => ({
getNearby: (_center, radiusM, limit) =>
order(CONTACTS[layerId])
.filter((item) => item.distanceM <= radiusM)
.slice(0, limit),
trackById: (id) => select(layerId, id),
});
const services = {
flights: aircraftLayer('flights'),
military: aircraftLayer('military'),
vessels: {
selectById: (id) => select('ais-live-vessels', id),
getAllPositions: () => [],
},
installations: { focusById: () => false },
navigation: { announceNavigationAuthority() {} },
};
const context = { state, services, parts };
parts.navigation = createNavigation(context);
parts.history = createHistory(context);
parts.focus = createFocus(context);
parts.model = {
contextTargetFlyToAllowed: () => true,
releaseAircraftTracking() {},
};
// The bookkeeping subject.js does on a selection (subject.selectSubject),
// without the panel and refresh it also drives.
parts.subject = {
subjectKey: (subject) =>
subject ? `${subject.layerId}:${subject.id}` : '',
subjectCohortFeedUnknown: () => false,
selectSubject(subject) {
const key = parts.subject.subjectKey(subject);
state.navigationVisited.add(key);
const suppress = state.suppressedHistoryKey === key;
state.pendingSelectionKey = state.suppressedHistoryKey = null;
const current = state.navigationHistory[state.navigationIndex];
if (!suppress && parts.subject.subjectKey(current) !== key) {
state.navigationHistory.splice(state.navigationIndex + 1);
state.navigationHistory.push(
parts.history.historySubjectSnapshot(
subject,
parts.history.historySourceItem(subject),
),
);
state.navigationIndex = state.navigationHistory.length - 1;
}
state.subject = subject;
},
};
state.results = {
cohorts: order(Object.keys(CONTACTS)).map((id) => ({
id,
summary: parts.navigation.summarizeAwarenessCohortForNavigation(
order(CONTACTS[id]),
{ available: true },
),
})),
};
parts.subject.selectSubject(SUBJECT);
return { state, controls: parts.history };
}
function walk(session) {
const key = () =>
`${session.state.subject.layerId}:${session.state.subject.id}`;
const steps = [];
const step = (label, direction, options) => {
const moved = session.controls.navigateHistory(direction, options);
steps.push(`${label}${moved ? '' : '!'} ${key()}`);
};
// Walk past the end of the cohort so the visited cycle is exhausted.
for (let i = 0; i < 8; i += 1) step('next', 1);
step('prev', -1);
step('prev', -1);
for (let i = 0; i < 4; i += 1)
step('next-helicopter', 1, { aircraftClass: 'helicopter' });
for (let i = 0; i < 3; i += 1)
step('next-military', 1, { targetLayer: 'military' });
return steps;
}
test('edge:military-awareness: nearest contacts are the same with no scene and in any feed order', () => {
for (const id of Object.keys(CONTACTS)) {
const forward = summarizeAwarenessCohort(CONTACTS[id], { limit: 3 });
const backward = summarizeAwarenessCohort([...CONTACTS[id]].reverse(), {
limit: 3,
});
assert.deepEqual(
backward.nearest.map((item) => item.icao24 || item.mmsi),
forward.nearest.map((item) => item.icao24 || item.mmsi),
id,
);
}
});
test('edge:military-awareness: NEXT, PREV, a type filter and exhaustion reach the same IDs on any scene', () => {
const sceneless = walk(contacts());
const attached = walk(
contacts({ display: { camera: { positionWC: at(1) } } }),
);
const reordered = walk(contacts({ reversed: true }));
assert.deepEqual(attached, sceneless);
assert.deepEqual(reordered, sceneless);
// Every contact is reached before the cycle starts over.
const firstLap = sceneless.slice(0, 6).map((entry) => entry.split(' ')[1]);
assert.equal(new Set(firstLap).size, 6, firstLap.join(', '));
assert.ok(
sceneless
.filter((entry) => entry.startsWith('next-helicopter'))
.every((entry) => /:(f2|f3|m2)$/.test(entry)),
'the type filter only lands on helicopters',
);
});
+4 -1
View File
@@ -4,6 +4,7 @@ import {
AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M,
findByDoublingRadius,
AWARENESS_RADIUS_M,
compareAwarenessIds,
} from '../../data/militaryAwarenessEngine.js';
import {
AWARENESS_MAX_EXAMPLES,
@@ -190,7 +191,9 @@ export function createNavigation({
candidates.sort(
(a, b) =>
(a.item.distanceM ?? a.item.distance ?? Infinity) -
(b.item.distanceM ?? b.item.distance ?? Infinity),
(b.item.distanceM ?? b.item.distance ?? Infinity) ||
compareAwarenessIds(a.layerId, b.layerId) ||
compareAwarenessIds(a.id, b.id),
);
return candidates[0] || null;
}