- The budget counted CSS pixels; lines are drawn in device pixels, so a screen scaled to 150% or 200% drew 1.5-2x the calibrated line. It now counts the canvas's drawn pixels. - A graph was re-asked only when the drawn stars changed, so with a star budget covering the whole catalogue, or a resize, its budget and centre stayed wherever the layer was turned on. A view that chose its stars again now asks, and a graph is rebuilt when the stars, the range or the budget changed (the budget by more than half the margin, or its centre by more than 5 pc). - From inside a system the budget was worked out in astronomical units about the system's origin. Graphs are now asked for in parsec space only; the flight back out asks. - Comparing budgets let a request re-asked with a slightly different one supersede its twin, and the twin's rejection cleared the state of the request that replaced it. A rejection now clears it only for the latest request. - The worker sorted every link to keep a few thousand, 2.2x an unbudgeted build. It now bands links by distance, keeps every band before the one the budget runs out in, and sorts only that one: 142-168 ms on the real catalogue against 233-388 ms, 103 ms unbudgeted, returning early when all fit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
/**
|
|
* Which stars are within reach of which, and how to get from one to another through them.
|
|
*
|
|
* A "jump link" is nothing more than a pair of catalogued stars closer together than some
|
|
* chosen range. It is not a feature of space — there are no corridors out there — it is a
|
|
* question asked of the catalogue: if a crossing of at most this far can be made, which stars
|
|
* can be strung together, and what is the shortest chain from here to there.
|
|
*
|
|
* Two facts about the catalogue shape everything here, and both are worth stating because the
|
|
* answers look like defects otherwise. It is magnitude-limited, so it is dense around the Sun
|
|
* and thins with distance: within 50 pc a 3 pc range links 99% of it into one piece, while over
|
|
* the whole 250 pc reach the same range leaves most stars alone. And a gap in it is a gap in
|
|
* what has been catalogued, not in what is there. So a route that cannot be found is a
|
|
* statement about the map, and `minimumRangeBetween` exists to say which.
|
|
*/
|
|
|
|
import { StarNeighbourhood } from './star-neighbourhood';
|
|
|
|
/** A chain of stars from one to another, each hop within the range that was asked for. */
|
|
export interface Route {
|
|
/** Star ids, departure first and destination last. One hop is two ids. */
|
|
readonly stars: readonly number[];
|
|
/** The sum of the hops, in parsecs. */
|
|
readonly totalPc: number;
|
|
/**
|
|
* The longest single hop. The range has to cover this and nothing wider, so it is what a
|
|
* reader checks a route against — and it is the figure `minimumRangeBetween` minimises.
|
|
*/
|
|
readonly longestHopPc: number;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): number[] {
|
|
const stars = [toId];
|
|
let at = toId;
|
|
while (at !== fromId) {
|
|
const previous = cameFrom.get(at);
|
|
if (previous === undefined) {
|
|
return [];
|
|
}
|
|
stars.push(previous);
|
|
at = previous;
|
|
}
|
|
return stars.reverse();
|
|
}
|
|
|
|
/**
|
|
* The shortest chain from one star to another in which no single hop exceeds `rangePc`, or
|
|
* `null` where the catalogue holds no such chain.
|
|
*
|
|
* Shortest by total distance travelled rather than by number of hops: two chains of the same
|
|
* 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 {
|
|
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 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 Frontier();
|
|
frontier.push(fromId, straightLineOn(origin.x, origin.y, origin.z));
|
|
|
|
while (frontier.size > 0 && settled.size < MAX_VISITED) {
|
|
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);
|
|
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 };
|
|
}
|
|
|
|
index.forEachWithin(starId, rangePc, (neighbour, distancePc) => {
|
|
if (settled.has(neighbour.id)) {
|
|
return;
|
|
}
|
|
const cost = costHere + distancePc;
|
|
if (cost < (travelled.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
|
|
travelled.set(neighbour.id, cost);
|
|
cameFrom.set(neighbour.id, starId);
|
|
hopTo.set(neighbour.id, distancePc);
|
|
frontier.push(neighbour.id, cost + straightLineOn(neighbour.x, neighbour.y, neighbour.z));
|
|
}
|
|
});
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 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. 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 {
|
|
const widest = routeBetween(index, fromId, toId, ceilingPc);
|
|
if (!widest) {
|
|
return null;
|
|
}
|
|
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 reachable;
|
|
}
|
|
|
|
/** How much of a graph to keep: the links nearest a point, up to a total length. */
|
|
export interface LinkBudget {
|
|
/** Links are kept in order of how near their nearer end is to this point. */
|
|
readonly centre: { readonly x: number; readonly y: number; readonly z: number };
|
|
/** The most the kept links may add up to, end to end, in parsecs. */
|
|
readonly lengthPc: number;
|
|
}
|
|
|
|
/**
|
|
* How many distance bands a budgeted graph is split into to find where its budget runs out, so that
|
|
* only the links in that one band are sorted rather than all of them.
|
|
*/
|
|
const DISTANCE_BANDS = 4096;
|
|
|
|
/**
|
|
* Every link within `rangePc` between two of the stars `index` holds, each pair once, as vertex
|
|
* pairs ready to draw: six floats a link, one end then the other. With a `budget`, only the links
|
|
* nearest its centre, as many as fit its length.
|
|
*
|
|
* For drawing the graph, which is the only thing that wants all of it: routing asks for a star's
|
|
* neighbours as it reaches that star and never builds this. Written straight into floats rather
|
|
* than collected as link objects first, since at 8 pc the drawn stars alone have hundreds of
|
|
* thousands of links, and the whole catalogue 3.7 million.
|
|
*/
|
|
export function jumpLinkSegments(index: StarNeighbourhood, rangePc: number, budget?: LinkBudget): Float32Array {
|
|
let vertices = new Float32Array(6 * 4096);
|
|
let length = 0;
|
|
index.forEachPairWithin(rangePc, (a, b) => {
|
|
if (length + 6 > vertices.length) {
|
|
const grown = new Float32Array(vertices.length * 2);
|
|
grown.set(vertices);
|
|
vertices = grown;
|
|
}
|
|
vertices[length++] = a.x;
|
|
vertices[length++] = a.y;
|
|
vertices[length++] = a.z;
|
|
vertices[length++] = b.x;
|
|
vertices[length++] = b.y;
|
|
vertices[length++] = b.z;
|
|
});
|
|
if (!budget) {
|
|
// Exact length rather than a view on the grown buffer: the answer is transferred whole, and a
|
|
// view would carry up to as much again in unused capacity with it.
|
|
return vertices.slice(0, length);
|
|
}
|
|
|
|
// Each link's nearer end's distance from the centre, and its length.
|
|
const { centre } = budget;
|
|
const count = length / 6;
|
|
const nearness = new Float32Array(count);
|
|
const lengths = new Float32Array(count);
|
|
let totalPc = 0;
|
|
let farthest = 0;
|
|
for (let link = 0; link < count; link++) {
|
|
const at = link * 6;
|
|
const ax = vertices[at] - centre.x;
|
|
const ay = vertices[at + 1] - centre.y;
|
|
const az = vertices[at + 2] - centre.z;
|
|
const bx = vertices[at + 3] - centre.x;
|
|
const by = vertices[at + 4] - centre.y;
|
|
const bz = vertices[at + 5] - centre.z;
|
|
nearness[link] = Math.sqrt(Math.min(ax * ax + ay * ay + az * az, bx * bx + by * by + bz * bz));
|
|
lengths[link] = Math.hypot(bx - ax, by - ay, bz - az);
|
|
totalPc += lengths[link];
|
|
farthest = Math.max(farthest, nearness[link]);
|
|
}
|
|
if (totalPc <= budget.lengthPc) {
|
|
return vertices.slice(0, length);
|
|
}
|
|
|
|
// Nearest first, without sorting them all: every link in the bands before the one where the budget
|
|
// runs out fits, and only that band's links are sorted to see how many of them do. Sorting all
|
|
// 730 000 links at 30 pc from the Sun to keep 4 400 doubled the time a graph took in the worker.
|
|
const bands = new Uint16Array(count);
|
|
const bandLengths = new Float64Array(DISTANCE_BANDS);
|
|
const bandsPerPc = farthest > 0 ? DISTANCE_BANDS / farthest : 0;
|
|
for (let link = 0; link < count; link++) {
|
|
bands[link] = Math.min(DISTANCE_BANDS - 1, Math.floor(nearness[link] * bandsPerPc));
|
|
bandLengths[bands[link]] += lengths[link];
|
|
}
|
|
let lastBand = 0;
|
|
let keptPc = 0;
|
|
while (keptPc + bandLengths[lastBand] <= budget.lengthPc) {
|
|
keptPc += bandLengths[lastBand++];
|
|
}
|
|
const keptLinks: number[] = [];
|
|
const boundary: number[] = [];
|
|
for (let link = 0; link < count; link++) {
|
|
const band = bands[link];
|
|
if (band < lastBand) {
|
|
keptLinks.push(link);
|
|
} else if (band === lastBand) {
|
|
boundary.push(link);
|
|
}
|
|
}
|
|
boundary.sort((a, b) => nearness[a] - nearness[b] || a - b);
|
|
for (const link of boundary) {
|
|
if (keptPc + lengths[link] > budget.lengthPc) {
|
|
break;
|
|
}
|
|
keptPc += lengths[link];
|
|
keptLinks.push(link);
|
|
}
|
|
|
|
const kept = new Float32Array(keptLinks.length * 6);
|
|
keptLinks.forEach((link, at) => kept.set(vertices.subarray(link * 6, link * 6 + 6), at * 6));
|
|
return kept;
|
|
}
|