Files
star-map/src/app/features/galaxy-system/jump-link-renderer.ts
T
SenrokaiandClaude Fable 5 68a919bd84 Route between stars, through the crossings a chosen range allows
The map could say where a star is and what is near it, and nothing about
getting from one to another. This adds the question and the answer: pick a
departure and a destination, choose how far a single crossing may be, and get
the chain — how many jumps, how far in total, and every star on the way, each
one a step you can fly to.

A jump link is not a feature of space. There are no corridors out there; a
link is a question asked of the catalogue, which is why the range is the
user's control rather than a constant. Two facts about that catalogue decide
what the answers look like, and both are stated in the code because they read
as defects otherwise. It is magnitude-limited, so it is dense around the Sun
and thins with distance — within 50 pc a 3 pc range links 99% of it into one
piece, while over the whole 250 pc reach the same range leaves most stars
alone. And a gap in it is a gap in what has been catalogued, not in what is
there.

That is why "no route" is not the end of the answer. Where no chain exists at
the range asked for, the panel says which range would open one — the chain
whose longest hop is as short as possible, found by the same search with the
cost of arriving somewhere being the worst hop taken rather than the sum — and
offers that number as a control to accept.

Departure defaults to wherever the view already is, so one field is usually
enough. Sol to Vega at 3 pc: four jumps, 10 pc, by way of Barnard's Star,
Struve 2398 B and HD 155876. Narrow it to 0.8 pc and it says 2.26 would reach.

The graph is drawn as one buffer of line segments and the route as a second,
brighter one over it, with the graph stepping back while a route is up: near
the Sun the links are a haze, and a thread through a bright cloud is not a
thread. Both fade out with the local layer, since from outside the Galaxy the
graph is a smear.

Two measurements shaped this. Asking the index for each star's neighbours in
turn — sixty-eight thousand sorted lists, thrown away — took eight seconds; the
grid now walks its own cells once and pairs them, which takes a quarter of one.
And the range control emits per pixel dragged, so the rebuild waits for the
hand to settle.

Three defects fixed on the way, all older than the routing:

hud-acquire animated with fill-mode `both`, which leaves its closing keyframe
applied for good — and that keyframe carries a clip-path. Every panel wearing
it has been clipping its own box ever since, so anything that had to escape
one was cut away and could not even be clicked. Nothing had needed to escape
until this panel's dropdown opened upward.

The routing fields returned nothing when typed into before the catalogue
finished loading, and stayed nothing until the next keystroke. The options are
derived from the query and the index together now, so they appear when the
second of the two arrives, whichever that is.

And a link was `3-7` walking one way and `7-3` walking the other, which is two
links to anything comparing them.

Verified: build clean, 571/571 unit, 9/9 end-to-end including two new specs —
one plotting Sol to Sirius, one narrowing the range until there is no route and
accepting the one it names — design detector clean, screenshots at 1440x900
and 390x844.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 18:39:22 +02:00

111 lines
4.4 KiB
TypeScript

import * as THREE from 'three/webgpu';
import { JumpLink } from '../../shared/astro/jump-links';
/** Faint, because there are tens of thousands of them and none is worth reading on its own. */
const LINK_OPACITY = 0.16;
/** The one route is the figure; the graph it is drawn on is the ground. */
const ROUTE_OPACITY = 0.9;
/**
* How far the graph falls back while a route is up. Near the Sun the catalogue is dense enough
* that the links are a solid haze, and a chain drawn through it would be one bright thread in a
* bright cloud; stepping the ground down is what makes the figure a figure.
*/
const GROUND_WHILE_ROUTED = 0.4;
export interface LinkPoint {
readonly x: number;
readonly y: number;
readonly z: number;
}
/**
* The jump-link graph, and one route through it.
*
* Both are line segments in the galaxy's parsec frame: the graph as a single buffer, because a
* pair of vertices per link is the cheapest way to draw a hundred thousand of them, and the
* route as a second, brighter one over the top. The route is a strip rather than a set of pairs
* so a chain of hops reads as one continuous thing.
*/
export class JumpLinkRenderer {
readonly object = new THREE.Group();
private readonly linkMaterial: THREE.LineBasicMaterial;
private readonly routeMaterial: THREE.LineBasicMaterial;
private readonly links: THREE.LineSegments;
private readonly route: THREE.Line;
private strength = 1;
private routed = false;
constructor(accent: THREE.ColorRepresentation) {
this.linkMaterial = new THREE.LineBasicMaterial({ color: accent, transparent: true, opacity: LINK_OPACITY, depthWrite: false });
this.routeMaterial = new THREE.LineBasicMaterial({ color: accent, transparent: true, opacity: ROUTE_OPACITY, depthWrite: false });
this.links = new THREE.LineSegments(new THREE.BufferGeometry(), this.linkMaterial);
this.route = new THREE.Line(new THREE.BufferGeometry(), this.routeMaterial);
// Both are rebuilt from scratch whenever they change, so their bounds are only ever right
// by accident between rebuilds; culling on a stale sphere drops the graph mid-pan.
this.links.frustumCulled = false;
this.route.frustumCulled = false;
this.object.add(this.links, this.route);
this.setLinks([], () => undefined);
this.setRoute([], () => undefined);
}
setLinks(links: readonly JumpLink[], positionOf: (starId: number) => LinkPoint | undefined): void {
const vertices = new Float32Array(links.length * 6);
let at = 0;
for (const link of links) {
const from = positionOf(link.from);
const to = positionOf(link.to);
if (!from || !to) {
continue;
}
vertices.set([from.x, from.y, from.z, to.x, to.y, to.z], at);
at += 6;
}
this.replaceGeometry(this.links, at === vertices.length ? vertices : vertices.subarray(0, at));
}
/** The chain to draw over the graph, departure first. Fewer than two stars draws nothing. */
setRoute(starIds: readonly number[], positionOf: (starId: number) => LinkPoint | undefined): void {
const points = starIds.map(positionOf).filter((point): point is LinkPoint => point !== undefined);
this.routed = points.length >= 2;
this.applyOpacity();
const vertices = new Float32Array(points.length < 2 ? 0 : points.length * 3);
points.forEach((point, i) => {
if (vertices.length > 0) {
vertices.set([point.x, point.y, point.z], i * 3);
}
});
this.replaceGeometry(this.route, vertices);
}
/** Crossfaded with the local layer: from outside the Galaxy the graph is a smear. */
setStrength(strength: number): void {
this.strength = THREE.MathUtils.clamp(strength, 0, 1);
this.applyOpacity();
this.object.visible = this.strength > 0;
}
private applyOpacity(): void {
this.linkMaterial.opacity = LINK_OPACITY * this.strength * (this.routed ? GROUND_WHILE_ROUTED : 1);
this.routeMaterial.opacity = ROUTE_OPACITY * this.strength;
}
dispose(): void {
this.links.geometry.dispose();
this.route.geometry.dispose();
this.linkMaterial.dispose();
this.routeMaterial.dispose();
}
private replaceGeometry(target: THREE.LineSegments | THREE.Line, vertices: Float32Array): void {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
const previous = target.geometry;
target.geometry = geometry;
previous.dispose();
}
}