Plot routes and build the jump-link graph in a Web Worker

Route plotting ran on the main thread, and so did the jump-link graph:

- the range search for a far target, HD 2626 at 236 pc, takes 4-5 s;
- the graph at 8 pc is 3.7 million links, 6-10 s to build, then as many
  link objects again to turn into vertices.

The map stopped for as long as either ran.

A Web Worker now does both. RoutingClient sends it the catalogue's ids and
positions once, and it keeps its own spatial index. A route question comes
back with the route, or with the range that would open one. A graph comes
back as one Float32Array of segment vertices, transferred rather than
copied.

On the scene side, only the latest route request is shown: an earlier
answer arriving later is dropped. Only the graph for the range last asked
for is drawn. The Routes panel says "Plotting…" and holds its button while
a request is out.

collectJumpLinks gave way to jumpLinkSegments, which writes the vertex pairs
straight into floats rather than building link objects first; the scene was
its only caller. The routing module (routing.ts) is the message protocol and
the one function answering it, so the worker is a dozen lines, and the same
answers are worked out in place where there is no Worker, as in the unit
tests' DOM. The worker is built with its own tsconfig, as the Angular
builder expects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-09-16 14:17:26 +02:00
co-authored by Claude Opus 5
parent 2d997e41db
commit 8c69a7a8b2
16 changed files with 409 additions and 77 deletions
@@ -0,0 +1,65 @@
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;
}
type Pending = (response: RoutingResponse) => void;
/**
* Asks the route questions of a worker holding its own copy of the catalogue, and hands back
* promises. Where there is no `Worker` — the unit tests' DOM has none — the same answers are
* worked out in place, from the index the scene already holds.
*/
export class RoutingClient {
private readonly worker?: Worker;
private readonly pending = new Map<number, Pending>();
private nextRequestId = 0;
constructor(stars: readonly StarRecord[], positions: Float32Array, private readonly localIndex: StarNeighbourhood) {
if (typeof Worker === 'undefined') {
return;
}
this.worker = new Worker(new URL('../../shared/astro/routing.worker', import.meta.url), { type: 'module' });
this.worker.addEventListener('message', ({ data }: MessageEvent<RoutingResponse>) => {
this.pending.get(data.requestId)?.(data);
this.pending.delete(data.requestId);
});
// 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`, three floats to an end. */
links(rangePc: number): Promise<Float32Array> {
return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc }).then((response) =>
response.kind === 'links' ? response.segments : new Float32Array(0)
);
}
dispose(): void {
this.worker?.terminate();
this.pending.clear();
}
private ask(request: RoutingRequest): Promise<RoutingResponse> {
if (!this.worker) {
return Promise.resolve(answerRouting(this.localIndex, request));
}
return new Promise((resolve) => {
this.pending.set(request.requestId, resolve);
this.worker!.postMessage(request);
});
}
}