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 { NavigationStore } from '../../shared/state/navigation.store';
|
||||
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
|
||||
import { JumpLinkRenderer } from './jump-link-renderer';
|
||||
import { StarFieldRenderer } from './star-field-renderer';
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
type Answer = { route: { stars: number[]; totalPc: number; longestHopPc: number } | null; neededRangePc: number | null };
|
||||
const answers: Array<(answer: Answer) => void> = [];
|
||||
|
||||
@@ -71,7 +71,10 @@ const LABEL_MIN_SEPARATION_NDC = 0.12;
|
||||
const LABEL_EDGE_NDC = 0.7;
|
||||
/** How far right of its point a label's text reaches, in aspect-scaled NDC (~135px at 1440). */
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -355,8 +358,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
);
|
||||
});
|
||||
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 linkedStars: Uint32Array | null = null;
|
||||
private jumpLinkRebuild?: ReturnType<typeof setTimeout>;
|
||||
/** The current system's neighbours, resolved on arrival: id, name, distance and bearing. */
|
||||
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.applyProjection(this.display().plan));
|
||||
// Reads both signals, so flipping the layer on and dragging the range each land here. The
|
||||
// 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.
|
||||
// Reads both signals, so flipping the layer on and dragging the range each land here.
|
||||
effect(() => {
|
||||
this.jumpRangePc();
|
||||
this.display().jumpLinks;
|
||||
clearTimeout(this.jumpLinkRebuild);
|
||||
this.jumpLinkRebuild = setTimeout(() => this.refreshJumpLinks(), JUMP_LINK_REBUILD_DELAY_MS);
|
||||
this.scheduleJumpLinks();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -807,6 +808,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.starField.refocus({ centre, pinned });
|
||||
this.starFieldFocus = centre.clone();
|
||||
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 {
|
||||
@@ -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
|
||||
* million links at 8 pc, so it is built in the worker. Only when the layer is on and the range
|
||||
* has actually moved — the control emits per pixel dragged — and only the graph for the range
|
||||
* last asked for is drawn, in whatever order the answers arrive.
|
||||
* Rebuilds the drawn graph: the links between the stars the field is drawing, so what is linked
|
||||
* is what can be seen and clicked. Hundreds of thousands of links at 8 pc, so it is built in the
|
||||
* worker, and only when the layer is on and the range or the drawn stars have actually changed.
|
||||
*
|
||||
* 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 {
|
||||
if (!this.jumpLinks || !this.routing) {
|
||||
if (!this.jumpLinks || !this.routing || !this.starField) {
|
||||
return;
|
||||
}
|
||||
const rangePc = this.jumpRangePc();
|
||||
@@ -1464,24 +1476,28 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
if (this.drawnJumpRangePc !== null) {
|
||||
this.jumpLinks.setSegments(new Float32Array(0));
|
||||
this.drawnJumpRangePc = null;
|
||||
this.linkedStars = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.drawnJumpRangePc === rangePc) {
|
||||
const drawn = this.starField.drawnStars;
|
||||
if (this.drawnJumpRangePc === rangePc && this.linkedStars === drawn) {
|
||||
return;
|
||||
}
|
||||
this.drawnJumpRangePc = rangePc;
|
||||
void this.routing.links(rangePc).then(
|
||||
this.linkedStars = drawn;
|
||||
void this.routing.links(rangePc, drawn).then(
|
||||
(segments) => {
|
||||
if (this.drawnJumpRangePc === rangePc) {
|
||||
this.jumpLinks?.setSegments(segments);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Replaced by a newer range, or failed. Either way this range is not drawn, and must not be
|
||||
// remembered as if it were, or asking for it again would be skipped.
|
||||
if (this.drawnJumpRangePc === rangePc) {
|
||||
// Replaced by a newer request, or failed. Either way this graph is not drawn, and must not
|
||||
// be remembered as if it were, or asking for it again would be skipped.
|
||||
if (this.drawnJumpRangePc === rangePc && this.linkedStars === drawn) {
|
||||
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`. */
|
||||
setSegments(vertices: Float32Array): void {
|
||||
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. */
|
||||
|
||||
@@ -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 index = new StarNeighbourhood(STARS);
|
||||
/** Every star drawn. */
|
||||
const ALL = Uint32Array.from(STARS.keys());
|
||||
|
||||
/** Flushes settled promises and their handlers. */
|
||||
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. */
|
||||
class FakeWorker {
|
||||
readonly sent: Array<RoutingRequest | { kind: 'catalogue' }> = [];
|
||||
readonly transferred: ArrayBufferLike[] = [];
|
||||
private readonly listeners: Record<string, Array<(event: { data?: unknown }) => void>> = {};
|
||||
terminated = false;
|
||||
|
||||
postMessage(message: RoutingRequest | { kind: 'catalogue' }): void {
|
||||
postMessage(message: RoutingRequest | { kind: 'catalogue' }, transfer: Transferable[] = []): void {
|
||||
this.sent.push(message);
|
||||
this.transferred.push(...(transfer as ArrayBufferLike[]));
|
||||
}
|
||||
|
||||
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 () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -95,8 +107,8 @@ describe('RoutingClient with a worker', () => {
|
||||
it('sends the catalogue first, then one request at a time', () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
|
||||
void client.links(8);
|
||||
void client.links(3);
|
||||
void client.links(8, ALL);
|
||||
void client.links(3, ALL);
|
||||
|
||||
expect(worker.sent[0].kind).toBe('catalogue');
|
||||
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.
|
||||
it('replaces a waiting graph with the newer one before it is ever built, and sends a route ahead of it', async () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
const first = client.links(5);
|
||||
const superseded = client.links(6).catch((error: unknown) => error);
|
||||
const latest = client.links(8);
|
||||
const first = client.links(5, ALL);
|
||||
const superseded = client.links(6, ALL).catch((error: unknown) => error);
|
||||
const latest = client.links(8, ALL);
|
||||
const route = client.route(100, 104, 1.5, 8);
|
||||
|
||||
const building = worker.requests[0];
|
||||
@@ -132,23 +144,68 @@ describe('RoutingClient with a worker', () => {
|
||||
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 once = client.links(7.5);
|
||||
const again = client.links(7.5);
|
||||
const once = client.route(100, 104, 1.5, 8);
|
||||
const again = client.route(100, 104, 1.5, 8);
|
||||
const widerRange = client.route(100, 104, 2.5, 8);
|
||||
|
||||
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) });
|
||||
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);
|
||||
expect(worker.requests).toHaveLength(1);
|
||||
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) });
|
||||
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();
|
||||
});
|
||||
|
||||
it('rejects a request the worker failed on, and goes on to the next', async () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
const failing = client.route(100, 104, 1.5, 8).catch((error: unknown) => error);
|
||||
const next = client.links(3);
|
||||
const next = client.links(3, ALL);
|
||||
|
||||
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 () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
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();
|
||||
|
||||
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 });
|
||||
expect(worker.terminated).toBe(true);
|
||||
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. */
|
||||
interface Outstanding {
|
||||
readonly request: RoutingRequest;
|
||||
/** The question without its id, so the same question asked twice can be recognised. */
|
||||
readonly question: string;
|
||||
readonly promise: Promise<RoutingResponse>;
|
||||
readonly resolve: (response: RoutingResponse) => void;
|
||||
readonly reject: (error: Error) => void;
|
||||
@@ -32,9 +30,19 @@ function outstanding(request: RoutingRequest): Outstanding {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { requestId, ...question } = request;
|
||||
return { request, question: JSON.stringify(question), promise, resolve, reject };
|
||||
return { request, 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. */
|
||||
@@ -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
|
||||
* promises.
|
||||
*
|
||||
* The worker answers one request at a time and cannot drop one it has started: a jump-link graph
|
||||
* at 8 pc is seconds of work. So requests are held here and sent one by one, and while one is out,
|
||||
* only the latest of each kind waits behind it — a newer graph replaces an older one before it is
|
||||
* ever built, and the older promise is rejected with {@link SupersededRequest}. Routes go ahead of
|
||||
* graphs, being quick and asked for by a click. The same question asked again while it is still
|
||||
* outstanding shares the answer rather than being worked out twice.
|
||||
* The worker answers one request at a time and cannot drop one it has started: a route with no path
|
||||
* can be seconds of work, and a graph of the drawn stars at 8 pc a few hundred milliseconds. So
|
||||
* requests are held here and sent one by one, and while one is out, only the latest of each kind
|
||||
* waits behind it — a newer graph replaces an older one before it is ever built, and the older
|
||||
* promise is rejected with {@link SupersededRequest}. Routes go ahead of graphs, being quick to ask
|
||||
* 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 —
|
||||
* 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> {
|
||||
return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc }).then((response) =>
|
||||
/**
|
||||
* Vertex pairs for every link within `rangePc` between two of the `drawn` stars (catalogue
|
||||
* 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)
|
||||
);
|
||||
}
|
||||
@@ -108,11 +120,13 @@ export class RoutingClient {
|
||||
if (!this.worker) {
|
||||
return new Promise((resolve) => resolve(answerRouting(this.localIndex, request)));
|
||||
}
|
||||
const asked = outstanding(request);
|
||||
const same = [this.inFlight, this.waiting[request.kind]].find((other) => other?.question === asked.question);
|
||||
// Shared rather than replaced: an identical request superseding the one it repeats would reject it,
|
||||
// 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) {
|
||||
return same.promise;
|
||||
}
|
||||
const asked = outstanding(request);
|
||||
this.waiting[request.kind]?.reject(new SupersededRequest());
|
||||
this.waiting[request.kind] = asked;
|
||||
this.sendNext();
|
||||
@@ -129,6 +143,8 @@ export class RoutingClient {
|
||||
}
|
||||
delete this.waiting[next.request.kind];
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -306,6 +306,14 @@ export class StarFieldRenderer {
|
||||
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
|
||||
* 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
|
||||
* draw: six floats a link, one end then the other.
|
||||
* Every link within `rangePc` between two of the stars `index` holds, each pair once, as vertex
|
||||
* pairs ready to draw: six floats a link, one end then the other.
|
||||
*
|
||||
* For drawing the graph, which is the only thing that wants all of it: routing asks for a star's
|
||||
* neighbours as it reaches that star and never builds this. 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 {
|
||||
let vertices = new Float32Array(6 * 4096);
|
||||
|
||||
@@ -49,10 +49,27 @@ describe('answerRouting', () => {
|
||||
});
|
||||
|
||||
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.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 =
|
||||
| { 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 =
|
||||
| { 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 {
|
||||
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);
|
||||
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
|
||||
* away, and the range it would need when there is none, can take seconds; a graph at 8 pc is
|
||||
* 3.7 million links. On the page's own thread either stops the map for as long as it runs.
|
||||
* away, and the range it would need when there is none, can take seconds; a graph of the drawn
|
||||
* 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;
|
||||
|
||||
|
||||
@@ -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`. */
|
||||
indexOf(id: number): number | undefined {
|
||||
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 {
|
||||
const index = this.indexById.get(id);
|
||||
return index === undefined ? undefined : this.points[index];
|
||||
|
||||
Reference in New Issue
Block a user