From 68a919bd84835ca7d4289fb2f905f497b21a73d6 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Thu, 20 Aug 2026 18:39:22 +0200 Subject: [PATCH 1/3] Route between stars, through the crossings a chosen range allows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- e2e/route-plotting.spec.ts | 61 +++++ .../galaxy-system-scene.component.ts | 135 ++++++++++- .../galaxy-system/jump-link-renderer.ts | 110 +++++++++ .../features/hud/hud-dock.component.spec.ts | 6 +- src/app/features/hud/hud-dock.component.ts | 43 +++- .../features/hud/routes-panel.component.ts | 227 ++++++++++++++++++ src/app/shared/astro/jump-links.spec.ts | 170 +++++++++++++ src/app/shared/astro/jump-links.ts | 206 ++++++++++++++++ src/app/shared/astro/star-neighbourhood.ts | 49 ++++ src/styles.css | 7 +- 10 files changed, 1005 insertions(+), 9 deletions(-) create mode 100644 e2e/route-plotting.spec.ts create mode 100644 src/app/features/galaxy-system/jump-link-renderer.ts create mode 100644 src/app/features/hud/routes-panel.component.ts create mode 100644 src/app/shared/astro/jump-links.spec.ts create mode 100644 src/app/shared/astro/jump-links.ts diff --git a/e2e/route-plotting.spec.ts b/e2e/route-plotting.spec.ts new file mode 100644 index 0000000..d308d06 --- /dev/null +++ b/e2e/route-plotting.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Route plotting', () => { + test('chains one star to another through the crossings a chosen range allows', async ({ page }) => { + test.setTimeout(90_000); + await page.goto('/?stars=4000'); + await expect(page.getByTestId('scene-canvas')).toBeVisible(); + + await page.getByRole('tab', { name: 'Routes' }).click(); + + // Sirius is 2.64 pc from the Sun, so the default 3 pc range crosses it in one. + await page.locator('#route-from').fill('Sol'); + const departure = page.locator('#dock-panel-routes ul li button').first(); + await expect(departure).toBeVisible({ timeout: 30_000 }); + await departure.click(); + + await page.locator('#route-to').fill('Sirius'); + const destination = page.locator('#dock-panel-routes ul li button').first(); + await expect(destination).toBeVisible(); + await destination.click(); + + await page.getByRole('button', { name: 'Plot route' }).click(); + + await expect(page.getByTestId('route-summary')).toHaveText(/1 jump · 2\.6\d pc/); + // The chain itself, departure first, each step a way to fly there. + const steps = page.getByTestId('route-steps').getByRole('button'); + await expect(steps).toHaveCount(2); + await expect(steps.first()).toContainText('Sol'); + await expect(steps.last()).toContainText('Sirius'); + }); + + test('says what range a crossing would need, rather than only that there is none', async ({ page }) => { + test.setTimeout(90_000); + await page.goto('/?stars=4000'); + await expect(page.getByTestId('scene-canvas')).toBeVisible(); + + await page.getByRole('tab', { name: 'Routes' }).click(); + await page.locator('#route-from').fill('Sol'); + const departure = page.locator('#dock-panel-routes ul li button').first(); + await expect(departure).toBeVisible({ timeout: 30_000 }); + await departure.click(); + + // Narrowed until nothing the catalogue holds is within reach of the Sun: its nearest + // neighbour is 1.30 pc away, so half a parsec strands it. + await page.locator('#route-range').fill('0.5'); + await page.locator('#route-to').fill('Sirius'); + const destination = page.locator('#dock-panel-routes ul li button').first(); + await expect(destination).toBeVisible(); + await destination.click(); + await page.getByRole('button', { name: 'Plot route' }).click(); + + const summary = page.getByTestId('route-summary'); + await expect(summary).toContainText('No route at this range', { timeout: 30_000 }); + // And the answer, not just the refusal: the range that would open one, offered as a control. + const raise = summary.getByRole('button'); + await expect(raise).toContainText(/pc would reach/); + await raise.click(); + + await expect(page.getByTestId('route-summary')).toContainText(/jump/, { timeout: 30_000 }); + }); +}); diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 7b4d6e7..5a7b5e6 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -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)" /> ` @@ -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(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(() => { + 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; /** 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(); + /** Stars alone, normalised once, for the two routing fields. Empty until the catalogue lands. */ + private readonly starSearchIndex = signal([]); 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(() => { + 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; diff --git a/src/app/features/galaxy-system/jump-link-renderer.ts b/src/app/features/galaxy-system/jump-link-renderer.ts new file mode 100644 index 0000000..e9566d7 --- /dev/null +++ b/src/app/features/galaxy-system/jump-link-renderer.ts @@ -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(); + } +} diff --git a/src/app/features/hud/hud-dock.component.spec.ts b/src/app/features/hud/hud-dock.component.spec.ts index 1eb9a55..62e4480 100644 --- a/src/app/features/hud/hud-dock.component.spec.ts +++ b/src/app/features/hud/hud-dock.component.spec.ts @@ -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', () => { diff --git a/src/app/features/hud/hud-dock.component.ts b/src/app/features/hud/hud-dock.component.ts index 7106938..6330226 100644 --- a/src/app/features/hud/hud-dock.component.ts +++ b/src/app/features/hud/hud-dock.component.ts @@ -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 = { search: 'Search', readout: 'Readout', display: 'Display' }; +const TAB_LABELS: Record = { 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: ` - + + ` }) export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { + /** A kept place, revisited from this page: a star means leaving it for the map. */ + goToBookmark(bookmark: Bookmark): void { + if (bookmark.kind === 'star') { + this.navigationStore.selectStar(Number(bookmark.id)); + void this.router.navigate(['/']); + } else { + void this.router.navigate(['/body', String(bookmark.id)]); + } + } + private readonly canvasRef = viewChild.required>('canvas'); private controls?: OrbitControls; @@ -87,6 +99,7 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { private readonly engine: EngineService, private readonly dataLoader: DataLoaderService, private readonly route: ActivatedRoute, + private readonly router: Router, private readonly navigationStore: NavigationStore ) {} diff --git a/src/app/features/body-detail/info-panel.component.ts b/src/app/features/body-detail/info-panel.component.ts index 7a0603a..565414f 100644 --- a/src/app/features/body-detail/info-panel.component.ts +++ b/src/app/features/body-detail/info-panel.component.ts @@ -1,6 +1,8 @@ -import { Component, computed, input } from '@angular/core'; +import { Component, computed, inject, input } from '@angular/core'; import { Router } from '@angular/router'; +import { BookmarksStore } from '../../shared/state/bookmarks.store'; +import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component'; import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; import { bodyReadouts } from './body-readouts'; import { BodyDetailViewModel } from './body-detail.model'; @@ -17,7 +19,7 @@ import { ReadoutSectionsComponent } from './readout-sections.component'; */ @Component({ selector: 'app-info-panel', - imports: [ChevronIconComponent, ReadoutSectionsComponent], + imports: [BookmarkIconComponent, ChevronIconComponent, ReadoutSectionsComponent], template: `
@@ -30,9 +32,21 @@ import { ReadoutSectionsComponent } from './readout-sections.component'; System -
-

