diff --git a/e2e/galaxy-view.spec.ts b/e2e/galaxy-view.spec.ts index 7ef7238..578b3cb 100644 --- a/e2e/galaxy-view.spec.ts +++ b/e2e/galaxy-view.spec.ts @@ -40,4 +40,23 @@ test.describe('Galaxy view', () => { await page.getByRole('button', { name: 'Solar Neighbourhood' }).click(); await expect(page.getByTestId('hud-title')).toHaveText('Local Stars', { timeout: 15_000 }); }); + + test('a scale bar and labelled rings say how far things are, and follow the zoom', async ({ page }) => { + test.setTimeout(90_000); + await page.goto('/?stars=4000'); + await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 }); + + // The rings are distances from the Sun, the survey's own edge called out among them. + await expect(page.getByText('Survey edge')).toBeVisible({ timeout: 30_000 }); + const scale = page.getByTestId('hud-scale'); + await expect(scale).toHaveAttribute('aria-label', /^Scale: [\d.]+ k?pc$/); + const opening = await scale.getAttribute('aria-label'); + + // Zooming in shortens the round length the bar stands for. + await page.getByTestId('scene-canvas').hover(); + for (let notch = 0; notch < 10; notch++) { + await page.mouse.wheel(0, -400); + } + await expect(scale).not.toHaveAttribute('aria-label', opening ?? '', { timeout: 15_000 }); + }); }); 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 77bc750..0b1631d 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -28,6 +28,7 @@ import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane'; import { MilkyWayRenderer } from './milky-way-renderer'; import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing'; import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity'; +import { distanceRings, formatRoundLength, scaleBar, type LengthUnit, type ScaleBar } from '../../shared/format/scale-bar'; import { BodyDetailViewModel } from '../body-detail/body-detail.model'; import { buildBodyViewModel, luminosityOf } from '../body-detail/body-view-model'; import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent, HudReadout } from '../hud/hud-dock.component'; @@ -145,9 +146,21 @@ const GALAXY_APPROACH_DISTANCE_PC = 0.05; const GALACTIC_NEAR_PC = 5; const GALACTIC_FAR_PC = 250000; -/** Rings for the local grid (parsecs from the Sun), with the catalogue's edge called out. */ -const LOCAL_GRID_RINGS_PC = [50, 100, 150, 200, 250]; +/** + * The radius Gaia is surveyed to, which the local grid calls out: inside it the catalogue holds + * every star Gaia measured to G < 12, and past it only the Hipparcos stars Gaia places there. + */ +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. + */ +const LOCAL_GRID_RING_COUNT = 5; const LOCAL_GRID_SPOKES = 12; +/** How far across the view the scale bar may run, in CSS pixels. */ +const SCALE_BAR_MAX_PX = 120; /** Rings for the galactic grid (parsecs from the centre), with the Sun's orbit called out. */ const GALACTIC_GRID_RINGS_PC = [2500, 5000, SUN_GALACTOCENTRIC_RADIUS_PC, 11000, 14000]; const GALACTIC_GRID_SPOKES = 24; @@ -224,7 +237,7 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector - + @if (objectCard(); as card) { } @@ -279,6 +292,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { readonly hudReadouts = signal([]); readonly hudNote = signal(''); readonly hudRange = signal(''); + readonly hudScale = signal(null); /** Which layers are drawn, as toggled from the dock. Applied by `applyDisplay`. */ readonly display = signal(DEFAULT_HUD_DISPLAY); @@ -346,6 +360,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { private galacticLabels: readonly LabeledPoint[] = []; private galacticGrid?: PolarGridPlane; private localGrid?: PolarGridPlane; + /** The rings `localGrid` was built with, so it is rebuilt only when they change. */ + private localGridRadii: readonly number[] = []; private tethers?: TetherField; /** Strength of the Galaxy-model crossfade, 0 (local view) to 1 (galactic view). */ private galacticStrength = 0; @@ -529,11 +545,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { centre: new THREE.Vector3(centre.x, centre.y, centre.z), emphasisRadii: [SUN_GALACTOCENTRIC_RADIUS_PC] }); - this.localGrid = new PolarGridPlane({ - ringRadii: LOCAL_GRID_RINGS_PC, - spokeCount: LOCAL_GRID_SPOKES, - emphasisRadii: [LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]] - }); + this.setLocalGridRadii(distanceRings(GALAXY_OVERVIEW_POSITION.length(), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC)); // A fixed set rather than whatever is currently labelled: a tether that appears and vanishes // as the camera drifts reads as a glitch. this.tethers = new TetherField(TETHERED_STAR_COUNT); @@ -544,7 +556,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { .map((star) => new THREE.Vector3(star.x, star.y, star.z)), LOCAL_PLANE_HEIGHT_PC ); - this.galaxyGroup.add(this.milkyWay.object, this.galacticGrid.object, this.localGrid.object, this.tethers.object); + this.galaxyGroup.add(this.milkyWay.object, this.galacticGrid.object, this.tethers.object); if (deepSky.length > 0) { this.deepSky = new DeepSkyRenderer(deepSky); @@ -687,7 +699,66 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { } } + /** Swaps the local grid for one with these rings, carrying its current strength across. */ + private setLocalGridRadii(radii: readonly number[]): void { + this.localGrid?.dispose(); + this.localGridRadii = radii; + this.localGrid = new PolarGridPlane({ ringRadii: radii, spokeCount: LOCAL_GRID_SPOKES, emphasisRadii: [SURVEY_EDGE_PC] }); + this.localGrid.setStrength(this.display().grid ? 1 - this.galacticStrength : 0); + this.galaxyGroup.add(this.localGrid.object); + } + + /** + * One label per ring of the local grid, naming its distance from the Sun. Each sits on the side + * of its ring facing what the view is centred on, so the ring running under the stars being + * looked at is the one named; a label pinned to one bearing is off screen most of the time. + */ + private ringLabels(camera: SceneCamera): LabeledPoint[] { + const normal = galacticNormal(); + const onPlane = (point: THREE.Vector3) => point.clone().addScaledVector(normal, -point.dot(normal)); + const target = this.controls?.target ?? GALAXY_OVERVIEW_TARGET; + // Toward what the view is centred on, when that is out among the rings. Otherwise across the + // far side of the grid, the part of it in front of the eye (the near side is under the + // camera and out of frame), or toward the top of the screen for a camera looking straight down. + let bearing = onPlane(target); + if (bearing.length() < (this.localGridRadii[0] ?? 0)) { + bearing = onPlane(target.clone().sub(camera.position)); + } + if (bearing.lengthSq() < 1e-12) { + bearing = onPlane(new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion)); + } + if (bearing.lengthSq() < 1e-12) { + return []; + } + bearing.normalize(); + return this.localGridRadii.map( + (radius): LabeledPoint => ({ + id: `ring-${radius}`, + name: formatRoundLength(radius, 'pc'), + ...(radius === SURVEY_EDGE_PC ? { kind: 'Survey edge' } : {}), + tone: 'ghost', + x: bearing.x * radius, + y: bearing.y * radius, + z: bearing.z * radius + }) + ); + } + + /** The scale bar for the current zoom, measured at the depth the view is centred on. */ + private scaleBarFor(camera: SceneCamera, unit: LengthUnit): ScaleBar | null { + const heightPx = this.canvasRef().nativeElement.clientHeight; + if (heightPx === 0) { + return null; + } + const halfHeight = this.engine.visibleHalfHeight(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)); + return scaleBar((2 * halfHeight) / heightPx, SCALE_BAR_MAX_PX, unit); + } + 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 @@ -735,7 +806,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { selectedId ); const backdropLabels = isGalactic ? this.galacticLabels : this.deepSkyLabels; - this.labelOverlay?.update([...starLabels, ...backdropLabels]); + const ringLabels = isGalactic || !this.display().grid ? [] : this.ringLabels(camera); + this.labelOverlay?.update([...starLabels, ...ringLabels, ...backdropLabels]); } /** @@ -1163,10 +1235,12 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { ]); this.hudNote.set('Orbits propagated from published elements to the current date.'); this.hudRange.set(formatAu(this.engine.visibleHalfHeight(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)) / Math.tan((this.engine.getPerspectiveCamera().fov * Math.PI) / 360))); + this.hudScale.set(this.scaleBarFor(camera, 'AU')); return; } this.hudRange.set(formatParsecs(this.effectiveDistance(camera))); + this.hudScale.set(this.scaleBarFor(camera, 'pc')); if (this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) { this.hudEyebrow.set('Galactic Scale'); @@ -1191,7 +1265,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { { label: 'Stars', value: this.starField && this.starField.drawnCount < this.stars.length ? `${this.starField.drawnCount} / ${this.stars.length}` : `${this.stars.length}` }, // The radius Gaia is surveyed to, not the edge of the map: the Hipparcos stars Gaia places // further out are drawn where it places them. - { label: 'Survey radius', value: `${LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]} pc` }, + { label: 'Survey radius', value: `${SURVEY_EDGE_PC} pc` }, { label: 'Exoplanets', value: `${this.exoplanets.length}` }, // The one thing the field itself cannot show: which of those points can be flown into. { label: 'Systems', value: `${this.enterableSystems}` } 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 f77dad1..9c676c9 100644 --- a/src/app/features/galaxy-system/starmap-hud.component.spec.ts +++ b/src/app/features/galaxy-system/starmap-hud.component.spec.ts @@ -79,4 +79,17 @@ describe('StarmapHudComponent', () => { // An empty nameplate is worse than none: it reads as a selection that failed to resolve. expect(render('galaxy').querySelector('[data-testid="hud-banner"]')).toBeNull(); }); + + it('shows the scale it is given, as a bar of that width', () => { + fixture.componentRef.setInput('scale', { label: '10 pc', widthPx: 100 }); + const bar = render('galaxy').querySelector('[data-testid="hud-scale"]'); + + expect(bar?.getAttribute('aria-label')).toBe('Scale: 10 pc'); + expect(bar?.textContent?.trim()).toBe('10 pc'); + expect(bar?.querySelector('span:last-child')?.style.width).toBe('100px'); + }); + + it('shows no scale bar while there is no scale to show', () => { + expect(render('galaxy').querySelector('[data-testid="hud-scale"]')).toBeNull(); + }); }); diff --git a/src/app/features/galaxy-system/starmap-hud.component.ts b/src/app/features/galaxy-system/starmap-hud.component.ts index 5334719..6b02f09 100644 --- a/src/app/features/galaxy-system/starmap-hud.component.ts +++ b/src/app/features/galaxy-system/starmap-hud.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; +import { ScaleBar } from '../../shared/format/scale-bar'; import { ViewLevel } from '../../shared/state/navigation.store'; import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component'; @@ -20,9 +21,9 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [ ]; /** - * The top of the map's heads-up display: the scale ladder on the left, the nameplate across - * the centre, and a centre reticle on whatever the camera is holding. Readouts and tools live - * in the dock along the bottom (`HudDockComponent`). + * The top of the map's heads-up display: the scale ladder on the left with the scale bar under + * it, the nameplate across the centre, and a centre reticle on whatever the camera is holding. + * Readouts and tools live in the dock along the bottom (`HudDockComponent`). * * Purely presentational — every value arrives as an input and the only thing it emits is a * request to move to another scale. The scene owns the camera and decides what that means. @@ -68,6 +69,15 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [ } + @if (scale(); as bar) { + + + } + @if (title()) {