diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts index d165b1c..b01499c 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts @@ -211,6 +211,30 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { expect(navigationStore.viewLevel()).toBe('galaxy'); }); + it('asks for no more label candidates once the last label it will show is placed', () => { + // Near the Sun a label candidate past the fifteenth can sit at the far end of the catalogue's + // brightness order, so asking for one more than is used can cost a walk of the whole order. + const component = fixture.componentInstance as unknown as { + spreadLabels(candidates: Iterable<{ id: number; name: string; x: number; y: number; z: number }>, camera: THREE.Camera, keepId: null): unknown[]; + }; + const camera = engine.getCamera(); + camera.updateMatrixWorld(true); + camera.updateProjectionMatrix(); + let pulled = 0; + const grid = function* () { + for (let row = 0; row < 5; row++) { + for (let column = 0; column < 5; column++) { + pulled++; + const point = new THREE.Vector3(-0.8 + column * 0.4, -0.8 + row * 0.4, 0.5).unproject(camera); + yield { id: row * 5 + column, name: `label-${pulled}`, x: point.x, y: point.y, z: point.z }; + } + } + }; + + expect(component.spreadLabels(grid(), camera, null)).toHaveLength(15); + expect(pulled).toBe(15); + }); + it('flies the camera into a selected star system: hides the galaxy group, shows the system group, and switches to AU-scale near/far planes', async () => { navigationStore.selectStar(SUN.id); await flushAsync(); 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 63bf8a0..8b20638 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -38,7 +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 { BrightnessIndex, brightestWithin, brightnessIndex } 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'; @@ -358,7 +358,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { /** 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); + private starsByBrightness: BrightnessIndex = brightnessIndex([]); /** Stars alone, normalised once, for the two routing fields. Empty until the catalogue lands. */ private readonly starSearchIndex = signal([]); private milkyWay?: MilkyWayRenderer; @@ -521,7 +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.starsByBrightness = brightnessIndex(stars); this.starSearchIndex.set( buildSearchIndex(stars.map((star) => ({ kind: 'star' as const, name: star.name, subtitle: star.spectralType, starId: star.id }))) ); @@ -785,8 +785,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // 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)) { + const candidates = function* (stars: readonly StarRecord[], index: BrightnessIndex): Generator { + for (const star of brightestWithin(stars, index, 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 }; } }; @@ -821,10 +821,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { const projected = new THREE.Vector3(); for (const candidate of candidates) { - if (chosen.length >= LABEL_MAX_COUNT) { - break; - } - projected.set(candidate.x, candidate.y, candidate.z).project(camera); const isKept = candidate.id === keepId; // Offscreen or behind the camera. @@ -851,6 +847,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { placed.push(point); chosen.push({ ...candidate, side }); + // Here rather than at the top of the loop: there, taking the fifteenth label asked the + // candidates for a sixteenth first, and near the Sun finding one walks most of the catalogue. + if (chosen.length >= LABEL_MAX_COUNT) { + break; + } } return chosen; diff --git a/src/app/shared/astro/brightest.spec.ts b/src/app/shared/astro/brightest.spec.ts index 7264a9f..a196160 100644 --- a/src/app/shared/astro/brightest.spec.ts +++ b/src/app/shared/astro/brightest.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { brightestWithin, brightnessOrder } from './brightest'; +import { brightestWithin, brightnessIndex, brightnessOrder } from './brightest'; interface TestStar { id: number; @@ -46,11 +46,11 @@ describe('brightestWithin', () => { it('yields exactly what filtering and then sorting the catalogue did, in the same order', () => { const stars = cloud(3000); - const order = brightnessOrder(stars); + const index = brightnessIndex(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); + const lazy = Array.from(brightestWithin(stars, index, centre, radius, alwaysId), (star) => star.id); expect(lazy).toEqual(filterThenSort(stars, centre, radius, alwaysId)); } }); @@ -61,7 +61,7 @@ describe('brightestWithin', () => { { 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]); + expect(Array.from(brightestWithin(stars, brightnessIndex(stars), { x: 0, y: 0, z: 0 }, 5, null), (star) => star.id)).toEqual([1]); }); it('reads no further than the caller takes', () => { @@ -77,7 +77,7 @@ describe('brightestWithin', () => { }); const taken: number[] = []; - for (const star of brightestWithin(counted, brightnessOrder(stars), { x: 0, y: 0, z: 0 }, 1000, null)) { + for (const star of brightestWithin(counted, brightnessIndex(stars), { x: 0, y: 0, z: 0 }, 1000, null)) { taken.push(star.id); if (taken.length === 15) { break; diff --git a/src/app/shared/astro/brightest.ts b/src/app/shared/astro/brightest.ts index 9b38d04..141f916 100644 --- a/src/app/shared/astro/brightest.ts +++ b/src/app/shared/astro/brightest.ts @@ -27,6 +27,38 @@ export function brightnessOrder(stars: readonly BrightnessRanked[]): Uint32Array return Uint32Array.from(stars.keys()).sort((a, b) => stars[a].magnitude - stars[b].magnitude); } +/** + * The brightness order, with each star's position and id laid out beside it in that order. + * + * A walk has to test every star it passes, and near the Sun it passes nearly all of them: a 4 pc + * label radius holds a few dozen stars, faint dwarfs deep in the order, so the walk rarely finds + * fifteen to name before the end. Reading the stars themselves in brightness order jumps all over + * the catalogue, and a full walk took 19-23 ms — slower than the scan and sort it replaced. Read + * from these arrays, laid out in the order they are walked, the same walk touches memory in + * sequence and reads a star only when it yields one. + */ +export interface BrightnessIndex { + /** Indices into the catalogue, brightest first. */ + readonly order: Uint32Array; + /** Positions in the same order, three to a star, at full precision so a star on a radius stays on it. */ + readonly positions: Float64Array; + readonly ids: Float64Array; +} + +export function brightnessIndex(stars: readonly T[]): BrightnessIndex { + const order = brightnessOrder(stars); + const positions = new Float64Array(order.length * 3); + const ids = new Float64Array(order.length); + order.forEach((index, at) => { + const star = stars[index]; + positions[at * 3] = star.x; + positions[at * 3 + 1] = star.y; + positions[at * 3 + 2] = star.z; + ids[at] = star.id; + }); + return { order, positions, ids }; +} + /** * 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 @@ -34,19 +66,19 @@ export function brightnessOrder(stars: readonly BrightnessRanked[]): Uint32Array */ export function* brightestWithin( stars: readonly T[], - order: Uint32Array, + index: BrightnessIndex, centre: Positioned, radiusPc: number, alwaysId: number | null ): Generator { + const { order, positions, ids } = index; 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; + for (let at = 0; at < order.length; at++) { + const dx = positions[at * 3] - centre.x; + const dy = positions[at * 3 + 1] - centre.y; + const dz = positions[at * 3 + 2] - centre.z; + if (dx * dx + dy * dy + dz * dz <= radiusSq || ids[at] === alwaysId) { + yield stars[order[at]]; } } }