Plot routes and build the jump-link graph in a Web Worker
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -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<Answer>; links(): Promise<Float32Array>; dispose(): void };
|
||||
routePending(): boolean;
|
||||
routeResult(): { stars: { id: number }[] } | null;
|
||||
onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void;
|
||||
};
|
||||
component.routing = {
|
||||
route: () => new Promise<Answer>((resolve) => answers.push(resolve)),
|
||||
links: () => Promise.resolve(new Float32Array(0)),
|
||||
dispose: () => undefined
|
||||
};
|
||||
|
||||
component.onRouteRequested({ fromId: SUN.id, toId: ALPHA_CENTAURI.id, rangePc: 2 });
|
||||
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
|
||||
expect(component.routePending()).toBe(true);
|
||||
|
||||
answers[1]({ route: { stars: [SUN.id, PROXIMA.id], totalPc: 1.3, longestHopPc: 1.3 }, neededRangePc: null });
|
||||
await flushAsync();
|
||||
answers[0]({ route: { stars: [SUN.id, ALPHA_CENTAURI.id], totalPc: 1.34, longestHopPc: 1.34 }, neededRangePc: null });
|
||||
await flushAsync();
|
||||
|
||||
expect(component.routeResult()?.stars.map((star) => star.id)).toEqual([SUN.id, PROXIMA.id]);
|
||||
expect(component.routePending()).toBe(false);
|
||||
});
|
||||
|
||||
it('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();
|
||||
|
||||
@@ -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<RouteResult | null>(null);
|
||||
/** A route has been asked for and not yet answered. */
|
||||
readonly routePending = signal(false);
|
||||
/**
|
||||
* Matches for whichever routing field is being typed into. Stars only: a route is a chain of
|
||||
* stars, and offering a moon as a destination would be offering a place that leads nowhere.
|
||||
@@ -438,6 +445,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.starField?.dispose();
|
||||
this.hostRings?.dispose();
|
||||
this.jumpLinks?.dispose();
|
||||
this.routing?.dispose();
|
||||
clearTimeout(this.jumpLinkRebuild);
|
||||
this.deepSky?.dispose();
|
||||
this.milkyWay?.dispose();
|
||||
@@ -530,6 +538,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.stars = stars;
|
||||
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
||||
this.neighbourhood = new StarNeighbourhood(stars);
|
||||
this.routing = new RoutingClient(stars, positions, this.neighbourhood);
|
||||
this.starsByBrightness = 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. */
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
|
||||
import { JumpLink } from '../../shared/astro/jump-links';
|
||||
|
||||
/** Faint, because there are tens of thousands of them and none is worth reading on its own. */
|
||||
const LINK_OPACITY = 0.16;
|
||||
/** The one route is the figure; the graph it is drawn on is the ground. */
|
||||
@@ -48,23 +46,13 @@ export class JumpLinkRenderer {
|
||||
this.links.frustumCulled = false;
|
||||
this.route.frustumCulled = false;
|
||||
this.object.add(this.links, this.route);
|
||||
this.setLinks([], () => undefined);
|
||||
this.setSegments(new Float32Array(0));
|
||||
this.setRoute([], () => undefined);
|
||||
}
|
||||
|
||||
setLinks(links: readonly JumpLink[], positionOf: (starId: number) => LinkPoint | undefined): void {
|
||||
const vertices = new Float32Array(links.length * 6);
|
||||
let at = 0;
|
||||
for (const link of links) {
|
||||
const from = positionOf(link.from);
|
||||
const to = positionOf(link.to);
|
||||
if (!from || !to) {
|
||||
continue;
|
||||
}
|
||||
vertices.set([from.x, from.y, from.z, to.x, to.y, to.z], at);
|
||||
at += 6;
|
||||
}
|
||||
this.replaceGeometry(this.links, at === vertices.length ? vertices : vertices.subarray(0, at));
|
||||
/** The graph, as vertex pairs: six floats a link, one end then the other. See `jumpLinkSegments`. */
|
||||
setSegments(vertices: Float32Array): void {
|
||||
this.replaceGeometry(this.links, vertices);
|
||||
}
|
||||
|
||||
/** The chain to draw over the graph, departure first. Fewer than two stars draws nothing. */
|
||||
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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<number, Pending>();
|
||||
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<RoutingResponse>) => {
|
||||
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<RouteAnswer> {
|
||||
return this.ask({ kind: 'route', requestId: this.nextRequestId++, fromId, toId, rangePc, ceilingPc }).then((response) =>
|
||||
response.kind === 'route' ? { route: response.route, neededRangePc: response.neededRangePc } : { route: null, neededRangePc: null }
|
||||
);
|
||||
}
|
||||
|
||||
/** Vertex pairs for every link within `rangePc`, three floats to an end. */
|
||||
links(rangePc: number): Promise<Float32Array> {
|
||||
return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc }).then((response) =>
|
||||
response.kind === 'links' ? response.segments : new Float32Array(0)
|
||||
);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.worker?.terminate();
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private ask(request: RoutingRequest): Promise<RoutingResponse> {
|
||||
if (!this.worker) {
|
||||
return Promise.resolve(answerRouting(this.localIndex, request));
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.pending.set(request.requestId, resolve);
|
||||
this.worker!.postMessage(request);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ function isWideViewport(): boolean {
|
||||
<section id="dock-panel-routes" role="tabpanel" aria-labelledby="dock-tab-routes" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3">
|
||||
<app-routes-panel
|
||||
[result]="routeResult()"
|
||||
[pending]="routePending()"
|
||||
[options]="routeOptions()"
|
||||
[currentStar]="currentStar()"
|
||||
(queryChange)="routeQuery.emit($event)"
|
||||
@@ -241,6 +242,7 @@ export class HudDockComponent implements OnInit {
|
||||
readonly defaultTab = input<DockTab | null>(null);
|
||||
/** Routing: what the scene found, what it offers for the fields, and where the view is. */
|
||||
readonly routeResult = input<RouteResult | null>(null);
|
||||
readonly routePending = input(false);
|
||||
readonly routeOptions = input<readonly RouteStarOption[]>([]);
|
||||
readonly currentStar = input<RouteStarOption | null>(null);
|
||||
/** The star the readout is about, where there is one to keep — a scale is not a place. */
|
||||
|
||||
@@ -100,11 +100,11 @@ type Field = 'from' | 'to';
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="!canPlot()"
|
||||
[disabled]="!canPlot() || pending()"
|
||||
(click)="plot()"
|
||||
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors enabled:hover:border-accent/70 enabled:hover:text-accent disabled:opacity-40 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
>
|
||||
Plot route
|
||||
{{ pending() ? 'Plotting…' : 'Plot route' }}
|
||||
</button>
|
||||
@if (result(); as plotted) {
|
||||
@if (plotted.stars.length) {
|
||||
@@ -154,6 +154,8 @@ export class RoutesPanelComponent {
|
||||
readonly result = input<RouteResult | null>(null);
|
||||
/** Matches for the field currently being typed into, ranked by the scene. */
|
||||
readonly options = input<readonly RouteStarOption[]>([]);
|
||||
/** The scene is still working the last request out; asking again would only queue behind it. */
|
||||
readonly pending = input(false);
|
||||
/** The star the view is currently inside, offered as the departure without typing. */
|
||||
readonly currentStar = input<RouteStarOption | null>(null);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user