Name the neighbours, from inside the system

A system view could say everything about the star it was inside and nothing
about where that star was. The four nearest catalogue stars are now named
around the edge of it, each with its distance, each a button that flies there
— so a chain of neighbours can be walked without pulling back out to the field
between hops.

These are bearings, not sky positions, and that is the one deliberate
compromise here. A true direction was tried first and does not work: at this
field of view the visible cone is about 30 degrees, so on average one
neighbour in fifteen falls inside the frame — measured, not guessed, at one
label of four in Sol and none at all after a small orbit. What survives the
ring is the half of the direction a viewer can act on, which way to turn to
face it, and the ring reads as instrument rather than as scene because it sits
at a fixed radius. Real distance was never an option: Proxima is 268 000 AU
from Sol, thirteen far planes out, so the distance goes on the type line.

Proximity is answered by a new pure module rather than by a scan. A uniform
grid over the catalogue answers both "the k nearest to this star" and "every
star within n parsecs", the second being what the jump-link graph in the next
PR is built from — one scan per node, and the quadratic would show. Its spec
pins the grid against a brute-force sweep of a pseudo-random cloud, because a
spatial index is an optimisation and never a different answer.

Where the ring meets the HUD, the HUD wins: placement is given the boxes the
readout, the strip and the object card occupy, and slides a name along the
ring until it clears them, or drops it rather than print it half hidden. That
rule is a pure function with its own spec.

Four defects found while verifying this, three of them older than it:

The dock's flex column was pointer-events-auto and as wide as its strip, so
an invisible band above the strip swallowed every click in it — including,
but not only, a neighbour's. The column is transparent now and each surface
opts back in.

The ring was sized against the frame's height alone, which on a phone held
upright put it a viewport and a half wide: no neighbour was reachable on any
portrait screen. It is sized against the shorter side.

Picking a search result reopened the readout, which on a narrow viewport is a
sheet over most of the scene — reopening it onto whatever was just flown to.
Below sm it now folds away.

A selectable label's two lines are adjacent spans, so it announced as
"Sirius2.64 pc"; it carries an explicit label saying what it does.

