The route search widened evenly from the departure, Dijkstra-style, with a budget of 20 000 stars. On the Gaia catalogue those are all within about 40 pc of the Sun, so it found no route to anything farther at any range: Sol to Mirfak (155 pc) failed at 3, 8, 15 and 30 pc alike. Every failure then asked minimumRangeBetween what range would work. That search widened the same way with a 30 pc ceiling, and it ran for up to a minute on the main thread before giving up with nothing. routeBetween is now an A* search. Each star is queued by the distance travelled to it plus the straight line on to the destination, on a binary heap rather than a linear scan of the frontier. It heads for the destination instead of flooding the core around the departure. minimumRangeBetween bisects the range, one routeBetween per step, because whether a chain exists can only become truer as the range grows. Its answer is always the longest hop of a route actually found, so a range it names always opens one. Its ceiling is now the Routes panel's own maximum, MAX_JUMP_RANGE_PC: a range the control cannot be set to is no answer, and raiseTo already clamped any figure above it. The spatial index keys its cells by one number packed from their three indices instead of an "ix,iy,iz" string. A search visits up to 125 cells for every star it expands, and building those strings was half of what a route cost. forEachWithin hands neighbours over unsorted and uncollected, which was most of the other half; within is now that, gathered and sorted. The no-route line said nothing in the catalogue bridged the gap; it now says no chain of jumps up to the panel's maximum reaches the star, which is what was searched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
164 lines
6.2 KiB
TypeScript
164 lines
6.2 KiB
TypeScript
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('puts the stars it is told to prefer first, and fills with the rest only when short', () => {
|
|
const index = new StarNeighbourhood(line(10));
|
|
const even = (point: StarPoint) => point.id % 2 === 0;
|
|
|
|
// Enough even stars: the odd ones next door, though nearer, do not get a look in.
|
|
expect(ids(index.nearestPreferring(4, 2, even))).toEqual([2, 6]);
|
|
// Not enough: every even star in reach, then the nearest of the others.
|
|
expect(ids(index.nearestPreferring(4, 6, even))).toEqual([2, 6, 0, 8, 3, 5]);
|
|
});
|
|
|
|
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]);
|
|
});
|
|
|
|
/** 400 stars scattered 20 pc either side of the origin on every axis, so cells on both sides of zero. */
|
|
function cloud(): StarPoint[] {
|
|
let seed = 3;
|
|
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 40 - 20;
|
|
return Array.from({ length: 400 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
|
|
}
|
|
|
|
it('visits every star within a radius and no other', () => {
|
|
const points = cloud();
|
|
const origin = points[0];
|
|
const expected = points
|
|
.filter((point) => point.id !== origin.id && Math.hypot(point.x - origin.x, point.y - origin.y, point.z - origin.z) <= 7)
|
|
.map((point) => point.id)
|
|
.sort((a, b) => a - b);
|
|
|
|
const visited: number[] = [];
|
|
new StarNeighbourhood(points).forEachWithin(origin.id, 7, (neighbour) => visited.push(neighbour.id));
|
|
|
|
expect(visited.sort((a, b) => a - b)).toEqual(expected);
|
|
});
|
|
|
|
// The pair walk reads each cell's indices back out of its key; read wrong, it quietly drops
|
|
// pairs instead of failing.
|
|
it('walks every pair within a radius exactly once', () => {
|
|
const points = cloud();
|
|
let expected = 0;
|
|
for (let i = 0; i < points.length; i++) {
|
|
for (let j = i + 1; j < points.length; j++) {
|
|
if (Math.hypot(points[j].x - points[i].x, points[j].y - points[i].y, points[j].z - points[i].z) <= 5) {
|
|
expected++;
|
|
}
|
|
}
|
|
}
|
|
|
|
const walked = new Set<string>();
|
|
let visits = 0;
|
|
new StarNeighbourhood(points).forEachPairWithin(5, (a, b) => {
|
|
visits++;
|
|
walked.add(a.id < b.id ? `${a.id}-${b.id}` : `${b.id}-${a.id}`);
|
|
});
|
|
|
|
expect(visits).toBe(expected);
|
|
expect(walked.size).toBe(expected);
|
|
});
|
|
});
|