diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index d25490e..63bf8a0 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -38,6 +38,7 @@ import { StarmapHudComponent } from './starmap-hud.component'; import { SystemObjectCardComponent } from './system-object-card.component'; import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer'; import { collectJumpLinks, minimumRangeBetween, routeBetween } from '../../shared/astro/jump-links'; +import { brightestWithin, brightnessOrder } from '../../shared/astro/brightest'; import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood'; import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component'; import { HostStarRings } from './host-star-rings'; @@ -356,6 +357,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { private deepSkyLabels: readonly LabeledPoint[] = []; /** Stars with at least one catalogued body, which are the ones the map can be flown into. */ private starIdsWithBodies = new Set(); + /** Catalogue indices, brightest first, for the labels to walk rather than sort. See `brightestWithin`. */ + private starsByBrightness: Uint32Array = new Uint32Array(0); /** Stars alone, normalised once, for the two routing fields. Empty until the catalogue lands. */ private readonly starSearchIndex = signal([]); private milkyWay?: MilkyWayRenderer; @@ -518,6 +521,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.stars = stars; this.starsById = new Map(stars.map((star) => [star.id, star])); this.neighbourhood = new StarNeighbourhood(stars); + this.starsByBrightness = brightnessOrder(stars); this.starSearchIndex.set( buildSearchIndex(stars.map((star) => ({ kind: 'star' as const, name: star.name, subtitle: star.spectralType, starId: star.id }))) ); @@ -766,47 +770,27 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // orbit distance, so a camera-relative rule names the stars closest to the near edge of the // view — a ring of labels around the outside of the thing the user is actually looking at. const target = this.controls?.target ?? GALAXY_OVERVIEW_TARGET; - const { x: cx, y: cy, z: cz } = target; const orbitDistance = (this.controls ? this.effectiveDistance(camera) : GALAXY_OVERVIEW_POSITION.length()) * LABEL_RADIUS_TO_ORBIT_DISTANCE; const labelRadius = THREE.MathUtils.clamp(orbitDistance, MIN_LABEL_RADIUS_PC, MAX_LABEL_RADIUS_PC); - const maxDistanceSq = labelRadius * labelRadius; - - const candidates: Array<{ star: StarRecord; distanceSq: number }> = []; - for (const star of this.stars) { - const dx = star.x - cx; - const dy = star.y - cy; - const dz = star.z - cz; - const distanceSq = dx * dx + dy * dy + dz * dz; - if (distanceSq <= maxDistanceSq || star.id === selectedId) { - candidates.push({ star, distanceSq }); - } - } + // Individual star names mean nothing once the whole Galaxy is in frame — at that range the + // entire catalogue is inside one pixel — so the labels hand over to the structural ones. + const isGalactic = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD; // Brightest first, not nearest first. Proximity was the right ranking when the catalogue was // a 50 pc bubble and everything in it was equally worth naming; across 250 pc it labels a // clump of whatever happens to be closest to the middle of the screen and never names the // stars that are actually prominent. Brightness is what makes a star worth a name. - candidates.sort((a, b) => a.star.magnitude - b.star.magnitude); - // Individual star names mean nothing once the whole Galaxy is in frame — at that range the - // entire catalogue is inside one pixel — so the labels hand over to the structural ones. - const isGalactic = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD; - // "System" rather than "Star" for anything with catalogued bodies: it is the one distinction - // the second line can draw that the map cannot otherwise show, since it says which of these - // points is somewhere you can actually go. - const starLabels: LabeledPoint[] = isGalactic - ? [] - : this.spreadLabels( - candidates.map(({ star }) => ({ - id: star.id, - name: star.name, - kind: this.starIdsWithBodies.has(star.id) ? 'System' : 'Star', - x: star.x, - y: star.y, - z: star.z - })), - camera, - selectedId - ); + // + // Walked lazily, and only as far as it takes to place the labels. "System" rather than "Star" + // for anything with catalogued bodies: it is the one distinction the second line can draw that + // the map cannot otherwise show, since it says which of these points is somewhere you can go. + const starIdsWithBodies = this.starIdsWithBodies; + const candidates = function* (stars: readonly StarRecord[], order: Uint32Array): Generator { + for (const star of brightestWithin(stars, order, target, labelRadius, selectedId)) { + yield { id: star.id, name: star.name, kind: starIdsWithBodies.has(star.id) ? 'System' : 'Star', x: star.x, y: star.y, z: star.z }; + } + }; + const starLabels: LabeledPoint[] = isGalactic ? [] : this.spreadLabels(candidates(this.stars, this.starsByBrightness), camera, selectedId); const backdropLabels = isGalactic ? this.galacticLabels : this.deepSkyLabels; const ringLabels = isGalactic || !this.display().grid ? [] : this.ringLabels(camera); this.labelOverlay?.update([...starLabels, ...ringLabels, ...backdropLabels]); @@ -831,7 +815,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { return canvas.clientHeight > 0 ? canvas.clientWidth / canvas.clientHeight : 1; } - private spreadLabels(candidates: readonly LabeledPoint[], camera: SceneCamera, keepId: number | string | null): LabeledPoint[] { + private spreadLabels(candidates: Iterable, camera: SceneCamera, keepId: number | string | null): LabeledPoint[] { const placed: THREE.Vector2[] = []; const chosen: LabeledPoint[] = []; const projected = new THREE.Vector3(); diff --git a/src/app/shared/astro/brightest.spec.ts b/src/app/shared/astro/brightest.spec.ts new file mode 100644 index 0000000..7264a9f --- /dev/null +++ b/src/app/shared/astro/brightest.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { brightestWithin, brightnessOrder } from './brightest'; + +interface TestStar { + id: number; + x: number; + y: number; + z: number; + magnitude: number; +} + +/** A pseudo-random cloud with repeated magnitudes, so ties are exercised. */ +function cloud(count: number): TestStar[] { + let seed = 5; + const random = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648; + return Array.from({ length: count }, (_, id) => ({ + id: id * 7, + x: random() * 200 - 100, + y: random() * 200 - 100, + z: random() * 200 - 100, + magnitude: Math.round(random() * 40) / 4 + })); +} + +describe('brightnessOrder', () => { + it('puts the brightest first and keeps catalogue order among equals', () => { + const stars = [{ magnitude: 5 }, { magnitude: -1 }, { magnitude: 5 }, { magnitude: 2 }]; + + expect(Array.from(brightnessOrder(stars))).toEqual([1, 3, 0, 2]); + }); + + it('orders nothing for an empty catalogue', () => { + expect(brightnessOrder([])).toHaveLength(0); + }); +}); + +describe('brightestWithin', () => { + // What the labels used to do on every pass: filter the whole catalogue, then sort what was left. + function filterThenSort(stars: TestStar[], centre: { x: number; y: number; z: number }, radius: number, alwaysId: number | null): number[] { + return stars + .filter((star) => Math.hypot(star.x - centre.x, star.y - centre.y, star.z - centre.z) <= radius || star.id === alwaysId) + .sort((a, b) => a.magnitude - b.magnitude) + .map((star) => star.id); + } + + it('yields exactly what filtering and then sorting the catalogue did, in the same order', () => { + const stars = cloud(3000); + const order = brightnessOrder(stars); + const centre = { x: 12, y: -30, z: 5 }; + + for (const [radius, alwaysId] of [[40, null], [15, 7 * 2999], [0, 7 * 11], [500, null]] as const) { + const lazy = Array.from(brightestWithin(stars, order, centre, radius, alwaysId), (star) => star.id); + expect(lazy).toEqual(filterThenSort(stars, centre, radius, alwaysId)); + } + }); + + it('includes a star lying exactly on the radius, as the scan it replaced did', () => { + const stars = [ + { id: 1, x: 3, y: 4, z: 0, magnitude: 1 }, + { id: 2, x: 3, y: 4.001, z: 0, magnitude: 0 } + ]; + + expect(Array.from(brightestWithin(stars, brightnessOrder(stars), { x: 0, y: 0, z: 0 }, 5, null), (star) => star.id)).toEqual([1]); + }); + + it('reads no further than the caller takes', () => { + const stars = cloud(3000); + let read = 0; + const counted = new Proxy(stars, { + get(target, key, receiver) { + if (typeof key === 'string' && /^\d+$/.test(key)) { + read++; + } + return Reflect.get(target, key, receiver); + } + }); + + const taken: number[] = []; + for (const star of brightestWithin(counted, brightnessOrder(stars), { x: 0, y: 0, z: 0 }, 1000, null)) { + taken.push(star.id); + if (taken.length === 15) { + break; + } + } + + expect(taken).toHaveLength(15); + expect(read).toBe(15); + }); +}); diff --git a/src/app/shared/astro/brightest.ts b/src/app/shared/astro/brightest.ts new file mode 100644 index 0000000..9b38d04 --- /dev/null +++ b/src/app/shared/astro/brightest.ts @@ -0,0 +1,52 @@ +/** + * The catalogue in order of brightness, worked out once and walked as often as needed. + * + * Two parts of the map want "the brightest stars in this region": the labels, which name about + * fifteen of them five times a second, and the star field, which draws a budget of them. Sorting + * the region each time is paid for every star in it. At the opening view the label region holds + * some 60 000 stars, and sorting them to name fifteen took 55-70 ms a pass, a stall five times + * a second on any machine. Walking one shared order and stopping when enough have been taken + * costs only the stars looked at before that. + */ + +export interface BrightnessRanked { + readonly magnitude: number; +} + +export interface Positioned { + readonly x: number; + readonly y: number; + readonly z: number; +} + +/** + * Indices into `stars`, brightest (lowest magnitude) first. Ties keep catalogue order: typed-array + * sort is required to be stable, exactly as the sort of the stars themselves was. + */ +export function brightnessOrder(stars: readonly BrightnessRanked[]): Uint32Array { + return Uint32Array.from(stars.keys()).sort((a, b) => stars[a].magnitude - stars[b].magnitude); +} + +/** + * The stars within `radiusPc` of `centre`, brightest first, plus the one star `alwaysId` names + * wherever it is — handed over lazily, so a caller that stops after the first few pays for no + * more than it read. + */ +export function* brightestWithin( + stars: readonly T[], + order: Uint32Array, + centre: Positioned, + radiusPc: number, + alwaysId: number | null +): Generator { + const radiusSq = radiusPc * radiusPc; + for (const index of order) { + const star = stars[index]; + const dx = star.x - centre.x; + const dy = star.y - centre.y; + const dz = star.z - centre.z; + if (dx * dx + dy * dy + dz * dz <= radiusSq || star.id === alwaysId) { + yield star; + } + } +}