From 0a0b301807aacfd9809d96bf085df5e8568c5033 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Wed, 16 Sep 2026 13:52:51 +0200 Subject: [PATCH 1/2] Name the brightest stars by walking one order, instead of sorting 60 000 five times a second The star labels are refreshed every 0.2 s. Each pass filtered the whole catalogue to the stars within the label radius, sorted them by magnitude, and turned every one into a label object, all to place at most fifteen. At the opening view the radius holds about 60 000 stars, so each pass was a 55-70 ms task on the main thread. A CPU profile of the opening view, on a Ryzen 7700X with an RTX 4080, counted 29 tasks over 50 ms in 6.7 s, one every 230 ms; updateLabels took 23% of the main thread. That is the stutter the frame-time bench measured on every GPU and every render budget. The catalogue is now sorted by brightness once, when it loads. brightestWithin walks that order and hands stars over lazily, and spreadLabels already stopped once it had placed fifteen labels, so a pass reads only the stars it looks at. The output is the same as before: the same stars, in the same order, with ties in catalogue order, the selected star named wherever it is, and a star exactly on the radius included. The spec checks it against the filter-then-sort it replaces. Stars are no longer scanned at all when the view is at galactic scale, where the result was thrown away. Profiled again on the same view: 0 tasks over 50 ms, and the scene's per-frame work over the window dropped from 2 028 ms to 342 ms. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- .../galaxy-system-scene.component.ts | 54 ++++------- src/app/shared/astro/brightest.spec.ts | 90 +++++++++++++++++++ src/app/shared/astro/brightest.ts | 52 +++++++++++ 3 files changed, 161 insertions(+), 35 deletions(-) create mode 100644 src/app/shared/astro/brightest.spec.ts create mode 100644 src/app/shared/astro/brightest.ts 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; + } + } +} From b071d87d8a5888c614175b457c24c3a21876ac62 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Wed, 16 Sep 2026 15:04:51 +0200 Subject: [PATCH 2/2] Answer the review: walk the brightness order in memory order, and stop at the fifteenth label The adversarial review confirmed a regression in this PR. Near the Sun, the label pass became three to four times slower than the scan and sort it replaced. Within about 11 pc of the Sun, and in any plan view zoomed tighter than that, the label radius clamps to 4 pc. That sphere holds a few dozen faint dwarfs deep in the brightness order, so the walk rarely finds fifteen stars to name and reads nearly the whole catalogue. Reading the star objects in brightness order jumps all over memory, so a full walk took 19-25 ms against the old 5-6 ms. The review also found that spreadLabels checked the label count at the top of its loop. After placing the fifteenth label it asked for a sixteenth candidate, which near the Sun can lie at the far end of the order. brightnessIndex now lays each star's position and id out beside the brightness order, in that order. The walk tests stars from those arrays in sequence and reads a star object only when it yields one. spreadLabels breaks straight after placing the fifteenth label. Measured on the real catalogue with the label logic reduced to what decides placement, camera at the given distance from the Sun (old sort / this PR as first pushed / now): 2 pc 4.9 / 24.7 / 2.5 ms 5 pc 5.7 / 23.0 / 3.1 ms 10 pc 6.3 / 18.2 / 0.95 ms 307 pc 22 / 0.01 / 0.00 ms (the opening view) The labels are identical in every case. Now faster than the old sort at every distance. A new scene test counts the candidates spreadLabels takes: exactly fifteen for fifteen labels. Negative controls, each caught: positions one axis off, ids in catalogue order, the selected star dropped, the radius edge excluded, and the count checked before taking a candidate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- .../galaxy-system-scene.component.spec.ts | 24 ++++++++++ .../galaxy-system-scene.component.ts | 19 ++++---- src/app/shared/astro/brightest.spec.ts | 10 ++-- src/app/shared/astro/brightest.ts | 48 +++++++++++++++---- 4 files changed, 79 insertions(+), 22 deletions(-) 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]]; } } }