Give the map a scale bar, and rings that say how far from the Sun
The map had one way to read a distance: the Range readout, a number for how far back the camera is. The local grid's five rings sat at 50 to 250 pc, fixed and unlabelled. They said nothing from inside a 2 pc hop, and nothing past 250 pc now that the Hipparcos stars Gaia places there are drawn. A scale bar now sits under the scale rail. It shows the longest round length (1, 2 or 5 x 10^n) that fits in 120 px, in AU inside a system and in parsecs or kiloparsecs outside. It is measured at the depth the view is centred on, since under perspective every depth has its own scale; under the plan view it is exact everywhere. The local grid's rings are now distances from the Sun, at a round step of about a fifth of the camera's distance and out past the camera: 50 to 350 pc from the opening view, 2 to 20 pc from twenty parsecs out. Each ring is labelled with its distance, on the side facing what the view is centred on, or across the far side of the grid when that is the Sun (the near side is under the camera and out of frame). The survey edge at 250 pc stays called out, as "Survey edge", whatever the step. The rounding lives in one place, scale-bar.ts, shared by the bar and the rings and tested there. Its formatter keeps three significant digits: one digit, enough for the bar's round lengths, printed the 250 pc ring as "300 pc". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -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>();
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
Reference in New Issue
Block a user