Route with A* over numeric cell keys, so a route can reach past the Sun's crowd
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
This commit is contained in:
@@ -39,6 +39,7 @@ import { SystemObjectCardComponent } from './system-object-card.component';
|
|||||||
import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
|
import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
|
||||||
import { collectJumpLinks, minimumRangeBetween, routeBetween } from '../../shared/astro/jump-links';
|
import { collectJumpLinks, minimumRangeBetween, routeBetween } from '../../shared/astro/jump-links';
|
||||||
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
|
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
|
||||||
|
import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component';
|
||||||
import { HostStarRings } from './host-star-rings';
|
import { HostStarRings } from './host-star-rings';
|
||||||
import { JumpLinkRenderer } from './jump-link-renderer';
|
import { JumpLinkRenderer } from './jump-link-renderer';
|
||||||
import { ReservedBox, ringPlacement } from './label-ring';
|
import { ReservedBox, ringPlacement } from './label-ring';
|
||||||
@@ -83,10 +84,11 @@ const PLAN_ZOOM_SPAN = 64;
|
|||||||
const ROUTE_OPTION_COUNT = 6;
|
const ROUTE_OPTION_COUNT = 6;
|
||||||
const MIN_ROUTE_QUERY_LENGTH = 2;
|
const MIN_ROUTE_QUERY_LENGTH = 2;
|
||||||
/**
|
/**
|
||||||
* The widest crossing `minimumRangeBetween` will consider when saying what a route would need.
|
* The widest crossing `minimumRangeBetween` will consider when saying what a route would need:
|
||||||
* Beyond this the catalogue is one component and the answer stops being informative.
|
* the Routes panel's own maximum, since a range the control cannot be set to is no answer. At
|
||||||
|
* 30 pc, as it was, the search could run for a minute through the dense core before answering.
|
||||||
*/
|
*/
|
||||||
const ROUTE_RANGE_CEILING_PC = 30;
|
const ROUTE_RANGE_CEILING_PC = MAX_JUMP_RANGE_PC;
|
||||||
|
|
||||||
/** How many neighbouring stars are named from inside a system. */
|
/** How many neighbouring stars are named from inside a system. */
|
||||||
const NEIGHBOUR_COUNT = 4;
|
const NEIGHBOUR_COUNT = 4;
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { ChangeDetectionStrategy, Component, computed, input, output, signal } f
|
|||||||
|
|
||||||
import { formatParsecs } from '../../shared/format/quantity';
|
import { formatParsecs } from '../../shared/format/quantity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The widest jump the Routes panel offers. Past it the drawn graph is a solid sheet of lines, and
|
||||||
|
* a route search through the dense core around the Sun walks thousands of stars at every step.
|
||||||
|
*/
|
||||||
|
export const MAX_JUMP_RANGE_PC = 8;
|
||||||
|
|
||||||
/** A star offered for one of the two fields, as the panel needs to show it. */
|
/** A star offered for one of the two fields, as the panel needs to show it. */
|
||||||
export interface RouteStarOption {
|
export interface RouteStarOption {
|
||||||
readonly id: number;
|
readonly id: number;
|
||||||
@@ -117,7 +123,7 @@ type Field = 'from' | 'to';
|
|||||||
{{ format(plotted.neededRangePc) }} would reach.
|
{{ format(plotted.neededRangePc) }} would reach.
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
Nothing in the catalogue bridges the gap.
|
No chain of jumps up to {{ format(maxRangePc) }} reaches it.
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
@@ -160,8 +166,7 @@ export class RoutesPanelComponent {
|
|||||||
readonly fields: readonly Field[] = ['from', 'to'];
|
readonly fields: readonly Field[] = ['from', 'to'];
|
||||||
/** A tenth of a parsec is finer than the catalogue's own distances are known to. */
|
/** A tenth of a parsec is finer than the catalogue's own distances are known to. */
|
||||||
readonly minRangePc = 0.5;
|
readonly minRangePc = 0.5;
|
||||||
/** Beyond this the graph is a solid sheet of lines and every pair of stars is connected. */
|
readonly maxRangePc = MAX_JUMP_RANGE_PC;
|
||||||
readonly maxRangePc = 8;
|
|
||||||
|
|
||||||
readonly rangePc = signal(3);
|
readonly rangePc = signal(3);
|
||||||
readonly open = signal<Field | null>(null);
|
readonly open = signal<Field | null>(null);
|
||||||
|
|||||||
@@ -81,9 +81,40 @@ describe('routeBetween', () => {
|
|||||||
|
|
||||||
expect(route?.longestHopPc).toBeCloseTo(4);
|
expect(route?.longestHopPc).toBeCloseTo(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('heads for the destination rather than exhausting a dense knot around the departure', () => {
|
||||||
|
// The Gaia catalogue in miniature: a crowd around the departure, larger than the search's
|
||||||
|
// budget, with the only way on a thin chain leading out of it. A search widening evenly from
|
||||||
|
// the departure spends the budget on the crowd and never reaches the chain's far end.
|
||||||
|
const route = routeBetween(knotAndChain(), 0, CHAIN_END, 1.5);
|
||||||
|
|
||||||
|
expect(route).not.toBeNull();
|
||||||
|
expect(route!.stars[route!.stars.length - 1]).toBe(CHAIN_END);
|
||||||
|
expect(route!.longestHopPc).toBeLessThanOrEqual(1.5);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 21 000 stars scattered through the 30 pc cube around the origin, about ten times the density
|
||||||
|
* around the real Sun and more than a search's budget, with a chain a parsec a hop running along
|
||||||
|
* x from the origin out through the crowd and on to 75 pc.
|
||||||
|
*/
|
||||||
|
const CHAIN_END = 75;
|
||||||
|
function knotAndChain(): StarNeighbourhood {
|
||||||
|
let seed = 7;
|
||||||
|
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 30 - 15;
|
||||||
|
const knot: StarPoint[] = Array.from({ length: 21000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
|
||||||
|
const chainOut: StarPoint[] = Array.from({ length: CHAIN_END }, (_, i) => ({ id: i + 1, x: i + 1, y: 0, z: 0 }));
|
||||||
|
return index([{ id: 0, x: 0, y: 0, z: 0 }, ...knot, ...chainOut]);
|
||||||
|
}
|
||||||
|
|
||||||
describe('minimumRangeBetween', () => {
|
describe('minimumRangeBetween', () => {
|
||||||
|
it('works out the range past a dense knot around the departure', () => {
|
||||||
|
// Past the crowd the chain's hops of a parsec are the only way on, so a parsec is the
|
||||||
|
// answer, to the half-step the panel rounds up to.
|
||||||
|
expect(minimumRangeBetween(knotAndChain(), 0, CHAIN_END, 8)).toBeCloseTo(1, 1);
|
||||||
|
});
|
||||||
|
|
||||||
it('names the shortest range that opens a way through', () => {
|
it('names the shortest range that opens a way through', () => {
|
||||||
// Hops of 1 and 4: no range under 4 connects them, and 4 exactly does.
|
// Hops of 1 and 4: no range under 4 connects them, and 4 exactly does.
|
||||||
const stepped = index([
|
const stepped = index([
|
||||||
|
|||||||
@@ -37,30 +37,72 @@ export interface JumpLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A cap on how much of the catalogue one search may walk. Reached only where a route does not
|
* A cap on how much of the catalogue one search may walk. A search that hits it has already
|
||||||
* exist and the range is wide enough to make most of the catalogue one component; a search that
|
* visited more stars than any real chain passes through: the longest measured, Sol to HD 2626 at
|
||||||
* hits it has already visited more stars than any real chain passes through.
|
* 236 pc in jumps of 8 pc, settles about 7 000.
|
||||||
*/
|
*/
|
||||||
const MAX_VISITED = 20000;
|
const MAX_VISITED = 20000;
|
||||||
|
|
||||||
/** Pops the smallest-cost entry. A linear scan: the frontier is small next to the work per node. */
|
/**
|
||||||
function takeCheapest<T>(frontier: Map<number, T>, costOf: (value: T) => number): [number, T] | undefined {
|
* How close to the true minimum `minimumRangeBetween` works a range out: half the Routes panel's
|
||||||
let bestId: number | undefined;
|
* own step, which it rounds up to. Never at the cost of an answer that fails to open a route,
|
||||||
let bestValue: T | undefined;
|
* since the figure it reports is always the longest hop of a route actually found.
|
||||||
let bestCost = Number.POSITIVE_INFINITY;
|
*/
|
||||||
for (const [id, value] of frontier) {
|
const RANGE_RESOLUTION_PC = 0.05;
|
||||||
const cost = costOf(value);
|
|
||||||
if (cost < bestCost) {
|
/** A binary min-heap of star ids by priority. Duplicates are allowed; stale ones are skipped on the way out. */
|
||||||
bestCost = cost;
|
class Frontier {
|
||||||
bestId = id;
|
private readonly ids: number[] = [];
|
||||||
bestValue = value;
|
private readonly priorities: number[] = [];
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.ids.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
push(id: number, priority: number): void {
|
||||||
|
let at = this.ids.length;
|
||||||
|
this.ids.push(id);
|
||||||
|
this.priorities.push(priority);
|
||||||
|
while (at > 0) {
|
||||||
|
const parent = (at - 1) >> 1;
|
||||||
|
if (this.priorities[parent] <= priority) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.ids[at] = this.ids[parent];
|
||||||
|
this.priorities[at] = this.priorities[parent];
|
||||||
|
at = parent;
|
||||||
}
|
}
|
||||||
|
this.ids[at] = id;
|
||||||
|
this.priorities[at] = priority;
|
||||||
}
|
}
|
||||||
if (bestId === undefined || bestValue === undefined) {
|
|
||||||
return undefined;
|
/** The id with the lowest priority, taken out. Only called while `size` is not zero. */
|
||||||
|
pop(): number {
|
||||||
|
const top = this.ids[0];
|
||||||
|
const lastId = this.ids.pop()!;
|
||||||
|
const lastPriority = this.priorities.pop()!;
|
||||||
|
const count = this.ids.length;
|
||||||
|
if (count > 0) {
|
||||||
|
let at = 0;
|
||||||
|
for (;;) {
|
||||||
|
const left = 2 * at + 1;
|
||||||
|
if (left >= count) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const right = left + 1;
|
||||||
|
const child = right < count && this.priorities[right] < this.priorities[left] ? right : left;
|
||||||
|
if (this.priorities[child] >= lastPriority) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.ids[at] = this.ids[child];
|
||||||
|
this.priorities[at] = this.priorities[child];
|
||||||
|
at = child;
|
||||||
|
}
|
||||||
|
this.ids[at] = lastId;
|
||||||
|
this.priorities[at] = lastPriority;
|
||||||
|
}
|
||||||
|
return top;
|
||||||
}
|
}
|
||||||
frontier.delete(bestId);
|
|
||||||
return [bestId, bestValue];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): number[] {
|
function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): number[] {
|
||||||
@@ -85,108 +127,97 @@ function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): n
|
|||||||
* length are not equally good, and the one that covers less ground is the one a reader means by
|
* length are not equally good, and the one that covers less ground is the one a reader means by
|
||||||
* "the way there". Neighbours are asked for as the search reaches each star rather than built
|
* "the way there". Neighbours are asked for as the search reaches each star rather than built
|
||||||
* into a graph first, so finding one route never costs a pass over the whole catalogue.
|
* into a graph first, so finding one route never costs a pass over the whole catalogue.
|
||||||
|
*
|
||||||
|
* An A* search: each star waits its turn by the distance travelled to it plus the straight line
|
||||||
|
* on to the destination, which no chain can beat, so the search heads for the destination rather
|
||||||
|
* than widening evenly in every direction. Widening evenly is what the Gaia catalogue broke. From
|
||||||
|
* the Sun it spent its whole budget on the 20 000 stars nearest, all inside about 40 pc, and so
|
||||||
|
* found no route to anything farther at any range; Mirfak, 155 pc out, is 27 jumps at 8 pc.
|
||||||
*/
|
*/
|
||||||
export function routeBetween(index: StarNeighbourhood, fromId: number, toId: number, rangePc: number): Route | null {
|
export function routeBetween(index: StarNeighbourhood, fromId: number, toId: number, rangePc: number): Route | null {
|
||||||
if (fromId === toId || rangePc <= 0 || !index.point(fromId) || !index.point(toId)) {
|
const origin = index.point(fromId);
|
||||||
|
const destination = index.point(toId);
|
||||||
|
if (fromId === toId || rangePc <= 0 || !origin || !destination) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const straightLineOn = (x: number, y: number, z: number) => Math.hypot(destination.x - x, destination.y - y, destination.z - z);
|
||||||
|
|
||||||
const best = new Map<number, number>([[fromId, 0]]);
|
const travelled = new Map<number, number>([[fromId, 0]]);
|
||||||
const cameFrom = new Map<number, number>();
|
const cameFrom = new Map<number, number>();
|
||||||
|
// Each hop's length as the range test measured it. The route's longest hop is read from these
|
||||||
|
// rather than measured again, so a range set to it is sure to admit the route a second time,
|
||||||
|
// which is what `minimumRangeBetween` relies on.
|
||||||
|
const hopTo = new Map<number, number>();
|
||||||
const settled = new Set<number>();
|
const settled = new Set<number>();
|
||||||
const frontier = new Map<number, number>([[fromId, 0]]);
|
const frontier = new Frontier();
|
||||||
|
frontier.push(fromId, straightLineOn(origin.x, origin.y, origin.z));
|
||||||
|
|
||||||
while (frontier.size > 0 && settled.size < MAX_VISITED) {
|
while (frontier.size > 0 && settled.size < MAX_VISITED) {
|
||||||
const cheapest = takeCheapest(frontier, (cost) => cost);
|
const starId = frontier.pop();
|
||||||
if (!cheapest) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const [starId, costHere] = cheapest;
|
|
||||||
if (settled.has(starId)) {
|
if (settled.has(starId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
settled.add(starId);
|
settled.add(starId);
|
||||||
|
const costHere = travelled.get(starId)!;
|
||||||
|
|
||||||
if (starId === toId) {
|
if (starId === toId) {
|
||||||
const stars = rebuild(cameFrom, fromId, toId);
|
const stars = rebuild(cameFrom, fromId, toId);
|
||||||
return stars.length === 0 ? null : { stars, totalPc: costHere, longestHopPc: longestHop(index, stars) };
|
if (stars.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let longestHopPc = 0;
|
||||||
|
for (let i = 1; i < stars.length; i++) {
|
||||||
|
longestHopPc = Math.max(longestHopPc, hopTo.get(stars[i])!);
|
||||||
|
}
|
||||||
|
return { stars, totalPc: costHere, longestHopPc };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const neighbour of index.within(starId, rangePc)) {
|
index.forEachWithin(starId, rangePc, (neighbour, distancePc) => {
|
||||||
if (settled.has(neighbour.id)) {
|
if (settled.has(neighbour.id)) {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
const cost = costHere + neighbour.distancePc;
|
const cost = costHere + distancePc;
|
||||||
if (cost < (best.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
|
if (cost < (travelled.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
|
||||||
best.set(neighbour.id, cost);
|
travelled.set(neighbour.id, cost);
|
||||||
cameFrom.set(neighbour.id, starId);
|
cameFrom.set(neighbour.id, starId);
|
||||||
frontier.set(neighbour.id, cost);
|
hopTo.set(neighbour.id, distancePc);
|
||||||
|
frontier.push(neighbour.id, cost + straightLineOn(neighbour.x, neighbour.y, neighbour.z));
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function longestHop(index: StarNeighbourhood, stars: readonly number[]): number {
|
|
||||||
let longest = 0;
|
|
||||||
for (let i = 1; i < stars.length; i++) {
|
|
||||||
const a = index.point(stars[i - 1]);
|
|
||||||
const b = index.point(stars[i]);
|
|
||||||
if (a && b) {
|
|
||||||
longest = Math.max(longest, Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return longest;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shortest range at which any chain at all exists between two stars, or `null` if none does
|
* The shortest range at which any chain at all exists between two stars, to within
|
||||||
* within `ceilingPc`.
|
* `RANGE_RESOLUTION_PC`, or `null` if none does within `ceilingPc`.
|
||||||
*
|
*
|
||||||
* This is what turns "no route" from a dead end into an answer: the range control can be told
|
* This is what turns "no route" from a dead end into an answer: the range control can be told
|
||||||
* what it would have to be raised to. It is the minimax path — the chain whose longest hop is as
|
* what it would have to be raised to. The exact figure is the minimax path, the chain whose
|
||||||
* short as possible — found by the same search as above, with the cost of reaching a star being
|
* longest hop is as short as possible. It used to be searched for directly, widening from the
|
||||||
* the longest hop taken to get there rather than the sum of them.
|
* departure in order of the worst hop needed, which from the Sun meant exhausting the whole dense
|
||||||
|
* core before anything farther could be reached: it gave up with nothing after up to a minute.
|
||||||
|
* Whether a chain exists can only become truer as the range grows, so the range is bisected
|
||||||
|
* instead, each step one directed `routeBetween`.
|
||||||
*/
|
*/
|
||||||
export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): number | null {
|
export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): number | null {
|
||||||
if (fromId === toId || ceilingPc <= 0 || !index.point(fromId) || !index.point(toId)) {
|
const widest = routeBetween(index, fromId, toId, ceilingPc);
|
||||||
|
if (!widest) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
let unreachable = 0;
|
||||||
const best = new Map<number, number>([[fromId, 0]]);
|
let reachable = widest.longestHopPc;
|
||||||
const settled = new Set<number>();
|
while (reachable - unreachable > RANGE_RESOLUTION_PC) {
|
||||||
const frontier = new Map<number, number>([[fromId, 0]]);
|
const range = (unreachable + reachable) / 2;
|
||||||
|
const route = routeBetween(index, fromId, toId, range);
|
||||||
while (frontier.size > 0 && settled.size < MAX_VISITED) {
|
if (route) {
|
||||||
const cheapest = takeCheapest(frontier, (cost) => cost);
|
reachable = route.longestHopPc;
|
||||||
if (!cheapest) {
|
} else {
|
||||||
break;
|
unreachable = range;
|
||||||
}
|
|
||||||
const [starId, worstHopHere] = cheapest;
|
|
||||||
if (settled.has(starId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
settled.add(starId);
|
|
||||||
|
|
||||||
if (starId === toId) {
|
|
||||||
return worstHopHere;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const neighbour of index.within(starId, ceilingPc)) {
|
|
||||||
if (settled.has(neighbour.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// What this chain would need: the longest hop on it, not the distance covered by it.
|
|
||||||
const needed = Math.max(worstHopHere, neighbour.distancePc);
|
|
||||||
if (needed < (best.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
|
|
||||||
best.set(neighbour.id, needed);
|
|
||||||
frontier.set(neighbour.id, needed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return reachable;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -115,4 +115,49 @@ describe('StarNeighbourhood', () => {
|
|||||||
|
|
||||||
expect(ids(index.nearest(1, 2)).sort()).toEqual([2, 3]);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,12 +38,27 @@ 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. */
|
/** Grows the search a shell of cells at a time; the cap stops a query in empty space forever. */
|
||||||
const MAX_RING = 12;
|
const MAX_RING = 12;
|
||||||
|
|
||||||
function cellKey(ix: number, iy: number, iz: number): string {
|
/**
|
||||||
return `${ix},${iy},${iz}`;
|
* Cells are keyed by one number packed from their three indices rather than by a string. A route
|
||||||
|
* search visits up to 125 cells for every star it expands, and building `"ix,iy,iz"` for each
|
||||||
|
* was half of what a route cost. Room for 65 536 cells either side of the Sun on every axis,
|
||||||
|
* 330 kpc at the default cell size, and the packed key stays inside a double's exact integers.
|
||||||
|
*/
|
||||||
|
const CELL_OFFSET = 65_536;
|
||||||
|
const CELL_SPAN = 131_072;
|
||||||
|
|
||||||
|
function cellKey(ix: number, iy: number, iz: number): number {
|
||||||
|
return ((ix + CELL_OFFSET) * CELL_SPAN + (iy + CELL_OFFSET)) * CELL_SPAN + (iz + CELL_OFFSET);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellIndices(key: number): [number, number, number] {
|
||||||
|
const iz = (key % CELL_SPAN) - CELL_OFFSET;
|
||||||
|
const rest = Math.floor(key / CELL_SPAN);
|
||||||
|
return [Math.floor(rest / CELL_SPAN) - CELL_OFFSET, (rest % CELL_SPAN) - CELL_OFFSET, iz];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StarNeighbourhood {
|
export class StarNeighbourhood {
|
||||||
private readonly cells = new Map<string, number[]>();
|
private readonly cells = new Map<number, number[]>();
|
||||||
private readonly points: readonly StarPoint[];
|
private readonly points: readonly StarPoint[];
|
||||||
private readonly indexById = new Map<number, number>();
|
private readonly indexById = new Map<number, number>();
|
||||||
private readonly cellSizePc: number;
|
private readonly cellSizePc: number;
|
||||||
@@ -135,34 +150,52 @@ export class StarNeighbourhood {
|
|||||||
* a jump-link graph is built from: one call per node gives that node's edges.
|
* a jump-link graph is built from: one call per node gives that node's edges.
|
||||||
*/
|
*/
|
||||||
within(id: number, radiusPc: number): Neighbour[] {
|
within(id: number, radiusPc: number): Neighbour[] {
|
||||||
|
const found: Neighbour[] = [];
|
||||||
|
this.forEachWithin(id, radiusPc, (neighbour, distancePc) => found.push({ id: neighbour.id, distancePc }));
|
||||||
|
found.sort((a, b) => a.distancePc - b.distancePc);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same stars as `within`, handed over one at a time in no particular order. What a search
|
||||||
|
* that expands thousands of stars wants: it has no use for each star's neighbours sorted and
|
||||||
|
* collected into a list, which was the other half of what a route cost.
|
||||||
|
*
|
||||||
|
* A distance is compared as a distance, not as its square, here and in the pair walk: squaring
|
||||||
|
* a range can round it just under the square of the very hop it was read from, and then a
|
||||||
|
* range set to a reported distance would not admit that hop again.
|
||||||
|
*/
|
||||||
|
forEachWithin(id: number, radiusPc: number, visit: (neighbour: StarPoint, distancePc: number) => void): void {
|
||||||
const origin = this.point(id);
|
const origin = this.point(id);
|
||||||
if (!origin || radiusPc <= 0) {
|
if (!origin || radiusPc <= 0) {
|
||||||
return [];
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const found: Neighbour[] = [];
|
|
||||||
const [ox, oy, oz] = this.cellFor(origin.x, origin.y, origin.z);
|
const [ox, oy, oz] = this.cellFor(origin.x, origin.y, origin.z);
|
||||||
const reach = Math.ceil(radiusPc / this.cellSizePc);
|
const reach = Math.ceil(radiusPc / this.cellSizePc);
|
||||||
|
|
||||||
for (let ix = ox - reach; ix <= ox + reach; ix++) {
|
for (let ix = ox - reach; ix <= ox + reach; ix++) {
|
||||||
for (let iy = oy - reach; iy <= oy + reach; iy++) {
|
for (let iy = oy - reach; iy <= oy + reach; iy++) {
|
||||||
for (let iz = oz - reach; iz <= oz + reach; iz++) {
|
for (let iz = oz - reach; iz <= oz + reach; iz++) {
|
||||||
for (const index of this.cells.get(cellKey(ix, iy, iz)) ?? []) {
|
const cell = this.cells.get(cellKey(ix, iy, iz));
|
||||||
|
if (!cell) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const index of cell) {
|
||||||
const candidate = this.points[index];
|
const candidate = this.points[index];
|
||||||
if (candidate.id === id) {
|
if (candidate.id === id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const distancePc = Math.hypot(candidate.x - origin.x, candidate.y - origin.y, candidate.z - origin.z);
|
const dx = candidate.x - origin.x;
|
||||||
|
const dy = candidate.y - origin.y;
|
||||||
|
const dz = candidate.z - origin.z;
|
||||||
|
const distancePc = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
if (distancePc <= radiusPc) {
|
if (distancePc <= radiusPc) {
|
||||||
found.push({ id: candidate.id, distancePc });
|
visit(candidate, distancePc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
found.sort((a, b) => a.distancePc - b.distancePc);
|
|
||||||
return found;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,10 +214,9 @@ export class StarNeighbourhood {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reach = Math.ceil(radiusPc / this.cellSizePc);
|
const reach = Math.ceil(radiusPc / this.cellSizePc);
|
||||||
const radiusSq = radiusPc * radiusPc;
|
|
||||||
|
|
||||||
for (const [key, cell] of this.cells) {
|
for (const [key, cell] of this.cells) {
|
||||||
const [ix, iy, iz] = key.split(',').map(Number);
|
const [ix, iy, iz] = cellIndices(key);
|
||||||
for (let dx = 0; dx <= reach; dx++) {
|
for (let dx = 0; dx <= reach; dx++) {
|
||||||
for (let dy = dx === 0 ? 0 : -reach; dy <= reach; dy++) {
|
for (let dy = dx === 0 ? 0 : -reach; dy <= reach; dy++) {
|
||||||
for (let dz = dx === 0 && dy === 0 ? 0 : -reach; dz <= reach; dz++) {
|
for (let dz = dx === 0 && dy === 0 ? 0 : -reach; dz <= reach; dz++) {
|
||||||
@@ -202,9 +234,9 @@ export class StarNeighbourhood {
|
|||||||
const dxp = b.x - a.x;
|
const dxp = b.x - a.x;
|
||||||
const dyp = b.y - a.y;
|
const dyp = b.y - a.y;
|
||||||
const dzp = b.z - a.z;
|
const dzp = b.z - a.z;
|
||||||
const distanceSq = dxp * dxp + dyp * dyp + dzp * dzp;
|
const distancePc = Math.sqrt(dxp * dxp + dyp * dyp + dzp * dzp);
|
||||||
if (distanceSq <= radiusSq) {
|
if (distancePc <= radiusPc) {
|
||||||
visit(a, b, Math.sqrt(distanceSq));
|
visit(a, b, distancePc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +246,7 @@ export class StarNeighbourhood {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private keyFor(x: number, y: number, z: number): string {
|
private keyFor(x: number, y: number, z: number): number {
|
||||||
const [ix, iy, iz] = this.cellFor(x, y, z);
|
const [ix, iy, iz] = this.cellFor(x, y, z);
|
||||||
return cellKey(ix, iy, iz);
|
return cellKey(ix, iy, iz);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user