Merge pull request #9 from avalon-vanguard/feat/hud-routes

Route between stars, through the crossings a chosen range allows
This commit is contained in:
Senrokai
2026-08-27 19:22:44 +02:00
committed by GitHub
10 changed files with 1004 additions and 8 deletions
@@ -22,12 +22,16 @@ import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramin
import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity';
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
import { buildBodyViewModel, luminosityOf } from '../body-detail/body-view-model';
import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent, HudReadout } from '../hud/hud-dock.component';
import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent, HudReadout } from '../hud/hud-dock.component';
import { RouteRequest, RouteResult, RouteStarOption } from '../hud/routes-panel.component';
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../search/search-ranking';
import { StarmapHudComponent } from './starmap-hud.component';
import { SystemObjectCardComponent } from './system-object-card.component';
import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
import { collectJumpLinks, minimumRangeBetween, routeBetween } from '../../shared/astro/jump-links';
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
import { HostStarRings } from './host-star-rings';
import { JumpLinkRenderer } from './jump-link-renderer';
import { ReservedBox, ringPlacement } from './label-ring';
import { LabeledPoint, LabelSide, StarLabelOverlay } from './star-label-overlay';
import { SystemOrbitsRenderer } from './system-orbits-renderer';
@@ -56,6 +60,18 @@ 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. */
const JUMP_LINK_REBUILD_DELAY_MS = 250;
/** How many matches each routing field offers, and how little may be typed to get any. */
const ROUTE_OPTION_COUNT = 6;
const MIN_ROUTE_QUERY_LENGTH = 2;
/**
* The widest crossing `minimumRangeBetween` will consider when saying what a route would need.
* Beyond this the catalogue is one component and the answer stops being informative.
*/
const ROUTE_RANGE_CEILING_PC = 30;
/** How many neighbouring stars are named from inside a system. */
const NEIGHBOUR_COUNT = 4;
/**
@@ -205,8 +221,16 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
[note]="hudNote()"
[range]="hudRange()"
[display]="display()"
[routing]="true"
[routeResult]="routeResult()"
[routeOptions]="routeOptions()"
[currentStar]="currentStarOption()"
defaultTab="readout"
(displayChange)="display.set($event)"
(routeQuery)="onRouteQuery($event)"
(routeRequested)="onRouteRequested($event)"
(routeStarSelected)="navigationStore.selectStar($event)"
(jumpRangeChange)="jumpRangePc.set($event)"
/>
</div>
`
@@ -258,6 +282,32 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private hostRings?: HostStarRings;
/** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */
private neighbourhood?: StarNeighbourhood;
private jumpLinks?: JumpLinkRenderer;
/** How far a single crossing may be. Drives both the drawn graph and the route walked on it. */
readonly jumpRangePc = signal(3);
readonly routeResult = signal<RouteResult | null>(null);
/**
* Matches for whichever routing field is being typed into. Stars only: a route is a chain of
* stars, and offering a moon as a destination would be offering a place that leads nowhere.
*
* Derived rather than assigned, because the two things it needs arrive in either order — the
* catalogue is still loading when the dock is already up, and a query typed before it lands
* used to return nothing and stay nothing until the next keystroke.
*/
readonly routeOptions = computed<readonly RouteStarOption[]>(() => {
const query = this.routeQuery().trim();
const index = this.starSearchIndex();
if (query.length < MIN_ROUTE_QUERY_LENGTH || index.length === 0) {
return [];
}
return rankSearchResults(index, query, ROUTE_OPTION_COUNT).flatMap((entry) =>
entry.starId === undefined ? [] : [{ id: entry.starId, name: entry.name, subtitle: entry.subtitle }]
);
});
private readonly routeQuery = signal('');
/** The range the drawn graph was last built at, so a redraw is skipped when nothing moved. */
private drawnJumpRangePc: number | 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 }[] = [];
/**
@@ -273,6 +323,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private deepSkyLabels: readonly LabeledPoint[] = [];
/** Stars with at least one catalogued body, which are the ones the map can be flown into. */
private starIdsWithBodies = new Set<number>();
/** Stars alone, normalised once, for the two routing fields. Empty until the catalogue lands. */
private readonly starSearchIndex = signal<IndexedSearchEntry[]>([]);
private milkyWay?: MilkyWayRenderer;
private galacticLabels: readonly LabeledPoint[] = [];
private galacticGrid?: PolarGridPlane;
@@ -313,6 +365,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
}
});
effect(() => this.applyDisplay(this.display()));
// 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.
effect(() => {
this.jumpRangePc();
this.display().jumpLinks;
clearTimeout(this.jumpLinkRebuild);
this.jumpLinkRebuild = setTimeout(() => this.refreshJumpLinks(), JUMP_LINK_REBUILD_DELAY_MS);
});
}
ngAfterViewInit(): void {
@@ -328,6 +389,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.controls?.dispose();
this.starField?.dispose();
this.hostRings?.dispose();
this.jumpLinks?.dispose();
clearTimeout(this.jumpLinkRebuild);
this.deepSky?.dispose();
this.milkyWay?.dispose();
this.galacticGrid?.dispose();
@@ -415,6 +478,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.stars = stars;
this.starsById = new Map(stars.map((star) => [star.id, star]));
this.neighbourhood = new StarNeighbourhood(stars);
this.starSearchIndex.set(
buildSearchIndex(stars.map((star) => ({ kind: 'star' as const, name: star.name, subtitle: star.spectralType, starId: star.id })))
);
this.bodies = bodies;
this.exoplanets = exoplanets;
// Which stars can be flown into: those with catalogued bodies of their own, plus the Sun.
@@ -429,6 +495,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.galaxyGroup.add(this.starField.object);
this.hostRings = new HostStarRings(stars.filter((star) => this.starIdsWithBodies.has(star.id)), HUD_ACCENT);
this.galaxyGroup.add(this.hostRings.object);
this.jumpLinks = new JumpLinkRenderer(HUD_ACCENT);
this.galaxyGroup.add(this.jumpLinks.object);
this.milkyWay = new MilkyWayRenderer();
this.galacticLabels = this.milkyWay.labelPoints();
@@ -537,6 +605,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.localGrid?.setStrength(display.grid ? 1 - this.galacticStrength : 0);
this.tethers?.setStrength(display.grid ? 1 - this.galacticStrength : 0);
this.hostRings?.setStrength(display.systems ? 1 - this.galacticStrength : 0);
this.jumpLinks?.setStrength(display.jumpLinks ? 1 - this.galacticStrength : 0);
// The backdrop shell is the sky as seen from here; from outside it, it is a wall.
this.deepSky?.setStrength(display.deepSky ? 1 - this.galacticStrength : 0);
// Same argument for the skybox, and more sharply: it is a photograph of the Milky Way taken
@@ -1059,6 +1128,68 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.refreshObjectCard();
};
/** Offered as the departure without typing, since it is where the view already is. */
readonly currentStarOption = computed<RouteStarOption | null>(() => {
const starId = this.navigationStore.selectedStarId();
const star = starId === null ? undefined : this.starsById.get(starId);
return star ? { id: star.id, name: star.name, subtitle: star.spectralType } : null;
});
onRouteQuery(query: string): void {
this.routeQuery.set(query);
}
/**
* Walks the graph, and where it cannot, says what range would. The search is lazy — it asks
* the index for a star's neighbours as it reaches that star — so plotting one route never
* costs a pass over the catalogue.
*/
onRouteRequested({ fromId, toId, rangePc }: RouteRequest): void {
if (!this.neighbourhood) {
return;
}
const route = routeBetween(this.neighbourhood, fromId, toId, rangePc);
if (route) {
this.routeResult.set({
stars: route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })),
totalPc: route.totalPc,
neededRangePc: null
});
this.jumpLinks?.setRoute(route.stars, (id) => this.starsById.get(id));
return;
}
this.routeResult.set({
stars: [],
totalPc: 0,
neededRangePc: minimumRangeBetween(this.neighbourhood, fromId, toId, ROUTE_RANGE_CEILING_PC)
});
this.jumpLinks?.setRoute([], () => undefined);
}
/**
* Rebuilds the drawn graph, which is the expensive half: every star's neighbours, once. Only
* when the layer is on and the range has actually moved — the control emits per pixel dragged.
*/
private refreshJumpLinks(): void {
if (!this.jumpLinks || !this.neighbourhood) {
return;
}
const rangePc = this.jumpRangePc();
if (!this.display().jumpLinks) {
if (this.drawnJumpRangePc !== null) {
this.jumpLinks.setLinks([], () => undefined);
this.drawnJumpRangePc = null;
}
return;
}
if (this.drawnJumpRangePc === rangePc) {
return;
}
this.drawnJumpRangePc = rangePc;
const links = collectJumpLinks(this.neighbourhood, rangePc);
this.jumpLinks.setLinks(links, (id) => this.starsById.get(id));
}
/** 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;
@@ -0,0 +1,110 @@
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();
}
}
@@ -67,6 +67,10 @@ describe('HudDockComponent', () => {
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
fixture.detectChanges();
expect(tabNames()).toEqual(['Search', 'Readout', 'Display']);
fixture.componentRef.setInput('routing', true);
fixture.detectChanges();
expect(tabNames()).toEqual(['Search', 'Readout', 'Routes', 'Display']);
});
it('opens the default tab on mount and renders the readout from its inputs', () => {
@@ -134,7 +138,7 @@ describe('HudDockComponent', () => {
fixture.componentRef.setInput('defaultTab', 'display');
fixture.detectChanges();
const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`);
expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true']);
expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false']);
});
it('opens the search on "/" from anywhere but a text field', () => {
+38 -5
View File
@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, inject, input, OnInit, output, signal, viewChild } from '@angular/core';
import { SearchComponent } from '../search/search.component';
import { RouteRequest, RouteResult, RoutesPanelComponent, RouteStarOption } from './routes-panel.component';
export interface HudReadout {
readonly label: string;
@@ -22,9 +23,11 @@ export interface HudDisplay {
readonly sky: boolean;
/** The rings on stars known to host planets. */
readonly systems: boolean;
/** The graph of crossings within the range the Routes panel is set to. */
readonly jumpLinks: boolean;
}
export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true };
export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true, jumpLinks: false };
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
{ key: 'labels', label: 'Labels' },
@@ -32,12 +35,13 @@ const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
{ key: 'grid', label: 'Grid' },
{ key: 'deepSky', label: 'Deep sky' },
{ key: 'sky', label: 'Sky' },
{ key: 'systems', label: 'Systems' }
{ key: 'systems', label: 'Systems' },
{ key: 'jumpLinks', label: 'Jump links' }
];
export type DockTab = 'search' | 'readout' | 'display';
export type DockTab = 'search' | 'readout' | 'routes' | 'display';
const TAB_LABELS: Record<DockTab, string> = { search: 'Search', readout: 'Readout', display: 'Display' };
const TAB_LABELS: Record<DockTab, string> = { search: 'Search', readout: 'Readout', routes: 'Routes', display: 'Display' };
/** Tailwind's `sm` breakpoint: below it the dock is a bare tab strip and its panel is a sheet. */
const WIDE_VIEWPORT = '(min-width: 640px)';
@@ -64,7 +68,7 @@ function isWideViewport(): boolean {
@Component({
selector: 'app-hud-dock',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SearchComponent],
imports: [RoutesPanelComponent, SearchComponent],
host: { class: 'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6' },
template: `
<!-- The column is transparent to the pointer and each surface in it opts back in: it is as
@@ -102,6 +106,19 @@ function isWideViewport(): boolean {
}
</section>
}
@case ('routes') {
<section id="dock-panel-routes" role="tabpanel" aria-labelledby="dock-tab-routes" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3">
<app-routes-panel
[result]="routeResult()"
[options]="routeOptions()"
[currentStar]="currentStar()"
(queryChange)="routeQuery.emit($event)"
(routeRequested)="routeRequested.emit($event)"
(starSelected)="onRouteStarSelected($event)"
(rangeChange)="jumpRangeChange.emit($event)"
/>
</section>
}
@case ('display') {
<section id="dock-panel-display" role="tabpanel" aria-labelledby="dock-tab-display" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
<p class="type-label text-muted">Layers</p>
@@ -166,14 +183,25 @@ export class HudDockComponent implements OnInit {
readonly display = input<HudDisplay | null>(null);
/** Which panel is open on a wide viewport when the dock mounts. */
readonly defaultTab = input<DockTab | null>(null);
/** Routing: what the scene found, what it offers for the fields, and where the view is. */
readonly routeResult = input<RouteResult | null>(null);
readonly routeOptions = input<readonly RouteStarOption[]>([]);
readonly currentStar = input<RouteStarOption | null>(null);
/** Present makes the Routes tab available; absent means this surface cannot route. */
readonly routing = input(false);
readonly displayChange = output<HudDisplay>();
readonly routeQuery = output<string>();
readonly routeRequested = output<RouteRequest>();
readonly routeStarSelected = output<number>();
readonly jumpRangeChange = output<number>();
readonly layers = DISPLAY_LAYERS;
readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived));
readonly tabs = computed<readonly DockTab[]>(() => [
'search',
...(this.title() ? (['readout'] as const) : []),
...(this.routing() ? (['routes'] as const) : []),
...(this.display() ? (['display'] as const) : [])
]);
@@ -205,6 +233,11 @@ export class HudDockComponent implements OnInit {
}
}
/** A step on a plotted route was chosen: fly there, and leave the route up to walk it from. */
onRouteStarSelected(starId: number): void {
this.routeStarSelected.emit(starId);
}
onPicked(): void {
// A result was chosen: the thing to look at is now the scene, so hand the panel back to the
// readout where there is one, and fold the sheet away where there is not. On a narrow
@@ -0,0 +1,227 @@
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
import { formatParsecs } from '../../shared/format/quantity';
/** A star offered for one of the two fields, as the panel needs to show it. */
export interface RouteStarOption {
readonly id: number;
readonly name: string;
readonly subtitle: string;
}
/** What the scene worked out, once it has been asked. */
export interface RouteResult {
/** The chain, departure first. Empty when there is no route at the range asked for. */
readonly stars: readonly { id: number; name: string }[];
readonly totalPc: number;
/** The shortest range that would open a route, where none was found at the one asked for. */
readonly neededRangePc: number | null;
}
export interface RouteRequest {
readonly fromId: number;
readonly toId: number;
readonly rangePc: number;
}
/** Which end of the journey a query is for. */
type Field = 'from' | 'to';
/**
* Departure, destination, range, and the chain between them.
*
* The one genuinely two-sided tool in the instrument, and the reason the range lives here
* rather than beside the layer toggle that draws the graph: the number that decides which
* crossings are possible is the same number in both places, and a control is easier to trust
* where its consequence is printed.
*
* Presentational. It knows how to ask; the scene knows the catalogue and does the walking.
*/
@Component({
selector: 'app-routes-panel',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex flex-col gap-3">
<div class="grid gap-2 sm:grid-cols-2">
@for (field of fields; track field) {
<div class="relative">
<label [for]="'route-' + field" class="type-label text-muted">{{ field === 'from' ? 'Departure' : 'Destination' }}</label>
<input
[id]="'route-' + field"
type="text"
autocomplete="off"
[value]="text(field)"
[placeholder]="field === 'from' ? 'From this system' : 'Search a star'"
(input)="onInput(field, $event)"
(keydown.escape)="closeOptions()"
class="hud-surface mt-1 w-full px-2.5 py-1.5 text-sm text-text caret-accent placeholder:text-muted focus:border-accent focus:outline-none"
/>
@if (open() === field && options().length) {
<ul class="hud-surface absolute bottom-full left-0 z-10 mb-1 max-h-48 w-full overflow-y-auto divide-y divide-border/25">
@for (option of options(); track option.id) {
<li>
<button
type="button"
(click)="choose(field, option)"
class="flex w-full items-baseline gap-3 px-2.5 py-1.5 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
>
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ option.name }}</span>
<span class="type-label shrink-0 truncate text-muted">{{ option.subtitle }}</span>
</button>
</li>
}
</ul>
}
</div>
}
</div>
<div class="flex items-center gap-3">
<label for="route-range" class="type-label shrink-0 text-muted">Jump range</label>
<input
id="route-range"
type="range"
[min]="minRangePc"
[max]="maxRangePc"
step="0.1"
[value]="rangePc()"
(input)="onRange($event)"
class="h-1 min-w-0 flex-1 appearance-none rounded-none bg-border accent-accent"
/>
<output for="route-range" class="w-20 shrink-0 text-right text-sm text-accent tabular-nums">{{ rangeLabel() }}</output>
</div>
<div class="flex items-center gap-3">
<button
type="button"
[disabled]="!canPlot()"
(click)="plot()"
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors enabled:hover:border-accent/70 enabled:hover:text-accent disabled:opacity-40 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
>
Plot route
</button>
@if (result(); as plotted) {
@if (plotted.stars.length) {
<p data-testid="route-summary" class="text-sm text-text tabular-nums">
{{ plotted.stars.length - 1 }} {{ plotted.stars.length === 2 ? 'jump' : 'jumps' }} <span class="text-muted">·</span> {{ format(plotted.totalPc) }}
</p>
} @else {
<p data-testid="route-summary" class="text-sm text-muted">
No route at this range.
@if (plotted.neededRangePc !== null) {
<button
type="button"
(click)="raiseTo(plotted.neededRangePc)"
class="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent"
>
{{ format(plotted.neededRangePc) }} would reach.
</button>
} @else {
Nothing in the catalogue bridges the gap.
}
</p>
}
}
</div>
@if (result()?.stars?.length) {
<ol data-testid="route-steps" class="hud-surface divide-y divide-border/25">
@for (step of result()!.stars; track step.id; let i = $index) {
<li>
<button
type="button"
(click)="starSelected.emit(step.id)"
class="flex w-full items-baseline gap-3 px-2.5 py-1.5 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
>
<span class="type-label w-6 shrink-0 text-muted tabular-nums">{{ i + 1 }}</span>
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ step.name }}</span>
</button>
</li>
}
</ol>
}
</div>
`
})
export class RoutesPanelComponent {
/** Whatever the scene found for the last query it was given. */
readonly result = input<RouteResult | null>(null);
/** Matches for the field currently being typed into, ranked by the scene. */
readonly options = input<readonly RouteStarOption[]>([]);
/** The star the view is currently inside, offered as the departure without typing. */
readonly currentStar = input<RouteStarOption | null>(null);
readonly queryChange = output<string>();
readonly routeRequested = output<RouteRequest>();
readonly starSelected = output<number>();
/** The graph is drawn at whatever range this panel is set to, so the scene follows it. */
readonly rangeChange = output<number>();
readonly fields: readonly Field[] = ['from', 'to'];
/** A tenth of a parsec is finer than the catalogue's own distances are known to. */
readonly minRangePc = 0.5;
/** Beyond this the graph is a solid sheet of lines and every pair of stars is connected. */
readonly maxRangePc = 8;
readonly rangePc = signal(3);
readonly open = signal<Field | null>(null);
private readonly chosen = signal<Record<Field, RouteStarOption | null>>({ from: null, to: null });
private readonly typed = signal<Record<Field, string>>({ from: '', to: '' });
/** Departure falls back to wherever the view already is, so one field is usually enough. */
private readonly departure = computed(() => this.chosen().from ?? this.currentStar());
readonly canPlot = computed(() => this.departure() !== null && this.chosen().to !== null);
readonly rangeLabel = computed(() => formatParsecs(this.rangePc()));
text(field: Field): string {
return this.chosen()[field]?.name ?? this.typed()[field];
}
format(distancePc: number): string {
return formatParsecs(distancePc);
}
onInput(field: Field, event: Event): void {
const value = (event.target as HTMLInputElement).value;
this.typed.update((current) => ({ ...current, [field]: value }));
// Typing over a chosen star un-chooses it: the field says what it will be searched for.
this.chosen.update((current) => ({ ...current, [field]: null }));
this.open.set(field);
this.queryChange.emit(value);
}
choose(field: Field, option: RouteStarOption): void {
this.chosen.update((current) => ({ ...current, [field]: option }));
this.closeOptions();
}
closeOptions(): void {
this.open.set(null);
this.queryChange.emit('');
}
onRange(event: Event): void {
const value = Number((event.target as HTMLInputElement).value);
this.rangePc.set(value);
this.rangeChange.emit(value);
}
raiseTo(rangePc: number): void {
// Rounded up to the control's own step, so the number shown is one it can actually hold —
// and up rather than down, since down would land just short of the crossing it names.
const stepped = Math.min(this.maxRangePc, Math.ceil(rangePc * 10) / 10);
this.rangePc.set(stepped);
this.rangeChange.emit(stepped);
this.plot();
}
plot(): void {
const from = this.departure();
const to = this.chosen().to;
if (from && to) {
this.routeRequested.emit({ fromId: from.id, toId: to.id, rangePc: this.rangePc() });
}
}
}
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it } from 'vitest';
import { collectJumpLinks, minimumRangeBetween, routeBetween } from './jump-links';
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
/** Stars a parsec apart along x, so a chain's length is the number of hops it takes. */
function chain(count: number): StarNeighbourhood {
return new StarNeighbourhood(Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 })));
}
function index(points: StarPoint[]): StarNeighbourhood {
return new StarNeighbourhood(points);
}
describe('routeBetween', () => {
it('walks the chain a hop at a time when that is all the range allows', () => {
const route = routeBetween(chain(5), 0, 4, 1.5);
expect(route?.stars).toEqual([0, 1, 2, 3, 4]);
expect(route?.totalPc).toBeCloseTo(4);
expect(route?.longestHopPc).toBeCloseTo(1);
});
it('goes straight there when the range reaches, however many stars lie between', () => {
// The direct crossing is never longer than a chain through anything — Euclid says so — so a
// range that covers it makes it the answer, and the stars in between are just scenery.
const route = routeBetween(chain(5), 0, 4, 5);
expect(route?.stars).toEqual([0, 4]);
expect(route?.totalPc).toBeCloseTo(4);
});
it('picks the shorter of two ways round when neither is a straight line', () => {
// 0 to 3 is 10 pc, out of a 6 pc range. Two ways round, both inside it: through 1, barely
// off the line, or through 2, well off it. Shorter is what "the way there" means.
const route = routeBetween(
index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 5, y: 0.5, z: 0 },
{ id: 2, x: 5, y: 3, z: 0 },
{ id: 3, x: 10, y: 0, z: 0 }
]),
0,
3,
6
);
expect(route?.stars).toEqual([0, 1, 3]);
expect(route?.totalPc).toBeCloseTo(10.05, 1);
});
it('finds nothing across a gap wider than the range', () => {
const split = index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 1, y: 0, z: 0 },
{ id: 2, x: 20, y: 0, z: 0 }
]);
expect(routeBetween(split, 0, 2, 5)).toBeNull();
});
it('answers nothing for a star that is not there, or for going nowhere', () => {
const line = chain(3);
expect(routeBetween(line, 0, 0, 2)).toBeNull();
expect(routeBetween(line, 0, 99, 2)).toBeNull();
expect(routeBetween(line, 0, 2, 0)).toBeNull();
});
it('reports the longest hop, which is what the range has to cover', () => {
const route = routeBetween(
index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 1, y: 0, z: 0 },
{ id: 2, x: 5, y: 0, z: 0 }
]),
0,
2,
4
);
expect(route?.longestHopPc).toBeCloseTo(4);
});
});
describe('minimumRangeBetween', () => {
it('names the shortest range that opens a way through', () => {
// Hops of 1 and 4: no range under 4 connects them, and 4 exactly does.
const stepped = index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 1, y: 0, z: 0 },
{ id: 2, x: 5, y: 0, z: 0 }
]);
expect(minimumRangeBetween(stepped, 0, 2, 50)).toBeCloseTo(4);
expect(routeBetween(stepped, 0, 2, 4)).not.toBeNull();
expect(routeBetween(stepped, 0, 2, 3.99)).toBeNull();
});
it('prefers a longer way whose worst hop is shorter, since that is what the range pays for', () => {
// Direct: one hop of 10. Round: three hops of at most 4. The range only has to cover 4.
const both = index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 0, y: 4, z: 0 },
{ id: 2, x: 6, y: 7, z: 0 },
{ id: 3, x: 10, y: 0, z: 0 }
]);
const needed = minimumRangeBetween(both, 0, 3, 50);
expect(needed).toBeLessThan(10);
expect(routeBetween(both, 0, 3, needed!)).not.toBeNull();
});
it('finds nothing when even the ceiling does not reach', () => {
const split = index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 100, y: 0, z: 0 }
]);
expect(minimumRangeBetween(split, 0, 1, 50)).toBeNull();
});
});
describe('collectJumpLinks', () => {
it('reports each pair once, not once from either end', () => {
const links = collectJumpLinks(chain(4), 1.5);
expect(links.map((link) => [link.from, link.to])).toEqual([
[0, 1],
[1, 2],
[2, 3]
]);
});
it('measures every link it reports', () => {
const links = collectJumpLinks(chain(3), 2.5);
expect(links.find((link) => link.from === 0 && link.to === 2)?.distancePc).toBeCloseTo(2);
});
it('draws nothing at no range', () => {
expect(collectJumpLinks(chain(4), 0)).toEqual([]);
});
it('agrees with every route it makes possible', () => {
// The graph drawn and the graph walked have to be the same graph, or the map shows a way
// the route cannot take.
let seed = 11;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 30 - 15;
const points: StarPoint[] = Array.from({ length: 120 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
const cloud = index(points);
// 9 rather than 6: at 6 this cloud falls into pieces and 0 never reaches 119, which an
// earlier version of this test hid by only checking the route it happened to find.
const range = 9;
const links = collectJumpLinks(cloud, range);
const drawn = new Set(links.map((link) => `${link.from}-${link.to}`));
const route = routeBetween(cloud, 0, 119, range);
// Asserted, not guarded: a skipped body would let the two disagree unnoticed.
expect(route).not.toBeNull();
expect(route!.stars.length).toBeGreaterThan(2);
for (let i = 1; i < route!.stars.length; i++) {
const [a, b] = [route!.stars[i - 1], route!.stars[i]].sort((x, y) => x - y);
expect(drawn.has(`${a}-${b}`)).toBe(true);
}
expect(links.length).toBeGreaterThan(0);
});
});
+206
View File
@@ -0,0 +1,206 @@
/**
* Which stars are within reach of which, and how to get from one to another through them.
*
* A "jump link" is nothing more than a pair of catalogued stars closer together than some
* chosen range. It is not a feature of space — there are no corridors out there — it is a
* question asked of the catalogue: if a crossing of at most this far can be made, which stars
* can be strung together, and what is the shortest chain from here to there.
*
* Two facts about the catalogue shape everything here, and both are worth stating because the
* answers look like 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. So a route that cannot be found is a
* statement about the map, and `minimumRangeBetween` exists to say which.
*/
import { StarNeighbourhood } from './star-neighbourhood';
/** A chain of stars from one to another, each hop within the range that was asked for. */
export interface Route {
/** Star ids, departure first and destination last. One hop is two ids. */
readonly stars: readonly number[];
/** The sum of the hops, in parsecs. */
readonly totalPc: number;
/**
* The longest single hop. The range has to cover this and nothing wider, so it is what a
* reader checks a route against — and it is the figure `minimumRangeBetween` minimises.
*/
readonly longestHopPc: number;
}
/** An unordered pair of stars within range of each other. */
export interface JumpLink {
readonly from: number;
readonly to: number;
readonly distancePc: number;
}
/**
* A cap on how much of the catalogue one search may walk. Reached only where a route does not
* exist and the range is wide enough to make most of the catalogue one component; a search that
* hits it has already visited more stars than any real chain passes through.
*/
const MAX_VISITED = 20000;
/** Pops the smallest-cost entry. A linear scan: the frontier is small next to the work per node. */
function takeCheapest<T>(frontier: Map<number, T>, costOf: (value: T) => number): [number, T] | undefined {
let bestId: number | undefined;
let bestValue: T | undefined;
let bestCost = Number.POSITIVE_INFINITY;
for (const [id, value] of frontier) {
const cost = costOf(value);
if (cost < bestCost) {
bestCost = cost;
bestId = id;
bestValue = value;
}
}
if (bestId === undefined || bestValue === undefined) {
return undefined;
}
frontier.delete(bestId);
return [bestId, bestValue];
}
function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): number[] {
const stars = [toId];
let at = toId;
while (at !== fromId) {
const previous = cameFrom.get(at);
if (previous === undefined) {
return [];
}
stars.push(previous);
at = previous;
}
return stars.reverse();
}
/**
* The shortest chain from one star to another in which no single hop exceeds `rangePc`, or
* `null` where the catalogue holds no such chain.
*
* Shortest by total distance travelled rather than by number of hops: two chains of the same
* length are not equally good, and the one that covers less ground is the one a reader means by
* "the way there". Neighbours are asked for as the search reaches each star rather than built
* into a graph first, so finding one route never costs a pass over the whole catalogue.
*/
export function routeBetween(index: StarNeighbourhood, fromId: number, toId: number, rangePc: number): Route | null {
if (fromId === toId || rangePc <= 0 || !index.point(fromId) || !index.point(toId)) {
return null;
}
const best = new Map<number, number>([[fromId, 0]]);
const cameFrom = new Map<number, number>();
const settled = new Set<number>();
const frontier = new Map<number, number>([[fromId, 0]]);
while (frontier.size > 0 && settled.size < MAX_VISITED) {
const cheapest = takeCheapest(frontier, (cost) => cost);
if (!cheapest) {
break;
}
const [starId, costHere] = cheapest;
if (settled.has(starId)) {
continue;
}
settled.add(starId);
if (starId === toId) {
const stars = rebuild(cameFrom, fromId, toId);
return stars.length === 0 ? null : { stars, totalPc: costHere, longestHopPc: longestHop(index, stars) };
}
for (const neighbour of index.within(starId, rangePc)) {
if (settled.has(neighbour.id)) {
continue;
}
const cost = costHere + neighbour.distancePc;
if (cost < (best.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
best.set(neighbour.id, cost);
cameFrom.set(neighbour.id, starId);
frontier.set(neighbour.id, cost);
}
}
}
return null;
}
function longestHop(index: StarNeighbourhood, stars: readonly number[]): number {
let longest = 0;
for (let i = 1; i < stars.length; i++) {
const a = index.point(stars[i - 1]);
const b = index.point(stars[i]);
if (a && b) {
longest = Math.max(longest, Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z));
}
}
return longest;
}
/**
* The shortest range at which any chain at all exists between two stars, or `null` if none does
* within `ceilingPc`.
*
* This is what turns "no route" from a dead end into an answer: the range control can be told
* what it would have to be raised to. It is the minimax path — the chain whose longest hop is as
* short as possible — found by the same search as above, with the cost of reaching a star being
* the longest hop taken to get there rather than the sum of them.
*/
export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): number | null {
if (fromId === toId || ceilingPc <= 0 || !index.point(fromId) || !index.point(toId)) {
return null;
}
const best = new Map<number, number>([[fromId, 0]]);
const settled = new Set<number>();
const frontier = new Map<number, number>([[fromId, 0]]);
while (frontier.size > 0 && settled.size < MAX_VISITED) {
const cheapest = takeCheapest(frontier, (cost) => cost);
if (!cheapest) {
break;
}
const [starId, worstHopHere] = cheapest;
if (settled.has(starId)) {
continue;
}
settled.add(starId);
if (starId === toId) {
return worstHopHere;
}
for (const neighbour of index.within(starId, ceilingPc)) {
if (settled.has(neighbour.id)) {
continue;
}
// What this chain would need: the longest hop on it, not the distance covered by it.
const needed = Math.max(worstHopHere, neighbour.distancePc);
if (needed < (best.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
best.set(neighbour.id, needed);
frontier.set(neighbour.id, needed);
}
}
}
return null;
}
/**
* Every link within `rangePc` in the whole catalogue, each pair once.
*
* 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.
*/
export function collectJumpLinks(index: StarNeighbourhood, rangePc: number): JumpLink[] {
const links: JumpLink[] = [];
index.forEachPairWithin(rangePc, (a, b, distancePc) => {
// The smaller id first, always. The grid hands pairs over in whatever order it walks its
// cells, and a link that is `3-7` here and `7-3` there is two links to anything comparing.
links.push(a.id < b.id ? { from: a.id, to: b.id, distancePc } : { from: b.id, to: a.id, distancePc });
});
return links;
}
@@ -151,6 +151,55 @@ export class StarNeighbourhood {
return found;
}
/**
* Visits every pair of stars within `radiusPc` of each other, once per pair.
*
* The same question `within` answers, asked of the whole catalogue at once — and a different
* shape of answer, because asking it star by star is asking it twice per pair and paying for a
* sorted list of each star's neighbours that the caller then throws away. Sixty-eight thousand
* of those took eight seconds; walking the grid once takes a fraction of it.
*
* Each cell is paired with itself and with the half of its surrounding cells that lie after it
* in the scan, which is what makes each pair come up exactly once.
*/
forEachPairWithin(radiusPc: number, visit: (a: StarPoint, b: StarPoint, distancePc: number) => void): void {
if (radiusPc <= 0) {
return;
}
const reach = Math.ceil(radiusPc / this.cellSizePc);
const radiusSq = radiusPc * radiusPc;
for (const [key, cell] of this.cells) {
const [ix, iy, iz] = key.split(',').map(Number);
for (let dx = 0; dx <= reach; dx++) {
for (let dy = dx === 0 ? 0 : -reach; dy <= reach; dy++) {
for (let dz = dx === 0 && dy === 0 ? 0 : -reach; dz <= reach; dz++) {
const other = dx === 0 && dy === 0 && dz === 0 ? cell : this.cells.get(cellKey(ix + dx, iy + dy, iz + dz));
if (!other) {
continue;
}
const sameCell = other === cell;
for (let i = 0; i < cell.length; i++) {
const a = this.points[cell[i]];
// Within one cell, only the pairs after this one; across two, all of them — the
// other cell is only ever visited from this side.
for (let j = sameCell ? i + 1 : 0; j < other.length; j++) {
const b = this.points[other[j]];
const dxp = b.x - a.x;
const dyp = b.y - a.y;
const dzp = b.z - a.z;
const distanceSq = dxp * dxp + dyp * dyp + dzp * dzp;
if (distanceSq <= radiusSq) {
visit(a, b, Math.sqrt(distanceSq));
}
}
}
}
}
}
}
}
private keyFor(x: number, y: number, z: number): string {
const [ix, iy, iz] = this.cellFor(x, y, z);
return cellKey(ix, iy, iz);
+6 -1
View File
@@ -87,8 +87,13 @@ body {
/* The one authored moment: a panel doesn't fade in, it acquires — wiping down under an
* exponential ease-out the way a readout fills once an instrument locks onto a target. */
/* `backwards`, not `both`: `both` leaves the final keyframe applied for good, and that keyframe
* carries a clip-path — which goes on clipping the panel's own box forever after, so anything
* that has to escape it (a dropdown opening upward, a tooltip) is cut away and cannot even be
* clicked. `backwards` holds the opening frame before the wipe and lets the element return to
* its own styles after it. */
@utility hud-acquire {
animation: hud-acquire 380ms cubic-bezier(0.16, 1, 0.3, 1) both;
animation: hud-acquire 380ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
}
@keyframes hud-acquire {