Merge pull request #25 from avalon-vanguard/perf/link-drawn-stars

Link only the stars that are drawn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
Senrokai
2026-09-17 18:05:48 +02:00
committed by GitHub
co-authored by Claude Opus 5
11 changed files with 267 additions and 58 deletions
@@ -11,6 +11,7 @@ import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store'; import { NavigationStore } from '../../shared/state/navigation.store';
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component'; import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
import { JumpLinkRenderer } from './jump-link-renderer';
import { StarFieldRenderer } from './star-field-renderer'; import { StarFieldRenderer } from './star-field-renderer';
// jsdom does not implement ResizeObserver; the component only uses it to react to real // jsdom does not implement ResizeObserver; the component only uses it to react to real
@@ -266,6 +267,85 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
refocus.mockRestore(); refocus.mockRestore();
}); });
describe('the jump-link graph', () => {
type LinkScene = {
routing: { links(rangePc: number, drawn: Uint32Array): Promise<Float32Array>; route(): Promise<never>; dispose(): void };
display: { update(change: (display: { jumpLinks: boolean }) => unknown): void };
jumpRangePc: { set(rangePc: number): void };
routeResult: { set(value: unknown): void };
controls: { target: THREE.Vector3 };
starField: { drawnStars: Uint32Array; drawn: Uint32Array };
};
/** Real time, since the rebuild waits on a real timer for the range and the drawn stars to settle. */
const settle = () => new Promise((resolve) => setTimeout(resolve, 300));
function linkScene(links: LinkScene['routing']['links']): LinkScene {
const component = fixture.componentInstance as unknown as LinkScene;
component.routing = { links, route: () => new Promise<never>(() => undefined), dispose: () => undefined };
component.display.update((display) => ({ ...display, jumpLinks: true }));
TestBed.tick();
return component;
}
/** Makes the next refocus choose a different set: the field is told it draws one star, then the view moves. */
async function changeDrawnStars(component: LinkScene, targetX: number): Promise<void> {
component.starField.drawn = Uint32Array.of(0);
component.controls.target.set(targetX, 0, 0);
await advanceFrames(engine, 0.3);
}
it('links the stars being drawn, and asks again once a new set of them holds still', async () => {
const links = vi.fn((_rangePc: number, _drawn: Uint32Array) => Promise.resolve(new Float32Array(0)));
const component = linkScene(links);
await settle();
expect(links).toHaveBeenCalledTimes(1);
expect(links.mock.calls[0]).toEqual([3, component.starField.drawnStars]);
await changeDrawnStars(component, 40);
expect(links).toHaveBeenCalledTimes(1);
await settle();
expect(links).toHaveBeenCalledTimes(2);
expect(links.mock.calls[1][1]).toBe(component.starField.drawnStars);
expect(links.mock.calls[1][1]).not.toBe(links.mock.calls[0][1]);
// A route re-chooses the drawn stars around its pins, and here they come out the same: no new graph.
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }], totalPc: 0, neededRangePc: null });
await advanceFrames(engine, 0.3);
await settle();
expect(links).toHaveBeenCalledTimes(2);
});
it('draws a late graph for the range still asked for, and not one for a range left behind', async () => {
const answers: Array<(segments: Float32Array) => void> = [];
const setSegments = vi.spyOn(JumpLinkRenderer.prototype, 'setSegments');
const component = linkScene(() => new Promise<Float32Array>((resolve) => answers.push(resolve)));
await settle();
await changeDrawnStars(component, 40);
await settle();
expect(answers).toHaveLength(2);
// For stars no longer drawn, but at the range still asked for: newer than what is on screen.
const olderSet = new Float32Array(6);
answers[0](olderSet);
await flushAsync();
expect(setSegments).toHaveBeenLastCalledWith(olderSet);
component.jumpRangePc.set(5);
TestBed.tick();
await settle();
expect(answers).toHaveLength(3);
answers[1](new Float32Array(12));
await flushAsync();
expect(setSegments).toHaveBeenLastCalledWith(olderSet);
const current = new Float32Array(18);
answers[2](current);
await flushAsync();
expect(setSegments).toHaveBeenLastCalledWith(current);
setSegments.mockRestore();
});
});
it('shows the answer to the latest route asked for, whatever order the answers arrive in', async () => { 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 }; type Answer = { route: { stars: number[]; totalPc: number; longestHopPc: number } | null; neededRangePc: number | null };
const answers: Array<(answer: Answer) => void> = []; const answers: Array<(answer: Answer) => void> = [];
@@ -71,7 +71,10 @@ const LABEL_MIN_SEPARATION_NDC = 0.12;
const LABEL_EDGE_NDC = 0.7; const LABEL_EDGE_NDC = 0.7;
/** How far right of its point a label's text reaches, in aspect-scaled NDC (~135px at 1440). */ /** How far right of its point a label's text reaches, in aspect-scaled NDC (~135px at 1440). */
const LABEL_REACH_NDC = 0.3; const LABEL_REACH_NDC = 0.3;
/** How long the range control has to be still before the graph is rebuilt at its value. */ /**
* How long the range control, and the set of drawn stars, have to be still before the graph is
* rebuilt for them: a drag emits per pixel, and a flight re-chooses the drawn stars every few passes.
*/
const JUMP_LINK_REBUILD_DELAY_MS = 250; const JUMP_LINK_REBUILD_DELAY_MS = 250;
/** /**
@@ -355,8 +358,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
); );
}); });
private readonly routeQuery = signal(''); private readonly routeQuery = signal('');
/** The range the drawn graph was last built at, so a redraw is skipped when nothing moved. */ /** The range and the stars the drawn graph was last asked for, so a rebuild is skipped when neither moved. */
private drawnJumpRangePc: number | null = null; private drawnJumpRangePc: number | null = null;
private linkedStars: Uint32Array | null = null;
private jumpLinkRebuild?: ReturnType<typeof setTimeout>; private jumpLinkRebuild?: ReturnType<typeof setTimeout>;
/** The current system's neighbours, resolved on arrival: id, name, distance and bearing. */ /** The current system's neighbours, resolved on arrival: id, name, distance and bearing. */
private neighbours: readonly { star: StarRecord; distancePc: number; direction: THREE.Vector3 }[] = []; private neighbours: readonly { star: StarRecord; distancePc: number; direction: THREE.Vector3 }[] = [];
@@ -420,14 +424,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
}); });
effect(() => this.applyDisplay(this.display())); effect(() => this.applyDisplay(this.display()));
effect(() => this.applyProjection(this.display().plan)); effect(() => this.applyProjection(this.display().plan));
// Reads both signals, so flipping the layer on and dragging the range each land here. The // Reads both signals, so flipping the layer on and dragging the range each land here.
// rebuild is a quarter-second of walking the catalogue, and the range control emits per
// pixel dragged, so it waits for the hand to settle rather than running once per pixel.
effect(() => { effect(() => {
this.jumpRangePc(); this.jumpRangePc();
this.display().jumpLinks; this.display().jumpLinks;
clearTimeout(this.jumpLinkRebuild); this.scheduleJumpLinks();
this.jumpLinkRebuild = setTimeout(() => this.refreshJumpLinks(), JUMP_LINK_REBUILD_DELAY_MS);
}); });
} }
@@ -807,6 +808,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.starField.refocus({ centre, pinned }); this.starField.refocus({ centre, pinned });
this.starFieldFocus = centre.clone(); this.starFieldFocus = centre.clone();
this.starFieldPins = pins; this.starFieldPins = pins;
// The graph links the drawn stars, so a new set wants a new graph once it stops changing.
this.scheduleJumpLinks();
} }
private updateLabels(camera: SceneCamera): void { private updateLabels(camera: SceneCamera): void {
@@ -1449,14 +1452,23 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
); );
} }
/** Rebuilds the graph once the range and the drawn stars have held still. */
private scheduleJumpLinks(): void {
clearTimeout(this.jumpLinkRebuild);
this.jumpLinkRebuild = setTimeout(() => this.refreshJumpLinks(), JUMP_LINK_REBUILD_DELAY_MS);
}
/** /**
* Rebuilds the drawn graph, which is the expensive half: every star's neighbours, once, and 3.7 * Rebuilds the drawn graph: the links between the stars the field is drawing, so what is linked
* million links at 8 pc, so it is built in the worker. Only when the layer is on and the range * is what can be seen and clicked. Hundreds of thousands of links at 8 pc, so it is built in the
* has actually moved — the control emits per pixel dragged — and only the graph for the range * worker, and only when the layer is on and the range or the drawn stars have actually changed.
* last asked for is drawn, in whatever order the answers arrive. *
* An answer is drawn if it is for the range last asked for, even when the drawn stars have moved
* on since: the client answers in the order it was asked, so it is never older than the graph on
* screen, and holding out for the latest set would draw nothing while the view keeps moving.
*/ */
private refreshJumpLinks(): void { private refreshJumpLinks(): void {
if (!this.jumpLinks || !this.routing) { if (!this.jumpLinks || !this.routing || !this.starField) {
return; return;
} }
const rangePc = this.jumpRangePc(); const rangePc = this.jumpRangePc();
@@ -1464,24 +1476,28 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
if (this.drawnJumpRangePc !== null) { if (this.drawnJumpRangePc !== null) {
this.jumpLinks.setSegments(new Float32Array(0)); this.jumpLinks.setSegments(new Float32Array(0));
this.drawnJumpRangePc = null; this.drawnJumpRangePc = null;
this.linkedStars = null;
} }
return; return;
} }
if (this.drawnJumpRangePc === rangePc) { const drawn = this.starField.drawnStars;
if (this.drawnJumpRangePc === rangePc && this.linkedStars === drawn) {
return; return;
} }
this.drawnJumpRangePc = rangePc; this.drawnJumpRangePc = rangePc;
void this.routing.links(rangePc).then( this.linkedStars = drawn;
void this.routing.links(rangePc, drawn).then(
(segments) => { (segments) => {
if (this.drawnJumpRangePc === rangePc) { if (this.drawnJumpRangePc === rangePc) {
this.jumpLinks?.setSegments(segments); this.jumpLinks?.setSegments(segments);
} }
}, },
() => { () => {
// Replaced by a newer range, or failed. Either way this range is not drawn, and must not be // Replaced by a newer request, or failed. Either way this graph is not drawn, and must not
// remembered as if it were, or asking for it again would be skipped. // be remembered as if it were, or asking for it again would be skipped.
if (this.drawnJumpRangePc === rangePc) { if (this.drawnJumpRangePc === rangePc && this.linkedStars === drawn) {
this.drawnJumpRangePc = null; this.drawnJumpRangePc = null;
this.linkedStars = null;
} }
} }
); );
@@ -53,6 +53,10 @@ export class JumpLinkRenderer {
/** The graph, as vertex pairs: six floats a link, one end then the other. See `jumpLinkSegments`. */ /** The graph, as vertex pairs: six floats a link, one end then the other. See `jumpLinkSegments`. */
setSegments(vertices: Float32Array): void { setSegments(vertices: Float32Array): void {
this.replaceGeometry(this.links, vertices); this.replaceGeometry(this.links, vertices);
// Given rather than left for the renderer to compute: it wants a bounding sphere to sort by and,
// finding none, walks every vertex on the main thread in the first frame that draws the graph.
// The graph is never culled, and it sorts by its centre, where the catalogue is centred too.
this.links.geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), Infinity);
} }
/** 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. */
@@ -18,6 +18,8 @@ const STARS: StarRecord[] = Array.from({ length: 6 }, (_, i) => ({
})); }));
const POSITIONS = Float32Array.from(STARS.flatMap((star) => [star.x, star.y, star.z])); const POSITIONS = Float32Array.from(STARS.flatMap((star) => [star.x, star.y, star.z]));
const index = new StarNeighbourhood(STARS); const index = new StarNeighbourhood(STARS);
/** Every star drawn. */
const ALL = Uint32Array.from(STARS.keys());
/** Flushes settled promises and their handlers. */ /** Flushes settled promises and their handlers. */
const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -25,11 +27,13 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
/** A worker that records what it is sent and answers only when told to. */ /** A worker that records what it is sent and answers only when told to. */
class FakeWorker { class FakeWorker {
readonly sent: Array<RoutingRequest | { kind: 'catalogue' }> = []; readonly sent: Array<RoutingRequest | { kind: 'catalogue' }> = [];
readonly transferred: ArrayBufferLike[] = [];
private readonly listeners: Record<string, Array<(event: { data?: unknown }) => void>> = {}; private readonly listeners: Record<string, Array<(event: { data?: unknown }) => void>> = {};
terminated = false; terminated = false;
postMessage(message: RoutingRequest | { kind: 'catalogue' }): void { postMessage(message: RoutingRequest | { kind: 'catalogue' }, transfer: Transferable[] = []): void {
this.sent.push(message); this.sent.push(message);
this.transferred.push(...(transfer as ArrayBufferLike[]));
} }
addEventListener(type: string, listener: (event: { data?: unknown }) => void): void { addEventListener(type: string, listener: (event: { data?: unknown }) => void): void {
@@ -86,7 +90,15 @@ describe('RoutingClient without a worker', () => {
it('answers the graph as segments', async () => { it('answers the graph as segments', async () => {
const client = new RoutingClient(STARS, POSITIONS, index); const client = new RoutingClient(STARS, POSITIONS, index);
expect(Array.from(await client.links(1.5))).toEqual(Array.from(jumpLinkSegments(index, 1.5))); expect(Array.from(await client.links(1.5, ALL))).toEqual(Array.from(jumpLinkSegments(index, 1.5)));
client.dispose();
});
it('links only the stars it is told are drawn', async () => {
const client = new RoutingClient(STARS, POSITIONS, index);
// Stars at x = 0, 1 and 3: only the first two are within 1.5 pc of each other.
expect(Array.from(await client.links(1.5, Uint32Array.of(0, 1, 3)))).toEqual([0, 0, 0, 1, 0, 0]);
client.dispose(); client.dispose();
}); });
}); });
@@ -95,8 +107,8 @@ describe('RoutingClient with a worker', () => {
it('sends the catalogue first, then one request at a time', () => { it('sends the catalogue first, then one request at a time', () => {
const { client, worker } = clientWithFake(); const { client, worker } = clientWithFake();
void client.links(8); void client.links(8, ALL);
void client.links(3); void client.links(3, ALL);
expect(worker.sent[0].kind).toBe('catalogue'); expect(worker.sent[0].kind).toBe('catalogue');
expect(worker.requests).toHaveLength(1); expect(worker.requests).toHaveLength(1);
@@ -107,9 +119,9 @@ describe('RoutingClient with a worker', () => {
// range slider used to queue another, and a route asked for after them waited behind them all. // 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 () => { 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 { client, worker } = clientWithFake();
const first = client.links(5); const first = client.links(5, ALL);
const superseded = client.links(6).catch((error: unknown) => error); const superseded = client.links(6, ALL).catch((error: unknown) => error);
const latest = client.links(8); const latest = client.links(8, ALL);
const route = client.route(100, 104, 1.5, 8); const route = client.route(100, 104, 1.5, 8);
const building = worker.requests[0]; const building = worker.requests[0];
@@ -132,23 +144,68 @@ describe('RoutingClient with a worker', () => {
client.dispose(); client.dispose();
}); });
it('shares the answer to a question already on its way rather than asking it twice', async () => { it('shares the answer to a route already on its way rather than asking it twice', async () => {
const { client, worker } = clientWithFake(); const { client, worker } = clientWithFake();
const once = client.links(7.5); const once = client.route(100, 104, 1.5, 8);
const again = client.links(7.5); const again = client.route(100, 104, 1.5, 8);
const widerRange = client.route(100, 104, 2.5, 8);
expect(worker.requests).toHaveLength(1); expect(worker.requests).toHaveLength(1);
worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: 4 });
expect(await again).toEqual(await once);
await flush();
// The same two stars at another range is another question.
expect(worker.requests.map((request) => request.rangePc)).toEqual([1.5, 2.5]);
worker.answer({ kind: 'route', requestId: worker.requests[1].requestId, route: null, neededRangePc: null });
await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null });
client.dispose();
});
// Turning the layer off and on again while the worker is busy asks for the same graph twice. Were
// the second to replace the first, the first's rejection would wipe the scene's record of the second.
it('shares a graph already on its way for the same range and the same list of drawn stars', async () => {
const { client, worker } = clientWithFake();
const drawn = Uint32Array.of(0, 1, 2);
const building = client.links(3, drawn);
const waiting = client.links(5, drawn);
const again = client.links(5, drawn);
const sameAsBuilding = client.links(3, drawn);
worker.answer({ kind: 'links', requestId: worker.requests[0].requestId, segments: new Float32Array(6) }); worker.answer({ kind: 'links', requestId: worker.requests[0].requestId, segments: new Float32Array(6) });
await expect(building).resolves.toHaveLength(6);
await expect(sameAsBuilding).resolves.toHaveLength(6);
await flush();
worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(12) });
await expect(waiting).resolves.toHaveLength(12);
await expect(again).resolves.toHaveLength(12);
expect(worker.requests.map((request) => request.kind === 'links' && request.rangePc)).toEqual([3, 5]);
client.dispose();
});
expect(await again).toBe(await once); it('builds a graph for each set of drawn stars asked about, and never gives the list away', async () => {
expect(worker.requests).toHaveLength(1); const { client, worker } = clientWithFake();
const near = Uint32Array.of(0, 1, 2);
const far = Uint32Array.of(3, 4, 5);
void client.links(3, near);
const second = client.links(3, far);
worker.answer({ kind: 'links', requestId: worker.requests[0].requestId, segments: new Float32Array(6) });
await flush();
expect(worker.requests.map((request) => request.kind === 'links' && Array.from(request.drawn))).toEqual([[0, 1, 2], [3, 4, 5]]);
worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(12) });
await expect(second).resolves.toHaveLength(12);
// The star field goes on drawing and picking from these lists, so they are copied, not moved.
expect(worker.transferred).not.toContain(near.buffer);
expect(worker.transferred).not.toContain(far.buffer);
client.dispose(); client.dispose();
}); });
it('rejects a request the worker failed on, and goes on to the next', async () => { it('rejects a request the worker failed on, and goes on to the next', async () => {
const { client, worker } = clientWithFake(); const { client, worker } = clientWithFake();
const failing = client.route(100, 104, 1.5, 8).catch((error: unknown) => error); const failing = client.route(100, 104, 1.5, 8).catch((error: unknown) => error);
const next = client.links(3); const next = client.links(3, ALL);
worker.answer({ kind: 'failed', requestId: worker.requests[0].requestId, message: 'out of memory' }); worker.answer({ kind: 'failed', requestId: worker.requests[0].requestId, message: 'out of memory' });
@@ -163,12 +220,12 @@ describe('RoutingClient with a worker', () => {
it('answers in place what a worker that failed to load left outstanding, and everything after', async () => { it('answers in place what a worker that failed to load left outstanding, and everything after', async () => {
const { client, worker } = clientWithFake(); const { client, worker } = clientWithFake();
const route = client.route(100, 104, 1.5, 8); const route = client.route(100, 104, 1.5, 8);
const graph = client.links(1.5); const graph = client.links(1.5, Uint32Array.of(0, 1, 3));
worker.fail(); worker.fail();
await expect(route).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5), neededRangePc: null }); 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))); expect(Array.from(await graph)).toEqual([0, 0, 0, 1, 0, 0]);
await expect(client.route(100, 105, 1.5, 8)).resolves.toMatchObject({ route: null }); await expect(client.route(100, 105, 1.5, 8)).resolves.toMatchObject({ route: null });
expect(worker.terminated).toBe(true); expect(worker.terminated).toBe(true);
client.dispose(); client.dispose();
@@ -18,8 +18,6 @@ export class SupersededRequest extends Error {
/** A request made and not yet answered: what was asked, and the promise whoever asked is holding. */ /** A request made and not yet answered: what was asked, and the promise whoever asked is holding. */
interface Outstanding { interface Outstanding {
readonly request: RoutingRequest; 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 promise: Promise<RoutingResponse>;
readonly resolve: (response: RoutingResponse) => void; readonly resolve: (response: RoutingResponse) => void;
readonly reject: (error: Error) => void; readonly reject: (error: Error) => void;
@@ -32,9 +30,19 @@ function outstanding(request: RoutingRequest): Outstanding {
resolve = onResolve; resolve = onResolve;
reject = onReject; reject = onReject;
}); });
// eslint-disable-next-line @typescript-eslint/no-unused-vars return { request, promise, resolve, reject };
const { requestId, ...question } = request; }
return { request, question: JSON.stringify(question), promise, resolve, reject };
/**
* Whether two requests ask the same question. A graph is the same when it is for the same range and
* the very same list of drawn stars: the star field replaces that list whenever the set changes, so
* one array is one set, and comparing 70 000 indices would cost more than sharing could save.
*/
function asksTheSame(a: RoutingRequest, b: RoutingRequest): boolean {
if (a.kind === 'links' || b.kind === 'links') {
return a.kind === 'links' && b.kind === 'links' && a.rangePc === b.rangePc && a.drawn === b.drawn;
}
return a.fromId === b.fromId && a.toId === b.toId && a.rangePc === b.rangePc && a.ceilingPc === b.ceilingPc;
} }
/** The routing worker, where this environment has one. */ /** The routing worker, where this environment has one. */
@@ -46,12 +54,13 @@ function startRoutingWorker(): Worker | undefined {
* Asks the route questions of a worker holding its own copy of the catalogue, and hands back * Asks the route questions of a worker holding its own copy of the catalogue, and hands back
* promises. * promises.
* *
* The worker answers one request at a time and cannot drop one it has started: a jump-link graph * The worker answers one request at a time and cannot drop one it has started: a route with no path
* at 8 pc is seconds of work. So requests are held here and sent one by one, and while one is out, * can be seconds of work, and a graph of the drawn stars at 8 pc a few hundred milliseconds. So
* only the latest of each kind waits behind it — a newer graph replaces an older one before it is * requests are held here and sent one by one, and while one is out, only the latest of each kind
* ever built, and the older promise is rejected with {@link SupersededRequest}. Routes go ahead of * waits behind it — a newer graph replaces an older one before it is ever built, and the older
* graphs, being quick and asked for by a click. The same question asked again while it is still * promise is rejected with {@link SupersededRequest}. Routes go ahead of graphs, being quick to ask
* outstanding shares the answer rather than being worked out twice. * for 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; see `asksTheSame`.
* *
* Where there is no worker — the unit tests' DOM has none, and a worker can fail to load or crash — * 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. * the same answers are worked out in place, from the index the scene already holds.
@@ -89,9 +98,12 @@ export class RoutingClient {
); );
} }
/** Vertex pairs for every link within `rangePc`, three floats to an end. */ /**
links(rangePc: number): Promise<Float32Array> { * Vertex pairs for every link within `rangePc` between two of the `drawn` stars (catalogue
return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc }).then((response) => * indices), three floats to an end.
*/
links(rangePc: number, drawn: Uint32Array): Promise<Float32Array> {
return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc, drawn }).then((response) =>
response.kind === 'links' ? response.segments : new Float32Array(0) response.kind === 'links' ? response.segments : new Float32Array(0)
); );
} }
@@ -108,11 +120,13 @@ export class RoutingClient {
if (!this.worker) { if (!this.worker) {
return new Promise((resolve) => resolve(answerRouting(this.localIndex, request))); return new Promise((resolve) => resolve(answerRouting(this.localIndex, request)));
} }
const asked = outstanding(request); // Shared rather than replaced: an identical request superseding the one it repeats would reject it,
const same = [this.inFlight, this.waiting[request.kind]].find((other) => other?.question === asked.question); // and whoever holds that promise would take the rejection for its own question.
const same = [this.inFlight, this.waiting[request.kind]].find((other) => other !== undefined && asksTheSame(other.request, request));
if (same) { if (same) {
return same.promise; return same.promise;
} }
const asked = outstanding(request);
this.waiting[request.kind]?.reject(new SupersededRequest()); this.waiting[request.kind]?.reject(new SupersededRequest());
this.waiting[request.kind] = asked; this.waiting[request.kind] = asked;
this.sendNext(); this.sendNext();
@@ -129,6 +143,8 @@ export class RoutingClient {
} }
delete this.waiting[next.request.kind]; delete this.waiting[next.request.kind];
this.inFlight = next; this.inFlight = next;
// Cloned, never transferred: a graph's `drawn` is the star field's own list, still drawn and
// picked from, and answered in place from should the worker die.
this.worker.postMessage(next.request); this.worker.postMessage(next.request);
} }
@@ -306,6 +306,14 @@ export class StarFieldRenderer {
return this.drawn.length; return this.drawn.length;
} }
/**
* The catalogue indices being drawn. Replaced by a refocus that changes them, never changed in
* place, so the same array means the same stars.
*/
get drawnStars(): Uint32Array {
return this.drawn;
}
/** /**
* Chooses the drawn stars again for where the view now is, and rewrites the instance buffers * Chooses the drawn stars again for where the view now is, and rewrites the instance buffers
* with them. See {@link selectDrawnStars}. * with them. See {@link selectDrawnStars}.
+4 -3
View File
@@ -214,12 +214,13 @@ export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, to
} }
/** /**
* Every link within `rangePc` in the whole catalogue, each pair once, as vertex pairs ready to * Every link within `rangePc` between two of the stars `index` holds, each pair once, as vertex
* draw: six floats a link, one end then the other. * 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 * 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 * 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. * than collected as link objects first, since at 8 pc the drawn stars alone have hundreds of
* thousands of links, and the whole catalogue 3.7 million.
*/ */
export function jumpLinkSegments(index: StarNeighbourhood, rangePc: number): Float32Array { export function jumpLinkSegments(index: StarNeighbourhood, rangePc: number): Float32Array {
let vertices = new Float32Array(6 * 4096); let vertices = new Float32Array(6 * 4096);
+19 -2
View File
@@ -49,10 +49,27 @@ describe('answerRouting', () => {
}); });
it('answers the graph as the segments it draws', () => { it('answers the graph as the segments it draws', () => {
const answer = answerRouting(index, { kind: 'links', requestId: 3, rangePc: 1.5 }); const answer = answerRouting(index, { kind: 'links', requestId: 3, rangePc: 1.5, drawn: Uint32Array.from(POINTS.keys()) });
expect(answer.kind).toBe('links'); expect(answer.kind).toBe('links');
expect(answer.requestId).toBe(3); expect(answer.requestId).toBe(3);
expect(answer.kind === 'links' && Array.from(answer.segments)).toEqual(Array.from(jumpLinkSegments(direct, 1.5))); expect(answer.kind === 'links' && linkEnds(answer.segments)).toEqual(linkEnds(jumpLinkSegments(direct, 1.5)));
});
it('links only the drawn stars, including a pair exactly the range apart', () => {
// Stars at x = 0, 1, 2 and 4 drawn; the one at 3, which would bridge 2 and 4, is not. At 1 pc
// every link is exactly the range long, and the cells are exactly the range wide.
const answer = answerRouting(index, { kind: 'links', requestId: 4, rangePc: 1, drawn: Uint32Array.of(0, 1, 2, 4) });
expect(answer.kind === 'links' && linkEnds(answer.segments)).toEqual(['0-1', '1-2']);
}); });
}); });
/** Each link as its two ends' x, lower first, in order: the pairs, whatever order they were walked in. */
function linkEnds(segments: Float32Array): string[] {
const ends: string[] = [];
for (let at = 0; at < segments.length; at += 6) {
ends.push([segments[at], segments[at + 3]].sort((a, b) => a - b).join('-'));
}
return ends.sort();
}
+6 -2
View File
@@ -19,7 +19,8 @@ export interface RoutingCatalogue {
export type RoutingRequest = export type RoutingRequest =
| { readonly kind: 'route'; readonly requestId: number; readonly fromId: number; readonly toId: number; readonly rangePc: number; readonly ceilingPc: number } | { 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 }; /** `drawn` is the stars the map is drawing, as positions in the catalogue that was sent: only they are linked. */
| { readonly kind: 'links'; readonly requestId: number; readonly rangePc: number; readonly drawn: Uint32Array };
export type RoutingResponse = export type RoutingResponse =
| { readonly kind: 'route'; readonly requestId: number; readonly route: Route | null; readonly neededRangePc: number | null } | { readonly kind: 'route'; readonly requestId: number; readonly route: Route | null; readonly neededRangePc: number | null }
@@ -38,7 +39,10 @@ export function indexCatalogue({ ids, positions }: RoutingCatalogue): StarNeighb
*/ */
export function answerRouting(index: StarNeighbourhood, request: RoutingRequest): RoutingResponse { export function answerRouting(index: StarNeighbourhood, request: RoutingRequest): RoutingResponse {
if (request.kind === 'links') { if (request.kind === 'links') {
return { kind: 'links', requestId: request.requestId, segments: jumpLinkSegments(index, request.rangePc) }; // An index of its own over the drawn stars, in cells as wide as the range, so each cell is
// paired with its immediate neighbours only: 14 cells a cell at 8 pc rather than 63.
const drawn = new StarNeighbourhood(Array.from(request.drawn, (at) => index.pointAt(at)), request.rangePc);
return { kind: 'links', requestId: request.requestId, segments: jumpLinkSegments(drawn, request.rangePc) };
} }
const route = routeBetween(index, request.fromId, request.toId, request.rangePc); const route = routeBetween(index, request.fromId, request.toId, request.rangePc);
return { return {
+3 -2
View File
@@ -5,8 +5,9 @@ import { StarNeighbourhood } from './star-neighbourhood';
/** /**
* Walks routes and builds the jump-link graph off the main thread. A search to a star 236 pc * 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 * away, and the range it would need when there is none, can take seconds; a graph of the drawn
* 3.7 million links. On the page's own thread either stops the map for as long as it runs. * stars at 8 pc is hundreds of thousands of links. On the page's own thread either stops the map
* for as long as it runs.
*/ */
let index: StarNeighbourhood | undefined; let index: StarNeighbourhood | undefined;
+6 -1
View File
@@ -79,12 +79,17 @@ export class StarNeighbourhood {
}); });
} }
/** The star this id names, or `undefined` — the caller's id may not be in the catalogue. */
/** Where the star this id names sits in the list the index was built from, or `undefined`. */ /** Where the star this id names sits in the list the index was built from, or `undefined`. */
indexOf(id: number): number | undefined { indexOf(id: number): number | undefined {
return this.indexById.get(id); return this.indexById.get(id);
} }
/** The star at this position in the list the index was built from. */
pointAt(index: number): StarPoint {
return this.points[index];
}
/** The star this id names, or `undefined` — the caller's id may not be in the catalogue. */
point(id: number): StarPoint | undefined { point(id: number): StarPoint | undefined {
const index = this.indexById.get(id); const index = this.indexById.get(id);
return index === undefined ? undefined : this.points[index]; return index === undefined ? undefined : this.points[index];