diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4932d7f..fed5b14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/package.json b/package.json index c138c64..6f6cc1d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts index 1b94354..e3e7b66 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts @@ -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; links(): Promise; dispose(): void }; + routePending(): boolean; + routeResult(): { stars: { id: number }[] } | null; + onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void; + }; + component.routing = { + route: () => new Promise((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; links(): Promise; 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. diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 7feb5a6..6eadbb9 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -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(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. */ diff --git a/src/app/features/galaxy-system/jump-link-renderer.ts b/src/app/features/galaxy-system/jump-link-renderer.ts index e9566d7..db71dc3 100644 --- a/src/app/features/galaxy-system/jump-link-renderer.ts +++ b/src/app/features/galaxy-system/jump-link-renderer.ts @@ -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. */ diff --git a/src/app/features/galaxy-system/routing-client.spec.ts b/src/app/features/galaxy-system/routing-client.spec.ts new file mode 100644 index 0000000..3e5ea14 --- /dev/null +++ b/src/app/features/galaxy-system/routing-client.spec.ts @@ -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 = []; + private readonly listeners: Record 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(); + }); +}); diff --git a/src/app/features/galaxy-system/routing-client.ts b/src/app/features/galaxy-system/routing-client.ts new file mode 100644 index 0000000..2e1b398 --- /dev/null +++ b/src/app/features/galaxy-system/routing-client.ts @@ -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; + 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((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> = {}; + 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) => 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 { + 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 { + 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 { + 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))); + } + } + } +} diff --git a/src/app/features/hud/hud-dock.component.ts b/src/app/features/hud/hud-dock.component.ts index 3c9ae97..1330b25 100644 --- a/src/app/features/hud/hud-dock.component.ts +++ b/src/app/features/hud/hud-dock.component.ts @@ -132,6 +132,7 @@ function isWideViewport(): boolean {
(null); /** Routing: what the scene found, what it offers for the fields, and where the view is. */ readonly routeResult = input(null); + readonly routePending = input(false); readonly routeOptions = input([]); readonly currentStar = input(null); /** The star the readout is about, where there is one to keep — a scale is not a place. */ diff --git a/src/app/features/hud/routes-panel.component.ts b/src/app/features/hud/routes-panel.component.ts index 293a204..395b2b9 100644 --- a/src/app/features/hud/routes-panel.component.ts +++ b/src/app/features/hud/routes-panel.component.ts @@ -100,11 +100,11 @@ type Field = 'from' | 'to';
@if (result(); as plotted) { @if (plotted.stars.length) { @@ -154,6 +154,8 @@ export class RoutesPanelComponent { readonly result = input(null); /** Matches for the field currently being typed into, ranked by the scene. */ readonly options = input([]); + /** 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(null); diff --git a/src/app/shared/astro/jump-links.spec.ts b/src/app/shared/astro/jump-links.spec.ts index 7620716..b47520e 100644 --- a/src/app/shared/astro/jump-links.spec.ts +++ b/src/app/shared/astro/jump-links.spec.ts @@ -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); }); }); diff --git a/src/app/shared/astro/jump-links.ts b/src/app/shared/astro/jump-links.ts index 753dfe6..78efd1e 100644 --- a/src/app/shared/astro/jump-links.ts +++ b/src/app/shared/astro/jump-links.ts @@ -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); } diff --git a/src/app/shared/astro/routing.spec.ts b/src/app/shared/astro/routing.spec.ts new file mode 100644 index 0000000..5fc0e0f --- /dev/null +++ b/src/app/shared/astro/routing.spec.ts @@ -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))); + }); +}); diff --git a/src/app/shared/astro/routing.ts b/src/app/shared/astro/routing.ts new file mode 100644 index 0000000..0b8b615 --- /dev/null +++ b/src/app/shared/astro/routing.ts @@ -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) + }; +} diff --git a/src/app/shared/astro/routing.worker.ts b/src/app/shared/astro/routing.worker.ts new file mode 100644 index 0000000..403d224 --- /dev/null +++ b/src/app/shared/astro/routing.worker.ts @@ -0,0 +1,25 @@ +/// + +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) => { + 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) }); + } +}); diff --git a/tsconfig.app.json b/tsconfig.app.json index cb151e1..4d2eda2 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -9,6 +9,7 @@ "src/**/*.ts" ], "exclude": [ - "src/**/*.spec.ts" + "src/**/*.spec.ts", + "src/**/*.worker.ts" ] } diff --git a/tsconfig.json b/tsconfig.json index d2fbb9c..7516612 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,9 @@ }, { "path": "./tsconfig.spec.json" + }, + { + "path": "./tsconfig.worker.json" } ] } diff --git a/tsconfig.worker.json b/tsconfig.worker.json new file mode 100644 index 0000000..f479c69 --- /dev/null +++ b/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" + ] +}