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:
@@ -37,14 +37,19 @@ jobs:
|
||||
# different dependency tree than the one committed.
|
||||
- run: npm ci
|
||||
|
||||
# Four TypeScript projects, checked by four different things. These two have no build of
|
||||
# their own, so nothing else would ever compile them.
|
||||
# Five TypeScript projects, checked by four different things. These three have no build of
|
||||
# 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
|
||||
run: npm run etl:typecheck
|
||||
|
||||
- name: Typecheck the end-to-end tests
|
||||
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.
|
||||
- name: Unit tests
|
||||
run: npm test -- --no-watch
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
"etl": "tsx tools/etl/build.ts",
|
||||
"etl:typecheck": "tsc -p tools/etl/tsconfig.json --noEmit",
|
||||
"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,
|
||||
"packageManager": "npm@11.12.1",
|
||||
|
||||
@@ -266,6 +266,51 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
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', () => {
|
||||
// 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.
|
||||
|
||||
@@ -36,8 +36,8 @@ import { RouteRequest, RouteResult, RouteStarOption } from '../hud/routes-panel.
|
||||
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../search/search-ranking';
|
||||
import { StarmapHudComponent } from './starmap-hud.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 { collectJumpLinks, minimumRangeBetween, routeBetween } from '../../shared/astro/jump-links';
|
||||
import { BrightnessIndex, brightestWithin, brightnessIndex } from '../../shared/astro/brightest';
|
||||
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
|
||||
import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component';
|
||||
@@ -260,6 +260,7 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
|
||||
[display]="display()"
|
||||
[routing]="true"
|
||||
[routeResult]="routeResult()"
|
||||
[routePending]="routePending()"
|
||||
[routeOptions]="routeOptions()"
|
||||
[currentStar]="currentStarOption()"
|
||||
[keepableStarId]="navigationStore.selectedStarId()"
|
||||
@@ -325,10 +326,16 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
private hostRings?: HostStarRings;
|
||||
/** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */
|
||||
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;
|
||||
/** How far a single crossing may be. Drives both the drawn graph and the route walked on it. */
|
||||
readonly jumpRangePc = signal(3);
|
||||
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
|
||||
* 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.hostRings?.dispose();
|
||||
this.jumpLinks?.dispose();
|
||||
this.routing?.dispose();
|
||||
clearTimeout(this.jumpLinkRebuild);
|
||||
this.deepSky?.dispose();
|
||||
this.milkyWay?.dispose();
|
||||
@@ -530,6 +538,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.stars = stars;
|
||||
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
||||
this.neighbourhood = new StarNeighbourhood(stars);
|
||||
this.routing = new RoutingClient(stars, positions, this.neighbourhood);
|
||||
this.starsByBrightness = brightnessIndex(stars);
|
||||
this.starSearchIndex.set(
|
||||
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
|
||||
* the index for a star's neighbours as it reaches that star — so plotting one route never
|
||||
* costs a pass over the catalogue.
|
||||
* Walks the graph, and where it cannot, says what range would. Both run in a worker: a route
|
||||
* to a star 236 pc away, or the range one would need, can take seconds, and on this thread the
|
||||
* 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 {
|
||||
if (!this.neighbourhood) {
|
||||
if (!this.routing) {
|
||||
return;
|
||||
}
|
||||
const route = routeBetween(this.neighbourhood, fromId, toId, rangePc);
|
||||
if (route) {
|
||||
this.routeResult.set({
|
||||
stars: route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })),
|
||||
totalPc: route.totalPc,
|
||||
neededRangePc: null
|
||||
});
|
||||
this.jumpLinks?.setRoute(route.stars, (id) => this.starsById.get(id));
|
||||
return;
|
||||
}
|
||||
this.routeResult.set({
|
||||
stars: [],
|
||||
totalPc: 0,
|
||||
neededRangePc: minimumRangeBetween(this.neighbourhood, fromId, toId, ROUTE_RANGE_CEILING_PC)
|
||||
});
|
||||
this.jumpLinks?.setRoute([], () => undefined);
|
||||
const request = ++this.routeRequest;
|
||||
this.routePending.set(true);
|
||||
void this.routing.route(fromId, toId, rangePc, ROUTE_RANGE_CEILING_PC).then(
|
||||
({ route, neededRangePc }) => {
|
||||
if (request !== this.routeRequest) {
|
||||
return;
|
||||
}
|
||||
this.routePending.set(false);
|
||||
this.routeResult.set({
|
||||
stars: route ? route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })) : [],
|
||||
totalPc: route?.totalPc ?? 0,
|
||||
neededRangePc
|
||||
});
|
||||
this.jumpLinks?.setRoute(route?.stars ?? [], (id) => this.starsById.get(id));
|
||||
},
|
||||
(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
|
||||
* when the layer is on and the range has actually moved — the control emits per pixel dragged.
|
||||
* Rebuilds the drawn graph, which is the expensive half: every star's neighbours, once, and 3.7
|
||||
* 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 {
|
||||
if (!this.jumpLinks || !this.neighbourhood) {
|
||||
if (!this.jumpLinks || !this.routing) {
|
||||
return;
|
||||
}
|
||||
const rangePc = this.jumpRangePc();
|
||||
if (!this.display().jumpLinks) {
|
||||
if (this.drawnJumpRangePc !== null) {
|
||||
this.jumpLinks.setLinks([], () => undefined);
|
||||
this.jumpLinks.setSegments(new Float32Array(0));
|
||||
this.drawnJumpRangePc = null;
|
||||
}
|
||||
return;
|
||||
@@ -1450,8 +1471,20 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
this.drawnJumpRangePc = rangePc;
|
||||
const links = collectJumpLinks(this.neighbourhood, rangePc);
|
||||
this.jumpLinks.setLinks(links, (id) => this.starsById.get(id));
|
||||
void this.routing.links(rangePc).then(
|
||||
(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. */
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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. */
|
||||
const LINK_OPACITY = 0.16;
|
||||
/** 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.route.frustumCulled = false;
|
||||
this.object.add(this.links, this.route);
|
||||
this.setLinks([], () => undefined);
|
||||
this.setSegments(new Float32Array(0));
|
||||
this.setRoute([], () => undefined);
|
||||
}
|
||||
|
||||
setLinks(links: readonly JumpLink[], positionOf: (starId: number) => LinkPoint | undefined): void {
|
||||
const vertices = new Float32Array(links.length * 6);
|
||||
let at = 0;
|
||||
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 graph, as vertex pairs: six floats a link, one end then the other. See `jumpLinkSegments`. */
|
||||
setSegments(vertices: Float32Array): void {
|
||||
this.replaceGeometry(this.links, vertices);
|
||||
}
|
||||
|
||||
/** 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">
|
||||
<app-routes-panel
|
||||
[result]="routeResult()"
|
||||
[pending]="routePending()"
|
||||
[options]="routeOptions()"
|
||||
[currentStar]="currentStar()"
|
||||
(queryChange)="routeQuery.emit($event)"
|
||||
@@ -241,6 +242,7 @@ export class HudDockComponent implements OnInit {
|
||||
readonly defaultTab = input<DockTab | null>(null);
|
||||
/** Routing: what the scene found, what it offers for the fields, and where the view is. */
|
||||
readonly routeResult = input<RouteResult | null>(null);
|
||||
readonly routePending = input(false);
|
||||
readonly routeOptions = input<readonly RouteStarOption[]>([]);
|
||||
readonly currentStar = input<RouteStarOption | null>(null);
|
||||
/** 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">
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="!canPlot()"
|
||||
[disabled]="!canPlot() || pending()"
|
||||
(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"
|
||||
>
|
||||
Plot route
|
||||
{{ pending() ? 'Plotting…' : 'Plot route' }}
|
||||
</button>
|
||||
@if (result(); as plotted) {
|
||||
@if (plotted.stars.length) {
|
||||
@@ -154,6 +154,8 @@ export class RoutesPanelComponent {
|
||||
readonly result = input<RouteResult | null>(null);
|
||||
/** Matches for the field currently being typed into, ranked by the scene. */
|
||||
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. */
|
||||
readonly currentStar = input<RouteStarOption | null>(null);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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';
|
||||
|
||||
/** 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', () => {
|
||||
const links = collectJumpLinks(chain(4), 1.5);
|
||||
/**
|
||||
* The links a segment buffer draws, as unordered pairs of star ids, read back from where each end
|
||||
* 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([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 3]
|
||||
]);
|
||||
/** Stars a parsec apart along x, as points, for reading a segment buffer back. */
|
||||
function chainPoints(count: number): StarPoint[] {
|
||||
return Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
|
||||
}
|
||||
|
||||
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', () => {
|
||||
const links = collectJumpLinks(chain(3), 2.5);
|
||||
it('puts both ends of every link where its stars are', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
@@ -185,8 +218,7 @@ describe('collectJumpLinks', () => {
|
||||
// earlier version of this test hid by only checking the route it happened to find.
|
||||
const range = 9;
|
||||
|
||||
const links = collectJumpLinks(cloud, range);
|
||||
const drawn = new Set(links.map((link) => `${link.from}-${link.to}`));
|
||||
const drawn = new Set(linksDrawn(jumpLinkSegments(cloud, range), points));
|
||||
|
||||
const route = routeBetween(cloud, 0, 119, range);
|
||||
// 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);
|
||||
expect(drawn.has(`${a}-${b}`)).toBe(true);
|
||||
}
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
expect(drawn.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,13 +29,6 @@ export interface Route {
|
||||
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
|
||||
* 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
|
||||
* star's neighbours as it reaches that star and never builds this.
|
||||
* For drawing the graph, which is the only thing that wants all of it: routing asks for a star's
|
||||
* 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[] {
|
||||
const links: JumpLink[] = [];
|
||||
index.forEachPairWithin(rangePc, (a, b, distancePc) => {
|
||||
// The smaller id first, always. The grid hands pairs over in whatever order it walks its
|
||||
// cells, and a link that is `3-7` here and `7-3` there is two links to anything comparing.
|
||||
links.push(a.id < b.id ? { from: a.id, to: b.id, distancePc } : { from: b.id, to: a.id, distancePc });
|
||||
export function jumpLinkSegments(index: StarNeighbourhood, rangePc: number): Float32Array {
|
||||
let vertices = new Float32Array(6 * 4096);
|
||||
let length = 0;
|
||||
index.forEachPairWithin(rangePc, (a, b) => {
|
||||
if (length + 6 > vertices.length) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -9,6 +9,7 @@
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.worker.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.worker.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user