diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts index e3e7b66..5b4173d 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.spec.ts @@ -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; route(): Promise; 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(() => 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 { + 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((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> = []; diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 6eadbb9..7b65a40 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -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; /** 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; } } ); diff --git a/src/app/features/galaxy-system/jump-link-renderer.ts b/src/app/features/galaxy-system/jump-link-renderer.ts index db71dc3..ebbb09d 100644 --- a/src/app/features/galaxy-system/jump-link-renderer.ts +++ b/src/app/features/galaxy-system/jump-link-renderer.ts @@ -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. */ diff --git a/src/app/features/galaxy-system/routing-client.spec.ts b/src/app/features/galaxy-system/routing-client.spec.ts index 3e5ea14..03c2b0e 100644 --- a/src/app/features/galaxy-system/routing-client.spec.ts +++ b/src/app/features/galaxy-system/routing-client.spec.ts @@ -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 = []; + readonly transferred: ArrayBufferLike[] = []; private readonly listeners: Record 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(); diff --git a/src/app/features/galaxy-system/routing-client.ts b/src/app/features/galaxy-system/routing-client.ts index 2e1b398..f165898 100644 --- a/src/app/features/galaxy-system/routing-client.ts +++ b/src/app/features/galaxy-system/routing-client.ts @@ -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; 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 { - 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 { + 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); } diff --git a/src/app/features/galaxy-system/star-field-renderer.ts b/src/app/features/galaxy-system/star-field-renderer.ts index 8379d0f..967bc44 100644 --- a/src/app/features/galaxy-system/star-field-renderer.ts +++ b/src/app/features/galaxy-system/star-field-renderer.ts @@ -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}. diff --git a/src/app/shared/astro/jump-links.ts b/src/app/shared/astro/jump-links.ts index 78efd1e..2709818 100644 --- a/src/app/shared/astro/jump-links.ts +++ b/src/app/shared/astro/jump-links.ts @@ -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); diff --git a/src/app/shared/astro/routing.spec.ts b/src/app/shared/astro/routing.spec.ts index 5fc0e0f..8509a45 100644 --- a/src/app/shared/astro/routing.spec.ts +++ b/src/app/shared/astro/routing.spec.ts @@ -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(); +} diff --git a/src/app/shared/astro/routing.ts b/src/app/shared/astro/routing.ts index 0b8b615..cb2dc6f 100644 --- a/src/app/shared/astro/routing.ts +++ b/src/app/shared/astro/routing.ts @@ -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 { diff --git a/src/app/shared/astro/routing.worker.ts b/src/app/shared/astro/routing.worker.ts index 403d224..6c9e49c 100644 --- a/src/app/shared/astro/routing.worker.ts +++ b/src/app/shared/astro/routing.worker.ts @@ -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; diff --git a/src/app/shared/astro/star-neighbourhood.ts b/src/app/shared/astro/star-neighbourhood.ts index 8c12306..45d2979 100644 --- a/src/app/shared/astro/star-neighbourhood.ts +++ b/src/app/shared/astro/star-neighbourhood.ts @@ -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];