Merge pull request #23 from avalon-vanguard/star-map/perf/routing-worker
Plot routes and build the jump-link graph in a Web Worker Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -37,14 +37,19 @@ jobs:
|
|||||||
# different dependency tree than the one committed.
|
# different dependency tree than the one committed.
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
# Four TypeScript projects, checked by four different things. These two have no build of
|
# Five TypeScript projects, checked by four different things. These three have no build of
|
||||||
# their own, so nothing else would ever compile them.
|
# their own that checks them, so nothing else would ever compile them. The worker is bundled by
|
||||||
|
# the build, but esbuild only strips its types, and `tsconfig.app.json` leaves it out, since
|
||||||
|
# its lib is `webworker` rather than `dom`.
|
||||||
- name: Typecheck the ETL
|
- name: Typecheck the ETL
|
||||||
run: npm run etl:typecheck
|
run: npm run etl:typecheck
|
||||||
|
|
||||||
- name: Typecheck the end-to-end tests
|
- name: Typecheck the end-to-end tests
|
||||||
run: npm run e2e:typecheck
|
run: npm run e2e:typecheck
|
||||||
|
|
||||||
|
- name: Typecheck the routing worker
|
||||||
|
run: npm run worker:typecheck
|
||||||
|
|
||||||
# `tsconfig.spec.json` is compiled here, `tsconfig.app.json` by the build below.
|
# `tsconfig.spec.json` is compiled here, `tsconfig.app.json` by the build below.
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: npm test -- --no-watch
|
run: npm test -- --no-watch
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
"etl": "tsx tools/etl/build.ts",
|
"etl": "tsx tools/etl/build.ts",
|
||||||
"etl:typecheck": "tsc -p tools/etl/tsconfig.json --noEmit",
|
"etl:typecheck": "tsc -p tools/etl/tsconfig.json --noEmit",
|
||||||
"e2e": "playwright test",
|
"e2e": "playwright test",
|
||||||
"e2e:typecheck": "tsc -p e2e/tsconfig.json --noEmit"
|
"e2e:typecheck": "tsc -p e2e/tsconfig.json --noEmit",
|
||||||
|
"worker:typecheck": "tsc -p tsconfig.worker.json --noEmit"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@11.12.1",
|
"packageManager": "npm@11.12.1",
|
||||||
|
|||||||
@@ -266,6 +266,51 @@ 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('releases the routes panel when a route cannot be worked out, so it can be tried again', async () => {
|
||||||
|
const component = fixture.componentInstance as unknown as {
|
||||||
|
routing: { route(): Promise<never>; links(): Promise<Float32Array>; dispose(): void };
|
||||||
|
routePending(): boolean;
|
||||||
|
onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void;
|
||||||
|
};
|
||||||
|
const logged = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
component.routing = { route: () => Promise.reject(new Error('worker gone')), links: () => Promise.resolve(new Float32Array(0)), dispose: () => undefined };
|
||||||
|
|
||||||
|
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
expect(component.routePending()).toBe(false);
|
||||||
|
expect(logged).toHaveBeenCalled();
|
||||||
|
logged.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it('asks for no more label candidates once the last label it will show is placed', () => {
|
it('asks for no more label candidates once the last label it will show is placed', () => {
|
||||||
// Near the Sun a label candidate past the fifteenth can sit at the far end of the catalogue's
|
// Near the Sun a label candidate past the fifteenth can sit at the far end of the catalogue's
|
||||||
// brightness order, so asking for one more than is used can cost a walk of the whole order.
|
// brightness order, so asking for one more than is used can cost a walk of the whole order.
|
||||||
|
|||||||
@@ -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 { BrightnessIndex, brightestWithin, brightnessIndex } from '../../shared/astro/brightest';
|
import { BrightnessIndex, brightestWithin, brightnessIndex } 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 = brightnessIndex(stars);
|
this.starsByBrightness = brightnessIndex(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 })))
|
||||||
@@ -1404,44 +1413,56 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walks the graph, and where it cannot, says what range would. The search is lazy — it asks
|
* 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(
|
||||||
stars: route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })),
|
({ route, neededRangePc }) => {
|
||||||
totalPc: route.totalPc,
|
if (request !== this.routeRequest) {
|
||||||
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([], () => undefined);
|
this.jumpLinks?.setRoute(route?.stars ?? [], (id) => this.starsById.get(id));
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
// A request replaced by a newer one is settled this way too; only the latest matters.
|
||||||
|
if (request !== this.routeRequest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Released rather than left saying "Plotting…" with the button held, so it can be tried again.
|
||||||
|
this.routePending.set(false);
|
||||||
|
console.error('Route could not be plotted.', error);
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuilds the drawn graph, which is the expensive half: every star's neighbours, once. Only
|
* 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;
|
||||||
@@ -1450,8 +1471,20 @@ 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(
|
||||||
this.jumpLinks.setLinks(links, (id) => this.starsById.get(id));
|
(segments) => {
|
||||||
|
if (this.drawnJumpRangePc === rangePc) {
|
||||||
|
this.jumpLinks?.setSegments(segments);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Replaced by a newer range, or failed. Either way this range is not drawn, and must not be
|
||||||
|
// remembered as if it were, or asking for it again would be skipped.
|
||||||
|
if (this.drawnJumpRangePc === rangePc) {
|
||||||
|
this.drawnJumpRangePc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A pinned body wins over a hovered one, so the card does not change under the pointer. */
|
/** 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,176 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { jumpLinkSegments, routeBetween } from '../../shared/astro/jump-links';
|
||||||
|
import { RoutingRequest, RoutingResponse } from '../../shared/astro/routing';
|
||||||
|
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
|
||||||
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
|
import { RoutingClient, SupersededRequest } from './routing-client';
|
||||||
|
|
||||||
|
const STARS: StarRecord[] = Array.from({ length: 6 }, (_, i) => ({
|
||||||
|
id: 100 + i,
|
||||||
|
name: `star-${i}`,
|
||||||
|
x: i < 5 ? i : 9,
|
||||||
|
y: 0,
|
||||||
|
z: 0,
|
||||||
|
magnitude: 5,
|
||||||
|
spectralType: 'G2V',
|
||||||
|
colorIndex: 0.6
|
||||||
|
}));
|
||||||
|
const POSITIONS = Float32Array.from(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
||||||
|
const index = new StarNeighbourhood(STARS);
|
||||||
|
|
||||||
|
/** Flushes settled promises and their handlers. */
|
||||||
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
/** A worker that records what it is sent and answers only when told to. */
|
||||||
|
class FakeWorker {
|
||||||
|
readonly sent: Array<RoutingRequest | { kind: 'catalogue' }> = [];
|
||||||
|
private readonly listeners: Record<string, Array<(event: { data?: unknown }) => void>> = {};
|
||||||
|
terminated = false;
|
||||||
|
|
||||||
|
postMessage(message: RoutingRequest | { kind: 'catalogue' }): void {
|
||||||
|
this.sent.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: (event: { data?: unknown }) => void): void {
|
||||||
|
(this.listeners[type] ??= []).push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate(): void {
|
||||||
|
this.terminated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The requests sent so far, catalogue aside. */
|
||||||
|
get requests(): RoutingRequest[] {
|
||||||
|
return this.sent.filter((message): message is RoutingRequest => message.kind !== 'catalogue');
|
||||||
|
}
|
||||||
|
|
||||||
|
answer(response: RoutingResponse): void {
|
||||||
|
for (const listener of this.listeners['message'] ?? []) listener({ data: response });
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(): void {
|
||||||
|
for (const listener of this.listeners['error'] ?? []) listener({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientWithFake(): { client: RoutingClient; worker: FakeWorker } {
|
||||||
|
const worker = new FakeWorker();
|
||||||
|
const client = new RoutingClient(STARS, POSITIONS, index, () => worker as unknown as Worker);
|
||||||
|
return { client, worker };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unit tests' DOM has no Worker, which is exactly the case the client answers in place.
|
||||||
|
describe('RoutingClient without a worker', () => {
|
||||||
|
it('has no Worker to use here, so the in-place answers are what is being tested', () => {
|
||||||
|
expect(typeof Worker).toBe('undefined');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers a route from the index it was given', async () => {
|
||||||
|
const client = new RoutingClient(STARS, POSITIONS, index);
|
||||||
|
|
||||||
|
await expect(client.route(100, 104, 1.5, 8)).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5), neededRangePc: null });
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers a refused route with the range that would open it', async () => {
|
||||||
|
const client = new RoutingClient(STARS, POSITIONS, index);
|
||||||
|
|
||||||
|
const answer = await client.route(100, 105, 1.5, 8);
|
||||||
|
|
||||||
|
expect(answer.route).toBeNull();
|
||||||
|
expect(answer.neededRangePc).toBeCloseTo(5, 1);
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers the graph as segments', async () => {
|
||||||
|
const client = new RoutingClient(STARS, POSITIONS, index);
|
||||||
|
|
||||||
|
expect(Array.from(await client.links(1.5))).toEqual(Array.from(jumpLinkSegments(index, 1.5)));
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RoutingClient with a worker', () => {
|
||||||
|
it('sends the catalogue first, then one request at a time', () => {
|
||||||
|
const { client, worker } = clientWithFake();
|
||||||
|
|
||||||
|
void client.links(8);
|
||||||
|
void client.links(3);
|
||||||
|
|
||||||
|
expect(worker.sent[0].kind).toBe('catalogue');
|
||||||
|
expect(worker.requests).toHaveLength(1);
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A graph at 8 pc is seconds of work the worker cannot drop once started. Every pause on the
|
||||||
|
// range slider used to queue another, and a route asked for after them waited behind them all.
|
||||||
|
it('replaces a waiting graph with the newer one before it is ever built, and sends a route ahead of it', async () => {
|
||||||
|
const { client, worker } = clientWithFake();
|
||||||
|
const first = client.links(5);
|
||||||
|
const superseded = client.links(6).catch((error: unknown) => error);
|
||||||
|
const latest = client.links(8);
|
||||||
|
const route = client.route(100, 104, 1.5, 8);
|
||||||
|
|
||||||
|
const building = worker.requests[0];
|
||||||
|
worker.answer({ kind: 'links', requestId: building.requestId, segments: new Float32Array(6) });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(await superseded).toBeInstanceOf(SupersededRequest);
|
||||||
|
expect(worker.requests.map((request) => request.kind)).toEqual(['links', 'route']);
|
||||||
|
await expect(first).resolves.toHaveLength(6);
|
||||||
|
|
||||||
|
const routeRequest = worker.requests[1];
|
||||||
|
worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4 });
|
||||||
|
await expect(route).resolves.toEqual({ route: null, neededRangePc: 4 });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(worker.requests.map((request) => (request.kind === 'links' ? request.rangePc : request.kind))).toEqual([5, 'route', 8]);
|
||||||
|
const lastGraph = worker.requests[2];
|
||||||
|
worker.answer({ kind: 'links', requestId: lastGraph.requestId, segments: new Float32Array(12) });
|
||||||
|
await expect(latest).resolves.toHaveLength(12);
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares the answer to a question already on its way rather than asking it twice', async () => {
|
||||||
|
const { client, worker } = clientWithFake();
|
||||||
|
const once = client.links(7.5);
|
||||||
|
const again = client.links(7.5);
|
||||||
|
|
||||||
|
expect(worker.requests).toHaveLength(1);
|
||||||
|
worker.answer({ kind: 'links', requestId: worker.requests[0].requestId, segments: new Float32Array(6) });
|
||||||
|
|
||||||
|
expect(await again).toBe(await once);
|
||||||
|
expect(worker.requests).toHaveLength(1);
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a request the worker failed on, and goes on to the next', async () => {
|
||||||
|
const { client, worker } = clientWithFake();
|
||||||
|
const failing = client.route(100, 104, 1.5, 8).catch((error: unknown) => error);
|
||||||
|
const next = client.links(3);
|
||||||
|
|
||||||
|
worker.answer({ kind: 'failed', requestId: worker.requests[0].requestId, message: 'out of memory' });
|
||||||
|
|
||||||
|
expect(((await failing) as Error).message).toBe('out of memory');
|
||||||
|
await flush();
|
||||||
|
expect(worker.requests.map((request) => request.kind)).toEqual(['route', 'links']);
|
||||||
|
worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(0) });
|
||||||
|
await expect(next).resolves.toHaveLength(0);
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers in place what a worker that failed to load left outstanding, and everything after', async () => {
|
||||||
|
const { client, worker } = clientWithFake();
|
||||||
|
const route = client.route(100, 104, 1.5, 8);
|
||||||
|
const graph = client.links(1.5);
|
||||||
|
|
||||||
|
worker.fail();
|
||||||
|
|
||||||
|
await expect(route).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5), neededRangePc: null });
|
||||||
|
expect(Array.from(await graph)).toEqual(Array.from(jumpLinkSegments(index, 1.5)));
|
||||||
|
await expect(client.route(100, 105, 1.5, 8)).resolves.toMatchObject({ route: null });
|
||||||
|
expect(worker.terminated).toBe(true);
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { answerRouting, RoutingRequest, RoutingResponse } from '../../shared/astro/routing';
|
||||||
|
import { Route } from '../../shared/astro/jump-links';
|
||||||
|
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
|
||||||
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
|
|
||||||
|
export interface RouteAnswer {
|
||||||
|
readonly route: Route | null;
|
||||||
|
readonly neededRangePc: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A request dropped before it was sent, because a newer one of the same kind replaced it. */
|
||||||
|
export class SupersededRequest extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Superseded by a newer request');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A request made and not yet answered: what was asked, and the promise whoever asked is holding. */
|
||||||
|
interface Outstanding {
|
||||||
|
readonly request: RoutingRequest;
|
||||||
|
/** The question without its id, so the same question asked twice can be recognised. */
|
||||||
|
readonly question: string;
|
||||||
|
readonly promise: Promise<RoutingResponse>;
|
||||||
|
readonly resolve: (response: RoutingResponse) => void;
|
||||||
|
readonly reject: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function outstanding(request: RoutingRequest): Outstanding {
|
||||||
|
let resolve!: (response: RoutingResponse) => void;
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
const promise = new Promise<RoutingResponse>((onResolve, onReject) => {
|
||||||
|
resolve = onResolve;
|
||||||
|
reject = onReject;
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { requestId, ...question } = request;
|
||||||
|
return { request, question: JSON.stringify(question), promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The routing worker, where this environment has one. */
|
||||||
|
function startRoutingWorker(): Worker | undefined {
|
||||||
|
return typeof Worker === 'undefined' ? undefined : new Worker(new URL('../../shared/astro/routing.worker', import.meta.url), { type: 'module' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the route questions of a worker holding its own copy of the catalogue, and hands back
|
||||||
|
* promises.
|
||||||
|
*
|
||||||
|
* The worker answers one request at a time and cannot drop one it has started: a jump-link graph
|
||||||
|
* at 8 pc is seconds of work. So requests are held here and sent one by one, and while one is out,
|
||||||
|
* only the latest of each kind waits behind it — a newer graph replaces an older one before it is
|
||||||
|
* ever built, and the older promise is rejected with {@link SupersededRequest}. Routes go ahead of
|
||||||
|
* graphs, being quick and asked for by a click. The same question asked again while it is still
|
||||||
|
* outstanding shares the answer rather than being worked out twice.
|
||||||
|
*
|
||||||
|
* Where there is no worker — the unit tests' DOM has none, and a worker can fail to load or crash —
|
||||||
|
* the same answers are worked out in place, from the index the scene already holds.
|
||||||
|
*/
|
||||||
|
export class RoutingClient {
|
||||||
|
private worker?: Worker;
|
||||||
|
private inFlight?: Outstanding;
|
||||||
|
private readonly waiting: Partial<Record<RoutingRequest['kind'], Outstanding>> = {};
|
||||||
|
private nextRequestId = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
stars: readonly StarRecord[],
|
||||||
|
positions: Float32Array,
|
||||||
|
private readonly localIndex: StarNeighbourhood,
|
||||||
|
startWorker: () => Worker | undefined = startRoutingWorker
|
||||||
|
) {
|
||||||
|
this.worker = startWorker();
|
||||||
|
if (!this.worker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.worker.addEventListener('message', ({ data }: MessageEvent<RoutingResponse>) => this.settle(data));
|
||||||
|
// A worker that fails to load, or dies, answers nothing further: everything outstanding, and
|
||||||
|
// everything asked from here on, is worked out in place instead of waiting for good.
|
||||||
|
this.worker.addEventListener('error', () => this.abandonWorker());
|
||||||
|
this.worker.addEventListener('messageerror', () => this.abandonWorker());
|
||||||
|
// Copies, since the scene goes on using its own; transferred, so the copy is sent and not cloned again.
|
||||||
|
const ids = Int32Array.from(stars, (star) => star.id);
|
||||||
|
const copy = positions.slice();
|
||||||
|
this.worker.postMessage({ kind: 'catalogue', ids, positions: copy }, [ids.buffer, copy.buffer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
route(fromId: number, toId: number, rangePc: number, ceilingPc: number): Promise<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.worker = undefined;
|
||||||
|
this.inFlight = undefined;
|
||||||
|
delete this.waiting.route;
|
||||||
|
delete this.waiting.links;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ask(request: RoutingRequest): Promise<RoutingResponse> {
|
||||||
|
if (!this.worker) {
|
||||||
|
return new Promise((resolve) => resolve(answerRouting(this.localIndex, request)));
|
||||||
|
}
|
||||||
|
const asked = outstanding(request);
|
||||||
|
const same = [this.inFlight, this.waiting[request.kind]].find((other) => other?.question === asked.question);
|
||||||
|
if (same) {
|
||||||
|
return same.promise;
|
||||||
|
}
|
||||||
|
this.waiting[request.kind]?.reject(new SupersededRequest());
|
||||||
|
this.waiting[request.kind] = asked;
|
||||||
|
this.sendNext();
|
||||||
|
return asked.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendNext(): void {
|
||||||
|
if (this.inFlight || !this.worker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = this.waiting.route ?? this.waiting.links;
|
||||||
|
if (!next) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete this.waiting[next.request.kind];
|
||||||
|
this.inFlight = next;
|
||||||
|
this.worker.postMessage(next.request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private settle(response: RoutingResponse): void {
|
||||||
|
const answered = this.inFlight;
|
||||||
|
if (!answered || answered.request.requestId !== response.requestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.inFlight = undefined;
|
||||||
|
if (response.kind === 'failed') {
|
||||||
|
answered.reject(new Error(response.message));
|
||||||
|
} else {
|
||||||
|
answered.resolve(response);
|
||||||
|
}
|
||||||
|
this.sendNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
private abandonWorker(): void {
|
||||||
|
this.worker?.terminate();
|
||||||
|
this.worker = undefined;
|
||||||
|
const stranded = [this.inFlight, this.waiting.route, this.waiting.links];
|
||||||
|
this.inFlight = undefined;
|
||||||
|
delete this.waiting.route;
|
||||||
|
delete this.waiting.links;
|
||||||
|
for (const request of stranded) {
|
||||||
|
if (!request) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
request.resolve(answerRouting(this.localIndex, request.request));
|
||||||
|
} catch (error) {
|
||||||
|
request.reject(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,50 @@
|
|||||||
|
/**
|
||||||
|
* The route questions the map asks of the whole catalogue, as messages: what a worker is sent,
|
||||||
|
* what it sends back, and the one function that turns the first into the second.
|
||||||
|
*
|
||||||
|
* Kept apart from the worker itself so it runs the same on either side of the thread boundary.
|
||||||
|
* The scene asks through `RoutingClient`, which hands these to a Web Worker where one exists and
|
||||||
|
* answers them in place where one does not.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { jumpLinkSegments, minimumRangeBetween, Route, routeBetween } from './jump-links';
|
||||||
|
import { StarNeighbourhood } from './star-neighbourhood';
|
||||||
|
|
||||||
|
/** The catalogue, sent once: ids, and positions packed three to a star in the same order. */
|
||||||
|
export interface RoutingCatalogue {
|
||||||
|
readonly kind: 'catalogue';
|
||||||
|
readonly ids: Int32Array;
|
||||||
|
readonly positions: Float32Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoutingRequest =
|
||||||
|
| { readonly kind: 'route'; readonly requestId: number; readonly fromId: number; readonly toId: number; readonly rangePc: number; readonly ceilingPc: number }
|
||||||
|
| { readonly kind: 'links'; readonly requestId: number; readonly rangePc: number };
|
||||||
|
|
||||||
|
export type RoutingResponse =
|
||||||
|
| { readonly kind: 'route'; readonly requestId: number; readonly route: Route | null; readonly neededRangePc: number | null }
|
||||||
|
| { readonly kind: 'links'; readonly requestId: number; readonly segments: Float32Array }
|
||||||
|
/** The question threw in the worker. Sent back so the request settles instead of waiting for good. */
|
||||||
|
| { readonly kind: 'failed'; readonly requestId: number; readonly message: string };
|
||||||
|
|
||||||
|
/** A spatial index over a catalogue sent as a {@link RoutingCatalogue}. */
|
||||||
|
export function indexCatalogue({ ids, positions }: RoutingCatalogue): StarNeighbourhood {
|
||||||
|
return new StarNeighbourhood(Array.from(ids, (id, i) => ({ id, x: positions[i * 3], y: positions[i * 3 + 1], z: positions[i * 3 + 2] })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answers one request. A route that cannot be made comes back with the range that would make
|
||||||
|
* one, searched no wider than `ceilingPc`, so a refusal is always also an offer.
|
||||||
|
*/
|
||||||
|
export function answerRouting(index: StarNeighbourhood, request: RoutingRequest): RoutingResponse {
|
||||||
|
if (request.kind === 'links') {
|
||||||
|
return { kind: 'links', requestId: request.requestId, segments: jumpLinkSegments(index, request.rangePc) };
|
||||||
|
}
|
||||||
|
const route = routeBetween(index, request.fromId, request.toId, request.rangePc);
|
||||||
|
return {
|
||||||
|
kind: 'route',
|
||||||
|
requestId: request.requestId,
|
||||||
|
route,
|
||||||
|
neededRangePc: route ? null : minimumRangeBetween(index, request.fromId, request.toId, request.ceilingPc)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/// <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.
|
||||||
|
try {
|
||||||
|
const response = answerRouting(index!, data);
|
||||||
|
postMessage(response, response.kind === 'links' ? [response.segments.buffer] : []);
|
||||||
|
} catch (error) {
|
||||||
|
postMessage({ kind: 'failed', requestId: data.requestId, message: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
+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