The route search stops after MAX_VISITED stars and returned null, which everything downstream read as "the catalogue holds no chain". On the real catalogue that was wrong for real questions: Sol to HD 120147 (136 pc) at 5 pc is 50 jumps, and the panel said there was no route. The budget also sat under what the shipped catalogue needs, so it is now 40 000 rather than 20 000: both that route and a star at 170 pc are found, and Sol to HD 2626 at 6 pc, which used to be refused after 4.7 s, plots 56 jumps in about 2 s. A search now reports whether it gave up. The range search no longer counts a give-up as proof that nothing routes below it — that is what reported ranges up to 29% too wide — and it stops after two of them, since those are the probes that cost the most and settle the least: for HD 2626 at 3 pc it offers 5.92 pc in about 4 s, against 6.13 pc in 4.7 s. At the panel's widest range the refused route and the range search are the same question, so it is asked once. Where nothing can be said, the panel says "Too many stars to search at this range." rather than claiming there is no route. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
198 lines
7.8 KiB
TypeScript
198 lines
7.8 KiB
TypeScript
import { answerRouting, RoutingRequest, RoutingResponse } from '../../shared/astro/routing';
|
|
import { LinkBudget, Route } from '../../shared/astro/jump-links';
|
|
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
|
|
import { StarRecord } from '../../shared/models/star.model';
|
|
|
|
export interface RouteAnswer {
|
|
readonly route: Route | null;
|
|
readonly neededRangePc: number | null;
|
|
/** True when the search gave up rather than ruling a route out; see `routeBetween`. */
|
|
readonly gaveUp: boolean;
|
|
}
|
|
|
|
/** A request dropped before it was sent, because a newer one of the same kind replaced it. */
|
|
export class SupersededRequest extends Error {
|
|
constructor() {
|
|
super('Superseded by a newer request');
|
|
}
|
|
}
|
|
|
|
/** A request made and not yet answered: what was asked, and the promise whoever asked is holding. */
|
|
interface Outstanding {
|
|
readonly request: RoutingRequest;
|
|
readonly promise: Promise<RoutingResponse>;
|
|
readonly resolve: (response: RoutingResponse) => void;
|
|
readonly reject: (error: Error) => void;
|
|
}
|
|
|
|
function outstanding(request: RoutingRequest): Outstanding {
|
|
let resolve!: (response: RoutingResponse) => void;
|
|
let reject!: (error: Error) => void;
|
|
const promise = new Promise<RoutingResponse>((onResolve, onReject) => {
|
|
resolve = onResolve;
|
|
reject = onReject;
|
|
});
|
|
return { request, promise, resolve, reject };
|
|
}
|
|
|
|
/**
|
|
* Whether two requests ask the same question. A graph is the same when it is for the same range, the
|
|
* same budget and the very same list of drawn stars: the star field replaces that list whenever the
|
|
* set changes, so one array is one set, and comparing 70 000 indices would cost more than sharing
|
|
* could save.
|
|
*/
|
|
function asksTheSame(a: RoutingRequest, b: RoutingRequest): boolean {
|
|
if (a.kind === 'links' || b.kind === 'links') {
|
|
return (
|
|
a.kind === 'links' &&
|
|
b.kind === 'links' &&
|
|
a.rangePc === b.rangePc &&
|
|
a.drawn === b.drawn &&
|
|
a.budget?.lengthPc === b.budget?.lengthPc &&
|
|
a.budget?.centre.x === b.budget?.centre.x &&
|
|
a.budget?.centre.y === b.budget?.centre.y &&
|
|
a.budget?.centre.z === b.budget?.centre.z
|
|
);
|
|
}
|
|
return a.fromId === b.fromId && a.toId === b.toId && a.rangePc === b.rangePc && a.ceilingPc === b.ceilingPc;
|
|
}
|
|
|
|
/** The routing worker, where this environment has one. */
|
|
function startRoutingWorker(): Worker | undefined {
|
|
return typeof Worker === 'undefined' ? undefined : new Worker(new URL('../../shared/astro/routing.worker', import.meta.url), { type: 'module' });
|
|
}
|
|
|
|
/**
|
|
* Asks the route questions of a worker holding its own copy of the catalogue, and hands back
|
|
* promises.
|
|
*
|
|
* The worker answers one request at a time and cannot drop one it has started: a route with no path
|
|
* can be seconds of work, and a graph of the drawn stars at 8 pc a few hundred milliseconds. So
|
|
* requests are held here and sent one by one, and while one is out, only the latest of each kind
|
|
* waits behind it — a newer graph replaces an older one before it is ever built, and the older
|
|
* promise is rejected with {@link SupersededRequest}. Routes go ahead of graphs, being quick to ask
|
|
* for and asked for by a click. The same question asked again while it is still outstanding shares
|
|
* the answer rather than being worked out twice; see `asksTheSame`.
|
|
*
|
|
* Where there is no worker — the unit tests' DOM has none, and a worker can fail to load or crash —
|
|
* the same answers are worked out in place, from the index the scene already holds.
|
|
*/
|
|
export class RoutingClient {
|
|
private worker?: Worker;
|
|
private inFlight?: Outstanding;
|
|
private readonly waiting: Partial<Record<RoutingRequest['kind'], Outstanding>> = {};
|
|
private nextRequestId = 0;
|
|
|
|
constructor(
|
|
stars: readonly StarRecord[],
|
|
positions: Float32Array,
|
|
private readonly localIndex: StarNeighbourhood,
|
|
startWorker: () => Worker | undefined = startRoutingWorker
|
|
) {
|
|
this.worker = startWorker();
|
|
if (!this.worker) {
|
|
return;
|
|
}
|
|
this.worker.addEventListener('message', ({ data }: MessageEvent<RoutingResponse>) => this.settle(data));
|
|
// A worker that fails to load, or dies, answers nothing further: everything outstanding, and
|
|
// everything asked from here on, is worked out in place instead of waiting for good.
|
|
this.worker.addEventListener('error', () => this.abandonWorker());
|
|
this.worker.addEventListener('messageerror', () => this.abandonWorker());
|
|
// Copies, since the scene goes on using its own; transferred, so the copy is sent and not cloned again.
|
|
const ids = Int32Array.from(stars, (star) => star.id);
|
|
const copy = positions.slice();
|
|
this.worker.postMessage({ kind: 'catalogue', ids, positions: copy }, [ids.buffer, copy.buffer]);
|
|
}
|
|
|
|
route(fromId: number, toId: number, rangePc: number, ceilingPc: number): Promise<RouteAnswer> {
|
|
return this.ask({ kind: 'route', requestId: this.nextRequestId++, fromId, toId, rangePc, ceilingPc }).then((response) =>
|
|
response.kind === 'route'
|
|
? { route: response.route, neededRangePc: response.neededRangePc, gaveUp: response.gaveUp }
|
|
: { route: null, neededRangePc: null, gaveUp: false }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Vertex pairs for every link within `rangePc` between two of the `drawn` stars (catalogue
|
|
* indices), three floats to an end; only those nearest the budget's centre that fit it, if given.
|
|
*/
|
|
links(rangePc: number, drawn: Uint32Array, budget?: LinkBudget): Promise<Float32Array> {
|
|
return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc, drawn, budget }).then((response) =>
|
|
response.kind === 'links' ? response.segments : new Float32Array(0)
|
|
);
|
|
}
|
|
|
|
dispose(): void {
|
|
this.worker?.terminate();
|
|
this.worker = undefined;
|
|
this.inFlight = undefined;
|
|
delete this.waiting.route;
|
|
delete this.waiting.links;
|
|
}
|
|
|
|
private ask(request: RoutingRequest): Promise<RoutingResponse> {
|
|
if (!this.worker) {
|
|
return new Promise((resolve) => resolve(answerRouting(this.localIndex, request)));
|
|
}
|
|
// Shared rather than replaced: an identical request superseding the one it repeats would reject it,
|
|
// and whoever holds that promise would take the rejection for its own question.
|
|
const same = [this.inFlight, this.waiting[request.kind]].find((other) => other !== undefined && asksTheSame(other.request, request));
|
|
if (same) {
|
|
return same.promise;
|
|
}
|
|
const asked = outstanding(request);
|
|
this.waiting[request.kind]?.reject(new SupersededRequest());
|
|
this.waiting[request.kind] = asked;
|
|
this.sendNext();
|
|
return asked.promise;
|
|
}
|
|
|
|
private sendNext(): void {
|
|
if (this.inFlight || !this.worker) {
|
|
return;
|
|
}
|
|
const next = this.waiting.route ?? this.waiting.links;
|
|
if (!next) {
|
|
return;
|
|
}
|
|
delete this.waiting[next.request.kind];
|
|
this.inFlight = next;
|
|
// Cloned, never transferred: a graph's `drawn` is the star field's own list, still drawn and
|
|
// picked from, and answered in place from should the worker die.
|
|
this.worker.postMessage(next.request);
|
|
}
|
|
|
|
private settle(response: RoutingResponse): void {
|
|
const answered = this.inFlight;
|
|
if (!answered || answered.request.requestId !== response.requestId) {
|
|
return;
|
|
}
|
|
this.inFlight = undefined;
|
|
if (response.kind === 'failed') {
|
|
answered.reject(new Error(response.message));
|
|
} else {
|
|
answered.resolve(response);
|
|
}
|
|
this.sendNext();
|
|
}
|
|
|
|
private abandonWorker(): void {
|
|
this.worker?.terminate();
|
|
this.worker = undefined;
|
|
const stranded = [this.inFlight, this.waiting.route, this.waiting.links];
|
|
this.inFlight = undefined;
|
|
delete this.waiting.route;
|
|
delete this.waiting.links;
|
|
for (const request of stranded) {
|
|
if (!request) {
|
|
continue;
|
|
}
|
|
try {
|
|
request.resolve(answerRouting(this.localIndex, request.request));
|
|
} catch (error) {
|
|
request.reject(error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
}
|
|
}
|
|
}
|