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:
+2
-1
@@ -37,7 +37,8 @@
|
|||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.css"
|
"src/styles.css"
|
||||||
]
|
],
|
||||||
|
"webWorkerTsConfig": "tsconfig.worker.json"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
|||||||
@@ -239,6 +239,34 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
refocus.mockRestore();
|
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 () => {
|
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);
|
navigationStore.selectStar(SUN.id);
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ import { RouteRequest, RouteResult, RouteStarOption } from '../hud/routes-panel.
|
|||||||
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../search/search-ranking';
|
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../search/search-ranking';
|
||||||
import { StarmapHudComponent } from './starmap-hud.component';
|
import { StarmapHudComponent } from './starmap-hud.component';
|
||||||
import { SystemObjectCardComponent } from './system-object-card.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 { 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 { brightestWithin, brightnessOrder } from '../../shared/astro/brightest';
|
||||||
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
|
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
|
||||||
import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component';
|
import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component';
|
||||||
@@ -260,6 +260,7 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
|
|||||||
[display]="display()"
|
[display]="display()"
|
||||||
[routing]="true"
|
[routing]="true"
|
||||||
[routeResult]="routeResult()"
|
[routeResult]="routeResult()"
|
||||||
|
[routePending]="routePending()"
|
||||||
[routeOptions]="routeOptions()"
|
[routeOptions]="routeOptions()"
|
||||||
[currentStar]="currentStarOption()"
|
[currentStar]="currentStarOption()"
|
||||||
[keepableStarId]="navigationStore.selectedStarId()"
|
[keepableStarId]="navigationStore.selectedStarId()"
|
||||||
@@ -325,10 +326,16 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
private hostRings?: HostStarRings;
|
private hostRings?: HostStarRings;
|
||||||
/** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */
|
/** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */
|
||||||
private neighbourhood?: StarNeighbourhood;
|
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;
|
private jumpLinks?: JumpLinkRenderer;
|
||||||
/** How far a single crossing may be. Drives both the drawn graph and the route walked on it. */
|
/** How far a single crossing may be. Drives both the drawn graph and the route walked on it. */
|
||||||
readonly jumpRangePc = signal(3);
|
readonly jumpRangePc = signal(3);
|
||||||
readonly routeResult = signal<RouteResult | null>(null);
|
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
|
* 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.
|
* 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.starField?.dispose();
|
||||||
this.hostRings?.dispose();
|
this.hostRings?.dispose();
|
||||||
this.jumpLinks?.dispose();
|
this.jumpLinks?.dispose();
|
||||||
|
this.routing?.dispose();
|
||||||
clearTimeout(this.jumpLinkRebuild);
|
clearTimeout(this.jumpLinkRebuild);
|
||||||
this.deepSky?.dispose();
|
this.deepSky?.dispose();
|
||||||
this.milkyWay?.dispose();
|
this.milkyWay?.dispose();
|
||||||
@@ -530,6 +538,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.stars = stars;
|
this.stars = stars;
|
||||||
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
||||||
this.neighbourhood = new StarNeighbourhood(stars);
|
this.neighbourhood = new StarNeighbourhood(stars);
|
||||||
|
this.routing = new RoutingClient(stars, positions, this.neighbourhood);
|
||||||
this.starsByBrightness = brightnessOrder(stars);
|
this.starsByBrightness = brightnessOrder(stars);
|
||||||
this.starSearchIndex.set(
|
this.starSearchIndex.set(
|
||||||
buildSearchIndex(stars.map((star) => ({ kind: 'star' as const, name: star.name, subtitle: star.spectralType, starId: star.id })))
|
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
|
* Walks the graph, and where it cannot, says what range would. Both run in a worker: a route
|
||||||
* the index for a star's neighbours as it reaches that star — so plotting one route never
|
* to a star 236 pc away, or the range one would need, can take seconds, and on this thread the
|
||||||
* costs a pass over the catalogue.
|
* 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 {
|
onRouteRequested({ fromId, toId, rangePc }: RouteRequest): void {
|
||||||
if (!this.neighbourhood) {
|
if (!this.routing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const route = routeBetween(this.neighbourhood, fromId, toId, rangePc);
|
const request = ++this.routeRequest;
|
||||||
if (route) {
|
this.routePending.set(true);
|
||||||
this.routeResult.set({
|
void this.routing.route(fromId, toId, rangePc, ROUTE_RANGE_CEILING_PC).then(({ route, neededRangePc }) => {
|
||||||
stars: route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })),
|
if (request !== this.routeRequest) {
|
||||||
totalPc: route.totalPc,
|
|
||||||
neededRangePc: null
|
|
||||||
});
|
|
||||||
this.jumpLinks?.setRoute(route.stars, (id) => this.starsById.get(id));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.routePending.set(false);
|
||||||
this.routeResult.set({
|
this.routeResult.set({
|
||||||
stars: [],
|
stars: route ? route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })) : [],
|
||||||
totalPc: 0,
|
totalPc: route?.totalPc ?? 0,
|
||||||
neededRangePc: minimumRangeBetween(this.neighbourhood, fromId, toId, ROUTE_RANGE_CEILING_PC)
|
neededRangePc
|
||||||
|
});
|
||||||
|
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
|
* Rebuilds the drawn graph, which is the expensive half: every star's neighbours, once, and 3.7
|
||||||
* when the layer is on and the range has actually moved — the control emits per pixel dragged.
|
* 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 {
|
private refreshJumpLinks(): void {
|
||||||
if (!this.jumpLinks || !this.neighbourhood) {
|
if (!this.jumpLinks || !this.routing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rangePc = this.jumpRangePc();
|
const rangePc = this.jumpRangePc();
|
||||||
if (!this.display().jumpLinks) {
|
if (!this.display().jumpLinks) {
|
||||||
if (this.drawnJumpRangePc !== null) {
|
if (this.drawnJumpRangePc !== null) {
|
||||||
this.jumpLinks.setLinks([], () => undefined);
|
this.jumpLinks.setSegments(new Float32Array(0));
|
||||||
this.drawnJumpRangePc = null;
|
this.drawnJumpRangePc = null;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1441,8 +1451,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.drawnJumpRangePc = rangePc;
|
this.drawnJumpRangePc = rangePc;
|
||||||
const links = collectJumpLinks(this.neighbourhood, rangePc);
|
void this.routing.links(rangePc).then((segments) => {
|
||||||
this.jumpLinks.setLinks(links, (id) => this.starsById.get(id));
|
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. */
|
/** 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 * 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. */
|
/** Faint, because there are tens of thousands of them and none is worth reading on its own. */
|
||||||
const LINK_OPACITY = 0.16;
|
const LINK_OPACITY = 0.16;
|
||||||
/** The one route is the figure; the graph it is drawn on is the ground. */
|
/** 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.links.frustumCulled = false;
|
||||||
this.route.frustumCulled = false;
|
this.route.frustumCulled = false;
|
||||||
this.object.add(this.links, this.route);
|
this.object.add(this.links, this.route);
|
||||||
this.setLinks([], () => undefined);
|
this.setSegments(new Float32Array(0));
|
||||||
this.setRoute([], () => undefined);
|
this.setRoute([], () => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLinks(links: readonly JumpLink[], positionOf: (starId: number) => LinkPoint | undefined): void {
|
/** The graph, as vertex pairs: six floats a link, one end then the other. See `jumpLinkSegments`. */
|
||||||
const vertices = new Float32Array(links.length * 6);
|
setSegments(vertices: Float32Array): void {
|
||||||
let at = 0;
|
this.replaceGeometry(this.links, vertices);
|
||||||
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 chain to draw over the graph, departure first. Fewer than two stars draws nothing. */
|
/** 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">
|
<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
|
<app-routes-panel
|
||||||
[result]="routeResult()"
|
[result]="routeResult()"
|
||||||
|
[pending]="routePending()"
|
||||||
[options]="routeOptions()"
|
[options]="routeOptions()"
|
||||||
[currentStar]="currentStar()"
|
[currentStar]="currentStar()"
|
||||||
(queryChange)="routeQuery.emit($event)"
|
(queryChange)="routeQuery.emit($event)"
|
||||||
@@ -241,6 +242,7 @@ export class HudDockComponent implements OnInit {
|
|||||||
readonly defaultTab = input<DockTab | null>(null);
|
readonly defaultTab = input<DockTab | null>(null);
|
||||||
/** Routing: what the scene found, what it offers for the fields, and where the view is. */
|
/** Routing: what the scene found, what it offers for the fields, and where the view is. */
|
||||||
readonly routeResult = input<RouteResult | null>(null);
|
readonly routeResult = input<RouteResult | null>(null);
|
||||||
|
readonly routePending = input(false);
|
||||||
readonly routeOptions = input<readonly RouteStarOption[]>([]);
|
readonly routeOptions = input<readonly RouteStarOption[]>([]);
|
||||||
readonly currentStar = input<RouteStarOption | null>(null);
|
readonly currentStar = input<RouteStarOption | null>(null);
|
||||||
/** The star the readout is about, where there is one to keep — a scale is not a place. */
|
/** 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">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!canPlot()"
|
[disabled]="!canPlot() || pending()"
|
||||||
(click)="plot()"
|
(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"
|
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>
|
</button>
|
||||||
@if (result(); as plotted) {
|
@if (result(); as plotted) {
|
||||||
@if (plotted.stars.length) {
|
@if (plotted.stars.length) {
|
||||||
@@ -154,6 +154,8 @@ export class RoutesPanelComponent {
|
|||||||
readonly result = input<RouteResult | null>(null);
|
readonly result = input<RouteResult | null>(null);
|
||||||
/** Matches for the field currently being typed into, ranked by the scene. */
|
/** Matches for the field currently being typed into, ranked by the scene. */
|
||||||
readonly options = input<readonly RouteStarOption[]>([]);
|
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. */
|
/** The star the view is currently inside, offered as the departure without typing. */
|
||||||
readonly currentStar = input<RouteStarOption | null>(null);
|
readonly currentStar = input<RouteStarOption | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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';
|
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
|
||||||
|
|
||||||
/** Stars a parsec apart along x, so a chain's length is the number of hops it takes. */
|
/** 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', () => {
|
* The links a segment buffer draws, as unordered pairs of star ids, read back from where each end
|
||||||
const links = collectJumpLinks(chain(4), 1.5);
|
* 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([
|
/** Stars a parsec apart along x, as points, for reading a segment buffer back. */
|
||||||
[0, 1],
|
function chainPoints(count: number): StarPoint[] {
|
||||||
[1, 2],
|
return Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
|
||||||
[2, 3]
|
}
|
||||||
]);
|
|
||||||
|
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', () => {
|
it('puts both ends of every link where its stars are', () => {
|
||||||
const links = collectJumpLinks(chain(3), 2.5);
|
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', () => {
|
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', () => {
|
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.
|
// earlier version of this test hid by only checking the route it happened to find.
|
||||||
const range = 9;
|
const range = 9;
|
||||||
|
|
||||||
const links = collectJumpLinks(cloud, range);
|
const drawn = new Set(linksDrawn(jumpLinkSegments(cloud, range), points));
|
||||||
const drawn = new Set(links.map((link) => `${link.from}-${link.to}`));
|
|
||||||
|
|
||||||
const route = routeBetween(cloud, 0, 119, range);
|
const route = routeBetween(cloud, 0, 119, range);
|
||||||
// Asserted, not guarded: a skipped body would let the two disagree unnoticed.
|
// 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);
|
const [a, b] = [route!.stars[i - 1], route!.stars[i]].sort((x, y) => x - y);
|
||||||
expect(drawn.has(`${a}-${b}`)).toBe(true);
|
expect(drawn.has(`${a}-${b}`)).toBe(true);
|
||||||
}
|
}
|
||||||
expect(links.length).toBeGreaterThan(0);
|
expect(drawn.size).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,13 +29,6 @@ export interface Route {
|
|||||||
readonly longestHopPc: number;
|
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
|
* 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
|
* 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
|
* For drawing the graph, which is the only thing that wants all of it: routing asks for a star's
|
||||||
* star's neighbours as it reaches that star and never builds this.
|
* 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[] {
|
export function jumpLinkSegments(index: StarNeighbourhood, rangePc: number): Float32Array {
|
||||||
const links: JumpLink[] = [];
|
let vertices = new Float32Array(6 * 4096);
|
||||||
index.forEachPairWithin(rangePc, (a, b, distancePc) => {
|
let length = 0;
|
||||||
// The smaller id first, always. The grid hands pairs over in whatever order it walks its
|
index.forEachPairWithin(rangePc, (a, b) => {
|
||||||
// cells, and a link that is `3-7` here and `7-3` there is two links to anything comparing.
|
if (length + 6 > vertices.length) {
|
||||||
links.push(a.id < b.id ? { from: a.id, to: b.id, distancePc } : { from: b.id, to: a.id, distancePc });
|
const grown = new Float32Array(vertices.length * 2);
|
||||||
|
grown.set(vertices);
|
||||||
|
vertices = grown;
|
||||||
|
}
|
||||||
|
vertices[length++] = a.x;
|
||||||
|
vertices[length++] = a.y;
|
||||||
|
vertices[length++] = a.z;
|
||||||
|
vertices[length++] = b.x;
|
||||||
|
vertices[length++] = b.y;
|
||||||
|
vertices[length++] = b.z;
|
||||||
});
|
});
|
||||||
return links;
|
// Exact length rather than a view on the grown buffer: the answer is transferred whole, and a
|
||||||
|
// view would carry up to as much again in unused capacity with it.
|
||||||
|
return vertices.slice(0, length);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { jumpLinkSegments, minimumRangeBetween, routeBetween } from './jump-links';
|
||||||
|
import { answerRouting, indexCatalogue } from './routing';
|
||||||
|
import { StarNeighbourhood } from './star-neighbourhood';
|
||||||
|
|
||||||
|
/** Stars a parsec apart along x, then a gap of 5 pc to one more. */
|
||||||
|
const POINTS = [...Array.from({ length: 5 }, (_, i) => ({ id: 10 + i, x: i, y: 0, z: 0 })), { id: 99, x: 9, y: 0, z: 0 }];
|
||||||
|
|
||||||
|
function catalogue() {
|
||||||
|
return {
|
||||||
|
kind: 'catalogue' as const,
|
||||||
|
ids: Int32Array.from(POINTS, (point) => point.id),
|
||||||
|
positions: Float32Array.from(POINTS.flatMap((point) => [point.x, point.y, point.z]))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('indexCatalogue', () => {
|
||||||
|
it('indexes the catalogue as it was packed, id by id', () => {
|
||||||
|
const index = indexCatalogue(catalogue());
|
||||||
|
|
||||||
|
for (const point of POINTS) {
|
||||||
|
expect(index.point(point.id)).toEqual(point);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('answerRouting', () => {
|
||||||
|
const index = indexCatalogue(catalogue());
|
||||||
|
const direct = new StarNeighbourhood(POINTS);
|
||||||
|
|
||||||
|
it('answers a route the range allows, with nothing to raise it to', () => {
|
||||||
|
const answer = answerRouting(index, { kind: 'route', requestId: 7, fromId: 10, toId: 14, rangePc: 1.5, ceilingPc: 8 });
|
||||||
|
|
||||||
|
expect(answer).toEqual({ kind: 'route', requestId: 7, route: routeBetween(direct, 10, 14, 1.5), neededRangePc: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers a route the range does not allow with the range that would', () => {
|
||||||
|
const answer = answerRouting(index, { kind: 'route', requestId: 8, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 8 });
|
||||||
|
|
||||||
|
expect(answer).toEqual({ kind: 'route', requestId: 8, route: null, neededRangePc: minimumRangeBetween(direct, 10, 99, 8) });
|
||||||
|
expect(answer.kind === 'route' && answer.neededRangePc).toBeCloseTo(5, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers nothing to raise to when even the ceiling does not reach', () => {
|
||||||
|
const answer = answerRouting(index, { kind: 'route', requestId: 9, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 3 });
|
||||||
|
|
||||||
|
expect(answer).toMatchObject({ route: null, neededRangePc: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers the graph as the segments it draws', () => {
|
||||||
|
const answer = answerRouting(index, { kind: 'links', requestId: 3, rangePc: 1.5 });
|
||||||
|
|
||||||
|
expect(answer.kind).toBe('links');
|
||||||
|
expect(answer.requestId).toBe(3);
|
||||||
|
expect(answer.kind === 'links' && Array.from(answer.segments)).toEqual(Array.from(jumpLinkSegments(direct, 1.5)));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,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)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import { answerRouting, indexCatalogue, RoutingCatalogue, RoutingRequest } from './routing';
|
||||||
|
import { StarNeighbourhood } from './star-neighbourhood';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks routes and builds the jump-link graph off the main thread. A search to a star 236 pc
|
||||||
|
* away, and the range it would need when there is none, can take seconds; a graph at 8 pc is
|
||||||
|
* 3.7 million links. On the page's own thread either stops the map for as long as it runs.
|
||||||
|
*/
|
||||||
|
let index: StarNeighbourhood | undefined;
|
||||||
|
|
||||||
|
addEventListener('message', ({ data }: MessageEvent<RoutingCatalogue | RoutingRequest>) => {
|
||||||
|
if (data.kind === 'catalogue') {
|
||||||
|
index = indexCatalogue(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The catalogue is always the first message, and a worker's messages arrive in order.
|
||||||
|
const response = answerRouting(index!, data);
|
||||||
|
postMessage(response, response.kind === 'links' ? [response.segments.buffer] : []);
|
||||||
|
});
|
||||||
+2
-1
@@ -9,6 +9,7 @@
|
|||||||
"src/**/*.ts"
|
"src/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"src/**/*.spec.ts"
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.worker.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.spec.json"
|
"path": "./tsconfig.spec.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.worker.json"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/worker",
|
||||||
|
"lib": ["es2022", "webworker"],
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.worker.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user