Verified: build clean, 558/558 unit, 7/7 end-to-end including a new spec that
flies Sol to Barnard's Star by its label, design detector clean, screenshots
at 1440x900 and 390x844 in Sol and Proxima Centauri, and the keyboard path
walked: both names are in the tab order, focusable, with the accent ring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-08-20 17:06:25 +02:00
co-authored by Claude Fable 5
parent 7591bcc0ea
commit 44c6a1f15e
12 changed files with 822 additions and 22 deletions
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
/** A line of stars one parsec apart along x, so every expected distance is an integer. */
function line(count: number): StarPoint[] {
return Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
}
function ids(found: { id: number }[]): number[] {
return found.map((neighbour) => neighbour.id);
}
describe('StarNeighbourhood', () => {
it('names the nearest stars in order, and never the star itself', () => {
const index = new StarNeighbourhood(line(10));
expect(ids(index.nearest(4, 3))).toEqual([3, 5, 2]);
});
it('measures the separation it found each star by', () => {
const index = new StarNeighbourhood([
{ id: 1, x: 0, y: 0, z: 0 },
{ id: 2, x: 3, y: 4, z: 0 }
]);
expect(index.nearest(1, 1)[0].distancePc).toBeCloseTo(5);
});
it('reaches past its own cell for a star sitting alone in one', () => {
// 5 pc cells: these three are in three different cells, and the nearest is 12 pc out.
const index = new StarNeighbourhood([
{ id: 1, x: 0, y: 0, z: 0 },
{ id: 2, x: 12, y: 0, z: 0 },
{ id: 3, x: 40, y: 0, z: 0 }
]);
expect(ids(index.nearest(1, 2))).toEqual([2, 3]);
});
it('does not stop at the first ring that fills the list, where the next holds something closer', () => {
// The diagonal neighbour is in the ring-1 shell but 8.7 pc away; the one straight along x is
// in the ring-2 shell and only 6 pc away. Stopping at the first full ring would miss it.
const index = new StarNeighbourhood([
{ id: 1, x: 0, y: 0, z: 0 },
{ id: 2, x: 5, y: 5, z: 5 },
{ id: 3, x: 6, y: 0, z: 0 }
]);
expect(ids(index.nearest(1, 1))).toEqual([3]);
});
it('agrees with a brute-force scan over a pseudo-random cloud', () => {
// The property that matters: the grid is an optimisation, never a different answer.
let seed = 7;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 100 - 50;
const cloud: StarPoint[] = Array.from({ length: 400 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
const index = new StarNeighbourhood(cloud);
for (const origin of [cloud[0], cloud[199], cloud[399]]) {
const brute = cloud
.filter((point) => point.id !== origin.id)
.map((point) => ({ id: point.id, distancePc: Math.hypot(point.x - origin.x, point.y - origin.y, point.z - origin.z) }))
.sort((a, b) => a.distancePc - b.distancePc);
expect(ids(index.nearest(origin.id, 5))).toEqual(ids(brute.slice(0, 5)));
expect(ids(index.within(origin.id, 20))).toEqual(ids(brute.filter((neighbour) => neighbour.distancePc <= 20)));
}
});
it('takes only the stars a filter accepts', () => {
const index = new StarNeighbourhood(line(10));
expect(ids(index.nearest(4, 2, (point) => point.id % 2 === 0))).toEqual([2, 6]);
});
it('answers nothing for a star it has never heard of', () => {
const index = new StarNeighbourhood(line(3));
expect(index.nearest(99, 3)).toEqual([]);
expect(index.within(99, 10)).toEqual([]);
expect(index.point(99)).toBeUndefined();
});
it('asks for nothing and gets nothing', () => {
const index = new StarNeighbourhood(line(5));
expect(index.nearest(0, 0)).toEqual([]);
expect(index.within(0, 0)).toEqual([]);
});
it('finds every star inside a radius and none on the far side of it', () => {
const index = new StarNeighbourhood(line(20));
expect(ids(index.within(10, 2.5))).toEqual([9, 11, 8, 12]);
});
it('holds stars that share a position without losing either', () => {
// Real catalogue rows do this: Gl 65 A and B are one binary, two entries, one position.
const index = new StarNeighbourhood([
{ id: 1, x: 0, y: 0, z: 0 },
{ id: 2, x: 2.63, y: 0, z: 0 },
{ id: 3, x: 2.63, y: 0, z: 0 }
]);
expect(ids(index.nearest(1, 2)).sort()).toEqual([2, 3]);
});
});
+178
View File
@@ -0,0 +1,178 @@
/**
* Which stars are near which, over the whole catalogue.
*
* Two questions are asked of the same catalogue and answered here once: "what are the k nearest
* stars to this one" (the neighbour labels shown from inside a system) and "which pairs lie
* within n parsecs of each other" (the jump-link graph). A linear scan answers the first
* acceptably — 68 000 distance tests, once, on entering a system — and the second not at all: a
* graph over a few thousand nodes is a few thousand scans, and the quadratic shows.
*
* So both run on a uniform grid keyed by cell coordinates. The catalogue is a dense blob around
* the Sun thinning out to 250 pc, which is exactly the distribution a uniform grid handles
* badly in the dense middle and well everywhere else — but the queries are all small radii in
* that same dense middle, where a cell holds a handful of stars, so the cost lands where the
* answers are. A KD-tree would be tighter and is not yet worth its code.
*/
/** A catalogued star reduced to what proximity needs: an id and a position in parsecs. */
export interface StarPoint {
readonly id: number;
readonly x: number;
readonly y: number;
readonly z: number;
}
/** A star found near another, with the separation that found it. */
export interface Neighbour {
readonly id: number;
readonly distancePc: number;
}
/**
* Cell edge in parsecs. Sized so a cell in the crowded inner catalogue holds a few dozen stars:
* small enough that a 5 pc query touches a handful of cells, large enough that a 250 pc
* catalogue does not allocate a map with a million keys.
*/
const DEFAULT_CELL_SIZE_PC = 5;
/** Grows the search a shell of cells at a time; the cap stops a query in empty space forever. */
const MAX_RING = 12;
function cellKey(ix: number, iy: number, iz: number): string {
return `${ix},${iy},${iz}`;
}
export class StarNeighbourhood {
private readonly cells = new Map<string, number[]>();
private readonly points: readonly StarPoint[];
private readonly indexById = new Map<number, number>();
private readonly cellSizePc: number;
constructor(points: readonly StarPoint[], cellSizePc: number = DEFAULT_CELL_SIZE_PC) {
this.points = points;
this.cellSizePc = cellSizePc > 0 ? cellSizePc : DEFAULT_CELL_SIZE_PC;
points.forEach((point, index) => {
this.indexById.set(point.id, index);
const key = this.keyFor(point.x, point.y, point.z);
const cell = this.cells.get(key);
if (cell) {
cell.push(index);
} else {
this.cells.set(key, [index]);
}
});
}
/** The star this id names, or `undefined` — the caller's id may not be in the catalogue. */
point(id: number): StarPoint | undefined {
const index = this.indexById.get(id);
return index === undefined ? undefined : this.points[index];
}
/**
* The `count` stars nearest to `id`, nearest first, excluding the star itself.
*
* Searches outward a shell of cells at a time and stops only once the shell it just finished
* lies further away than the furthest result held — the ring that contains the kth star can
* still be beaten by a closer star in the next ring out, since a cell's near corner is nearer
* than its centre.
*/
nearest(id: number, count: number, filter?: (point: StarPoint) => boolean): Neighbour[] {
const origin = this.point(id);
if (!origin || count <= 0) {
return [];
}
const found: Neighbour[] = [];
const [ox, oy, oz] = this.cellFor(origin.x, origin.y, origin.z);
for (let ring = 0; ring <= MAX_RING; ring++) {
// Everything in this ring is at least this far away, so once the results already held are
// all closer than that, no further ring can improve them.
if (found.length >= count && (ring - 1) * this.cellSizePc > found[found.length - 1].distancePc) {
break;
}
for (const index of this.ringIndices(ox, oy, oz, ring)) {
const candidate = this.points[index];
if (candidate.id === id || (filter && !filter(candidate))) {
continue;
}
const distancePc = Math.hypot(candidate.x - origin.x, candidate.y - origin.y, candidate.z - origin.z);
if (found.length >= count && distancePc >= found[found.length - 1].distancePc) {
continue;
}
// Insertion sort into a list that is never longer than `count`: cheaper than sorting
// every candidate the rings turn up, of which there are far more than are kept.
const at = found.findIndex((other) => distancePc < other.distancePc);
found.splice(at === -1 ? found.length : at, 0, { id: candidate.id, distancePc });
if (found.length > count) {
found.pop();
}
}
}
return found;
}
/**
* Every star within `radiusPc` of `id`, nearest first, excluding the star itself. This is what
* a jump-link graph is built from: one call per node gives that node's edges.
*/
within(id: number, radiusPc: number): Neighbour[] {
const origin = this.point(id);
if (!origin || radiusPc <= 0) {
return [];
}
const found: Neighbour[] = [];
const [ox, oy, oz] = this.cellFor(origin.x, origin.y, origin.z);
const reach = Math.ceil(radiusPc / this.cellSizePc);
for (let ix = ox - reach; ix <= ox + reach; ix++) {
for (let iy = oy - reach; iy <= oy + reach; iy++) {
for (let iz = oz - reach; iz <= oz + reach; iz++) {
for (const index of this.cells.get(cellKey(ix, iy, iz)) ?? []) {
const candidate = this.points[index];
if (candidate.id === id) {
continue;
}
const distancePc = Math.hypot(candidate.x - origin.x, candidate.y - origin.y, candidate.z - origin.z);
if (distancePc <= radiusPc) {
found.push({ id: candidate.id, distancePc });
}
}
}
}
}
found.sort((a, b) => a.distancePc - b.distancePc);
return found;
}
private keyFor(x: number, y: number, z: number): string {
const [ix, iy, iz] = this.cellFor(x, y, z);
return cellKey(ix, iy, iz);
}
private cellFor(x: number, y: number, z: number): [number, number, number] {
return [Math.floor(x / this.cellSizePc), Math.floor(y / this.cellSizePc), Math.floor(z / this.cellSizePc)];
}
/** Indices in the hollow shell of cells exactly `ring` cells out from the centre one. */
private *ringIndices(ox: number, oy: number, oz: number, ring: number): Generator<number> {
for (let ix = ox - ring; ix <= ox + ring; ix++) {
for (let iy = oy - ring; iy <= oy + ring; iy++) {
for (let iz = oz - ring; iz <= oz + ring; iz++) {
// Only the shell: everything inside it was searched by a previous, smaller ring.
const onShell = Math.abs(ix - ox) === ring || Math.abs(iy - oy) === ring || Math.abs(iz - oz) === ring;
if (!onShell) {
continue;
}
yield* this.cells.get(cellKey(ix, iy, iz)) ?? [];
}
}
}
}
}