{{ body().name }}

-

{{ readouts().kindLabel }} · {{ body().hostStarName }}

+
+
+

{{ body().name }}

+

{{ readouts().kindLabel }} · {{ body().hostStarName }}

+
+
@@ -42,6 +56,8 @@ import { ReadoutSectionsComponent } from './readout-sections.component'; export class InfoPanelComponent { readonly body = input.required(); + readonly bookmarks = inject(BookmarksStore); + readonly readouts = computed(() => bodyReadouts(this.body())); constructor(private readonly router: Router) {} diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 5a7b5e6..46ec544 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -13,6 +13,7 @@ import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox'; import { loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog'; import { StarRecord } from '../../shared/models/star.model'; +import { Bookmark } from '../../shared/state/bookmarks.store'; import { NavigationStore, ViewLevel } from '../../shared/state/navigation.store'; import { CameraRigController } from './camera-rig-controller'; import { DeepSkyRenderer } from './deep-sky-renderer'; @@ -225,12 +226,14 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector [routeResult]="routeResult()" [routeOptions]="routeOptions()" [currentStar]="currentStarOption()" + [keepableStarId]="navigationStore.selectedStarId()" defaultTab="readout" (displayChange)="display.set($event)" (routeQuery)="onRouteQuery($event)" (routeRequested)="onRouteRequested($event)" (routeStarSelected)="navigationStore.selectStar($event)" (jumpRangeChange)="jumpRangePc.set($event)" + (bookmarkChosen)="goToBookmark($event)" />
` @@ -1204,6 +1207,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.clearObjectCard(); } + /** A kept place, revisited: a star is a system to fly into, a body is a page to open. */ + goToBookmark(bookmark: Bookmark): void { + if (bookmark.kind === 'star') { + this.navigationStore.selectStar(Number(bookmark.id)); + } else { + this.openObjectDetail(String(bookmark.id)); + } + } + /** The deliberate step out to the dedicated route, from the card's own control. */ openObjectDetail(id: string): void { this.navigationStore.selectBody(id); diff --git a/src/app/features/galaxy-system/system-object-card.component.ts b/src/app/features/galaxy-system/system-object-card.component.ts index 4a68ef2..369332e 100644 --- a/src/app/features/galaxy-system/system-object-card.component.ts +++ b/src/app/features/galaxy-system/system-object-card.component.ts @@ -1,7 +1,9 @@ -import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core'; import { BodyDetailViewModel } from '../body-detail/body-detail.model'; import { bodyReadouts } from '../body-detail/body-readouts'; +import { BookmarksStore } from '../../shared/state/bookmarks.store'; +import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component'; import { ReadoutSectionsComponent } from '../body-detail/readout-sections.component'; import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; @@ -19,7 +21,7 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; @Component({ selector: 'app-system-object-card', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ChevronIconComponent, ReadoutSectionsComponent], + imports: [BookmarkIconComponent, ChevronIconComponent, ReadoutSectionsComponent], // Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against // the scene rather than against whatever box the inline default would have left it in — which // put the card off the bottom-left corner of the viewport entirely. @@ -36,6 +38,16 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';

{{ body().name }}

{{ readouts().kindLabel }} · {{ body().hostStarName }}

+ -
-
-

{{ body().name }}

-

{{ readouts().kindLabel }} · {{ body().hostStarName }}

-
- +
+

{{ body().name }}

+

{{ readouts().kindLabel }} · {{ body().hostStarName }}

@@ -56,8 +42,6 @@ import { ReadoutSectionsComponent } from './readout-sections.component'; export class InfoPanelComponent { readonly body = input.required(); - readonly bookmarks = inject(BookmarksStore); - readonly readouts = computed(() => bodyReadouts(this.body())); constructor(private readonly router: Router) {} diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 46ec544..5a7b5e6 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -13,7 +13,6 @@ import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox'; import { loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog'; import { StarRecord } from '../../shared/models/star.model'; -import { Bookmark } from '../../shared/state/bookmarks.store'; import { NavigationStore, ViewLevel } from '../../shared/state/navigation.store'; import { CameraRigController } from './camera-rig-controller'; import { DeepSkyRenderer } from './deep-sky-renderer'; @@ -226,14 +225,12 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector [routeResult]="routeResult()" [routeOptions]="routeOptions()" [currentStar]="currentStarOption()" - [keepableStarId]="navigationStore.selectedStarId()" defaultTab="readout" (displayChange)="display.set($event)" (routeQuery)="onRouteQuery($event)" (routeRequested)="onRouteRequested($event)" (routeStarSelected)="navigationStore.selectStar($event)" (jumpRangeChange)="jumpRangePc.set($event)" - (bookmarkChosen)="goToBookmark($event)" /> ` @@ -1207,15 +1204,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.clearObjectCard(); } - /** A kept place, revisited: a star is a system to fly into, a body is a page to open. */ - goToBookmark(bookmark: Bookmark): void { - if (bookmark.kind === 'star') { - this.navigationStore.selectStar(Number(bookmark.id)); - } else { - this.openObjectDetail(String(bookmark.id)); - } - } - /** The deliberate step out to the dedicated route, from the card's own control. */ openObjectDetail(id: string): void { this.navigationStore.selectBody(id); diff --git a/src/app/features/galaxy-system/system-object-card.component.ts b/src/app/features/galaxy-system/system-object-card.component.ts index 369332e..4a68ef2 100644 --- a/src/app/features/galaxy-system/system-object-card.component.ts +++ b/src/app/features/galaxy-system/system-object-card.component.ts @@ -1,9 +1,7 @@ -import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { BodyDetailViewModel } from '../body-detail/body-detail.model'; import { bodyReadouts } from '../body-detail/body-readouts'; -import { BookmarksStore } from '../../shared/state/bookmarks.store'; -import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component'; import { ReadoutSectionsComponent } from '../body-detail/readout-sections.component'; import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; @@ -21,7 +19,7 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; @Component({ selector: 'app-system-object-card', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [BookmarkIconComponent, ChevronIconComponent, ReadoutSectionsComponent], + imports: [ChevronIconComponent, ReadoutSectionsComponent], // Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against // the scene rather than against whatever box the inline default would have left it in — which // put the card off the bottom-left corner of the viewport entirely. @@ -38,16 +36,6 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';

{{ body().name }}

{{ readouts().kindLabel }} · {{ body().hostStarName }}

-