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:
2026-09-11 19:26:39 +02:00
co-authored by Claude Opus 5
parent 4eb61ff58e
commit 43b9b1f081
6 changed files with 289 additions and 14 deletions
+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)));
}