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:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { collectJumpLinks, minimumRangeBetween, routeBetween } from './jump-links';
|
||||
import { jumpLinkSegments, minimumRangeBetween, routeBetween } from './jump-links';
|
||||
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
|
||||
|
||||
/** Stars a parsec apart along x, so a chain's length is the number of hops it takes. */
|
||||
@@ -153,25 +153,58 @@ describe('minimumRangeBetween', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectJumpLinks', () => {
|
||||
it('reports each pair once, not once from either end', () => {
|
||||
const links = collectJumpLinks(chain(4), 1.5);
|
||||
/**
|
||||
* The links a segment buffer draws, as unordered pairs of star ids, read back from where each end
|
||||
* sits. Positions are compared as the float32 the buffer holds.
|
||||
*/
|
||||
function linksDrawn(segments: Float32Array, points: readonly StarPoint[]): string[] {
|
||||
const idAt = new Map(points.map((point) => [[point.x, point.y, point.z].map(Math.fround).join(), point.id]));
|
||||
const links: string[] = [];
|
||||
for (let at = 0; at < segments.length; at += 6) {
|
||||
const a = idAt.get(Array.from(segments.subarray(at, at + 3)).join())!;
|
||||
const b = idAt.get(Array.from(segments.subarray(at + 3, at + 6)).join())!;
|
||||
links.push(a < b ? `${a}-${b}` : `${b}-${a}`);
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
expect(links.map((link) => [link.from, link.to])).toEqual([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 3]
|
||||
]);
|
||||
/** Stars a parsec apart along x, as points, for reading a segment buffer back. */
|
||||
function chainPoints(count: number): StarPoint[] {
|
||||
return Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
|
||||
}
|
||||
|
||||
describe('jumpLinkSegments', () => {
|
||||
it('draws each pair once, not once from either end', () => {
|
||||
const segments = jumpLinkSegments(chain(4), 1.5);
|
||||
|
||||
expect(linksDrawn(segments, chainPoints(4)).sort()).toEqual(['0-1', '1-2', '2-3']);
|
||||
});
|
||||
|
||||
it('measures every link it reports', () => {
|
||||
const links = collectJumpLinks(chain(3), 2.5);
|
||||
it('puts both ends of every link where its stars are', () => {
|
||||
const segments = jumpLinkSegments(chain(3), 2.5);
|
||||
|
||||
expect(links.find((link) => link.from === 0 && link.to === 2)?.distancePc).toBeCloseTo(2);
|
||||
expect(segments).toHaveLength(3 * 6);
|
||||
expect(linksDrawn(segments, chainPoints(3)).sort()).toEqual(['0-1', '0-2', '1-2']);
|
||||
});
|
||||
|
||||
it('draws nothing at no range', () => {
|
||||
expect(collectJumpLinks(chain(4), 0)).toEqual([]);
|
||||
expect(jumpLinkSegments(chain(4), 0)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('grows past its first buffer without losing a link', () => {
|
||||
// 5 000 stars a tenth of a parsec apart, ten neighbours each way in range: some 50 000 links, far past
|
||||
// the 4 096 the buffer starts with, so it has to grow several times.
|
||||
const count = 5000;
|
||||
const line = new StarNeighbourhood(Array.from({ length: count }, (_, i) => ({ id: i, x: i / 10, y: 0, z: 0 })));
|
||||
// 1.05 rather than 1: the tenth neighbour sits at 1.0, which float steps of a tenth put either side of it.
|
||||
const segments = jumpLinkSegments(line, 1.05);
|
||||
|
||||
let expected = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
expected += Math.min(10, count - 1 - i);
|
||||
}
|
||||
expect(segments.length / 6).toBe(expected);
|
||||
expect(segments.buffer.byteLength).toBe(segments.byteLength);
|
||||
});
|
||||
|
||||
it('agrees with every route it makes possible', () => {
|
||||
@@ -185,8 +218,7 @@ describe('collectJumpLinks', () => {
|
||||
// earlier version of this test hid by only checking the route it happened to find.
|
||||
const range = 9;
|
||||
|
||||
const links = collectJumpLinks(cloud, range);
|
||||
const drawn = new Set(links.map((link) => `${link.from}-${link.to}`));
|
||||
const drawn = new Set(linksDrawn(jumpLinkSegments(cloud, range), points));
|
||||
|
||||
const route = routeBetween(cloud, 0, 119, range);
|
||||
// Asserted, not guarded: a skipped body would let the two disagree unnoticed.
|
||||
@@ -196,6 +228,6 @@ describe('collectJumpLinks', () => {
|
||||
const [a, b] = [route!.stars[i - 1], route!.stars[i]].sort((x, y) => x - y);
|
||||
expect(drawn.has(`${a}-${b}`)).toBe(true);
|
||||
}
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
expect(drawn.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,13 +29,6 @@ export interface Route {
|
||||
readonly longestHopPc: number;
|
||||
}
|
||||
|
||||
/** An unordered pair of stars within range of each other. */
|
||||
export interface JumpLink {
|
||||
readonly from: number;
|
||||
readonly to: number;
|
||||
readonly distancePc: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cap on how much of the catalogue one search may walk. A search that hits it has already
|
||||
* visited more stars than any real chain passes through: the longest measured, Sol to HD 2626 at
|
||||
@@ -221,17 +214,30 @@ export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, to
|
||||
}
|
||||
|
||||
/**
|
||||
* Every link within `rangePc` in the whole catalogue, each pair once.
|
||||
* Every link within `rangePc` in the whole catalogue, each pair once, as vertex pairs ready to
|
||||
* draw: six floats a link, one end then the other.
|
||||
*
|
||||
* For drawing the graph, which is the only thing that wants all of it: routing asks for a
|
||||
* star's neighbours as it reaches that star and never builds this.
|
||||
* For drawing the graph, which is the only thing that wants all of it: routing asks for a star's
|
||||
* neighbours as it reaches that star and never builds this. Written straight into floats rather
|
||||
* than collected as link objects first, since at 8 pc there are 3.7 million links.
|
||||
*/
|
||||
export function collectJumpLinks(index: StarNeighbourhood, rangePc: number): JumpLink[] {
|
||||
const links: JumpLink[] = [];
|
||||
index.forEachPairWithin(rangePc, (a, b, distancePc) => {
|
||||
// The smaller id first, always. The grid hands pairs over in whatever order it walks its
|
||||
// cells, and a link that is `3-7` here and `7-3` there is two links to anything comparing.
|
||||
links.push(a.id < b.id ? { from: a.id, to: b.id, distancePc } : { from: b.id, to: a.id, distancePc });
|
||||
export function jumpLinkSegments(index: StarNeighbourhood, rangePc: number): Float32Array {
|
||||
let vertices = new Float32Array(6 * 4096);
|
||||
let length = 0;
|
||||
index.forEachPairWithin(rangePc, (a, b) => {
|
||||
if (length + 6 > vertices.length) {
|
||||
const grown = new Float32Array(vertices.length * 2);
|
||||
grown.set(vertices);
|
||||
vertices = grown;
|
||||
}
|
||||
vertices[length++] = a.x;
|
||||
vertices[length++] = a.y;
|
||||
vertices[length++] = a.z;
|
||||
vertices[length++] = b.x;
|
||||
vertices[length++] = b.y;
|
||||
vertices[length++] = b.z;
|
||||
});
|
||||
return links;
|
||||
// Exact length rather than a view on the grown buffer: the answer is transferred whole, and a
|
||||
// view would carry up to as much again in unused capacity with it.
|
||||
return vertices.slice(0, length);
|
||||
}
|
||||
|
||||
@@ -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] : []);
|
||||
});
|
||||
Reference in New Issue
Block a user