diff --git a/e2e/galaxy-view.spec.ts b/e2e/galaxy-view.spec.ts index f1bf3e1..227b5ad 100644 --- a/e2e/galaxy-view.spec.ts +++ b/e2e/galaxy-view.spec.ts @@ -12,7 +12,9 @@ test.describe('Galaxy view', () => { await expect(page.getByTestId('scene-canvas')).toBeVisible(); await expect(page.getByPlaceholder('Search stars, planets, exoplanets…')).toBeVisible(); await expect(backButtonLocator(page)).toHaveCount(0); - await expect(page.getByText('Local Stars')).toBeVisible(); + // The readout panel's own title, not just the text anywhere on screen: the selected-object + // banner across the top names the same thing, so a bare text match is ambiguous. + await expect(page.getByTestId('hud-title')).toHaveText('Local Stars'); }); test('the scale ladder flies out to the whole Galaxy and back to the solar neighbourhood', async ({ page }) => { @@ -32,6 +34,6 @@ test.describe('Galaxy view', () => { await expect(page.getByRole('button', { name: 'Milky Way' })).toHaveCount(0); await page.getByRole('button', { name: 'Solar Neighbourhood' }).click(); - await expect(page.getByText('Local Stars')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('hud-title')).toHaveText('Local Stars', { timeout: 15_000 }); }); }); diff --git a/src/app/features/galaxy-system/deep-sky-renderer.ts b/src/app/features/galaxy-system/deep-sky-renderer.ts index 640161c..261f2dd 100644 --- a/src/app/features/galaxy-system/deep-sky-renderer.ts +++ b/src/app/features/galaxy-system/deep-sky-renderer.ts @@ -87,7 +87,7 @@ export function deepSkyLabelPoints( ): LabeledPoint[] { return records.slice(0, limit).map((record) => { const position = backdropPosition(record, radiusPc); - return { id: record.id, name: record.name, x: position.x, y: position.y, z: position.z }; + return { id: record.id, name: record.name, kind: record.kind.toUpperCase(), x: position.x, y: position.y, z: position.z }; }); } 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 5df2651..12b0027 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -199,6 +199,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { private starField?: StarFieldRenderer; private deepSky?: DeepSkyRenderer; 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(); private milkyWay?: MilkyWayRenderer; private galacticLabels: readonly LabeledPoint[] = []; private galacticGrid?: PolarGridPlane; @@ -339,6 +341,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.starsById = new Map(stars.map((star) => [star.id, star])); this.bodies = bodies; this.exoplanets = exoplanets; + // Built once rather than per label refresh: it is a scan of every body and exoplanet, and the + // labels are recomputed whenever the camera moves. + this.starIdsWithBodies = new Set([...bodies.map((body) => body.systemStarId), ...exoplanets.map((exoplanet) => exoplanet.hostStarId)].filter( + (id): id is number => id !== null && id !== undefined + )); this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search)); this.galaxyGroup.add(this.starField.object); @@ -410,6 +417,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.labelUpdateAccumulator = 0; if (this.galaxyGroup.visible) { this.updateLabels(camera); + } else if (this.systemGroup.visible) { + this.updateSystemLabels(camera); } this.updateHud(camera); } @@ -498,50 +507,121 @@ 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; - const starLabels = isGalactic ? [] : this.spreadLabels(candidates, camera, selectedId); + // "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 + ); const backdropLabels = isGalactic ? this.galacticLabels : this.deepSkyLabels; this.labelOverlay?.update([...starLabels, ...backdropLabels]); } /** - * Takes the nearest stars in order and keeps only those that land clear of the labels already - * placed, dropping the rest. + * Takes candidate labels in priority order and keeps only those that land clear of the labels + * already placed, dropping the rest. * - * Nearest-first alone is not enough: the Sun's fifteen nearest neighbours are all inside four - * parsecs, so from anything but point-blank range their names print on top of each other in a - * single unreadable clump. Rejecting on screen separation instead of on distance means the set - * naturally opens up as the camera closes in, and stays legible when it pulls back. + * Priority alone is not enough at either scale. The Sun's fifteen nearest neighbours are all + * inside four parsecs, so from anything but point-blank range their names print on top of each + * other in a single unreadable clump; the inner four planets do exactly the same thing when a + * system is framed out to Pluto. Rejecting on screen separation rather than on distance means + * the set naturally opens up as the camera closes in, and stays legible when it pulls back. + * + * `keepId` is exempt from both tests — it is the selection, which is about to be flown to, and + * its label going missing mid-flight reads as the target having been lost. */ - private spreadLabels(candidates: readonly { star: StarRecord }[], camera: THREE.PerspectiveCamera, selectedId: number | null): StarRecord[] { + private spreadLabels(candidates: readonly LabeledPoint[], camera: THREE.PerspectiveCamera, keepId: number | string | null): LabeledPoint[] { const placed: THREE.Vector2[] = []; - const chosen: StarRecord[] = []; + const chosen: LabeledPoint[] = []; const projected = new THREE.Vector3(); - for (const { star } of candidates) { + for (const candidate of candidates) { if (chosen.length >= LABEL_MAX_COUNT) { break; } - projected.set(star.x, star.y, star.z).project(camera); - const isSelected = star.id === selectedId; - // Offscreen or behind the camera. The selection is exempt: it is about to be flown to, and - // its label going missing mid-flight reads as the target having been lost. - if (!isSelected && (projected.z < -1 || projected.z > 1 || Math.abs(projected.x) > 1 || Math.abs(projected.y) > 1)) { + projected.set(candidate.x, candidate.y, candidate.z).project(camera); + const isKept = candidate.id === keepId; + // Offscreen or behind the camera. + if (!isKept && (projected.z < -1 || projected.z > 1 || Math.abs(projected.x) > 1 || Math.abs(projected.y) > 1)) { continue; } const point = new THREE.Vector2(projected.x * camera.aspect, projected.y); - if (!isSelected && placed.some((other) => other.distanceTo(point) < LABEL_MIN_SEPARATION_NDC)) { + if (!isKept && placed.some((other) => other.distanceTo(point) < LABEL_MIN_SEPARATION_NDC)) { continue; } placed.push(point); - chosen.push(star); + chosen.push(candidate); } return chosen; } + /** + * Names the bodies of the system the view is inside. + * + * Outermost first, because that is the order that survives the separation test usefully: with + * the whole system in frame the outer planets are the ones far enough apart to label, and the + * inner four are a single clump around the star. Closing in reverses it on its own — the outer + * orbits leave the frame and their labels drop out, freeing the space for the inner planets. + * + * Moons are left out entirely: they sit within a marker's width of their planet at system + * framing, so their labels could only ever print on top of it. + */ + private updateSystemLabels(camera: THREE.PerspectiveCamera): void { + const renderer = this.systemRenderer; + if (!renderer) { + this.labelOverlay?.update([]); + return; + } + + const records = new Map([ + ...this.bodies.map((body): [string, { name: string; semiMajorAxisAu: number }] => [ + body.id, + { name: body.name, semiMajorAxisAu: body.orbit.semiMajorAxisAu } + ]), + ...this.exoplanets.map((exoplanet): [string, { name: string; semiMajorAxisAu: number }] => [ + exoplanet.id, + { name: exoplanet.name, semiMajorAxisAu: exoplanet.orbit?.semiMajorAxisAu ?? 0 } + ]) + ]); + const position = new THREE.Vector3(); + + const points: Array = []; + for (const member of renderer.members) { + if (member.kind === 'moon') { + continue; + } + const record = records.get(member.id); + member.marker.getWorldPosition(position); + points.push({ + id: member.id, + name: record?.name ?? member.id, + kind: member.kind === 'exoplanet' ? 'Exoplanet' : member.kind === 'dwarf' ? 'Dwarf Planet' : 'Planet', + semiMajorAxisAu: record?.semiMajorAxisAu ?? 0, + x: position.x, + y: position.y, + z: position.z + }); + } + + points.sort((a, b) => b.semiMajorAxisAu - a.semiMajorAxisAu); + this.labelOverlay?.update(this.spreadLabels(points, camera, null)); + } + /** Refreshes the readout panel for whichever scale the view is currently at. */ private updateHud(camera: THREE.PerspectiveCamera): void { const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId); diff --git a/src/app/features/galaxy-system/milky-way-renderer.ts b/src/app/features/galaxy-system/milky-way-renderer.ts index a06b6b2..e1fbb3b 100644 --- a/src/app/features/galaxy-system/milky-way-renderer.ts +++ b/src/app/features/galaxy-system/milky-way-renderer.ts @@ -99,7 +99,7 @@ export class MilkyWayRenderer { labelPoints(): readonly LabeledPoint[] { return GALACTIC_LANDMARKS.map((landmark) => { const position = landmarkPositionPc(landmark); - return { id: `galactic:${landmark.id}`, name: landmark.name, x: position.x, y: position.y, z: position.z }; + return { id: `galactic:${landmark.id}`, name: landmark.name, kind: landmark.kind, x: position.x, y: position.y, z: position.z }; }); } diff --git a/src/app/features/galaxy-system/star-label-overlay.spec.ts b/src/app/features/galaxy-system/star-label-overlay.spec.ts new file mode 100644 index 0000000..69ce07f --- /dev/null +++ b/src/app/features/galaxy-system/star-label-overlay.spec.ts @@ -0,0 +1,90 @@ +import * as THREE from 'three/webgpu'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { StarLabelOverlay } from './star-label-overlay'; + +describe('StarLabelOverlay', () => { + let scene: THREE.Scene; + let overlay: StarLabelOverlay; + let camera: THREE.PerspectiveCamera; + + /** + * Every label element currently in the layer, in DOM order. + * + * `CSS2DRenderer` only attaches an element to its layer when it projects it, so the labels do + * not exist in the DOM until something has been rendered — which is why this draws a frame + * rather than reading straight off `domElement`. + */ + function labels(): HTMLElement[] { + overlay.render(camera); + return [...overlay.domElement.querySelectorAll('.map-label')]; + } + + beforeEach(() => { + scene = new THREE.Scene(); + overlay = new StarLabelOverlay(scene); + overlay.setSize(800, 600); + camera = new THREE.PerspectiveCamera(50, 800 / 600, 0.1, 1000); + camera.position.set(0, 0, 20); + camera.updateMatrixWorld(); + }); + + it('prints what a thing is under what it is called', () => { + overlay.update([{ id: 1, name: 'Sirius', kind: 'System', x: 1, y: 2, z: 3 }]); + + const label = labels()[0]; + expect(label.querySelector('.map-label-name')?.textContent).toBe('Sirius'); + expect(label.querySelector('.map-label-kind')?.textContent).toBe('System'); + }); + + it('prints the name alone when there is no type to give', () => { + // The second line is optional, and an empty one would still cost its line height — which + // over a screen of labels shifts every name off the point it is anchored to. + overlay.update([{ id: 1, name: 'Sirius', x: 1, y: 2, z: 3 }]); + + expect(labels()[0].querySelector('.map-label-name')?.textContent).toBe('Sirius'); + expect(labels()[0].querySelector('.map-label-kind')).toBeNull(); + }); + + it('places the label at the point it belongs to', () => { + overlay.update([{ id: 7, name: 'Vega', kind: 'Star', x: 4, y: -5, z: 6 }]); + + const object = scene.children.find((child) => child.type === 'Object3D'); + expect(object?.position.toArray()).toEqual([4, -5, 6]); + }); + + it('touches the DOM only for labels that actually changed', () => { + overlay.update([ + { id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 }, + { id: 2, name: 'Vega', kind: 'Star', x: 0, y: 1, z: 0 } + ]); + const sirius = labels()[0]; + + overlay.update([ + { id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 }, + { id: 3, name: 'Altair', kind: 'Star', x: 0, y: 0, z: 1 } + ]); + + // Same element instance: the label that stayed was not torn down and rebuilt. + expect(labels()[0]).toBe(sirius); + expect(labels().map((label) => label.querySelector('.map-label-name')?.textContent)).toEqual(['Sirius', 'Altair']); + }); + + it('drops every label when given none', () => { + overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 }]); + overlay.update([]); + + expect(labels()).toHaveLength(0); + }); + + it('leaves nothing behind when disposed', () => { + overlay.update([ + { id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 }, + { id: 'ngc:224', name: 'Andromeda', kind: 'GALAXY', x: 0, y: 1, z: 0 } + ]); + overlay.dispose(); + + expect(labels()).toHaveLength(0); + expect(scene.children).toHaveLength(0); + }); +}); diff --git a/src/app/features/galaxy-system/star-label-overlay.ts b/src/app/features/galaxy-system/star-label-overlay.ts index af408de..2c99261 100644 --- a/src/app/features/galaxy-system/star-label-overlay.ts +++ b/src/app/features/galaxy-system/star-label-overlay.ts @@ -5,6 +5,16 @@ export interface LabeledPoint { /** Numeric for HYG stars, string for catalog designations such as deep-sky objects. */ id: number | string; name: string; + /** + * What sort of thing this is — `STAR`, `PLANET`, `NEBULA`, `ARM`. Printed under the name in + * smaller, dimmer, wider-tracked capitals. + * + * A name on its own is ambiguous in a map that mixes scales: "Orion" is an arm, a nebula and a + * constellation, and at a glance nothing distinguishes the label on one from the label on + * another. The second line is what makes a label say what it is pointing at, not just what it + * is called. + */ + kind?: string; x: number; y: number; z: number; @@ -31,7 +41,13 @@ export class StarLabelOverlay { this.cssRenderer.setSize(width, height); } - /** Shows exactly these labels, adding/removing DOM elements only for a changed set. */ + /** + * Shows exactly these labels, adding/removing DOM elements only for a changed set. + * + * A label that is already up is repositioned rather than left where it was: stars never move, + * but planets do, and a system's labels would otherwise stay pinned to wherever each body + * happened to be when its label first appeared. + */ update(points: readonly LabeledPoint[]): void { const idsToShow = new Set(points.map((point) => point.id)); @@ -42,7 +58,10 @@ export class StarLabelOverlay { } for (const point of points) { - if (!this.labelObjects.has(point.id)) { + const existing = this.labelObjects.get(point.id); + if (existing) { + existing.position.set(point.x, point.y, point.z); + } else { this.addLabel(point); } } @@ -62,8 +81,19 @@ export class StarLabelOverlay { const element = document.createElement('div'); // Tailwind utility classes assigned directly since this element lives outside Angular's // view encapsulation (see the class comment above) rather than through a component template. - element.className = 'translate-x-1.5 -translate-y-1.5 whitespace-nowrap font-body text-[11px] text-accent [text-shadow:0_0_4px_rgba(0,0,0,0.9)]'; - element.textContent = point.name; + element.className = 'map-label translate-x-1.5 -translate-y-1.5 whitespace-nowrap font-body'; + + const name = document.createElement('span'); + name.className = 'map-label-name'; + name.textContent = point.name; + element.appendChild(name); + + if (point.kind) { + const kind = document.createElement('span'); + kind.className = 'map-label-kind'; + kind.textContent = point.kind; + element.appendChild(kind); + } const object = new CSS2DObject(element); object.position.set(point.x, point.y, point.z); diff --git a/src/app/features/galaxy-system/starmap-hud.component.spec.ts b/src/app/features/galaxy-system/starmap-hud.component.spec.ts index 778ce3f..79ed7a0 100644 --- a/src/app/features/galaxy-system/starmap-hud.component.spec.ts +++ b/src/app/features/galaxy-system/starmap-hud.component.spec.ts @@ -88,4 +88,17 @@ describe('StarmapHudComponent', () => { expect(host.querySelector('dl')).toBeNull(); expect(host.textContent).not.toContain('undefined'); }); + + it('names what the view is holding on the banner across the top', () => { + fixture.componentRef.setInput('level', 'system'); + fixture.componentRef.setInput('title', 'Sol'); + fixture.detectChanges(); + + expect((fixture.nativeElement as HTMLElement).querySelector('.hud-banner')?.textContent?.trim()).toBe('Sol'); + }); + + it('shows no banner when the view is holding nothing', () => { + // An empty nameplate is worse than none: it reads as a selection that failed to resolve. + expect(render('galaxy').querySelector('.hud-banner')).toBeNull(); + }); }); diff --git a/src/app/features/galaxy-system/starmap-hud.component.ts b/src/app/features/galaxy-system/starmap-hud.component.ts index 7db2d07..2f6e3ff 100644 --- a/src/app/features/galaxy-system/starmap-hud.component.ts +++ b/src/app/features/galaxy-system/starmap-hud.component.ts @@ -45,48 +45,54 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
@if (showReticle()) { -
- - - - - - -
+ + } -