The rings are centred on the Sun and the frame need not be. Sizing their step from how far the frame reaches — 210 pc for a star at 190 with the camera 20 pc back — gives 20 pc rings at 180 and 200, both outside a frame 19 pc deep, so a view away from the Sun still had no ring on it and no ladder of labels either. `distanceRings` now takes the span the frame covers rather than its far edge, and the step is a fifth of that: 5 pc rings from 165 to 210 for the same view. Measured in the app, centred on a star 187 pc out in the galactic plane: 4 ring labels drawn 20 pc above the plane and 7 from 2 pc, against 1 and none before. The clearance was a radius around the anchor, and a label is a line of text hanging 135 px to one side of its anchor: at 0.065 NDC apart, past the radius, "50 pc" printed inside "Alpha Centauri". It is now tested against the span the name occupies, on the side it hangs, with the radius kept for the pair whose text runs the other way. Also from the review: the ladder in the clearance test was built at exactly the constant it tests, so 1057 of 2000 camera poses would have decided it by float round-trip error — the rungs now sit 0.02 either side of the rule. And two comments that were wrong: a frame one step short of the survey edge does get its callout, and CSS2DRenderer hides a label behind the camera rather than drawing it at the page edge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
85 lines
3.7 KiB
TypeScript
85 lines
3.7 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 short of it by less than one step still gets it, since the last ring
|
||
* overshoots `reach`; one that stops well short does not.
|
||
*
|
||
* 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 makes for `count` to `ceil(2.5 × count)` rings, and the callout can add
|
||
* one: 5 to 14 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)));
|
||
}
|