The jump-link graph linked the whole catalogue: 3.7 million links at 8 pc, 7.4-7.8 s in the worker and a 443-515 ms frame on the main thread when they landed, and most of them between stars that were neither drawn nor clickable. A graph request now carries the star field's drawn stars, and the worker links only those, over an index of its own with cells as wide as the range. The scene asks again once a new drawn set has held still for 250 ms. The renderer is handed the graph's bounding sphere instead of computing it: three.js walked every vertex on the main thread in the first frame that drew a new graph, 48-55 ms at 8 pc. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
177 lines
6.9 KiB
TypeScript
177 lines
6.9 KiB
TypeScript
import { answerRouting, RoutingRequest, RoutingResponse } from '../../shared/astro/routing';
|
|
import { 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;
|
|
}
|
|
|
|
/** 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;
|
|
/**
|
|
* A route question without its id, so the same route asked for twice can be recognised. Empty for
|
|
* a graph: the scene never asks for the same graph twice, and spelling out 70 000 drawn stars to
|
|
* compare costs more than the comparison could save.
|
|
*/
|
|
readonly question: string;
|
|
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;
|
|
});
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { requestId, ...question } = request;
|
|
return { request, question: request.kind === 'route' ? JSON.stringify(question) : '', promise, resolve, reject };
|
|
}
|
|
|
|
/** 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 jump-link graph
|
|
* at 8 pc is seconds of work. 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 and asked for by a click. The same route asked for again while it is still
|
|
* outstanding shares the answer rather than being worked out twice.
|
|
*
|
|
* 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 } : { route: null, neededRangePc: null }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Vertex pairs for every link within `rangePc` between two of the `drawn` stars (catalogue
|
|
* indices), three floats to an end.
|
|
*/
|
|
links(rangePc: number, drawn: Uint32Array): Promise<Float32Array> {
|
|
return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc, drawn }).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)));
|
|
}
|
|
const asked = outstanding(request);
|
|
const same = asked.question ? [this.inFlight, this.waiting[request.kind]].find((other) => other?.question === asked.question) : undefined;
|
|
if (same) {
|
|
return same.promise;
|
|
}
|
|
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)));
|
|
}
|
|
}
|
|
}
|
|
}
|