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:
@@ -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