Link only the stars that are drawn

The jump-link graph linked the whole catalogue: 3.7 million links at 8 pc, 7.4-7.8 s in the
worker and a 443-515 ms frame on the main thread when they landed, and most of them between stars
that were neither drawn nor clickable. A graph request now carries the star field's drawn stars,
and the worker links only those, over an index of its own with cells as wide as the range. The
scene asks again once a new drawn set has held still for 250 ms.

The renderer is handed the graph's bounding sphere instead of computing it: three.js walked every
vertex on the main thread in the first frame that drew a new graph, 48-55 ms at 8 pc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-09-17 16:16:38 +02:00
co-authored by Claude Opus 5
parent 96348161cc
commit c6206a8311
11 changed files with 225 additions and 49 deletions
+4 -3
View File
@@ -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);
+19 -2
View File
@@ -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();
}
+6 -2
View File
@@ -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 {
+3 -2
View File
@@ -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;
+6 -1
View File
@@ -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];