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) {
+
+