Route between stars, through the crossings a chosen range allows
The map could say where a star is and what is near it, and nothing about getting from one to another. This adds the question and the answer: pick a departure and a destination, choose how far a single crossing may be, and get the chain — how many jumps, how far in total, and every star on the way, each one a step you can fly to. A jump link is not a feature of space. There are no corridors out there; a link is a question asked of the catalogue, which is why the range is the user's control rather than a constant. Two facts about that catalogue decide what the answers look like, and both are stated in the code because they read as 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. That is why "no route" is not the end of the answer. Where no chain exists at the range asked for, the panel says which range would open one — the chain whose longest hop is as short as possible, found by the same search with the cost of arriving somewhere being the worst hop taken rather than the sum — and offers that number as a control to accept. Departure defaults to wherever the view already is, so one field is usually enough. Sol to Vega at 3 pc: four jumps, 10 pc, by way of Barnard's Star, Struve 2398 B and HD 155876. Narrow it to 0.8 pc and it says 2.26 would reach. The graph is drawn as one buffer of line segments and the route as a second, brighter one over it, with the graph stepping back while a route is up: near the Sun the links are a haze, and a thread through a bright cloud is not a thread. Both fade out with the local layer, since from outside the Galaxy the graph is a smear. Two measurements shaped this. Asking the index for each star's neighbours in turn — sixty-eight thousand sorted lists, thrown away — took eight seconds; the grid now walks its own cells once and pairs them, which takes a quarter of one. And the range control emits per pixel dragged, so the rebuild waits for the hand to settle. Three defects fixed on the way, all older than the routing: hud-acquire animated with fill-mode `both`, which leaves its closing keyframe applied for good — and that keyframe carries a clip-path. Every panel wearing it has been clipping its own box ever since, so anything that had to escape one was cut away and could not even be clicked. Nothing had needed to escape until this panel's dropdown opened upward. The routing fields returned nothing when typed into before the catalogue finished loading, and stayed nothing until the next keystroke. The options are derived from the query and the index together now, so they appear when the second of the two arrives, whichever that is. And a link was `3-7` walking one way and `7-3` walking the other, which is two links to anything comparing them. Verified: build clean, 571/571 unit, 9/9 end-to-end including two new specs — one plotting Sol to Sirius, one narrowing the range until there is no route and accepting the one it names — design detector clean, screenshots at 1440x900 and 390x844. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** An unordered pair of stars within range of each other. */
|
||||
export interface JumpLink {
|
||||
readonly from: number;
|
||||
readonly to: number;
|
||||
readonly distancePc: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (bestId === undefined || bestValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
frontier.delete(bestId);
|
||||
return [bestId, bestValue];
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
export function routeBetween(index: StarNeighbourhood, fromId: number, toId: number, rangePc: number): Route | null {
|
||||
if (fromId === toId || rangePc <= 0 || !index.point(fromId) || !index.point(toId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const best = new Map<number, number>([[fromId, 0]]);
|
||||
const cameFrom = new Map<number, number>();
|
||||
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, costHere] = cheapest;
|
||||
if (settled.has(starId)) {
|
||||
continue;
|
||||
}
|
||||
settled.add(starId);
|
||||
|
||||
if (starId === toId) {
|
||||
const stars = rebuild(cameFrom, fromId, toId);
|
||||
return stars.length === 0 ? null : { stars, totalPc: costHere, longestHopPc: longestHop(index, stars) };
|
||||
}
|
||||
|
||||
for (const neighbour of index.within(starId, rangePc)) {
|
||||
if (settled.has(neighbour.id)) {
|
||||
continue;
|
||||
}
|
||||
const cost = costHere + neighbour.distancePc;
|
||||
if (cost < (best.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
|
||||
best.set(neighbour.id, cost);
|
||||
cameFrom.set(neighbour.id, starId);
|
||||
frontier.set(neighbour.id, cost);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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`.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): number | null {
|
||||
if (fromId === toId || ceilingPc <= 0 || !index.point(fromId) || !index.point(toId)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every link within `rangePc` in the whole catalogue, each pair once.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function collectJumpLinks(index: StarNeighbourhood, rangePc: number): JumpLink[] {
|
||||
const links: JumpLink[] = [];
|
||||
index.forEachPairWithin(rangePc, (a, b, distancePc) => {
|
||||
// The smaller id first, always. The grid hands pairs over in whatever order it walks its
|
||||
// cells, and a link that is `3-7` here and `7-3` there is two links to anything comparing.
|
||||
links.push(a.id < b.id ? { from: a.id, to: b.id, distancePc } : { from: b.id, to: a.id, distancePc });
|
||||
});
|
||||
return links;
|
||||
}
|
||||
Reference in New Issue
Block a user