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:
@@ -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}.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user