Merge pull request #19 from avalon-vanguard/star-map/feat/scale-tools

Give the map a scale bar, and rings that say how far from the Sun

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
Senrokai
2026-09-17 15:05:01 +02:00
committed by GitHub
co-authored by Claude Opus 5
6 changed files with 289 additions and 14 deletions
+19
View File
@@ -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 });
});
});
@@ -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
<svg aria-hidden="true" class="pointer-events-none absolute inset-0 h-full w-full text-accent/60">
<line #leader x1="0" y1="0" x2="0" y2="0" stroke="currentColor" stroke-width="1" visibility="hidden" />
</svg>
<app-starmap-hud [level]="navigationStore.viewLevel()" [title]="hudTitle()" (levelSelected)="goToLevel($event)" />
<app-starmap-hud [level]="navigationStore.viewLevel()" [title]="hudTitle()" [scale]="hudScale()" (levelSelected)="goToLevel($event)" />
@if (objectCard(); as card) {
<app-system-object-card [body]="card" (dismissed)="dismissObjectCard()" (openRequested)="openObjectDetail(card.id)" />
}
@@ -279,6 +292,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
readonly hudReadouts = signal<readonly HudReadout[]>([]);
readonly hudNote = signal('');
readonly hudRange = signal('');
readonly hudScale = signal<ScaleBar | null>(null);
/** Which layers are drawn, as toggled from the dock. Applied by `applyDisplay`. */
readonly display = signal<HudDisplay>(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}` }
@@ -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<HTMLElement>('[data-testid="hud-scale"]');
expect(bar?.getAttribute('aria-label')).toBe('Scale: 10 pc');
expect(bar?.textContent?.trim()).toBe('10 pc');
expect(bar?.querySelector<HTMLElement>('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();
});
});
@@ -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 }[] = [
}
</nav>
@if (scale(); as bar) {
<!-- The map's scale bar, under the rail that names the scale: a round length, measured at the
depth the view is centred on, since under perspective every depth has its own. -->
<div data-testid="hud-scale" role="img" [attr.aria-label]="'Scale: ' + bar.label" class="absolute top-16 left-6 flex flex-col items-start gap-1">
<span class="type-label text-muted tabular-nums">{{ bar.label }}</span>
<span class="block h-1.5 border-x border-b border-accent/70" [style.width.px]="bar.widthPx"></span>
</div>
}
@if (title()) {
<!-- Hidden below lg: the readout panel names the same thing, and at narrower widths a
long star name runs into the scale rail on its left and under the object card on its
@@ -86,6 +96,8 @@ export class StarmapHudComponent {
/** What the view is holding, for the nameplate — the selected star, or nothing. */
readonly title = input('');
readonly showReticle = input(true);
/** The scale bar for the current zoom, worked out by the scene, which knows the camera. */
readonly scale = input<ScaleBar | null>(null);
readonly levelSelected = output<ViewLevel>();
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { distanceRings, formatRoundLength, roundLengthAtMost, scaleBar } from './scale-bar';
describe('roundLengthAtMost', () => {
it('rounds down to 1, 2 or 5 times a power of ten', () => {
expect(roundLengthAtMost(51)).toBe(50);
expect(roundLengthAtMost(3.3)).toBe(2);
expect(roundLengthAtMost(0.7)).toBe(0.5);
expect(roundLengthAtMost(1999)).toBe(1000);
});
it('keeps a length that is already round, including at a decade', () => {
expect(roundLengthAtMost(1000)).toBe(1000);
expect(roundLengthAtMost(100)).toBe(100);
expect(roundLengthAtMost(5)).toBe(5);
expect(roundLengthAtMost(0.2)).toBe(0.2);
});
it('has no length for nothing', () => {
for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(roundLengthAtMost(value)).toBeNull();
}
});
});
describe('distanceRings', () => {
// The opening view sits about 307 pc from the Sun: the rings the map always had, with the
// survey edge at the fifth, and on out past the camera for the stars now drawn beyond it.
it('reaches past the camera from the opening view', () => {
expect(distanceRings(307, 5, 250)).toEqual([50, 100, 150, 200, 250, 300, 350]);
});
it('closes in with the camera', () => {
expect(distanceRings(20, 5, 250)).toEqual([2, 4, 6, 8, 10, 12, 14, 16, 18, 20]);
expect(distanceRings(1, 5, 250)).toEqual([0.2, 0.4, 0.6, 0.8, 1]);
});
// Near Mirfak the camera is 155 pc out; rounding the step down to 20 pc must not leave the
// rings stopping at 100.
it('covers the whole distance whatever the rounding', () => {
expect(distanceRings(155, 5, 250)).toEqual([20, 40, 60, 80, 100, 120, 140, 160]);
});
it('marks the callout among rings the step does not land on', () => {
expect(distanceRings(1000, 5, 250)).toEqual([200, 250, 400, 600, 800, 1000]);
});
it('leaves the callout out when it is past the last ring', () => {
expect(distanceRings(100, 5, 250)).toEqual([20, 40, 60, 80, 100]);
});
it('draws no rings for a camera with no distance', () => {
expect(distanceRings(0, 5, 250)).toEqual([]);
});
});
describe('scaleBar', () => {
it('picks the longest round length that fits, and the width it spans', () => {
// A tenth of a parsec a pixel and 120 px of room: 12 pc would fit, and the round length
// under it is 10 pc, which spans 100 px.
expect(scaleBar(0.1, 120, 'pc')).toEqual({ label: '10 pc', widthPx: 100 });
});
it('draws nothing for a view with no extent', () => {
expect(scaleBar(0, 120, 'pc')).toBeNull();
});
});
describe('formatRoundLength', () => {
it('reads without trailing zeros, in kiloparsecs past a thousand', () => {
expect(formatRoundLength(2000, 'pc')).toBe('2 kpc');
expect(formatRoundLength(500, 'pc')).toBe('500 pc');
expect(formatRoundLength(0.2, 'pc')).toBe('0.2 pc');
expect(formatRoundLength(0.05, 'AU')).toBe('0.05 AU');
});
// Ring radii are multiples of a round step rather than round themselves; a one-digit format
// printed the 250 pc survey edge as "300 pc".
it('keeps every digit of a ring radius', () => {
expect(formatRoundLength(250, 'pc')).toBe('250 pc');
expect(formatRoundLength(150, 'pc')).toBe('150 pc');
expect(formatRoundLength(2500, 'pc')).toBe('2.5 kpc');
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* The round lengths a map is read against: its scale bar, and the spacing of its distance rings.
*
* Round means 1, 2 or 5 times a power of ten — the only lengths a reader can add up at a glance,
* which is why every printed map's scale bar uses them.
*/
/** The largest 1, 2 or 5 × a power of ten that is at most `value`, or `null` for no length at all. */
export function roundLengthAtMost(value: number): number | null {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
const power = 10 ** Math.floor(Math.log10(value));
const mantissa = value / power;
return (mantissa >= 5 ? 5 : mantissa >= 2 ? 2 : 1) * power;
}
/**
* 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`.
*/
export function distanceRings(reach: number, count: number, callout: number): number[] {
const step = roundLengthAtMost(reach / count);
if (step === null) {
return [];
}
// `toPrecision` clears the binary noise of stepping by a tenth: 0.1 × 3 is 0.30000000000000004.
const radii = Array.from({ length: Math.ceil(reach / step) }, (_, index) => Number((step * (index + 1)).toPrecision(12)));
if (callout > step && callout < radii[radii.length - 1] && !radii.includes(callout)) {
radii.push(callout);
radii.sort((a, b) => a - b);
}
return radii;
}
export type LengthUnit = 'pc' | 'AU';
/** A round length, and how many pixels it spans at the current zoom. */
export interface ScaleBar {
readonly label: string;
readonly widthPx: number;
}
/**
* The longest round length that fits in `maxWidthPx` when one pixel spans `unitsPerPx`.
*
* Under a perspective camera a pixel spans a different length at every depth, so the scene
* measures `unitsPerPx` at the point the view is centred on, which is where the map is being
* read. Under the plan view it is exact everywhere.
*/
export function scaleBar(unitsPerPx: number, maxWidthPx: number, unit: LengthUnit): ScaleBar | null {
const length = roundLengthAtMost(unitsPerPx * maxWidthPx);
if (length === null) {
return null;
}
return { label: formatRoundLength(length, unit), widthPx: length / unitsPerPx };
}
/** A scale or ring length, in kiloparsecs past a thousand parsecs. */
export function formatRoundLength(length: number, unit: LengthUnit): string {
if (unit === 'pc' && length >= 1000) {
return `${digitsOf(length / 1000)} kpc`;
}
return `${digitsOf(length)} ${unit}`;
}
/** `0.05`, `2`, `150`, never `2.00`: these lengths have no digits past the ones that carry them. */
function digitsOf(value: number): string {
return String(Number(value.toPrecision(3)));
}