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:
Senrokai
2026-09-17 15:05:15 +02:00
committed by GitHub
co-authored by Claude Opus 5
6 changed files with 254 additions and 108 deletions
@@ -39,6 +39,7 @@ import { SystemObjectCardComponent } from './system-object-card.component';
import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
import { collectJumpLinks, minimumRangeBetween, routeBetween } from '../../shared/astro/jump-links';
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component';
import { HostStarRings } from './host-star-rings';
import { JumpLinkRenderer } from './jump-link-renderer';
import { ReservedBox, ringPlacement } from './label-ring';
@@ -83,10 +84,11 @@ const PLAN_ZOOM_SPAN = 64;
const ROUTE_OPTION_COUNT = 6;
const MIN_ROUTE_QUERY_LENGTH = 2;
/**
* 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 widest crossing `minimumRangeBetween` will consider when saying what a route would need:
* 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. */
const NEIGHBOUR_COUNT = 4;
@@ -2,6 +2,12 @@ import { ChangeDetectionStrategy, Component, computed, input, output, signal } f
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. */
export interface RouteStarOption {
readonly id: number;
@@ -117,7 +123,7 @@ type Field = 'from' | 'to';
{{ format(plotted.neededRangePc) }} would reach.
</button>
} @else {
Nothing in the catalogue bridges the gap.
No chain of jumps up to {{ format(maxRangePc) }} reaches it.
}
</p>
}
@@ -160,8 +166,7 @@ export class RoutesPanelComponent {
readonly fields: readonly Field[] = ['from', 'to'];
/** A tenth of a parsec is finer than the catalogue's own distances are known to. */
readonly minRangePc = 0.5;
/** Beyond this the graph is a solid sheet of lines and every pair of stars is connected. */
readonly maxRangePc = 8;
readonly maxRangePc = MAX_JUMP_RANGE_PC;
readonly rangePc = signal(3);
readonly open = signal<Field | null>(null);
+31
View File
@@ -81,9 +81,40 @@ describe('routeBetween', () => {
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', () => {
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', () => {
// Hops of 1 and 4: no range under 4 connects them, and 4 exactly does.
const stepped = index([
+115 -84
View File
@@ -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
* exist and the range is wide enough to make most of the catalogue one component; a search that
* hits it has already visited more stars than any real chain passes through.
* A cap on how much of the catalogue one search may walk. A search that hits it has already
* visited more stars than any real chain passes through: the longest measured, Sol to HD 2626 at
* 236 pc in jumps of 8 pc, settles about 7 000.
*/
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 {
let bestId: number | undefined;
let bestValue: T | undefined;
let bestCost = Number.POSITIVE_INFINITY;
for (const [id, value] of frontier) {
const cost = costOf(value);
if (cost < bestCost) {
bestCost = cost;
bestId = id;
bestValue = value;
/**
* How close to the true minimum `minimumRangeBetween` works a range out: half the Routes panel's
* own step, which it rounds up to. Never at the cost of an answer that fails to open a route,
* since the figure it reports is always the longest hop of a route actually found.
*/
const RANGE_RESOLUTION_PC = 0.05;
/** A binary min-heap of star ids by priority. Duplicates are allowed; stale ones are skipped on the way out. */
class Frontier {
private readonly ids: number[] = [];
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;
}
if (bestId === undefined || bestValue === undefined) {
return undefined;
this.ids[at] = this.ids[parent];
this.priorities[at] = this.priorities[parent];
at = parent;
}
this.ids[at] = id;
this.priorities[at] = priority;
}
/** 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[] {
@@ -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
* "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.
*
* 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 {
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;
}
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>();
// 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 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) {
const cheapest = takeCheapest(frontier, (cost) => cost);
if (!cheapest) {
break;
}
const [starId, costHere] = cheapest;
const starId = frontier.pop();
if (settled.has(starId)) {
continue;
}
settled.add(starId);
const costHere = travelled.get(starId)!;
if (starId === 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)) {
continue;
return;
}
const cost = costHere + neighbour.distancePc;
if (cost < (best.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
best.set(neighbour.id, cost);
const cost = costHere + distancePc;
if (cost < (travelled.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
travelled.set(neighbour.id, cost);
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;
}
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
* within `ceilingPc`.
* The shortest range at which any chain at all exists between two stars, to within
* `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
* what it would have to be raised to. It is the minimax path — the chain whose longest hop is as
* short as possible — found by the same search as above, with the cost of reaching a star being
* the longest hop taken to get there rather than the sum of them.
* what it would have to be raised to. The exact figure is the minimax path, the chain whose
* longest hop is as short as possible. It used to be searched for directly, widening from the
* 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 {
if (fromId === toId || ceilingPc <= 0 || !index.point(fromId) || !index.point(toId)) {
const widest = routeBetween(index, fromId, toId, ceilingPc);
if (!widest) {
return null;
}
const best = new Map<number, number>([[fromId, 0]]);
const settled = new Set<number>();
const frontier = new Map<number, number>([[fromId, 0]]);
while (frontier.size > 0 && settled.size < MAX_VISITED) {
const cheapest = takeCheapest(frontier, (cost) => cost);
if (!cheapest) {
break;
}
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);
let unreachable = 0;
let reachable = widest.longestHopPc;
while (reachable - unreachable > RANGE_RESOLUTION_PC) {
const range = (unreachable + reachable) / 2;
const route = routeBetween(index, fromId, toId, range);
if (route) {
reachable = route.longestHopPc;
} else {
unreachable = range;
}
}
}
return null;
return reachable;
}
/**
@@ -115,4 +115,49 @@ describe('StarNeighbourhood', () => {
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);
});
});
+51 -19
View File
@@ -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. */
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 {
private readonly cells = new Map<string, number[]>();
private readonly cells = new Map<number, number[]>();
private readonly points: readonly StarPoint[];
private readonly indexById = new Map<number, 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.
*/
within(id: number, radiusPc: number): Neighbour[] {
const origin = this.point(id);
if (!origin || radiusPc <= 0) {
return [];
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;
}
const found: Neighbour[] = [];
/**
* 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);
if (!origin || radiusPc <= 0) {
return;
}
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 cell = this.cells.get(cellKey(ix, iy, iz));
if (!cell) {
continue;
}
for (const index of cell) {
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);
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) {
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;
}
const reach = Math.ceil(radiusPc / this.cellSizePc);
const radiusSq = radiusPc * radiusPc;
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 dy = dx === 0 ? 0 : -reach; dy <= reach; dy++) {
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 dyp = b.y - a.y;
const dzp = b.z - a.z;
const distanceSq = dxp * dxp + dyp * dyp + dzp * dzp;
if (distanceSq <= radiusSq) {
visit(a, b, Math.sqrt(distanceSq));
const distancePc = Math.sqrt(dxp * dxp + dyp * dyp + dzp * dzp);
if (distancePc <= radiusPc) {
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);
return cellKey(ix, iy, iz);
}