From fad62a4ba8c5fae36211701dbe72575747bbe19a Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Thu, 5 Nov 2026 01:16:28 +0100 Subject: [PATCH] feat(satellites): find the satellites nearest a position getNearestPositions makes one bounded pass over the shown points, keeps the nearest few in a short sorted list and reads latitude and longitude for those alone, leaving out dense extras, asked-for ids and classes such as debris. A caller that wants the nearest handful no longer builds and sorts an entry for every satellite. --- src/layers/satellites/controls.js | 62 +++++++++++++++++ .../satellites/nearestPositions.test.mjs | 67 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/layers/satellites/nearestPositions.test.mjs diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js index dee92342a..6b390871a 100644 --- a/src/layers/satellites/controls.js +++ b/src/layers/satellites/controls.js @@ -371,6 +371,68 @@ export function createControls({ state: layerState, services, parts, source }) { return result; }, + /** + * The shown satellites nearest a position, nearest first, from per-point + * state (no SGP4 re-propagation): a bounded pass that keeps only the + * nearest `maxCount`, and reads latitude and longitude for those alone. + * Dense extras are left out, as in `getAllPositions`. + * @param {Cesium.Cartesian3} center Earth-fixed position. + * @param {number} [maxCount=8] Most entries to return. + * @param {{excludeIds?: Array, + * excludeClasses?: Array}} [options] Satellites, and classes + * (e.g. 'debris'), to leave out. + * @returns {Array<{ id: number, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, satelliteClass: string|null, distanceM: number }>} + */ + getNearestPositions( + center, + maxCount = 8, + { excludeIds = [], excludeClasses = [] } = {}, + ) { + const nearest = []; + if (!center || !layerState._points || layerState._points.size === 0) + return nearest; + const cap = + Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 8; + const excluded = new Set(excludeIds.map(String)); + const classes = new Set(excludeClasses); + const dense = layerState._denseIds.length > 0; + for (const [noradId, point] of layerState._points) { + const p = point.position; + if (!p || excluded.has(String(noradId))) continue; + if (!_classVisible(noradId)) continue; + const sat = layerState._catalog.get(noradId); + if (dense && sat?.group === 'dense') continue; + if (classes.size && classes.has(sat?.satelliteClass)) continue; + const dx = p.x - center.x; + const dy = p.y - center.y; + const dz = p.z - center.z; + const distanceSq = dx * dx + dy * dy + dz * dz; + if (nearest.length === cap && distanceSq >= nearest[cap - 1].distanceSq) + continue; + // Insertion into a short sorted list: no copy or sort of the rest. + let at = nearest.length; + while (at > 0 && nearest[at - 1].distanceSq > distanceSq) at -= 1; + nearest.splice(at, 0, { noradId, point, sat, distanceSq }); + if (nearest.length > cap) nearest.pop(); + } + const result = []; + for (const { noradId, point, sat, distanceSq } of nearest) { + const carto = Cesium.Cartographic.fromCartesian(point.position); + if (!carto) continue; + result.push({ + id: noradId, + label: sat ? sat.name.trim() : String(noradId), + position: point.position, + latitude: Cesium.Math.toDegrees(carto.latitude), + longitude: Cesium.Math.toDegrees(carto.longitude), + altitudeM: carto.height, + satelliteClass: sat?.satelliteClass ?? null, + distanceM: Math.sqrt(distanceSq), + }); + } + return result; + }, + /** * Track a satellite by NORAD id (camera follow + orbit path + highlight), * same path as clicking its point. diff --git a/src/layers/satellites/nearestPositions.test.mjs b/src/layers/satellites/nearestPositions.test.mjs new file mode 100644 index 000000000..ab06d705c --- /dev/null +++ b/src/layers/satellites/nearestPositions.test.mjs @@ -0,0 +1,67 @@ +// src/layers/satellites/nearestPositions.test.mjs — the satellites nearest a +// position: bounded to the nearest few, nearest first, hidden classes, +// dense extras and asked-for exclusions left out. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createControls } from './controls.js'; + +function controlsWith(satellites, { classes, dense = [] } = {}) { + const state = { + _points: new Map(), + _catalog: new Map(), + _denseIds: dense, + _params: classes ? { classes } : {}, + }; + for (const sat of satellites) { + state._points.set(sat.id, { + position: Cesium.Cartesian3.fromDegrees(sat.lon, sat.lat, sat.alt), + }); + state._catalog.set(sat.id, { + name: `${sat.name} `, + satelliteClass: sat.cls, + group: sat.group, + }); + } + return createControls({ + state, + services: { layerState: { isExplicitLayerStateOrigin: () => false } }, + parts: {}, + }).methods; +} + +const SATELLITES = [ + { id: 1, name: 'NEAR', lat: 30.5, lon: -97, alt: 500_000, cls: 'imaging' }, + { id: 2, name: 'MID', lat: 33, lon: -97, alt: 500_000, cls: 'stations' }, + { id: 3, name: 'FAR', lat: -40, lon: 80, alt: 500_000, cls: 'imaging' }, + { id: 4, name: 'DEB', lat: 30.1, lon: -97, alt: 500_000, cls: 'debris' }, + { id: 5, name: 'TELECOM', lat: 30.2, lon: -97, alt: 500_000, cls: 'telecom' }, + { id: 6, name: 'SELF', lat: 30, lon: -97, alt: 420_000, cls: 'stations' }, + { id: 7, name: 'EXTRA', lat: 30.3, lon: -97, alt: 500_000, cls: 'imaging', group: 'dense' }, +]; + +const CENTRE = Cesium.Cartesian3.fromDegrees(-97, 30, 420_000); + +test('the nearest shown satellites come first, bounded, with their distance', () => { + const methods = controlsWith(SATELLITES, { + classes: { stations: true, imaging: true, debris: true, telecom: false }, + dense: [7], + }); + const nearest = methods.getNearestPositions(CENTRE, 3, { + excludeIds: [6], + excludeClasses: ['debris'], + }); + assert.deepEqual( + nearest.map((entry) => entry.label), + ['NEAR', 'MID', 'FAR'], + 'no self, debris, hidden telecom or dense extra', + ); + for (let i = 1; i < nearest.length; i += 1) + assert.ok(nearest[i].distanceM >= nearest[i - 1].distanceM); + const exact = Cesium.Cartesian3.distance(CENTRE, nearest[0].position); + assert.ok(Math.abs(nearest[0].distanceM - exact) < 1e-6); + assert.ok(Math.abs(nearest[0].latitude - 30.5) < 1e-6); + assert.equal(nearest[0].satelliteClass, 'imaging'); + assert.equal(methods.getNearestPositions(CENTRE, 1, { excludeIds: [6] })[0].label, 'DEB'); + assert.deepEqual(methods.getNearestPositions(null, 3), []); +});