Size the distance rings by what the frame reaches, and keep their labels off the star names

From the review of #19. The rings are distances from the Sun, but their step was taken from
`effectiveDistance`, which under the plan view means the extent of the frame rather than how far
the camera is from the Sun. Centred on a star 200 pc out and flipped to 2D, the grid became rings
of 2 to 20 pc: not one of them on screen. The step now comes from where the view is centred plus
how far the camera is orbiting it, which is the same distance under either projection.

The set was also rebuilt while the grid was hidden, and every rebuild disposes the rings and
builds every vertex again; it now happens only while the grid is drawn.

The ring labels went straight to the overlay: never culled to the frame, and free to land on a
star's name. They now have to be on screen and clear of the names already placed, by half the
separation two names keep — they are a ladder up one ray a twentieth of the screen apart, and
holding them apart from each other would take "Survey edge" off the map.

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-18 13:24:16 +02:00
co-authored by Claude Opus 5
parent b4017fcafc
commit 5dec528cee
3 changed files with 141 additions and 12 deletions
@@ -15,6 +15,7 @@ import { HudDisplay } from '../hud/hud-dock.component';
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
import { JumpLinkRenderer } from './jump-link-renderer';
import { StarFieldRenderer } from './star-field-renderer';
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
// jsdom does not implement ResizeObserver; the component only uses it to react to real
// layout changes, which never happen in this headless test.
@@ -418,6 +419,94 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
});
});
describe('the local grid of distance rings', () => {
type GridScene = {
controls: { target: THREE.Vector3; update(): void };
display: { update(change: (display: HudDisplay) => HudDisplay): void };
localGridRadii: readonly number[];
};
it('sizes the rings by how far the frame reaches from the Sun, under either projection', async () => {
const component = fixture.componentInstance as unknown as GridScene;
const camera = engine.getCamera();
// Centred on a star 200 pc out, seen from 20 pc away: the rings have to reach it.
component.controls.target.set(200, 0, 0);
camera.position.set(200, 0, 20);
component.controls.update();
await advanceFrames(engine, 0.3);
const underPerspective = [...component.localGridRadii];
component.display.update((display) => ({ ...display, plan: true }));
TestBed.tick();
await advanceFrames(engine, 0.3);
expect(underPerspective.at(-1)).toBeGreaterThanOrEqual(200);
// The plan view's wheel moves the frame rather than the camera, so "how far out the camera
// is" means something else there; what the rings have to cover does not.
expect([...component.localGridRadii]).toEqual(underPerspective);
});
it('leaves the rings alone while the grid is not drawn', async () => {
const component = fixture.componentInstance as unknown as GridScene;
const camera = engine.getCamera();
await advanceFrames(engine, 0.3);
component.display.update((display) => ({ ...display, grid: false }));
TestBed.tick();
await advanceFrames(engine, 0.3);
const hidden = [...component.localGridRadii];
// A zoom this size crosses two round steps, and each crossing rebuilds every ring's vertices.
camera.position.setLength(camera.position.length() / 8);
component.controls.update();
await advanceFrames(engine, 0.3);
expect([...component.localGridRadii]).toEqual(hidden);
});
it('drops a ring label that a star name has taken, or that is off screen, and keeps the ladder otherwise', () => {
const component = fixture.componentInstance as unknown as {
ringLabelsInTheClear(candidates: readonly LabeledPoint[], camera: THREE.Camera, stars: readonly LabeledPoint[]): LabeledPoint[];
};
const camera = engine.getCamera();
camera.updateMatrixWorld(true);
const at = (x: number, y: number) => new THREE.Vector3(x, y, 0.5).unproject(camera);
const near = at(0.1, 0.1);
const nextRungUp = at(0.1, 0.16);
const offScreen = at(1.6, 0.1);
const ladder: LabeledPoint[] = [
{ id: 'ring-50', name: '50 pc', x: near.x, y: near.y, z: near.z },
{ id: 'ring-100', name: '100 pc', x: nextRungUp.x, y: nextRungUp.y, z: nextRungUp.z },
{ id: 'ring-150', name: '150 pc', x: offScreen.x, y: offScreen.y, z: offScreen.z }
];
// A ladder of rings stays whole, though its rungs are closer than two star names would be.
expect(component.ringLabelsInTheClear(ladder, camera, []).map((label) => label.id)).toEqual(['ring-50', 'ring-100']);
// A star's name is worth more than a distance.
const star: LabeledPoint = { id: 7, name: 'Sirius', x: near.x, y: near.y, z: near.z };
expect(component.ringLabelsInTheClear(ladder, camera, [star]).map((label) => label.id)).toEqual(['ring-100']);
});
it('places the ring labels with the star names rather than over them', async () => {
const component = fixture.componentInstance as unknown as GridScene;
const update = vi.spyOn(StarLabelOverlay.prototype, 'update');
const cleared = vi.spyOn(GalaxySystemSceneComponent.prototype as unknown as { ringLabelsInTheClear: (...args: unknown[]) => LabeledPoint[] }, 'ringLabelsInTheClear');
const camera = engine.getCamera();
camera.position.set(0, 4, 10);
component.controls.target.set(0, 0, 0);
component.controls.update();
await advanceFrames(engine, 0.3);
const labels = (update.mock.calls.at(-1)?.[0] ?? []) as LabeledPoint[];
const rings = labels.filter((label) => String(label.id).startsWith('ring-'));
expect(rings.length).toBeGreaterThan(0);
// Handed over as the clearing pass left them, not as the grid produced them.
expect(cleared).toHaveBeenCalled();
expect(rings).toEqual(cleared.mock.results.at(-1)?.value);
update.mockRestore();
cleared.mockRestore();
});
});
it('keeps the stars of a plotted route drawn, and the selected star', async () => {
const component = fixture.componentInstance as unknown as { routeResult: { set(value: unknown): void } };
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
@@ -70,6 +70,12 @@ const LABEL_MAX_COUNT = 15;
const LABEL_MIN_SEPARATION_NDC = 0.12;
/** Beyond this the text of a right-hand label would run off the view: hang it on the left. */
const LABEL_EDGE_NDC = 0.7;
/**
* How far a ring label has to sit from a star's name, in NDC — half what two star names keep
* between them. A ring label is one short line, and the rungs of its ladder are a twentieth of the
* screen apart, so the full separation would have one name clear three rungs.
*/
const RING_LABEL_CLEARANCE_NDC = LABEL_MIN_SEPARATION_NDC / 2;
/** How far right of its point a label's text reaches, in aspect-scaled NDC (~135px at 1440). */
const LABEL_REACH_NDC = 0.3;
/**
@@ -194,10 +200,11 @@ const GALACTIC_FAR_PC = 250000;
*/
const SURVEY_EDGE_PC = 250;
/**
* The local grid's rings are distances from the Sun, at a round step that follows the camera:
* five of them out to about the camera's own distance, so 50 to 250 pc from the opening view and
* 2 to 10 pc from beside the Sun. A fixed set could only serve one end of the zoom: 50 pc rings
* say nothing from inside a 2 pc hop, and nothing marked the stars now drawn past 250 pc.
* How many rings the local grid aims for: the step is rounded down from a fifth of how far the
* frame reaches from the Sun, which makes five to fourteen of them. So 50 to 350 pc from the
* opening view, and 2 to 10 pc from beside the Sun. A fixed set could only serve one end of the
* zoom: 50 pc rings say nothing from inside a 2 pc hop, and nothing marked the stars now drawn
* past 250 pc.
*/
const LOCAL_GRID_RING_COUNT = 5;
const LOCAL_GRID_SPOKES = 12;
@@ -912,11 +919,29 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
}
}
/**
* The ring labels worth drawing: the ones on screen, and clear of the star names already placed.
*
* Not held apart from each other, as the star names are: they are a ladder up one ray, a few
* hundredths of the screen apart, and reading them in order is the point. What they must not do
* is sit on a star's name, which is worth more than a distance — or be handed to the overlay
* from behind or beside the camera, which draws them at the edge of the page rather than not at all.
*/
private ringLabelsInTheClear(candidates: readonly LabeledPoint[], camera: SceneCamera, stars: readonly LabeledPoint[]): LabeledPoint[] {
const projected = new THREE.Vector3();
const onScreen = (label: LabeledPoint): THREE.Vector2 | null => {
projected.set(label.x, label.y, label.z).project(camera);
const outside = projected.z < -1 || projected.z > 1 || Math.abs(projected.x) > 1 || Math.abs(projected.y) > 1;
return outside ? null : new THREE.Vector2(projected.x * this.viewportAspect(), projected.y);
};
const taken = stars.map(onScreen).filter((point): point is THREE.Vector2 => point !== null);
return candidates.filter((label) => {
const point = onScreen(label);
return point !== null && !taken.some((other) => other.distanceTo(point) < RING_LABEL_CLEARANCE_NDC);
});
}
private updateLabels(camera: SceneCamera): void {
const radii = distanceRings(this.effectiveDistance(camera), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC);
if (radii.join() !== this.localGridRadii.join()) {
this.setLocalGridRadii(radii);
}
const selectedId = this.navigationStore.selectedStarId();
// Measured from what the camera is looking at, not from where it is. Those differ by the
// orbit distance, so a camera-relative rule names the stars closest to the near edge of the
@@ -928,6 +953,20 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// 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;
// The rings are distances from the Sun, so what they have to cover is how far from the Sun the
// frame reaches: where the view is centred, plus how far out the camera is orbiting it. Under
// the plan view the orbit distance is the frame's own extent, since that is what the wheel
// moves there. Read as "how far the camera is from the Sun" instead, panning away from the Sun
// and flipping to the plan view left every ring off the frame.
// Rebuilt only while the grid is drawn: each new set disposes the old rings and builds every
// vertex of the new ones, and the set changes on any zoom that crosses a round step.
if (!isGalactic && this.display().grid) {
const orbitPc = this.engine.currentProjection === 'perspective' ? camera.position.distanceTo(target) : this.effectiveDistance(camera);
const radii = distanceRings(target.length() + orbitPc, LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC);
if (radii.join() !== this.localGridRadii.join()) {
this.setLocalGridRadii(radii);
}
}
// 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
@@ -944,7 +983,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
};
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);
const ringLabels = isGalactic || !this.display().grid ? [] : this.ringLabelsInTheClear(this.ringLabels(camera), camera, starLabels);
this.labelOverlay?.update([...starLabels, ...ringLabels, ...backdropLabels]);
}