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
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { AfterViewInit, Component, effect, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
|
||||
import { AfterViewInit, Component, computed, effect, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
@@ -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>
|
||||
`
|
||||
@@ -252,6 +276,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 }[] = [];
|
||||
/**
|
||||
@@ -267,6 +317,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;
|
||||
@@ -307,6 +359,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 {
|
||||
@@ -322,6 +383,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();
|
||||
@@ -409,6 +472,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.
|
||||
@@ -423,6 +489,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();
|
||||
@@ -531,6 +599,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
|
||||
@@ -1053,6 +1122,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', () => {
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user