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 9a23f43..a8d6d6a 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -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)" /> ` @@ -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(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 }[] = []; /** @@ -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(); + /** 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; @@ -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(() => { + 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: `