Merge pull request #23 from avalon-vanguard/star-map/perf/routing-worker

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
Senrokai
2026-09-17 15:05:51 +02:00
committed by GitHub
co-authored by Claude Opus 5
17 changed files with 689 additions and 82 deletions
+7 -2
View File
@@ -37,14 +37,19 @@ jobs:
# different dependency tree than the one committed. # different dependency tree than the one committed.
- run: npm ci - run: npm ci
# Four TypeScript projects, checked by four different things. These two have no build of # Five TypeScript projects, checked by four different things. These three have no build of
# their own, so nothing else would ever compile them. # their own that checks them, so nothing else would ever compile them. The worker is bundled by
# the build, but esbuild only strips its types, and `tsconfig.app.json` leaves it out, since
# its lib is `webworker` rather than `dom`.
- name: Typecheck the ETL - name: Typecheck the ETL
run: npm run etl:typecheck run: npm run etl:typecheck
- name: Typecheck the end-to-end tests - name: Typecheck the end-to-end tests
run: npm run e2e:typecheck run: npm run e2e:typecheck
- name: Typecheck the routing worker
run: npm run worker:typecheck
# `tsconfig.spec.json` is compiled here, `tsconfig.app.json` by the build below. # `tsconfig.spec.json` is compiled here, `tsconfig.app.json` by the build below.
- name: Unit tests - name: Unit tests
run: npm test -- --no-watch run: npm test -- --no-watch
+2 -1
View File
@@ -10,7 +10,8 @@
"etl": "tsx tools/etl/build.ts", "etl": "tsx tools/etl/build.ts",
"etl:typecheck": "tsc -p tools/etl/tsconfig.json --noEmit", "etl:typecheck": "tsc -p tools/etl/tsconfig.json --noEmit",
"e2e": "playwright test", "e2e": "playwright test",
"e2e:typecheck": "tsc -p e2e/tsconfig.json --noEmit" "e2e:typecheck": "tsc -p e2e/tsconfig.json --noEmit",
"worker:typecheck": "tsc -p tsconfig.worker.json --noEmit"
}, },
"private": true, "private": true,
"packageManager": "npm@11.12.1", "packageManager": "npm@11.12.1",
@@ -266,6 +266,51 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
refocus.mockRestore(); refocus.mockRestore();
}); });
it('shows the answer to the latest route asked for, whatever order the answers arrive in', async () => {
type Answer = { route: { stars: number[]; totalPc: number; longestHopPc: number } | null; neededRangePc: number | null };
const answers: Array<(answer: Answer) => void> = [];
const component = fixture.componentInstance as unknown as {
routing: { route(): Promise<Answer>; links(): Promise<Float32Array>; dispose(): void };
routePending(): boolean;
routeResult(): { stars: { id: number }[] } | null;
onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void;
};
component.routing = {
route: () => new Promise<Answer>((resolve) => answers.push(resolve)),
links: () => Promise.resolve(new Float32Array(0)),
dispose: () => undefined
};
component.onRouteRequested({ fromId: SUN.id, toId: ALPHA_CENTAURI.id, rangePc: 2 });
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
expect(component.routePending()).toBe(true);
answers[1]({ route: { stars: [SUN.id, PROXIMA.id], totalPc: 1.3, longestHopPc: 1.3 }, neededRangePc: null });
await flushAsync();
answers[0]({ route: { stars: [SUN.id, ALPHA_CENTAURI.id], totalPc: 1.34, longestHopPc: 1.34 }, neededRangePc: null });
await flushAsync();
expect(component.routeResult()?.stars.map((star) => star.id)).toEqual([SUN.id, PROXIMA.id]);
expect(component.routePending()).toBe(false);
});
it('releases the routes panel when a route cannot be worked out, so it can be tried again', async () => {
const component = fixture.componentInstance as unknown as {
routing: { route(): Promise<never>; links(): Promise<Float32Array>; dispose(): void };
routePending(): boolean;
onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void;
};
const logged = vi.spyOn(console, 'error').mockImplementation(() => undefined);
component.routing = { route: () => Promise.reject(new Error('worker gone')), links: () => Promise.resolve(new Float32Array(0)), dispose: () => undefined };
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
await flushAsync();
expect(component.routePending()).toBe(false);
expect(logged).toHaveBeenCalled();
logged.mockRestore();
});
it('asks for no more label candidates once the last label it will show is placed', () => { it('asks for no more label candidates once the last label it will show is placed', () => {
// Near the Sun a label candidate past the fifteenth can sit at the far end of the catalogue's // Near the Sun a label candidate past the fifteenth can sit at the far end of the catalogue's
// brightness order, so asking for one more than is used can cost a walk of the whole order. // brightness order, so asking for one more than is used can cost a walk of the whole order.
@@ -36,8 +36,8 @@ import { RouteRequest, RouteResult, RouteStarOption } from '../hud/routes-panel.
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../search/search-ranking'; import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../search/search-ranking';
import { StarmapHudComponent } from './starmap-hud.component'; import { StarmapHudComponent } from './starmap-hud.component';
import { SystemObjectCardComponent } from './system-object-card.component'; import { SystemObjectCardComponent } from './system-object-card.component';
import { RoutingClient } from './routing-client';
import { colorIndexToRgb, FOCUS_RADIUS_PC, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer'; import { colorIndexToRgb, FOCUS_RADIUS_PC, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
import { collectJumpLinks, minimumRangeBetween, routeBetween } from '../../shared/astro/jump-links';
import { BrightnessIndex, brightestWithin, brightnessIndex } from '../../shared/astro/brightest'; import { BrightnessIndex, brightestWithin, brightnessIndex } from '../../shared/astro/brightest';
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood'; import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component'; import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component';
@@ -260,6 +260,7 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
[display]="display()" [display]="display()"
[routing]="true" [routing]="true"
[routeResult]="routeResult()" [routeResult]="routeResult()"
[routePending]="routePending()"
[routeOptions]="routeOptions()" [routeOptions]="routeOptions()"
[currentStar]="currentStarOption()" [currentStar]="currentStarOption()"
[keepableStarId]="navigationStore.selectedStarId()" [keepableStarId]="navigationStore.selectedStarId()"
@@ -325,10 +326,16 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private hostRings?: HostStarRings; private hostRings?: HostStarRings;
/** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */ /** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */
private neighbourhood?: StarNeighbourhood; private neighbourhood?: StarNeighbourhood;
/** Routes and the jump-link graph, worked out off the main thread. See `RoutingClient`. */
private routing?: RoutingClient;
/** Which route request is the latest, so an answer to an earlier one is not shown over it. */
private routeRequest = 0;
private jumpLinks?: JumpLinkRenderer; private jumpLinks?: JumpLinkRenderer;
/** How far a single crossing may be. Drives both the drawn graph and the route walked on it. */ /** How far a single crossing may be. Drives both the drawn graph and the route walked on it. */
readonly jumpRangePc = signal(3); readonly jumpRangePc = signal(3);
readonly routeResult = signal<RouteResult | null>(null); readonly routeResult = signal<RouteResult | null>(null);
/** A route has been asked for and not yet answered. */
readonly routePending = signal(false);
/** /**
* Matches for whichever routing field is being typed into. Stars only: a route is a chain of * Matches for whichever routing field is being typed into. Stars only: a route is a chain of
* stars, and offering a moon as a destination would be offering a place that leads nowhere. * stars, and offering a moon as a destination would be offering a place that leads nowhere.
@@ -438,6 +445,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.starField?.dispose(); this.starField?.dispose();
this.hostRings?.dispose(); this.hostRings?.dispose();
this.jumpLinks?.dispose(); this.jumpLinks?.dispose();
this.routing?.dispose();
clearTimeout(this.jumpLinkRebuild); clearTimeout(this.jumpLinkRebuild);
this.deepSky?.dispose(); this.deepSky?.dispose();
this.milkyWay?.dispose(); this.milkyWay?.dispose();
@@ -530,6 +538,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.stars = stars; this.stars = stars;
this.starsById = new Map(stars.map((star) => [star.id, star])); this.starsById = new Map(stars.map((star) => [star.id, star]));
this.neighbourhood = new StarNeighbourhood(stars); this.neighbourhood = new StarNeighbourhood(stars);
this.routing = new RoutingClient(stars, positions, this.neighbourhood);
this.starsByBrightness = brightnessIndex(stars); this.starsByBrightness = brightnessIndex(stars);
this.starSearchIndex.set( this.starSearchIndex.set(
buildSearchIndex(stars.map((star) => ({ kind: 'star' as const, name: star.name, subtitle: star.spectralType, starId: star.id }))) buildSearchIndex(stars.map((star) => ({ kind: 'star' as const, name: star.name, subtitle: star.spectralType, starId: star.id })))
@@ -1404,44 +1413,56 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
} }
/** /**
* Walks the graph, and where it cannot, says what range would. The search is lazy — it asks * Walks the graph, and where it cannot, says what range would. Both run in a worker: a route
* the index for a star's neighbours as it reaches that star — so plotting one route never * to a star 236 pc away, or the range one would need, can take seconds, and on this thread the
* costs a pass over the catalogue. * map would stop for as long. Only the latest request is shown; an earlier one still running
* when a new one is made is answered into the void.
*/ */
onRouteRequested({ fromId, toId, rangePc }: RouteRequest): void { onRouteRequested({ fromId, toId, rangePc }: RouteRequest): void {
if (!this.neighbourhood) { if (!this.routing) {
return; return;
} }
const route = routeBetween(this.neighbourhood, fromId, toId, rangePc); const request = ++this.routeRequest;
if (route) { this.routePending.set(true);
this.routeResult.set({ void this.routing.route(fromId, toId, rangePc, ROUTE_RANGE_CEILING_PC).then(
stars: route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })), ({ route, neededRangePc }) => {
totalPc: route.totalPc, if (request !== this.routeRequest) {
neededRangePc: null return;
}); }
this.jumpLinks?.setRoute(route.stars, (id) => this.starsById.get(id)); this.routePending.set(false);
return; this.routeResult.set({
} stars: route ? route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })) : [],
this.routeResult.set({ totalPc: route?.totalPc ?? 0,
stars: [], neededRangePc
totalPc: 0, });
neededRangePc: minimumRangeBetween(this.neighbourhood, fromId, toId, ROUTE_RANGE_CEILING_PC) this.jumpLinks?.setRoute(route?.stars ?? [], (id) => this.starsById.get(id));
}); },
this.jumpLinks?.setRoute([], () => undefined); (error: unknown) => {
// A request replaced by a newer one is settled this way too; only the latest matters.
if (request !== this.routeRequest) {
return;
}
// Released rather than left saying "Plotting…" with the button held, so it can be tried again.
this.routePending.set(false);
console.error('Route could not be plotted.', error);
}
);
} }
/** /**
* Rebuilds the drawn graph, which is the expensive half: every star's neighbours, once. Only * Rebuilds the drawn graph, which is the expensive half: every star's neighbours, once, and 3.7
* when the layer is on and the range has actually moved — the control emits per pixel dragged. * million links at 8 pc, so it is built in the worker. Only when the layer is on and the range
* has actually moved — the control emits per pixel dragged — and only the graph for the range
* last asked for is drawn, in whatever order the answers arrive.
*/ */
private refreshJumpLinks(): void { private refreshJumpLinks(): void {
if (!this.jumpLinks || !this.neighbourhood) { if (!this.jumpLinks || !this.routing) {
return; return;
} }
const rangePc = this.jumpRangePc(); const rangePc = this.jumpRangePc();
if (!this.display().jumpLinks) { if (!this.display().jumpLinks) {
if (this.drawnJumpRangePc !== null) { if (this.drawnJumpRangePc !== null) {
this.jumpLinks.setLinks([], () => undefined); this.jumpLinks.setSegments(new Float32Array(0));
this.drawnJumpRangePc = null; this.drawnJumpRangePc = null;
} }
return; return;
@@ -1450,8 +1471,20 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
return; return;
} }
this.drawnJumpRangePc = rangePc; this.drawnJumpRangePc = rangePc;
const links = collectJumpLinks(this.neighbourhood, rangePc); void this.routing.links(rangePc).then(
this.jumpLinks.setLinks(links, (id) => this.starsById.get(id)); (segments) => {
if (this.drawnJumpRangePc === rangePc) {
this.jumpLinks?.setSegments(segments);
}
},
() => {
// Replaced by a newer range, or failed. Either way this range is not drawn, and must not be
// remembered as if it were, or asking for it again would be skipped.
if (this.drawnJumpRangePc === rangePc) {
this.drawnJumpRangePc = null;
}
}
);
} }
/** A pinned body wins over a hovered one, so the card does not change under the pointer. */ /** A pinned body wins over a hovered one, so the card does not change under the pointer. */
@@ -1,7 +1,5 @@
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { JumpLink } from '../../shared/astro/jump-links';
/** Faint, because there are tens of thousands of them and none is worth reading on its own. */ /** Faint, because there are tens of thousands of them and none is worth reading on its own. */
const LINK_OPACITY = 0.16; const LINK_OPACITY = 0.16;
/** The one route is the figure; the graph it is drawn on is the ground. */ /** The one route is the figure; the graph it is drawn on is the ground. */
@@ -48,23 +46,13 @@ export class JumpLinkRenderer {
this.links.frustumCulled = false; this.links.frustumCulled = false;
this.route.frustumCulled = false; this.route.frustumCulled = false;
this.object.add(this.links, this.route); this.object.add(this.links, this.route);
this.setLinks([], () => undefined); this.setSegments(new Float32Array(0));
this.setRoute([], () => undefined); this.setRoute([], () => undefined);
} }
setLinks(links: readonly JumpLink[], positionOf: (starId: number) => LinkPoint | undefined): void { /** The graph, as vertex pairs: six floats a link, one end then the other. See `jumpLinkSegments`. */
const vertices = new Float32Array(links.length * 6); setSegments(vertices: Float32Array): void {
let at = 0; this.replaceGeometry(this.links, vertices);
for (const link of links) {
const from = positionOf(link.from);
const to = positionOf(link.to);
if (!from || !to) {
continue;
}
vertices.set([from.x, from.y, from.z, to.x, to.y, to.z], at);
at += 6;
}
this.replaceGeometry(this.links, at === vertices.length ? vertices : vertices.subarray(0, at));
} }
/** The chain to draw over the graph, departure first. Fewer than two stars draws nothing. */ /** The chain to draw over the graph, departure first. Fewer than two stars draws nothing. */
@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest';
import { jumpLinkSegments, routeBetween } from '../../shared/astro/jump-links';
import { RoutingRequest, RoutingResponse } from '../../shared/astro/routing';
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
import { StarRecord } from '../../shared/models/star.model';
import { RoutingClient, SupersededRequest } from './routing-client';
const STARS: StarRecord[] = Array.from({ length: 6 }, (_, i) => ({
id: 100 + i,
name: `star-${i}`,
x: i < 5 ? i : 9,
y: 0,
z: 0,
magnitude: 5,
spectralType: 'G2V',
colorIndex: 0.6
}));
const POSITIONS = Float32Array.from(STARS.flatMap((star) => [star.x, star.y, star.z]));
const index = new StarNeighbourhood(STARS);
/** Flushes settled promises and their handlers. */
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
/** A worker that records what it is sent and answers only when told to. */
class FakeWorker {
readonly sent: Array<RoutingRequest | { kind: 'catalogue' }> = [];
private readonly listeners: Record<string, Array<(event: { data?: unknown }) => void>> = {};
terminated = false;
postMessage(message: RoutingRequest | { kind: 'catalogue' }): void {
this.sent.push(message);
}
addEventListener(type: string, listener: (event: { data?: unknown }) => void): void {
(this.listeners[type] ??= []).push(listener);
}
terminate(): void {
this.terminated = true;
}
/** The requests sent so far, catalogue aside. */
get requests(): RoutingRequest[] {
return this.sent.filter((message): message is RoutingRequest => message.kind !== 'catalogue');
}
answer(response: RoutingResponse): void {
for (const listener of this.listeners['message'] ?? []) listener({ data: response });
}
fail(): void {
for (const listener of this.listeners['error'] ?? []) listener({});
}
}
function clientWithFake(): { client: RoutingClient; worker: FakeWorker } {
const worker = new FakeWorker();
const client = new RoutingClient(STARS, POSITIONS, index, () => worker as unknown as Worker);
return { client, worker };
}
// The unit tests' DOM has no Worker, which is exactly the case the client answers in place.
describe('RoutingClient without a worker', () => {
it('has no Worker to use here, so the in-place answers are what is being tested', () => {
expect(typeof Worker).toBe('undefined');
});
it('answers a route from the index it was given', async () => {
const client = new RoutingClient(STARS, POSITIONS, index);
await expect(client.route(100, 104, 1.5, 8)).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5), neededRangePc: null });
client.dispose();
});
it('answers a refused route with the range that would open it', async () => {
const client = new RoutingClient(STARS, POSITIONS, index);
const answer = await client.route(100, 105, 1.5, 8);
expect(answer.route).toBeNull();
expect(answer.neededRangePc).toBeCloseTo(5, 1);
client.dispose();
});
it('answers the graph as segments', async () => {
const client = new RoutingClient(STARS, POSITIONS, index);
expect(Array.from(await client.links(1.5))).toEqual(Array.from(jumpLinkSegments(index, 1.5)));
client.dispose();
});
});
describe('RoutingClient with a worker', () => {
it('sends the catalogue first, then one request at a time', () => {
const { client, worker } = clientWithFake();
void client.links(8);
void client.links(3);
expect(worker.sent[0].kind).toBe('catalogue');
expect(worker.requests).toHaveLength(1);
client.dispose();
});
// A graph at 8 pc is seconds of work the worker cannot drop once started. Every pause on the
// range slider used to queue another, and a route asked for after them waited behind them all.
it('replaces a waiting graph with the newer one before it is ever built, and sends a route ahead of it', async () => {
const { client, worker } = clientWithFake();
const first = client.links(5);
const superseded = client.links(6).catch((error: unknown) => error);
const latest = client.links(8);
const route = client.route(100, 104, 1.5, 8);
const building = worker.requests[0];
worker.answer({ kind: 'links', requestId: building.requestId, segments: new Float32Array(6) });
await flush();
expect(await superseded).toBeInstanceOf(SupersededRequest);
expect(worker.requests.map((request) => request.kind)).toEqual(['links', 'route']);
await expect(first).resolves.toHaveLength(6);
const routeRequest = worker.requests[1];
worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4 });
await expect(route).resolves.toEqual({ route: null, neededRangePc: 4 });
await flush();
expect(worker.requests.map((request) => (request.kind === 'links' ? request.rangePc : request.kind))).toEqual([5, 'route', 8]);
const lastGraph = worker.requests[2];
worker.answer({ kind: 'links', requestId: lastGraph.requestId, segments: new Float32Array(12) });
await expect(latest).resolves.toHaveLength(12);
client.dispose();
});
it('shares the answer to a question already on its way rather than asking it twice', async () => {
const { client, worker } = clientWithFake();
const once = client.links(7.5);
const again = client.links(7.5);
expect(worker.requests).toHaveLength(1);
worker.answer({ kind: 'links', requestId: worker.requests[0].requestId, segments: new Float32Array(6) });
expect(await again).toBe(await once);
expect(worker.requests).toHaveLength(1);
client.dispose();
});
it('rejects a request the worker failed on, and goes on to the next', async () => {
const { client, worker } = clientWithFake();
const failing = client.route(100, 104, 1.5, 8).catch((error: unknown) => error);
const next = client.links(3);
worker.answer({ kind: 'failed', requestId: worker.requests[0].requestId, message: 'out of memory' });
expect(((await failing) as Error).message).toBe('out of memory');
await flush();
expect(worker.requests.map((request) => request.kind)).toEqual(['route', 'links']);
worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(0) });
await expect(next).resolves.toHaveLength(0);
client.dispose();
});
it('answers in place what a worker that failed to load left outstanding, and everything after', async () => {
const { client, worker } = clientWithFake();
const route = client.route(100, 104, 1.5, 8);
const graph = client.links(1.5);
worker.fail();
await expect(route).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5), neededRangePc: null });
expect(Array.from(await graph)).toEqual(Array.from(jumpLinkSegments(index, 1.5)));
await expect(client.route(100, 105, 1.5, 8)).resolves.toMatchObject({ route: null });
expect(worker.terminated).toBe(true);
client.dispose();
});
});
@@ -0,0 +1,167 @@
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;
/** The question without its id, so the same question asked twice can be recognised. */
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: 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 question asked 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`, 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.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 = [this.inFlight, this.waiting[request.kind]].find((other) => other?.question === asked.question);
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;
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)));
}
}
}
}
@@ -132,6 +132,7 @@ function isWideViewport(): boolean {
<section id="dock-panel-routes" role="tabpanel" aria-labelledby="dock-tab-routes" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3"> <section id="dock-panel-routes" role="tabpanel" aria-labelledby="dock-tab-routes" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3">
<app-routes-panel <app-routes-panel
[result]="routeResult()" [result]="routeResult()"
[pending]="routePending()"
[options]="routeOptions()" [options]="routeOptions()"
[currentStar]="currentStar()" [currentStar]="currentStar()"
(queryChange)="routeQuery.emit($event)" (queryChange)="routeQuery.emit($event)"
@@ -241,6 +242,7 @@ export class HudDockComponent implements OnInit {
readonly defaultTab = input<DockTab | null>(null); readonly defaultTab = input<DockTab | null>(null);
/** Routing: what the scene found, what it offers for the fields, and where the view is. */ /** Routing: what the scene found, what it offers for the fields, and where the view is. */
readonly routeResult = input<RouteResult | null>(null); readonly routeResult = input<RouteResult | null>(null);
readonly routePending = input(false);
readonly routeOptions = input<readonly RouteStarOption[]>([]); readonly routeOptions = input<readonly RouteStarOption[]>([]);
readonly currentStar = input<RouteStarOption | null>(null); readonly currentStar = input<RouteStarOption | null>(null);
/** The star the readout is about, where there is one to keep — a scale is not a place. */ /** The star the readout is about, where there is one to keep — a scale is not a place. */
@@ -100,11 +100,11 @@ type Field = 'from' | 'to';
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <button
type="button" type="button"
[disabled]="!canPlot()" [disabled]="!canPlot() || pending()"
(click)="plot()" (click)="plot()"
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors enabled:hover:border-accent/70 enabled:hover:text-accent disabled:opacity-40 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent" class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors enabled:hover:border-accent/70 enabled:hover:text-accent disabled:opacity-40 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
> >
Plot route {{ pending() ? 'Plotting…' : 'Plot route' }}
</button> </button>
@if (result(); as plotted) { @if (result(); as plotted) {
@if (plotted.stars.length) { @if (plotted.stars.length) {
@@ -154,6 +154,8 @@ export class RoutesPanelComponent {
readonly result = input<RouteResult | null>(null); readonly result = input<RouteResult | null>(null);
/** Matches for the field currently being typed into, ranked by the scene. */ /** Matches for the field currently being typed into, ranked by the scene. */
readonly options = input<readonly RouteStarOption[]>([]); readonly options = input<readonly RouteStarOption[]>([]);
/** The scene is still working the last request out; asking again would only queue behind it. */
readonly pending = input(false);
/** The star the view is currently inside, offered as the departure without typing. */ /** The star the view is currently inside, offered as the departure without typing. */
readonly currentStar = input<RouteStarOption | null>(null); readonly currentStar = input<RouteStarOption | null>(null);
+48 -16
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { collectJumpLinks, minimumRangeBetween, routeBetween } from './jump-links'; import { jumpLinkSegments, minimumRangeBetween, routeBetween } from './jump-links';
import { StarNeighbourhood, StarPoint } from './star-neighbourhood'; import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
/** Stars a parsec apart along x, so a chain's length is the number of hops it takes. */ /** Stars a parsec apart along x, so a chain's length is the number of hops it takes. */
@@ -153,25 +153,58 @@ describe('minimumRangeBetween', () => {
}); });
}); });
describe('collectJumpLinks', () => { /**
it('reports each pair once, not once from either end', () => { * The links a segment buffer draws, as unordered pairs of star ids, read back from where each end
const links = collectJumpLinks(chain(4), 1.5); * sits. Positions are compared as the float32 the buffer holds.
*/
function linksDrawn(segments: Float32Array, points: readonly StarPoint[]): string[] {
const idAt = new Map(points.map((point) => [[point.x, point.y, point.z].map(Math.fround).join(), point.id]));
const links: string[] = [];
for (let at = 0; at < segments.length; at += 6) {
const a = idAt.get(Array.from(segments.subarray(at, at + 3)).join())!;
const b = idAt.get(Array.from(segments.subarray(at + 3, at + 6)).join())!;
links.push(a < b ? `${a}-${b}` : `${b}-${a}`);
}
return links;
}
expect(links.map((link) => [link.from, link.to])).toEqual([ /** Stars a parsec apart along x, as points, for reading a segment buffer back. */
[0, 1], function chainPoints(count: number): StarPoint[] {
[1, 2], return Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
[2, 3] }
]);
describe('jumpLinkSegments', () => {
it('draws each pair once, not once from either end', () => {
const segments = jumpLinkSegments(chain(4), 1.5);
expect(linksDrawn(segments, chainPoints(4)).sort()).toEqual(['0-1', '1-2', '2-3']);
}); });
it('measures every link it reports', () => { it('puts both ends of every link where its stars are', () => {
const links = collectJumpLinks(chain(3), 2.5); const segments = jumpLinkSegments(chain(3), 2.5);
expect(links.find((link) => link.from === 0 && link.to === 2)?.distancePc).toBeCloseTo(2); expect(segments).toHaveLength(3 * 6);
expect(linksDrawn(segments, chainPoints(3)).sort()).toEqual(['0-1', '0-2', '1-2']);
}); });
it('draws nothing at no range', () => { it('draws nothing at no range', () => {
expect(collectJumpLinks(chain(4), 0)).toEqual([]); expect(jumpLinkSegments(chain(4), 0)).toHaveLength(0);
});
it('grows past its first buffer without losing a link', () => {
// 5 000 stars a tenth of a parsec apart, ten neighbours each way in range: some 50 000 links, far past
// the 4 096 the buffer starts with, so it has to grow several times.
const count = 5000;
const line = new StarNeighbourhood(Array.from({ length: count }, (_, i) => ({ id: i, x: i / 10, y: 0, z: 0 })));
// 1.05 rather than 1: the tenth neighbour sits at 1.0, which float steps of a tenth put either side of it.
const segments = jumpLinkSegments(line, 1.05);
let expected = 0;
for (let i = 0; i < count; i++) {
expected += Math.min(10, count - 1 - i);
}
expect(segments.length / 6).toBe(expected);
expect(segments.buffer.byteLength).toBe(segments.byteLength);
}); });
it('agrees with every route it makes possible', () => { it('agrees with every route it makes possible', () => {
@@ -185,8 +218,7 @@ describe('collectJumpLinks', () => {
// earlier version of this test hid by only checking the route it happened to find. // earlier version of this test hid by only checking the route it happened to find.
const range = 9; const range = 9;
const links = collectJumpLinks(cloud, range); const drawn = new Set(linksDrawn(jumpLinkSegments(cloud, range), points));
const drawn = new Set(links.map((link) => `${link.from}-${link.to}`));
const route = routeBetween(cloud, 0, 119, range); const route = routeBetween(cloud, 0, 119, range);
// Asserted, not guarded: a skipped body would let the two disagree unnoticed. // Asserted, not guarded: a skipped body would let the two disagree unnoticed.
@@ -196,6 +228,6 @@ describe('collectJumpLinks', () => {
const [a, b] = [route!.stars[i - 1], route!.stars[i]].sort((x, y) => x - y); const [a, b] = [route!.stars[i - 1], route!.stars[i]].sort((x, y) => x - y);
expect(drawn.has(`${a}-${b}`)).toBe(true); expect(drawn.has(`${a}-${b}`)).toBe(true);
} }
expect(links.length).toBeGreaterThan(0); expect(drawn.size).toBeGreaterThan(0);
}); });
}); });
+23 -17
View File
@@ -29,13 +29,6 @@ export interface Route {
readonly longestHopPc: number; readonly longestHopPc: number;
} }
/** An unordered pair of stars within range of each other. */
export interface JumpLink {
readonly from: number;
readonly to: number;
readonly distancePc: number;
}
/** /**
* A cap on how much of the catalogue one search may walk. A search that hits it has already * 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 * visited more stars than any real chain passes through: the longest measured, Sol to HD 2626 at
@@ -221,17 +214,30 @@ export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, to
} }
/** /**
* Every link within `rangePc` in the whole catalogue, each pair once. * Every link within `rangePc` in the whole catalogue, each pair once, as vertex pairs ready to
* draw: six floats a link, one end then the other.
* *
* For drawing the graph, which is the only thing that wants all of it: routing asks for a * For drawing the graph, which is the only thing that wants all of it: routing asks for a star's
* star's neighbours as it reaches that star and never builds this. * 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 there are 3.7 million links.
*/ */
export function collectJumpLinks(index: StarNeighbourhood, rangePc: number): JumpLink[] { export function jumpLinkSegments(index: StarNeighbourhood, rangePc: number): Float32Array {
const links: JumpLink[] = []; let vertices = new Float32Array(6 * 4096);
index.forEachPairWithin(rangePc, (a, b, distancePc) => { let length = 0;
// The smaller id first, always. The grid hands pairs over in whatever order it walks its index.forEachPairWithin(rangePc, (a, b) => {
// cells, and a link that is `3-7` here and `7-3` there is two links to anything comparing. if (length + 6 > vertices.length) {
links.push(a.id < b.id ? { from: a.id, to: b.id, distancePc } : { from: b.id, to: a.id, distancePc }); 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;
}); });
return links; // 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);
} }
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { jumpLinkSegments, minimumRangeBetween, routeBetween } from './jump-links';
import { answerRouting, indexCatalogue } from './routing';
import { StarNeighbourhood } from './star-neighbourhood';
/** Stars a parsec apart along x, then a gap of 5 pc to one more. */
const POINTS = [...Array.from({ length: 5 }, (_, i) => ({ id: 10 + i, x: i, y: 0, z: 0 })), { id: 99, x: 9, y: 0, z: 0 }];
function catalogue() {
return {
kind: 'catalogue' as const,
ids: Int32Array.from(POINTS, (point) => point.id),
positions: Float32Array.from(POINTS.flatMap((point) => [point.x, point.y, point.z]))
};
}
describe('indexCatalogue', () => {
it('indexes the catalogue as it was packed, id by id', () => {
const index = indexCatalogue(catalogue());
for (const point of POINTS) {
expect(index.point(point.id)).toEqual(point);
}
});
});
describe('answerRouting', () => {
const index = indexCatalogue(catalogue());
const direct = new StarNeighbourhood(POINTS);
it('answers a route the range allows, with nothing to raise it to', () => {
const answer = answerRouting(index, { kind: 'route', requestId: 7, fromId: 10, toId: 14, rangePc: 1.5, ceilingPc: 8 });
expect(answer).toEqual({ kind: 'route', requestId: 7, route: routeBetween(direct, 10, 14, 1.5), neededRangePc: null });
});
it('answers a route the range does not allow with the range that would', () => {
const answer = answerRouting(index, { kind: 'route', requestId: 8, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 8 });
expect(answer).toEqual({ kind: 'route', requestId: 8, route: null, neededRangePc: minimumRangeBetween(direct, 10, 99, 8) });
expect(answer.kind === 'route' && answer.neededRangePc).toBeCloseTo(5, 1);
});
it('offers nothing to raise to when even the ceiling does not reach', () => {
const answer = answerRouting(index, { kind: 'route', requestId: 9, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 3 });
expect(answer).toMatchObject({ route: null, neededRangePc: null });
});
it('answers the graph as the segments it draws', () => {
const answer = answerRouting(index, { kind: 'links', requestId: 3, rangePc: 1.5 });
expect(answer.kind).toBe('links');
expect(answer.requestId).toBe(3);
expect(answer.kind === 'links' && Array.from(answer.segments)).toEqual(Array.from(jumpLinkSegments(direct, 1.5)));
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* The route questions the map asks of the whole catalogue, as messages: what a worker is sent,
* what it sends back, and the one function that turns the first into the second.
*
* Kept apart from the worker itself so it runs the same on either side of the thread boundary.
* The scene asks through `RoutingClient`, which hands these to a Web Worker where one exists and
* answers them in place where one does not.
*/
import { jumpLinkSegments, minimumRangeBetween, Route, routeBetween } from './jump-links';
import { StarNeighbourhood } from './star-neighbourhood';
/** The catalogue, sent once: ids, and positions packed three to a star in the same order. */
export interface RoutingCatalogue {
readonly kind: 'catalogue';
readonly ids: Int32Array;
readonly positions: Float32Array;
}
export type RoutingRequest =
| { readonly kind: 'route'; readonly requestId: number; readonly fromId: number; readonly toId: number; readonly rangePc: number; readonly ceilingPc: number }
| { readonly kind: 'links'; readonly requestId: number; readonly rangePc: number };
export type RoutingResponse =
| { readonly kind: 'route'; readonly requestId: number; readonly route: Route | null; readonly neededRangePc: number | null }
| { readonly kind: 'links'; readonly requestId: number; readonly segments: Float32Array }
/** The question threw in the worker. Sent back so the request settles instead of waiting for good. */
| { readonly kind: 'failed'; readonly requestId: number; readonly message: string };
/** A spatial index over a catalogue sent as a {@link RoutingCatalogue}. */
export function indexCatalogue({ ids, positions }: RoutingCatalogue): StarNeighbourhood {
return new StarNeighbourhood(Array.from(ids, (id, i) => ({ id, x: positions[i * 3], y: positions[i * 3 + 1], z: positions[i * 3 + 2] })));
}
/**
* Answers one request. A route that cannot be made comes back with the range that would make
* one, searched no wider than `ceilingPc`, so a refusal is always also an offer.
*/
export function answerRouting(index: StarNeighbourhood, request: RoutingRequest): RoutingResponse {
if (request.kind === 'links') {
return { kind: 'links', requestId: request.requestId, segments: jumpLinkSegments(index, request.rangePc) };
}
const route = routeBetween(index, request.fromId, request.toId, request.rangePc);
return {
kind: 'route',
requestId: request.requestId,
route,
neededRangePc: route ? null : minimumRangeBetween(index, request.fromId, request.toId, request.ceilingPc)
};
}
+25
View File
@@ -0,0 +1,25 @@
/// <reference lib="webworker" />
import { answerRouting, indexCatalogue, RoutingCatalogue, RoutingRequest } from './routing';
import { StarNeighbourhood } from './star-neighbourhood';
/**
* Walks routes and builds the jump-link graph off the main thread. A search to a star 236 pc
* away, and the range it would need when there is none, can take seconds; a graph at 8 pc is
* 3.7 million links. On the page's own thread either stops the map for as long as it runs.
*/
let index: StarNeighbourhood | undefined;
addEventListener('message', ({ data }: MessageEvent<RoutingCatalogue | RoutingRequest>) => {
if (data.kind === 'catalogue') {
index = indexCatalogue(data);
return;
}
// The catalogue is always the first message, and a worker's messages arrive in order.
try {
const response = answerRouting(index!, data);
postMessage(response, response.kind === 'links' ? [response.segments.buffer] : []);
} catch (error) {
postMessage({ kind: 'failed', requestId: data.requestId, message: error instanceof Error ? error.message : String(error) });
}
});
+2 -1
View File
@@ -9,6 +9,7 @@
"src/**/*.ts" "src/**/*.ts"
], ],
"exclude": [ "exclude": [
"src/**/*.spec.ts" "src/**/*.spec.ts",
"src/**/*.worker.ts"
] ]
} }
+3
View File
@@ -26,6 +26,9 @@
}, },
{ {
"path": "./tsconfig.spec.json" "path": "./tsconfig.spec.json"
},
{
"path": "./tsconfig.worker.json"
} }
] ]
} }
+13
View File
@@ -0,0 +1,13 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/worker",
"lib": ["es2022", "webworker"],
"types": []
},
"include": [
"src/**/*.worker.ts"
]
}