/** * 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'; /** What a search found, and whether it looked everywhere the range reaches before answering. */ export interface RouteSearch { readonly route: Route | null; /** True when the search spent its budget: "no route" then means "gave up", not "there is none". */ readonly gaveUp: boolean; } /** A range that opens a route, and whether anything shorter was actually ruled out. */ export interface RangeSearch { /** A range a chain was found at, or `null` where none was found up to the ceiling. */ readonly rangePc: number | null; /** True when every shorter range was searched to exhaustion, so this is the least that works. */ readonly least: boolean; } /** 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, so a hopeless question cannot run for * ever. It is a budget, not a verdict: a search that spends it has proved nothing, and says so * through {@link RouteSearch.gaveUp}. * * Sized against the catalogue actually shipped rather than against the longest route. At 20 000 a * search from the Sun to HD 120147 (136 pc) in jumps of 5 pc gave up, though the chain it wanted, * 50 jumps, is there to be found; a star at 170 pc needed 58. Both are found at this budget. The * cost is paid by questions with no answer, which walk the whole of it: from the Sun to the * farthest star at 8 pc, 0.7 s at 20 000 against 2.1 s here, in the worker. */ const MAX_VISITED = 40000; /** * 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; /** * How many of `minimumRangeBetween`'s probes may give up, once it has a range of its own, before it * answers with what it has — and how many before it has one. * * A probe that finds a route is quick — it heads straight for the destination — while one that * gives up walks the whole search budget, about two seconds on the real catalogue. Those are also * the probes that buy the least: they cannot rule anything out. Two of them is the difference * between an answer of 7.96 pc in half a second and 5.76 pc in seventeen, for a star at 236 pc; it * lands on 5.97 pc in five. * * Until a probe succeeds there is nothing to answer with but the ceiling route's own longest hop, * which is the control's maximum, so the bound is looser there — but a bound, since the search is * one the panel waits on: five probes, ten seconds, rather than the resolution's own eight. */ const MAX_RANGE_GIVE_UPS = 2; const MAX_UNEARNED_GIVE_UPS = 5; /** 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, 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 no * chain where the catalogue holds none within the search's budget. * * 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. * * "No route" and "no chain" are not the same answer: a search that spends {@link MAX_VISITED} * reports that it gave up, so nothing downstream reads it as proof that no chain exists. */ export function routeBetween(index: StarNeighbourhood, fromId: number, toId: number, rangePc: number): RouteSearch { const origin = index.point(fromId); const destination = index.point(toId); if (fromId === toId || rangePc <= 0 || !origin || !destination) { return { route: null, gaveUp: false }; } const straightLineOn = (x: number, y: number, z: number) => Math.hypot(destination.x - x, destination.y - y, destination.z - z); const travelled = new Map([[fromId, 0]]); const cameFrom = new Map(); // 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(); const settled = new Set(); const frontier = new Frontier(); frontier.push(fromId, straightLineOn(origin.x, origin.y, origin.z)); let gaveUp = false; while (frontier.size > 0) { const starId = frontier.pop(); if (settled.has(starId)) { continue; } // Counted against the budget only once the frontier has been drained of stale duplicates, so // the flag below records why the search stopped rather than how full the set happened to be. if (settled.size >= MAX_VISITED) { gaveUp = true; break; } settled.add(starId); const costHere = travelled.get(starId)!; if (starId === toId) { const stars = rebuild(cameFrom, fromId, toId); if (stars.length === 0) { return { route: null, gaveUp: false }; } let longestHopPc = 0; for (let i = 1; i < stars.length; i++) { longestHopPc = Math.max(longestHopPc, hopTo.get(stars[i])!); } return { route: { stars, totalPc: costHere, longestHopPc }, gaveUp: false }; } 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)); } }); } // An empty frontier means the range reaches nothing further; a spent budget means only that the // search stopped looking. return { route: null, gaveUp }; } /** * A range at which a chain exists between two stars — the shortest, to within * `RANGE_RESOLUTION_PC`, where every shorter range could be ruled out — or `null` where no chain * was found up to `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 figure aimed at 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`. * * Each step has to answer "is there a chain at this range", and a search that gives up answers * nothing. It is still worth carrying on from — the ranges above it are the ones left to try — but * the result is no longer the least range, only a range that works, and `least` says which. The * number of steps that may give up is bounded for the same reason: each one walks the whole budget, * and 11 s of them for a star at 236 pc bought two decimal places nobody reads. Bounded more * loosely before the bisection has found a range of its own, since until then the only range it * could offer is the ceiling's, which is the control's maximum, for crossings that work well below * it. See {@link MAX_RANGE_GIVE_UPS}. */ export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): RangeSearch { const widest = routeBetween(index, fromId, toId, ceilingPc); if (!widest.route) { return { rangePc: null, least: !widest.gaveUp }; } const ceilingHopPc = widest.route.longestHopPc; let unreachable = 0; let reachable = ceilingHopPc; let giveUps = 0; // While `reachable` is still the ceiling route's own longest hop the bisection has nothing of its // own to answer with, and that figure sends the control to its maximum for a crossing that works // well below — 8.00 pc for a star that routes at 6. So it is allowed more probes there, not // unlimited ones: the panel is waiting on this. while (reachable - unreachable > RANGE_RESOLUTION_PC && giveUps < (reachable === ceilingHopPc ? MAX_UNEARNED_GIVE_UPS : MAX_RANGE_GIVE_UPS)) { const range = (unreachable + reachable) / 2; const { route, gaveUp } = routeBetween(index, fromId, toId, range); if (route) { reachable = route.longestHopPc; } else { // A search that gave up is worth going on from — the ranges above it are the ones left to // try — but it is not evidence that nothing routes here, so the answer stops being the least. unreachable = range; giveUps += gaveUp ? 1 : 0; } } // Without a give-up the loop can only have ended by closing on the resolution, so that is the least. return { rangePc: reachable, least: giveUps === 0 }; } /** 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; }