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 a61c1e8..e669b9b 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 @@ -10,6 +10,7 @@ import { DeepSkyRecord } from '../../shared/models/deepsky.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { StarRecord } from '../../shared/models/star.model'; import { NavigationStore } from '../../shared/state/navigation.store'; +import { LinkBudget } from '../../shared/astro/jump-links'; import { HudDisplay } from '../hud/hud-dock.component'; import { GalaxySystemSceneComponent } from './galaxy-system-scene.component'; import { JumpLinkRenderer } from './jump-link-renderer'; @@ -132,6 +133,13 @@ class FakeEngineService { resize(): void {} + /** The canvas's device pixels per CSS pixel, as the renderer was told. */ + pixelRatio = 1; + + getRenderer(): { getPixelRatio(): number } { + return { getPixelRatio: () => this.pixelRatio }; + } + /** Test helper: simulates one rendered frame by invoking every registered tick callback. */ tick(deltaSeconds: number): void { for (const callback of this.tickCallbacks) { @@ -425,7 +433,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { describe('the jump-link graph', () => { type LinkScene = { - routing: { links(rangePc: number, drawn: Uint32Array): Promise; route(): Promise; dispose(): void }; + routing: { links(rangePc: number, drawn: Uint32Array, budget?: LinkBudget): Promise; route(): Promise; dispose(): void }; display: { update(change: (display: { jumpLinks: boolean }) => unknown): void }; jumpRangePc: { set(rangePc: number): void }; routeResult: { set(value: unknown): void }; @@ -455,7 +463,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { const component = linkScene(links); await settle(); expect(links).toHaveBeenCalledTimes(1); - expect(links.mock.calls[0]).toEqual([3, component.starField.drawnStars]); + expect(links.mock.calls[0].slice(0, 2)).toEqual([3, component.starField.drawnStars]); await changeDrawnStars(component, 40); expect(links).toHaveBeenCalledTimes(1); @@ -471,6 +479,76 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => { expect(links).toHaveBeenCalledTimes(2); }); + it('asks for as much of the graph as a million pixels of line make, around where the view is centred', async () => { + const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0))); + Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1080 }); + // A screen scaled to 200%: 1080 CSS pixels are 2160 drawn ones, and the lines are drawn in those. + engine.pixelRatio = 2; + linkScene(links); + await settle(); + + const budget = links.mock.calls[0][2]; + // The view opens centred on the Sun: its frame's half-height there, over 1080 drawn pixels, is a pixel's worth of parsecs. + const halfHeight = engine.getCamera().position.length() * Math.tan((50 * Math.PI) / 360); + expect(budget?.centre).toEqual({ x: 0, y: 0, z: 0 }); + expect(budget?.lengthPc).toBeCloseTo((1_000_000 * halfHeight) / 1080, 3); + }); + + it('asks again once the view has zoomed past the budget it asked with, though the drawn stars are the same', async () => { + // All three stars fit the star budget, so the drawn set never changes: only the budget can. + const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0))); + Object.defineProperty((fixture.nativeElement as HTMLElement).querySelector('canvas')!, 'clientHeight', { value: 1080 }); + const component = linkScene(links); + await advanceFrames(engine, 0.3); + await settle(); + const asked = links.mock.calls.length; + + const camera = engine.getCamera(); + camera.position.sub(component.controls.target).multiplyScalar(0.5).add(component.controls.target); + await advanceFrames(engine, 0.3); + await settle(); + + expect(links.mock.calls.length).toBe(asked + 1); + expect(links.mock.calls.at(-1)![1]).toBe(links.mock.calls[0][1]); + }); + + it('asks for no graph from inside a system, where distances are in astronomical units', async () => { + const links = vi.fn((_rangePc: number, _drawn: Uint32Array, _budget?: LinkBudget) => Promise.resolve(new Float32Array(0))); + navigationStore.selectStar(SUN.id); + await flushAsync(); + await advanceFrames(engine, 2.5); + + linkScene(links); + await settle(); + + expect(links).not.toHaveBeenCalled(); + }); + + it('keeps what it asked for when an older request it replaced is rejected', async () => { + // Off and on again while a graph is still waiting: the waiting one is replaced, and its + // rejection must not be taken for the request that replaced it. + const pending: Array<{ resolve: (segments: Float32Array) => void; reject: (error: Error) => void }> = []; + const setSegments = vi.spyOn(JumpLinkRenderer.prototype, 'setSegments'); + const component = linkScene(() => new Promise((resolve, reject) => pending.push({ resolve, reject }))); + await settle(); + component.display.update((display) => ({ ...display, jumpLinks: false })); + TestBed.tick(); + await settle(); + component.display.update((display) => ({ ...display, jumpLinks: true })); + TestBed.tick(); + await settle(); + expect(pending).toHaveLength(2); + + pending[0].reject(new Error('Superseded by a newer request')); + await flushAsync(); + const graph = new Float32Array(6); + pending[1].resolve(graph); + await flushAsync(); + + expect(setSegments).toHaveBeenLastCalledWith(graph); + setSegments.mockRestore(); + }); + it('gives a view on the move a new graph at least every quarter second, rather than waiting for it to stop', async () => { const links = vi.fn((_rangePc: number, _drawn: Uint32Array) => Promise.resolve(new Float32Array(0))); const component = linkScene(links); 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 94d7ea3..971993d 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -39,6 +39,7 @@ import { SystemObjectCardComponent } from './system-object-card.component'; import { RoutingClient } from './routing-client'; import { colorIndexToRgb, FOCUS_RADIUS_PC, StarFieldRenderer, starRenderBudgetFromUrl, VIEW_MARGIN } from './star-field-renderer'; import { BrightnessIndex, brightestWithin, brightnessIndex } from '../../shared/astro/brightest'; +import { LinkBudget } from '../../shared/astro/jump-links'; import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood'; import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component'; import { HostStarRings } from './host-star-rings'; @@ -76,6 +77,35 @@ const LABEL_REACH_NDC = 0.3; * emits per pixel; and how often at most a view on the move gets a graph for its new drawn stars. */ const JUMP_LINK_REBUILD_DELAY_MS = 250; +/** + * Whether a graph asked for with one budget still serves another: the same, unless the view has + * zoomed by more than half its margin or its centre has moved by more than a fifth of the + * neighbourhood drawn whole. + */ +function servesTheSame(asked: LinkBudget | undefined, now: LinkBudget | undefined): boolean { + if (!asked || !now) { + return asked === now; + } + const moved = Math.hypot(now.centre.x - asked.centre.x, now.centre.y - asked.centre.y, now.centre.z - asked.centre.z); + return Math.abs(now.lengthPc / asked.lengthPc - 1) <= VIEW_MARGIN / 2 && moved <= STAR_FIELD_REFOCUS_PC; +} + +/** + * How much jump-link line the layer draws, in pixels of length on screen: about a million, measured + * where lines are longest. + * + * What a graph costs to draw is its length on screen, not its number of links: every pixel of it is + * blended over whatever is already there. On the Ryzen 7700X's integrated Radeon, standing in for an + * entry-level laptop, at 1920 × 1080 with the range at 8 pc: + * - near the Sun, about 10 ms a frame per million pixels. At 30 pc from it, 25 000 links were 4.8 + * million pixels and 60 ms; 5 000 were 0.9 million and 18 ms, about 55 frames a second; + * - at the opening view, where the links are short, 100 000 links were 1.8 million pixels and 12 ms. + * + * So a count could not serve both: the budget is a length, turned into parsecs at the depth the view + * is centred on, and spent on the links nearest that centre. The RTX 4080 draws every graph in the + * same 6 ms, but the budget is the same everywhere, like the stars'. + */ +const JUMP_LINK_PIXEL_BUDGET = 1_000_000; /** * How far in or out the plan view may be zoomed from the extent its distance frames. Under a @@ -371,6 +401,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { /** 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 linkedBudget: LinkBudget | undefined; + /** Counts graph requests, so a rejection can tell whether it is for the latest one. */ + private linkRequest = 0; 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 }[] = []; @@ -823,7 +856,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { const pinned = () => pinnedIds.map((id) => neighbourhood.indexOf(id)).filter((index): index is number => index !== undefined); const centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET; - const drawnBefore = this.starField.drawnStars; + let chose = false; // At galactic scale the whole catalogue is a smudge a few pixels across, and the view sweeps // hundreds of parsecs a pass: chosen once for the whole sky on the way out, then left alone, @@ -833,6 +866,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.starField.refocus({ centre, pinned: pinned(), hosts: this.hostStars }); this.starFieldCamera = null; this.starFieldPins = pins; + chose = true; } } else { const halfHeight = this.engine.visibleHalfHeight(camera.position.distanceTo(centre)); @@ -865,13 +899,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.starFieldFocus.copy(centre); this.starFieldHalfHeight = halfHeight; this.starFieldPins = pins; + chose = true; } } - // The graph links the drawn stars, so a new set wants a new graph. Not one per pass while the - // view keeps moving, and not one pushed back by every pass either, or an orbit would never get - // one: at most one every `JUMP_LINK_REBUILD_DELAY_MS`. - if (this.starField.drawnStars !== drawnBefore && this.jumpLinkRebuild === undefined) { + // The graph links the drawn stars, and spends its budget around the view's centre, so a view that + // has moved may want a new one; `refreshJumpLinks` asks only if the stars or the budget changed. + // Not one per pass while the view keeps moving, and not one pushed back by every pass either, or + // an orbit would never get one: at most one every `JUMP_LINK_REBUILD_DELAY_MS`. + if (chose && this.jumpLinkRebuild === undefined) { this.scheduleJumpLinks(); } } @@ -1544,32 +1580,59 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.jumpLinks.setSegments(new Float32Array(0)); this.drawnJumpRangePc = null; this.linkedStars = null; + this.linkedBudget = undefined; } return; } + // Asked for in parsec space only: inside a system the camera and its centre are in astronomical + // units about the system's own origin, which would make a budget of the wrong size in the wrong + // place. The flight back out chooses the drawn stars again, and that asks. + if (!this.galaxyGroup.visible) { + return; + } const drawn = this.starField.drawnStars; - if (this.drawnJumpRangePc === rangePc && this.linkedStars === drawn) { + const budget = this.jumpLinkBudget(); + if (this.drawnJumpRangePc === rangePc && this.linkedStars === drawn && servesTheSame(this.linkedBudget, budget)) { return; } this.drawnJumpRangePc = rangePc; this.linkedStars = drawn; - void this.routing.links(rangePc, drawn).then( + this.linkedBudget = budget; + const request = ++this.linkRequest; + void this.routing.links(rangePc, drawn, budget).then( (segments) => { if (this.drawnJumpRangePc === rangePc) { this.jumpLinks?.setSegments(segments); } }, () => { - // 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) { + // Replaced by a newer request, or failed. Only the latest request's rejection means no graph + // is on its way; then nothing is remembered as drawn, so asking again is not skipped. An older + // one's says nothing about the request that replaced it, which may ask the same thing. + if (request === this.linkRequest) { this.drawnJumpRangePc = null; this.linkedStars = null; + this.linkedBudget = undefined; } } ); } + /** + * How much of the graph to draw: the links nearest the view's centre, up to the length that + * `JUMP_LINK_PIXEL_BUDGET` pixels of line make at that depth. None without a canvas to measure. + */ + private jumpLinkBudget(): LinkBudget | undefined { + // In the pixels the lines are drawn in, not in CSS pixels: a scaled or HiDPI screen draws more of them. + const heightPx = this.canvasRef().nativeElement.clientHeight * this.engine.getRenderer().getPixelRatio(); + if (heightPx === 0) { + return undefined; + } + const centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET; + const halfHeight = this.engine.visibleHalfHeight(this.engine.getCamera().position.distanceTo(centre)); + return { centre: { x: centre.x, y: centre.y, z: centre.z }, lengthPc: (JUMP_LINK_PIXEL_BUDGET * 2 * halfHeight) / heightPx }; + } + /** A pinned body wins over a hovered one, so the card does not change under the pointer. */ private refreshObjectCard(): void { const id = this.pinnedBodyId ?? this.hoveredBodyId; diff --git a/src/app/features/galaxy-system/routing-client.spec.ts b/src/app/features/galaxy-system/routing-client.spec.ts index 03c2b0e..2d05f31 100644 --- a/src/app/features/galaxy-system/routing-client.spec.ts +++ b/src/app/features/galaxy-system/routing-client.spec.ts @@ -94,6 +94,16 @@ describe('RoutingClient without a worker', () => { client.dispose(); }); + it('keeps only the links its budget holds, nearest the centre first', async () => { + const client = new RoutingClient(STARS, POSITIONS, index); + + // Stars at x = 0 to 4 a parsec apart: from a centre at 3.9, one and a half parsecs is the link 3-4 alone. + const segments = await client.links(1.5, ALL, { centre: { x: 3.9, y: 0, z: 0 }, lengthPc: 1.5 }); + + expect(Array.from(segments)).toEqual([3, 0, 0, 4, 0, 0]); + client.dispose(); + }); + it('links only the stars it is told are drawn', async () => { const client = new RoutingClient(STARS, POSITIONS, index); @@ -196,6 +206,12 @@ describe('RoutingClient with a worker', () => { 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 same list at a different budget is a different graph. + void client.links(3, far, { centre: { x: 1, y: 0, z: 0 }, lengthPc: 10 }); + void client.links(3, far, { centre: { x: 1, y: 0, z: 0 }, lengthPc: 20 }); + worker.answer({ kind: 'links', requestId: worker.requests[2].requestId, segments: new Float32Array(0) }); + await flush(); + expect(worker.requests.map((request) => request.kind === 'links' && request.budget?.lengthPc)).toEqual([undefined, undefined, 10, 20]); // 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); diff --git a/src/app/features/galaxy-system/routing-client.ts b/src/app/features/galaxy-system/routing-client.ts index f165898..3677913 100644 --- a/src/app/features/galaxy-system/routing-client.ts +++ b/src/app/features/galaxy-system/routing-client.ts @@ -1,5 +1,5 @@ import { answerRouting, RoutingRequest, RoutingResponse } from '../../shared/astro/routing'; -import { Route } from '../../shared/astro/jump-links'; +import { LinkBudget, Route } from '../../shared/astro/jump-links'; import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood'; import { StarRecord } from '../../shared/models/star.model'; @@ -34,13 +34,23 @@ function outstanding(request: RoutingRequest): Outstanding { } /** - * 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. + * Whether two requests ask the same question. A graph is the same when it is for the same range, the + * same budget 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.kind === 'links' && + b.kind === 'links' && + a.rangePc === b.rangePc && + a.drawn === b.drawn && + a.budget?.lengthPc === b.budget?.lengthPc && + a.budget?.centre.x === b.budget?.centre.x && + a.budget?.centre.y === b.budget?.centre.y && + a.budget?.centre.z === b.budget?.centre.z + ); } return a.fromId === b.fromId && a.toId === b.toId && a.rangePc === b.rangePc && a.ceilingPc === b.ceilingPc; } @@ -100,10 +110,10 @@ export class RoutingClient { /** * Vertex pairs for every link within `rangePc` between two of the `drawn` stars (catalogue - * indices), three floats to an end. + * indices), three floats to an end; only those nearest the budget's centre that fit it, if given. */ - links(rangePc: number, drawn: Uint32Array): Promise { - return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc, drawn }).then((response) => + links(rangePc: number, drawn: Uint32Array, budget?: LinkBudget): Promise { + return this.ask({ kind: 'links', requestId: this.nextRequestId++, rangePc, drawn, budget }).then((response) => response.kind === 'links' ? response.segments : new Float32Array(0) ); } diff --git a/src/app/shared/astro/jump-links.spec.ts b/src/app/shared/astro/jump-links.spec.ts index b47520e..b070a93 100644 --- a/src/app/shared/astro/jump-links.spec.ts +++ b/src/app/shared/astro/jump-links.spec.ts @@ -168,6 +168,30 @@ function linksDrawn(segments: Float32Array, points: readonly StarPoint[]): strin return links; } +/** + * What a budget should keep, worked out the slow way: every link sorted by how near its nearer end + * is to the centre, then taken until one does not fit. Lengths and distances as the float32 buffer + * holds them. + */ +function nearestFirst(points: readonly StarPoint[], rangePc: number, centre: { x: number; y: number; z: number }, lengthPc: number): string[] { + const all = jumpLinkSegments(index([...points]), rangePc); + const links = Array.from({ length: all.length / 6 }, (_, link) => { + const v = Array.from(all.subarray(link * 6, link * 6 + 6)); + const nearer = Math.fround(Math.sqrt(Math.min((v[0] - centre.x) ** 2 + (v[1] - centre.y) ** 2 + (v[2] - centre.z) ** 2, (v[3] - centre.x) ** 2 + (v[4] - centre.y) ** 2 + (v[5] - centre.z) ** 2))); + return { link, nearer, length: Math.fround(Math.hypot(v[3] - v[0], v[4] - v[1], v[5] - v[2])), key: linksDrawn(all.subarray(link * 6, link * 6 + 6), points)[0] }; + }).sort((a, b) => a.nearer - b.nearer || a.link - b.link); + const kept: string[] = []; + let total = 0; + for (const { length, key } of links) { + if (total + length > lengthPc) { + break; + } + total += length; + kept.push(key); + } + return kept; +} + /** Stars a parsec apart along x, as points, for reading a segment buffer back. */ function chainPoints(count: number): StarPoint[] { return Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 })); @@ -191,6 +215,58 @@ describe('jumpLinkSegments', () => { expect(jumpLinkSegments(chain(4), 0)).toHaveLength(0); }); + it('keeps the links nearest the centre first, for as much length as the budget holds', () => { + // A parsec apart from 0 to 20, the centre at 10.3. By nearer end: 9-10 and 10-11 (0.3 away), + // then 11-12 (0.7), then 8-9 (1.3). Three parsecs of them fit in 3.5; a fourth would not. + const budget = { centre: { x: 10.3, y: 0, z: 0 }, lengthPc: 3.5 }; + + const segments = jumpLinkSegments(chain(21), 1.5, budget); + + expect(linksDrawn(segments, chainPoints(21)).sort()).toEqual(['10-11', '11-12', '9-10']); + expect(segments.buffer.byteLength).toBe(segments.byteLength); + }); + + it('keeps exactly the links a full nearest-first sort would, without sorting them all', () => { + let seed = 7; + const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 40 - 20; + const points: StarPoint[] = Array.from({ length: 600 }, (_, id) => ({ id, x: random(), y: random(), z: random() })); + const centre = { x: 3, y: -2, z: 1 }; + + for (const lengthPc of [0, 5, 60, 900, 4000, 1e9]) { + expect(linksDrawn(jumpLinkSegments(index(points), 4, { centre, lengthPc }), points).sort()).toEqual(nearestFirst(points, 4, centre, lengthPc).sort()); + } + }); + + it('sorts the distance band the budget runs out in, and stops at the first link there that does not fit', () => { + // One pair 4 kpc out makes each band about a parsec deep, so dozens of short links near the + // centre share the band the budget ends in, in whatever order the grid walks them. + let seed = 3; + const random = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648; + const points: StarPoint[] = [{ id: 0, x: 4000, y: 0, z: 0 }, { id: 1, x: 4000.03, y: 0, z: 0 }]; + for (let pair = 0; pair < 40; pair++) { + const r = 0.05 + random() * 0.9; + const theta = random() * Math.PI * 2; + const x = r * Math.cos(theta); + const y = r * Math.sin(theta); + points.push({ id: 2 + pair * 2, x, y, z: 0 }, { id: 3 + pair * 2, x, y, z: 0.005 + random() * 0.04 }); + } + const centre = { x: 0, y: 0, z: 0 }; + + for (const lengthPc of [0.1, 0.3, 0.5]) { + expect(linksDrawn(jumpLinkSegments(index(points), 0.05, { centre, lengthPc }), points).sort()).toEqual(nearestFirst(points, 0.05, centre, lengthPc).sort()); + } + }); + + it('counts the budget in parsecs of link, not in links', () => { + // Stars at 0, 1 and 3: a 2 pc link nearest the centre, then a 1 pc one. Two and a half parsecs + // hold the first and not both, though two links would fit a count of two and a half. + const points: StarPoint[] = [{ id: 0, x: 0, y: 0, z: 0 }, { id: 1, x: 1, y: 0, z: 0 }, { id: 2, x: 3, y: 0, z: 0 }]; + + const segments = jumpLinkSegments(index(points), 2.5, { centre: { x: 3, y: 0, z: 0 }, lengthPc: 2.5 }); + + expect(linksDrawn(segments, points)).toEqual(['1-2']); + }); + it('grows past its first buffer without losing a link', () => { // 5 000 stars a tenth of a parsec apart, ten neighbours each way in range: some 50 000 links, far past // the 4 096 the buffer starts with, so it has to grow several times. diff --git a/src/app/shared/astro/jump-links.ts b/src/app/shared/astro/jump-links.ts index 2709818..fca2f03 100644 --- a/src/app/shared/astro/jump-links.ts +++ b/src/app/shared/astro/jump-links.ts @@ -213,16 +213,31 @@ export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, to return reachable; } +/** How much of a graph to keep: the links nearest a point, up to a total length. */ +export interface LinkBudget { + /** Links are kept in order of how near their nearer end is to this point. */ + readonly centre: { readonly x: number; readonly y: number; readonly z: number }; + /** The most the kept links may add up to, end to end, in parsecs. */ + readonly lengthPc: number; +} + +/** + * How many distance bands a budgeted graph is split into to find where its budget runs out, so that + * only the links in that one band are sorted rather than all of them. + */ +const DISTANCE_BANDS = 4096; + /** * 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. + * pairs ready to draw: six floats a link, one end then the other. With a `budget`, only the links + * nearest its centre, as many as fit its length. * * 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 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, budget?: LinkBudget): Float32Array { let vertices = new Float32Array(6 * 4096); let length = 0; index.forEachPairWithin(rangePc, (a, b) => { @@ -238,7 +253,71 @@ export function jumpLinkSegments(index: StarNeighbourhood, rangePc: number): Flo vertices[length++] = b.y; vertices[length++] = b.z; }); - // Exact length rather than a view on the grown buffer: the answer is transferred whole, and a - // view would carry up to as much again in unused capacity with it. - return vertices.slice(0, length); + if (!budget) { + // Exact length rather than a view on the grown buffer: the answer is transferred whole, and a + // view would carry up to as much again in unused capacity with it. + return vertices.slice(0, length); + } + + // Each link's nearer end's distance from the centre, and its length. + const { centre } = budget; + const count = length / 6; + const nearness = new Float32Array(count); + const lengths = new Float32Array(count); + let totalPc = 0; + let farthest = 0; + for (let link = 0; link < count; link++) { + const at = link * 6; + const ax = vertices[at] - centre.x; + const ay = vertices[at + 1] - centre.y; + const az = vertices[at + 2] - centre.z; + const bx = vertices[at + 3] - centre.x; + const by = vertices[at + 4] - centre.y; + const bz = vertices[at + 5] - centre.z; + nearness[link] = Math.sqrt(Math.min(ax * ax + ay * ay + az * az, bx * bx + by * by + bz * bz)); + lengths[link] = Math.hypot(bx - ax, by - ay, bz - az); + totalPc += lengths[link]; + farthest = Math.max(farthest, nearness[link]); + } + if (totalPc <= budget.lengthPc) { + return vertices.slice(0, length); + } + + // Nearest first, without sorting them all: every link in the bands before the one where the budget + // runs out fits, and only that band's links are sorted to see how many of them do. Sorting all + // 730 000 links at 30 pc from the Sun to keep 4 400 doubled the time a graph took in the worker. + const bands = new Uint16Array(count); + const bandLengths = new Float64Array(DISTANCE_BANDS); + const bandsPerPc = farthest > 0 ? DISTANCE_BANDS / farthest : 0; + for (let link = 0; link < count; link++) { + bands[link] = Math.min(DISTANCE_BANDS - 1, Math.floor(nearness[link] * bandsPerPc)); + bandLengths[bands[link]] += lengths[link]; + } + let lastBand = 0; + let keptPc = 0; + while (keptPc + bandLengths[lastBand] <= budget.lengthPc) { + keptPc += bandLengths[lastBand++]; + } + const keptLinks: number[] = []; + const boundary: number[] = []; + for (let link = 0; link < count; link++) { + const band = bands[link]; + if (band < lastBand) { + keptLinks.push(link); + } else if (band === lastBand) { + boundary.push(link); + } + } + boundary.sort((a, b) => nearness[a] - nearness[b] || a - b); + for (const link of boundary) { + if (keptPc + lengths[link] > budget.lengthPc) { + break; + } + keptPc += lengths[link]; + keptLinks.push(link); + } + + const kept = new Float32Array(keptLinks.length * 6); + keptLinks.forEach((link, at) => kept.set(vertices.subarray(link * 6, link * 6 + 6), at * 6)); + return kept; } diff --git a/src/app/shared/astro/routing.ts b/src/app/shared/astro/routing.ts index cb2dc6f..f917d7c 100644 --- a/src/app/shared/astro/routing.ts +++ b/src/app/shared/astro/routing.ts @@ -7,7 +7,7 @@ * answers them in place where one does not. */ -import { jumpLinkSegments, minimumRangeBetween, Route, routeBetween } from './jump-links'; +import { jumpLinkSegments, LinkBudget, minimumRangeBetween, Route, routeBetween } from './jump-links'; import { StarNeighbourhood } from './star-neighbourhood'; /** The catalogue, sent once: ids, and positions packed three to a star in the same order. */ @@ -19,8 +19,11 @@ export interface RoutingCatalogue { export type RoutingRequest = | { readonly kind: 'route'; readonly requestId: number; readonly fromId: number; readonly toId: number; readonly rangePc: number; readonly ceilingPc: 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 }; + /** + * `drawn` is the stars the map is drawing, as positions in the catalogue that was sent: only they + * are linked. `budget`, where given, keeps only the links nearest the view that fit its length. + */ + | { readonly kind: 'links'; readonly requestId: number; readonly rangePc: number; readonly drawn: Uint32Array; readonly budget?: LinkBudget }; export type RoutingResponse = | { readonly kind: 'route'; readonly requestId: number; readonly route: Route | null; readonly neededRangePc: number | null } @@ -42,7 +45,7 @@ export function answerRouting(index: StarNeighbourhood, request: RoutingRequest) // 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) }; + return { kind: 'links', requestId: request.requestId, segments: jumpLinkSegments(drawn, request.rangePc, request.budget) }; } const route = routeBetween(index, request.fromId, request.toId, request.rangePc); return {