Merge pull request #20 from avalon-vanguard/star-map/feat/fast-routes
Route with A* over numeric cell keys, so a route can reach past the Sun's crowd 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