diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 0b1631d..d25490e 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -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; diff --git a/src/app/features/hud/routes-panel.component.ts b/src/app/features/hud/routes-panel.component.ts index 9cbd0a9..293a204 100644 --- a/src/app/features/hud/routes-panel.component.ts +++ b/src/app/features/hud/routes-panel.component.ts @@ -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. } @else { - Nothing in the catalogue bridges the gap. + No chain of jumps up to {{ format(maxRangePc) }} reaches it. }

} @@ -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(null); diff --git a/src/app/shared/astro/jump-links.spec.ts b/src/app/shared/astro/jump-links.spec.ts index 76040cf..7620716 100644 --- a/src/app/shared/astro/jump-links.spec.ts +++ b/src/app/shared/astro/jump-links.spec.ts @@ -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([ diff --git a/src/app/shared/astro/jump-links.ts b/src/app/shared/astro/jump-links.ts index a94d26d..753dfe6 100644 --- a/src/app/shared/astro/jump-links.ts +++ b/src/app/shared/astro/jump-links.ts @@ -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(frontier: Map, 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; + } + 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, fromId: number, toId: number): number[] { @@ -85,108 +127,97 @@ function rebuild(cameFrom: Map, 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([[fromId, 0]]); + 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 Map([[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([[fromId, 0]]); - const settled = new Set(); - const frontier = new Map([[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; } /** diff --git a/src/app/shared/astro/star-neighbourhood.spec.ts b/src/app/shared/astro/star-neighbourhood.spec.ts index 7625e3c..5e57b50 100644 --- a/src/app/shared/astro/star-neighbourhood.spec.ts +++ b/src/app/shared/astro/star-neighbourhood.spec.ts @@ -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(); + 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); + }); }); diff --git a/src/app/shared/astro/star-neighbourhood.ts b/src/app/shared/astro/star-neighbourhood.ts index 11f9a38..a9139c0 100644 --- a/src/app/shared/astro/star-neighbourhood.ts +++ b/src/app/shared/astro/star-neighbourhood.ts @@ -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(); + private readonly cells = new Map(); private readonly points: readonly StarPoint[]; private readonly indexById = new Map(); 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 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); if (!origin || radiusPc <= 0) { - return []; + return; } - - const found: Neighbour[] = []; 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); }