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 e669b9b..70d5d66 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 @@ -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'); 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 971993d..2250ae1 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -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]); } diff --git a/src/app/shared/format/scale-bar.ts b/src/app/shared/format/scale-bar.ts index 7eb8f88..ff10fbd 100644 --- a/src/app/shared/format/scale-bar.ts +++ b/src/app/shared/format/scale-bar.ts @@ -17,9 +17,10 @@ export function roundLengthAtMost(value: number): number | null { /** * Rings at a round step of about `reach / count`, out to `reach` or just past it, plus `callout` - * wherever it falls among them: the grid's own radii are round, and the one radius that means - * something in its own right is marked whether the step lands on it or not. Rounding the step - * down makes for `count` to `2.5 × count` rings, never fewer than it takes to cover `reach`. + * where it falls between the first ring and the last: the grid's own radii are round, and the one + * radius that means something in its own right is marked whether the step lands on it or not. A + * frame that does not reach it has no ring for it. Rounding the step down makes for `count` to + * `ceil(2.5 × count)` rings, and the callout can add one: 5 to 14 for a count of 5. */ export function distanceRings(reach: number, count: number, callout: number): number[] { const step = roundLengthAtMost(reach / count);