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" + ] +}