Link only the stars that are drawn
The jump-link graph linked the whole catalogue: 3.7 million links at 8 pc, 7.4-7.8 s in the worker and a 443-515 ms frame on the main thread when they landed, and most of them between stars that were neither drawn nor clickable. A graph request now carries the star field's drawn stars, and the worker links only those, over an index of its own with cells as wide as the range. The scene asks again once a new drawn set has held still for 250 ms. The renderer is handed the graph's bounding sphere instead of computing it: three.js walked every vertex on the main thread in the first frame that drew a new graph, 48-55 ms at 8 pc. 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,42 @@ 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);
|
||||||
|
|
||||||
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);
|
||||||
|
expect(worker.requests).toHaveLength(1);
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a graph for each set of drawn stars asked about, and never gives the list away', async () => {
|
||||||
|
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) });
|
worker.answer({ kind: 'links', requestId: worker.requests[0].requestId, segments: new Float32Array(6) });
|
||||||
|
await flush();
|
||||||
|
|
||||||
expect(await again).toBe(await once);
|
expect(worker.requests.map((request) => request.kind === 'links' && Array.from(request.drawn))).toEqual([[0, 1, 2], [3, 4, 5]]);
|
||||||
expect(worker.requests).toHaveLength(1);
|
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 +194,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,7 +18,11 @@ 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. */
|
/**
|
||||||
|
* A route question without its id, so the same route asked for twice can be recognised. Empty for
|
||||||
|
* a graph: the scene never asks for the same graph twice, and spelling out 70 000 drawn stars to
|
||||||
|
* compare costs more than the comparison could save.
|
||||||
|
*/
|
||||||
readonly question: string;
|
readonly question: string;
|
||||||
readonly promise: Promise<RoutingResponse>;
|
readonly promise: Promise<RoutingResponse>;
|
||||||
readonly resolve: (response: RoutingResponse) => void;
|
readonly resolve: (response: RoutingResponse) => void;
|
||||||
@@ -34,7 +38,7 @@ function outstanding(request: RoutingRequest): Outstanding {
|
|||||||
});
|
});
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { requestId, ...question } = request;
|
const { requestId, ...question } = request;
|
||||||
return { request, question: JSON.stringify(question), promise, resolve, reject };
|
return { request, question: request.kind === 'route' ? JSON.stringify(question) : '', promise, resolve, reject };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The routing worker, where this environment has one. */
|
/** The routing worker, where this environment has one. */
|
||||||
@@ -50,7 +54,7 @@ function startRoutingWorker(): Worker | undefined {
|
|||||||
* at 8 pc is seconds of work. So requests are held here and sent one by one, and while one is out,
|
* at 8 pc is seconds of work. So requests are held here and sent one by one, and while one is out,
|
||||||
* only the latest of each kind waits behind it — a newer graph replaces an older one before it is
|
* only the latest of each kind waits behind it — a newer graph replaces an older one before it is
|
||||||
* ever built, and the older promise is rejected with {@link SupersededRequest}. Routes go ahead of
|
* ever built, and the older promise is rejected with {@link SupersededRequest}. Routes go ahead of
|
||||||
* graphs, being quick and asked for by a click. The same question asked again while it is still
|
* graphs, being quick and asked for by a click. The same route asked for again while it is still
|
||||||
* outstanding shares the answer rather than being worked out twice.
|
* outstanding shares the answer rather than being worked out twice.
|
||||||
*
|
*
|
||||||
* Where there is no worker — the unit tests' DOM has none, and a worker can fail to load or crash —
|
* Where there is no worker — the unit tests' DOM has none, and a worker can fail to load or crash —
|
||||||
@@ -89,9 +93,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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -109,7 +116,7 @@ export class RoutingClient {
|
|||||||
return new Promise((resolve) => resolve(answerRouting(this.localIndex, request)));
|
return new Promise((resolve) => resolve(answerRouting(this.localIndex, request)));
|
||||||
}
|
}
|
||||||
const asked = outstanding(request);
|
const asked = outstanding(request);
|
||||||
const same = [this.inFlight, this.waiting[request.kind]].find((other) => other?.question === asked.question);
|
const same = asked.question ? [this.inFlight, this.waiting[request.kind]].find((other) => other?.question === asked.question) : undefined;
|
||||||
if (same) {
|
if (same) {
|
||||||
return same.promise;
|
return same.promise;
|
||||||
}
|
}
|
||||||
@@ -129,6 +136,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