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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-09-16 15:04:51 +02:00
co-authored by Claude Opus 5
parent 0a0b301807
commit b071d87d8a
4 changed files with 79 additions and 22 deletions
@@ -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<number>();
/** 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<IndexedSearchEntry[]>([]);
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<LabeledPoint> {
for (const star of brightestWithin(stars, order, target, labelRadius, selectedId)) {
const candidates = function* (stars: readonly StarRecord[], index: BrightnessIndex): Generator<LabeledPoint> {
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;