Files
star-map/src/app/shared/format/scale-bar.ts
T
SenrokaiandClaude Opus 5 0c5efec505 Measure the ring span along the plane the rings lie in
The span went to `distanceRings` as the target's straight-line distance from the
Sun, but a ring of radius r passes within |r - p| of the view's centre, where p
is how far out that centre is *along* the galactic plane. For a target above the
plane the two differ by its height, so the band was centred on a radius no ring
has — and `ringLabels` picks its bearing by comparing its own in-plane distance
against the innermost ring, a comparison the new first ring quietly broke.

Two comments and a constant, from the same review. A frame short of the survey
edge gets its callout only when its last ring overshoots it: 245 pc does, 235 pc
does not, which is now a test rather than a sentence. The ring count can reach
16, not 14, now that the span need not start at the Sun. And a ring label was
measured as 135 px of star name when "50 pc" is a third of that, which rejected
rungs a hand's breadth clear of the name: RING_LABEL_REACH_NDC, 0.23, is the
widest of them — "1.5 kpc" with "Survey edge" under it.

Three mutants, three caught. Measured again in the app: unchanged for a star in
the plane (4 labels at 20 pc above it, 7 at 2 pc), and the rings now follow the
plane for one 195 pc above it rather than ringing a place the grid does not
reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 15:57:44 +02:00

87 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 across the span from `nearest` to `reach`, at a round step of about a `count`th of it,
* plus `callout` where it falls between the first ring and the last: 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. A frame that stops short of it gets it only when the last ring — the first multiple
* of `step` at or past `reach` — is past it: reach 245 with a 20 pc step gets it, reach 235 does
* not, since its last ring is 240.
*
* Two numbers rather than one because these rings are centred on a fixed point — the Sun — and a
* frame need not be. Looking at something 200 pc out from 20 pc away, what is on screen is a band
* 200 pc wide at its narrowest and nowhere near the Sun; a step sized to the whole 220 puts every
* ring off the frame. The span is what the frame covers, so the step is what it can resolve.
*
* Rounding the step down, over a span that need not start at the Sun, makes for `count` to
* `ceil(2.5 × count) + 2` rings — `ceil(reach / step) - floor(nearest / step) + 1` — and the
* callout can add one: 5 to 16 for a count of 5.
*/
export function distanceRings(nearest: number, reach: number, count: number, callout: number): number[] {
const step = roundLengthAtMost((reach - nearest) / count);
if (step === null) {
return [];
}
// The ring just inside the near edge of the span, so the band is crossed rather than started at.
const first = Math.max(1, Math.floor(nearest / step));
const last = Math.ceil(reach / step);
// `toPrecision` clears the binary noise of stepping by a tenth: 0.1 × 3 is 0.30000000000000004.
const radii = Array.from({ length: last - first + 1 }, (_, index) => Number((step * (first + index)).toPrecision(12)));
if (callout > radii[0] && 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)));
}