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
87 lines
3.9 KiB
TypeScript
87 lines
3.9 KiB
TypeScript
/**
|
||
* 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)));
|
||
}
|