From 8c69a7a8b2e63fd1a6eed4e0440f9e36ae9cbaef Mon Sep 17 00:00:00 2001 From: Senrokai Date: Wed, 16 Sep 2026 14:17:26 +0200 Subject: [PATCH 1/2] Plot routes and build the jump-link graph in a Web Worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route plotting ran on the main thread, and so did the jump-link graph: - the range search for a far target, HD 2626 at 236 pc, takes 4-5 s; - the graph at 8 pc is 3.7 million links, 6-10 s to build, then as many link objects again to turn into vertices. The map stopped for as long as either ran. A Web Worker now does both. RoutingClient sends it the catalogue's ids and positions once, and it keeps its own spatial index. A route question comes back with the route, or with the range that would open one. A graph comes back as one Float32Array of segment vertices, transferred rather than copied. On the scene side, only the latest route request is shown: an earlier answer arriving later is dropped. Only the graph for the range last asked for is drawn. The Routes panel says "Plotting…" and holds its button while a request is out. collectJumpLinks gave way to jumpLinkSegments, which writes the vertex pairs straight into floats rather than building link objects first; the scene was its only caller. The routing module (routing.ts) is the message protocol and the one function answering it, so the worker is a dozen lines, and the same answers are worked out in place where there is no Worker, as in the unit tests' DOM. The worker is built with its own tsconfig, as the Angular builder expects. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- angular.json | 3 +- .../galaxy-system-scene.component.spec.ts | 28 ++++++++ .../galaxy-system-scene.component.ts | 61 ++++++++++------- .../galaxy-system/jump-link-renderer.ts | 20 ++---- .../galaxy-system/routing-client.spec.ts | 51 +++++++++++++++ .../features/galaxy-system/routing-client.ts | 65 +++++++++++++++++++ src/app/features/hud/hud-dock.component.ts | 2 + .../features/hud/routes-panel.component.ts | 6 +- src/app/shared/astro/jump-links.spec.ts | 64 +++++++++++++----- src/app/shared/astro/jump-links.ts | 40 +++++++----- src/app/shared/astro/routing.spec.ts | 58 +++++++++++++++++ src/app/shared/astro/routing.ts | 48 ++++++++++++++ src/app/shared/astro/routing.worker.ts | 21 ++++++ tsconfig.app.json | 3 +- tsconfig.json | 3 + tsconfig.worker.json | 13 ++++ 16 files changed, 409 insertions(+), 77 deletions(-) create mode 100644 src/app/features/galaxy-system/routing-client.spec.ts create mode 100644 src/app/features/galaxy-system/routing-client.ts create mode 100644 src/app/shared/astro/routing.spec.ts create mode 100644 src/app/shared/astro/routing.ts create mode 100644 src/app/shared/astro/routing.worker.ts create mode 100644 tsconfig.worker.json diff --git a/angular.json b/angular.json index a7f9ddc..6cf8740 100644 --- a/angular.json +++ b/angular.json @@ -37,7 +37,8 @@ ], "styles": [ "src/styles.css" - ] + ], + "webWorkerTsConfig": "tsconfig.worker.json" }, "configurations": { "production": { 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 13ebe9f..83ed17e 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 @@ -239,6 +239,34 @@ 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('flies the camera into a selected star system: hides the galaxy group, shows the system group, and switches to AU-scale near/far planes', async () => { navigationStore.selectStar(SUN.id); await flushAsync(); 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 c6a2d14..a86d380 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 { brightestWithin, brightnessOrder } 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 = brightnessOrder(stars); this.starSearchIndex.set( buildSearchIndex(stars.map((star) => ({ kind: 'star' as const, name: star.name, subtitle: star.spectralType, starId: star.id }))) @@ -1395,44 +1404,45 @@ 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) { + 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.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })), - totalPc: route.totalPc, - neededRangePc: null + 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)); - return; - } - this.routeResult.set({ - stars: [], - 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); } /** - * 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; @@ -1441,8 +1451,11 @@ 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); + } + }); } /** 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..1154298 --- /dev/null +++ b/src/app/features/galaxy-system/routing-client.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { jumpLinkSegments, routeBetween } from '../../shared/astro/jump-links'; +import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood'; +import { StarRecord } from '../../shared/models/star.model'; +import { RoutingClient } 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])); + +// The unit tests' DOM has no Worker, which is exactly the case the client answers in place. +describe('RoutingClient without a worker', () => { + const index = new StarNeighbourhood(STARS); + + 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(); + }); +}); 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..e1cfaba --- /dev/null +++ b/src/app/features/galaxy-system/routing-client.ts @@ -0,0 +1,65 @@ +import { answerRouting, RoutingRequest, RoutingResponse } from '../../shared/astro/routing'; +import { Route } from '../../shared/astro/jump-links'; +import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood'; +import { StarRecord } from '../../shared/models/star.model'; + +export interface RouteAnswer { + readonly route: Route | null; + readonly neededRangePc: number | null; +} + +type Pending = (response: RoutingResponse) => void; + +/** + * Asks the route questions of a worker holding its own copy of the catalogue, and hands back + * promises. Where there is no `Worker` — the unit tests' DOM has none — the same answers are + * worked out in place, from the index the scene already holds. + */ +export class RoutingClient { + private readonly worker?: Worker; + private readonly pending = new Map(); + private nextRequestId = 0; + + constructor(stars: readonly StarRecord[], positions: Float32Array, private readonly localIndex: StarNeighbourhood) { + if (typeof Worker === 'undefined') { + return; + } + this.worker = new Worker(new URL('../../shared/astro/routing.worker', import.meta.url), { type: 'module' }); + this.worker.addEventListener('message', ({ data }: MessageEvent) => { + this.pending.get(data.requestId)?.(data); + this.pending.delete(data.requestId); + }); + // Copies, since the scene goes on using its own; transferred, so the copy is sent and not cloned again. + const ids = Int32Array.from(stars, (star) => star.id); + const copy = positions.slice(); + this.worker.postMessage({ kind: 'catalogue', ids, positions: copy }, [ids.buffer, copy.buffer]); + } + + route(fromId: number, toId: number, rangePc: number, ceilingPc: number): Promise { + 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.pending.clear(); + } + + private ask(request: RoutingRequest): Promise { + if (!this.worker) { + return Promise.resolve(answerRouting(this.localIndex, request)); + } + return new Promise((resolve) => { + this.pending.set(request.requestId, resolve); + this.worker!.postMessage(request); + }); + } +} 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..eb8aaa3 --- /dev/null +++ b/src/app/shared/astro/routing.ts @@ -0,0 +1,48 @@ +/** + * 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 }; + +/** 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..e40b2dd --- /dev/null +++ b/src/app/shared/astro/routing.worker.ts @@ -0,0 +1,21 @@ +/// + +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. + const response = answerRouting(index!, data); + postMessage(response, response.kind === 'links' ? [response.segments.buffer] : []); +}); 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" + ] +} From f156e038229a4210a4dad2b215642a430134d1b3 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Wed, 16 Sep 2026 15:27:50 +0200 Subject: [PATCH 2/2] Answer the review: send the worker one request at a time, keep only the latest, and never wait for a dead one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adversarial review confirmed three defects in this PR, all reproduced in the browser. 1. Superseded graphs queued up in front of routes. The worker answers messages one at a time and cannot drop one it has started. With the jump-link layer on, every pause on the range slider posted a full graph build, seconds of work at 6-8 pc. Answers no longer wanted were thrown away only once built. A route asked for afterwards waited behind every one of them: a one-jump route took 44 s. RoutingClient now holds requests and sends them one at a time. While one is out, only the latest of each kind waits: a newer graph replaces an older one before it is ever built, and the older promise is rejected with SupersededRequest. Routes go ahead of graphs. The same question asked again while outstanding shares the answer rather than being worked twice, as when the layer is turned off and on during a build. The same scenario in the browser (layer on, range stepped 5 -> 8 pc with 400 ms pauses, then Sol to Proxima): the route came back in 110 ms. The worker was sent "links 3, links 5, route, links 8"; 6 and 7 were never built. 2. A worker that failed left the panel stuck. With no error handling, a worker that failed to load (a 404 on its chunk after a redeploy) or threw left "Plotting…" and a disabled button for good, and a graph at a range could not be asked for again. The worker now answers an exception with a 'failed' message, which rejects that request. A worker that fails to load or dies is abandoned, and what it left outstanding, and everything asked afterwards, is answered in place. The scene releases the panel when a route fails, and forgets a graph range that was never drawn so it can be asked for again. 3. Nothing type-checked the worker. The application builder never reads webWorkerTsConfig, and bundles the worker with esbuild, which strips types without checking them. tsconfig.app.json leaves the file out. A type error in the worker shipped. `npm run worker:typecheck` (tsc -p tsconfig.worker.json) now runs in CI beside the other project checks. webWorkerTsConfig is removed from angular.json, since it only suggested that something checked the worker. Tests with a fake worker cover one request at a time, a waiting graph replaced and a route sent ahead of it, a question shared, a failure rejected and the next request sent, and a failed worker's requests answered in place. A scene test covers the panel released after a failed route. Negative controls, each caught: several requests sent at once, a waiting graph kept, graphs ahead of routes, a question asked twice, a failure answered as a success, a failed worker waited on, the panel left pending, and a type error in the worker (caught by worker:typecheck). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- .github/workflows/ci.yml | 9 +- angular.json | 3 +- package.json | 3 +- .../galaxy-system-scene.component.spec.ts | 17 +++ .../galaxy-system-scene.component.ts | 50 +++++-- .../galaxy-system/routing-client.spec.ts | 131 ++++++++++++++++- .../features/galaxy-system/routing-client.ts | 138 +++++++++++++++--- src/app/shared/astro/routing.ts | 4 +- src/app/shared/astro/routing.worker.ts | 8 +- 9 files changed, 319 insertions(+), 44 deletions(-) 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/angular.json b/angular.json index 6cf8740..a7f9ddc 100644 --- a/angular.json +++ b/angular.json @@ -37,8 +37,7 @@ ], "styles": [ "src/styles.css" - ], - "webWorkerTsConfig": "tsconfig.worker.json" + ] }, "configurations": { "production": { 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 b0a7048..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 @@ -294,6 +294,23 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { 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 b04513c..6eadbb9 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -1424,18 +1424,29 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { } 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; + 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); } - 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)); - }); + ); } /** @@ -1460,11 +1471,20 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { return; } this.drawnJumpRangePc = rangePc; - void this.routing.links(rangePc).then((segments) => { - if (this.drawnJumpRangePc === rangePc) { - this.jumpLinks?.setSegments(segments); + 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/routing-client.spec.ts b/src/app/features/galaxy-system/routing-client.spec.ts index 1154298..3e5ea14 100644 --- a/src/app/features/galaxy-system/routing-client.spec.ts +++ b/src/app/features/galaxy-system/routing-client.spec.ts @@ -1,9 +1,10 @@ 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 } from './routing-client'; +import { RoutingClient, SupersededRequest } from './routing-client'; const STARS: StarRecord[] = Array.from({ length: 6 }, (_, i) => ({ id: 100 + i, @@ -16,11 +17,51 @@ const STARS: StarRecord[] = Array.from({ length: 6 }, (_, i) => ({ 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', () => { - const index = new StarNeighbourhood(STARS); - it('has no Worker to use here, so the in-place answers are what is being tested', () => { expect(typeof Worker).toBe('undefined'); }); @@ -49,3 +90,87 @@ describe('RoutingClient without a worker', () => { 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 index e1cfaba..2e1b398 100644 --- a/src/app/features/galaxy-system/routing-client.ts +++ b/src/app/features/galaxy-system/routing-client.ts @@ -8,27 +8,75 @@ export interface RouteAnswer { readonly neededRangePc: number | null; } -type Pending = (response: RoutingResponse) => void; +/** 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. Where there is no `Worker` — the unit tests' DOM has none — the same answers are - * worked out in place, from the index the scene already holds. + * 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 readonly worker?: Worker; - private readonly pending = new Map(); + private worker?: Worker; + private inFlight?: Outstanding; + private readonly waiting: Partial> = {}; private nextRequestId = 0; - constructor(stars: readonly StarRecord[], positions: Float32Array, private readonly localIndex: StarNeighbourhood) { - if (typeof Worker === 'undefined') { + constructor( + stars: readonly StarRecord[], + positions: Float32Array, + private readonly localIndex: StarNeighbourhood, + startWorker: () => Worker | undefined = startRoutingWorker + ) { + this.worker = startWorker(); + if (!this.worker) { return; } - this.worker = new Worker(new URL('../../shared/astro/routing.worker', import.meta.url), { type: 'module' }); - this.worker.addEventListener('message', ({ data }: MessageEvent) => { - this.pending.get(data.requestId)?.(data); - this.pending.delete(data.requestId); - }); + 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(); @@ -50,16 +98,70 @@ export class RoutingClient { dispose(): void { this.worker?.terminate(); - this.pending.clear(); + this.worker = undefined; + this.inFlight = undefined; + delete this.waiting.route; + delete this.waiting.links; } private ask(request: RoutingRequest): Promise { if (!this.worker) { - return Promise.resolve(answerRouting(this.localIndex, request)); + 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))); + } } - return new Promise((resolve) => { - this.pending.set(request.requestId, resolve); - this.worker!.postMessage(request); - }); } } diff --git a/src/app/shared/astro/routing.ts b/src/app/shared/astro/routing.ts index eb8aaa3..0b8b615 100644 --- a/src/app/shared/astro/routing.ts +++ b/src/app/shared/astro/routing.ts @@ -23,7 +23,9 @@ export type RoutingRequest = 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 }; + | { 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 { diff --git a/src/app/shared/astro/routing.worker.ts b/src/app/shared/astro/routing.worker.ts index e40b2dd..403d224 100644 --- a/src/app/shared/astro/routing.worker.ts +++ b/src/app/shared/astro/routing.worker.ts @@ -16,6 +16,10 @@ addEventListener('message', ({ data }: MessageEvent