Files
star-map/src/app/features/galaxy-system/galaxy-system-scene.component.ts
T
SenrokaiandClaude Opus 5 f156e03822 Answer the review: send the worker one request at a time, keep only the latest, and never wait for a dead one
The adversarial review confirmed three defects in this PR, all reproduced in
the browser.

1. Superseded graphs queued up in front of routes. The worker answers
   messages one at a time and cannot drop one it has started. With the
   jump-link layer on, every pause on the range slider posted a full graph
   build, seconds of work at 6-8 pc. Answers no longer wanted were thrown
   away only once built. A route asked for afterwards waited behind every
   one of them: a one-jump route took 44 s.

   RoutingClient now holds requests and sends them one at a time. While one
   is out, only the latest of each kind waits: a newer graph replaces an
   older one before it is ever built, and the older promise is rejected with
   SupersededRequest. Routes go ahead of graphs. The same question asked
   again while outstanding shares the answer rather than being worked twice,
   as when the layer is turned off and on during a build.

   The same scenario in the browser (layer on, range stepped 5 -> 8 pc with
   400 ms pauses, then Sol to Proxima): the route came back in 110 ms. The
   worker was sent "links 3, links 5, route, links 8"; 6 and 7 were never
   built.

2. A worker that failed left the panel stuck. With no error handling, a
   worker that failed to load (a 404 on its chunk after a redeploy) or
   threw left "Plotting…" and a disabled button for good, and a graph at a
   range could not be asked for again.

   The worker now answers an exception with a 'failed' message, which
   rejects that request. A worker that fails to load or dies is abandoned,
   and what it left outstanding, and everything asked afterwards, is
   answered in place. The scene releases the panel when a route fails, and
   forgets a graph range that was never drawn so it can be asked for again.

3. Nothing type-checked the worker. The application builder never reads
   webWorkerTsConfig, and bundles the worker with esbuild, which strips
   types without checking them. tsconfig.app.json leaves the file out. A
   type error in the worker shipped.

   `npm run worker:typecheck` (tsc -p tsconfig.worker.json) now runs in CI
   beside the other project checks. webWorkerTsConfig is removed from
   angular.json, since it only suggested that something checked the worker.

Tests with a fake worker cover one request at a time, a waiting graph
replaced and a route sent ahead of it, a question shared, a failure rejected
and the next request sent, and a failed worker's requests answered in place.
A scene test covers the panel released after a failed route. Negative
controls, each caught: several requests sent at once, a waiting graph kept,
graphs ahead of routes, a question asked twice, a failure answered as a
success, a failed worker waited on, the panel left pending, and a type error
in the worker (caught by worker:typecheck).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-16 15:27:50 +02:00

1740 lines
85 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { dateToJulianDate } from '../../shared/astro/constants';
import {
GALACTIC_BASIS_EQUATORIAL,
MILKY_WAY_ARMS,
SUN_GALACTOCENTRIC_RADIUS_PC,
galacticCentrePositionPc,
galacticToEquatorial
} from '../../shared/astro/galaxy';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { EngineService, SceneCamera } from '../../core/engine/engine.service';
import { BodyRecord } from '../../shared/models/body.model';
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
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 { isDesignation } from '../../shared/models/star-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';
import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
import { MilkyWayRenderer } from './milky-way-renderer';
import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing';
import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity';
import { distanceRings, formatRoundLength, scaleBar, type LengthUnit, type ScaleBar } from '../../shared/format/scale-bar';
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 { 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 { RoutingClient } from './routing-client';
import { colorIndexToRgb, FOCUS_RADIUS_PC, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
import { BrightnessIndex, brightestWithin, brightnessIndex } from '../../shared/astro/brightest';
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component';
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';
/** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */
const SOL_STAR_ID = 0;
/** Stars drawn from a colour rather than a photograph get a more restrained halo. */
const DIM_STAR_GLOW_SCALE = 0.6;
/**
* How far from what the camera is looking at a star can be and still be named, as a fraction of
* how far back the camera is — so the net widens as the view pulls out and closes as it dives
* in, instead of naming the same handful of stars at every scale. Bounded at both ends.
*/
const LABEL_RADIUS_TO_ORBIT_DISTANCE = 0.35;
const MIN_LABEL_RADIUS_PC = 4;
const MAX_LABEL_RADIUS_PC = 400;
/** Caps how many labels are shown at once, to keep the DOM light. */
const LABEL_MAX_COUNT = 15;
/**
* Minimum on-screen separation between two labels, in NDC (roughly 6% of the viewport height).
* Nearer stars win the space; see `spreadLabels`.
*/
const LABEL_MIN_SEPARATION_NDC = 0.12;
/** Beyond this the text of a right-hand label would run off the view: hang it on the left. */
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 far in or out the plan view may be zoomed from the extent its distance frames. Under a
* parallel projection the wheel changes the frame rather than the distance, so the orbit limits
* stop applying and this is what stands in for them.
*/
const PLAN_ZOOM_SPAN = 64;
/** 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:
* the Routes panel's own maximum, since a range the control cannot be set to is no answer. At
* 30 pc, as it was, the search could run for a minute through the dense core before answering.
*/
const ROUTE_RANGE_CEILING_PC = MAX_JUMP_RANGE_PC;
/** How many neighbouring stars are named from inside a system. */
const NEIGHBOUR_COUNT = 4;
/**
* How far out from the centre of the view a neighbour's name sits, as a fraction of the frame's
* half-height. Clear of the scale rail at the top and the dock at the bottom.
*/
const NEIGHBOUR_RING_NDC = 0.74;
/**
* How far in front of the camera a neighbour's name is planted, in AU. Any depth projects to
* the same place on the ring, but not to the same stability: unprojecting at the middle of the
* depth buffer lands ~0.008 AU from the eye, where a hundredth of a degree of camera drift
* swings the label across the screen. Out here the same drift moves it by a pixel.
*/
const NEIGHBOUR_DEPTH_AU = 500;
/** Radius of the selection arcs, in pixels — the leader line starts at their rim. */
const SELECTION_RADIUS_PX = 14;
const HUD_ACCENT = 0x4dd7ff;
/**
* How many deep-sky objects get a permanent label. These sit on a fixed backdrop shell rather
* than near the camera, so proximity is meaningless for them — the brightest handful are simply
* always named.
*/
const DEEP_SKY_LABEL_COUNT = 12;
/** How often (seconds) the visible label set is recomputed; doesn't need to be per-frame. */
const LABEL_UPDATE_INTERVAL_SECONDS = 0.2;
/**
* How far the view's centre may drift, in parsecs, before the star field chooses its stars again: a
* fifth of the radius it draws whole, so nothing within four fifths of it ever goes missing, and
* a slow pan does not rewrite the buffers every label pass.
*/
const STAR_FIELD_REFOCUS_PC = FOCUS_RADIUS_PC / 5;
/** Pointer travel (px) above which a press counts as an orbit drag rather than a selection. */
const CLICK_DRAG_SLOP_PX = 5;
/**
* Opening pose for the local view, expressed in the galactic frame rather than the equatorial
* one: about 35 degrees above the galactic plane, looking down at the Sun. Picked so the grid
* reads as a floor under the star field instead of slicing across it edge-on, which is what an
* arbitrary equatorial direction gives — the plane is tilted 63 degrees to the equator.
*/
const GALAXY_OVERVIEW_POSITION = (() => {
const view = galacticToEquatorial({ x: -105, y: -230, z: 175 });
return new THREE.Vector3(view.x, view.y, view.z);
})();
const GALAXY_OVERVIEW_TARGET = new THREE.Vector3(0, 0, 0);
const GALAXY_NEAR_PC = 0.01;
const GALAXY_FAR_PC = 5000;
const GALAXY_MIN_DISTANCE_PC = 0.5;
/** Far enough out to hold the whole Galaxy in frame; the near/far planes swap to match. */
const GALAXY_MAX_DISTANCE_PC = 70000;
/** How close (pc) the camera dives toward a selected star before the unit-space swap. */
const GALAXY_APPROACH_DISTANCE_PC = 0.05;
/**
* Depth range for the galactic scale. The local view needs a 1-centimetre-of-a-parsec near
* plane to fly into a star; the galactic view needs a far plane a hundred thousand parsecs out.
* Asking one projection to span both would leave the depth buffer with nothing left to
* distinguish two arms with. They swap at the crossfade instead, which happens while the camera
* is hundreds of parsecs from anything and so is invisible.
*/
const GALACTIC_NEAR_PC = 5;
const GALACTIC_FAR_PC = 250000;
/**
* The radius Gaia is surveyed to, which the local grid calls out: inside it the catalogue holds
* every star Gaia measured to G < 12, and past it only the Hipparcos stars Gaia places there.
*/
const SURVEY_EDGE_PC = 250;
/**
* The local grid's rings are distances from the Sun, at a round step that follows the camera:
* five of them out to about the camera's own distance, so 50 to 250 pc from the opening view and
* 2 to 10 pc from beside the Sun. A fixed set could only serve one end of the zoom: 50 pc rings
* say nothing from inside a 2 pc hop, and nothing marked the stars now drawn past 250 pc.
*/
const LOCAL_GRID_RING_COUNT = 5;
const LOCAL_GRID_SPOKES = 12;
/** How far across the view the scale bar may run, in CSS pixels. */
const SCALE_BAR_MAX_PX = 120;
/** Rings for the galactic grid (parsecs from the centre), with the Sun's orbit called out. */
const GALACTIC_GRID_RINGS_PC = [2500, 5000, SUN_GALACTOCENTRIC_RADIUS_PC, 11000, 14000];
const GALACTIC_GRID_SPOKES = 24;
/** The local grid passes through the Sun, which is the origin, so tethers drop to height zero. */
const LOCAL_PLANE_HEIGHT_PC = 0;
/**
* How many stars get a permanent drop line to the local grid, and which ones: the brightest in
* the catalogue rather than the Sun's nearest neighbours.
*
* Nearest-to-the-Sun was the right set when the catalogue stopped at 50 pc and the camera sat
* just outside it. Across 250 pc those same stars are a speck at the centre, while the brightest
* are spread through the whole volume — and are the ones the eye is already on.
*/
const TETHERED_STAR_COUNT = 60;
/** Camera pose for the whole-Galaxy overview: above the disc, out past the Sun, looking in. */
const GALACTIC_OVERVIEW_HEIGHT_PC = 26000;
const GALACTIC_OVERVIEW_BACK_PC = 11000;
/** Above this share of the Galaxy-model crossfade, the HUD calls the view galactic. */
const GALACTIC_LEVEL_THRESHOLD = 0.5;
const SYSTEM_NEAR_AU = 0.002;
const SYSTEM_FAR_AU = 20000;
const SYSTEM_MIN_DISTANCE_AU = 0.05;
const SYSTEM_MAX_DISTANCE_AU = 5000;
/** Where the camera lands (AU) immediately after swapping into system space, pre-settle. */
const SYSTEM_ENTRY_DISTANCE_AU = 200;
/** How far out (AU) the camera flies before swapping back to galaxy/parsec space. */
const SYSTEM_EXIT_DISTANCE_AU = 400;
const APPROACH_DURATION_SECONDS = 1.0;
const SETTLE_DURATION_SECONDS = 0.9;
const EXIT_DURATION_SECONDS = 0.9;
const RETURN_DURATION_SECONDS = 1.1;
const GALACTIC_FLIGHT_SECONDS = 2.4;
/**
* Where the camera sits to hold the whole Galaxy: above the disc and back past the Sun, looking
* at the centre — near enough to the angle the Galaxy is usually drawn from, and it keeps the
* Sun between the camera and the centre so "you are here" stays legible.
*/
function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector3 } {
const centre = galacticCentrePositionPc();
const target = new THREE.Vector3(centre.x, centre.y, centre.z);
const awayFromCentre = target.clone().negate().normalize();
const position = target.clone().add(galacticNormal().multiplyScalar(GALACTIC_OVERVIEW_HEIGHT_PC)).add(awayFromCentre.multiplyScalar(GALACTIC_OVERVIEW_BACK_PC));
return { position, target };
}
/**
* Hosts the shared galaxy + system scene: pan/zoom/rotate camera controls, click-to-select
* picking, proximity-based name labels, and — once a star is selected — a camera-flight
* transition into that star's system (real solar-system bodies for the Sun, cross-referenced
* exoplanets for other stars) with orbit ellipses and planet/moon markers. Owns its own
* `EngineService` instance.
*/
@Component({
selector: 'app-galaxy-system-scene',
providers: [EngineService],
imports: [HudDockComponent, StarmapHudComponent, SystemObjectCardComponent],
template: `
<div class="relative h-full w-full">
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
<!-- Between the canvas and the labels, not up in the HUD where it used to live: everything
in this stack paints in tree order, so from there a decorative gradient was laid over
the names near the edge of the frame — which is exactly where the neighbour ring is. -->
<div aria-hidden="true" class="hud-vignette pointer-events-none absolute inset-0"></div>
<!-- isolate: CSS2DRenderer gives every label its own z-index for depth ordering; without a
stacking context here those indices escape and the labels paint over the HUD. -->
<div #labelHost class="pointer-events-none absolute inset-0 isolate overflow-hidden"></div>
<!-- Leader from the selected body to its card, drawn in screen space and repositioned in
the render loop; visibility is toggled there too, so no change detection per frame. -->
<svg aria-hidden="true" class="pointer-events-none absolute inset-0 h-full w-full text-accent/60">
<line #leader x1="0" y1="0" x2="0" y2="0" stroke="currentColor" stroke-width="1" visibility="hidden" />
</svg>
<app-starmap-hud [level]="navigationStore.viewLevel()" [title]="hudTitle()" [scale]="hudScale()" (levelSelected)="goToLevel($event)" />
@if (objectCard(); as card) {
<app-system-object-card [body]="card" (dismissed)="dismissObjectCard()" (openRequested)="openObjectDetail(card.id)" />
}
<app-hud-dock
[eyebrow]="hudEyebrow()"
[title]="hudTitle()"
[subtitle]="hudSubtitle()"
[readouts]="hudReadouts()"
[note]="hudNote()"
[range]="hudRange()"
[display]="display()"
[routing]="true"
[routeResult]="routeResult()"
[routePending]="routePending()"
[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)"
/>
</div>
`
})
export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
private readonly labelHostRef = viewChild.required<ElementRef<HTMLDivElement>>('labelHost');
private readonly leaderRef = viewChild.required<ElementRef<SVGLineElement>>('leader');
private readonly objectCardRef = viewChild<SystemObjectCardComponent, ElementRef<HTMLElement>>(SystemObjectCardComponent, { read: ElementRef });
private readonly dockRef = viewChild<HudDockComponent, ElementRef<HTMLElement>>(HudDockComponent, { read: ElementRef });
/**
* The card's own box, looked up when the card changes rather than in the render loop that
* draws the leader to it. The host element is a stable wrapper; the panel inside it is what
* moves, and it is only replaced when a different body is selected.
*/
private readonly objectCardElement = computed(() => this.objectCardRef()?.nativeElement.querySelector('[data-testid="object-card"]') ?? null);
private readonly raycaster = new THREE.Raycaster();
private readonly galaxyGroup = new THREE.Group();
private readonly systemGroup = new THREE.Group();
private readonly starMarkerMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
/** Rebuilt per system, since the star's radius is derived from that system's innermost orbit. */
private starMarkerGeometry?: THREE.SphereGeometry;
/** Readout panel contents, refreshed on the same cadence as the labels rather than per frame. */
readonly hudEyebrow = signal('');
readonly hudTitle = signal('');
readonly hudSubtitle = signal('');
readonly hudReadouts = signal<readonly HudReadout[]>([]);
readonly hudNote = signal('');
readonly hudRange = signal('');
readonly hudScale = signal<ScaleBar | null>(null);
/** Which layers are drawn, as toggled from the dock. Applied by `applyDisplay`. */
readonly display = signal<HudDisplay>(DEFAULT_HUD_DISPLAY);
/**
* The body whose card is showing: whichever is pinned by a click, else whatever the pointer is
* over. Undefined outside the system view, and cleared when the view leaves one.
*/
readonly objectCard = signal<BodyDetailViewModel | undefined>(undefined);
private enterableSystems = 0;
private pinnedBodyId: string | null = null;
private hoveredBodyId: string | null = null;
/** The body the card is about — what the selection mark brackets and the leader line leaves. */
private cardBodyId: string | null = null;
private controls?: OrbitControls;
private rig?: CameraRigController;
private starField?: StarFieldRenderer;
/** Where the star field last chose its stars for, and which it was told to keep. See `refocusStarField`. */
private starFieldFocus: THREE.Vector3 | null = null;
private starFieldPins = '';
private hostRings?: HostStarRings;
/** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */
private neighbourhood?: StarNeighbourhood;
/** Routes and the jump-link graph, worked out off the main thread. See `RoutingClient`. */
private routing?: RoutingClient;
/** Which route request is the latest, so an answer to an earlier one is not shown over it. */
private routeRequest = 0;
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);
/** A route has been asked for and not yet answered. */
readonly routePending = signal(false);
/**
* 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 }[] = [];
/**
* The HUD boxes the ring prints around, read on the label pass rather than per frame: each
* read is a forced layout, and the panels move when a tab is switched, not between frames.
*/
private reserved: readonly ReservedBox[] = [];
/** Scratch for the per-frame ring maths, so holding the ring still allocates nothing. */
private readonly ringBearing = new THREE.Vector3();
private readonly ringInverse = new THREE.Quaternion();
private readonly ringPoint = new THREE.Vector3();
private deepSky?: DeepSkyRenderer;
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>();
/** Catalogue indices, brightest first, for the labels to walk rather than sort. See `brightestWithin`. */
private starsByBrightness: BrightnessIndex = brightnessIndex([]);
/** 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;
private localGrid?: PolarGridPlane;
/** The rings `localGrid` was built with, so it is rebuilt only when they change. */
private localGridRadii: readonly number[] = [];
private tethers?: TetherField;
/** Strength of the Galaxy-model crossfade, 0 (local view) to 1 (galactic view). */
private galacticStrength = 0;
private labelOverlay?: StarLabelOverlay;
private stars: readonly StarRecord[] = [];
private starsById = new Map<number, StarRecord>();
private bodies: readonly BodyRecord[] = [];
private exoplanets: readonly ExoplanetRecord[] = [];
private resizeObserver?: ResizeObserver;
private unsubscribeTick?: () => void;
private labelUpdateAccumulator = 0;
private pointerDownAt: { x: number; y: number } | null = null;
private ready = false;
private busy = false;
/** Scale the HUD asked for while a system transition was still unwinding. */
private pendingLevel: ViewLevel | null = null;
/** Id of the star whose system is currently shown (or being flown to/from); null = galaxy view. */
private currentStarId: number | null = null;
private systemRenderer?: SystemOrbitsRenderer;
private starMarker?: THREE.Mesh;
private starGlow?: THREE.Sprite;
constructor(
private readonly engine: EngineService,
private readonly dataLoader: DataLoaderService,
private readonly router: Router,
readonly navigationStore: NavigationStore
) {
effect(() => {
const selectedStarId = this.navigationStore.selectedStarId();
if (this.ready) {
this.reconcileSelection(selectedStarId);
}
});
effect(() => this.applyDisplay(this.display()));
effect(() => this.applyProjection(this.display().plan));
// 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 {
void this.bootstrap();
}
ngOnDestroy(): void {
this.unsubscribeTick?.();
this.resizeObserver?.disconnect();
this.canvasRef().nativeElement.removeEventListener('pointerdown', this.handlePointerDown);
this.canvasRef().nativeElement.removeEventListener('click', this.handleClick);
this.canvasRef().nativeElement.removeEventListener('pointermove', this.handlePointerMove);
this.controls?.dispose();
this.starField?.dispose();
this.hostRings?.dispose();
this.jumpLinks?.dispose();
this.routing?.dispose();
clearTimeout(this.jumpLinkRebuild);
this.deepSky?.dispose();
this.milkyWay?.dispose();
this.galacticGrid?.dispose();
this.localGrid?.dispose();
this.tethers?.dispose();
this.labelOverlay?.dispose();
this.systemRenderer?.dispose();
(this.starMarker?.material as THREE.Material | undefined)?.dispose();
(this.starGlow?.material as THREE.SpriteMaterial | undefined)?.dispose();
this.starMarkerGeometry?.dispose();
this.starMarkerMaterial.dispose();
this.engine.dispose();
}
/**
* Moves the view to a wider scale, from the HUD's scale ladder.
*
* The two outer levels are one continuous space, so "go to the Milky Way" is a camera flight
* rather than a scene change. Leaving a system is not: it has to unwind the unit-space swap
* first, so a request made from inside a system is parked until the exit flight lands.
*/
goToLevel(level: ViewLevel): void {
if (level === 'system') {
return;
}
if (this.currentStarId !== null || this.busy) {
this.pendingLevel = level;
this.navigationStore.selectStar(null);
return;
}
this.flyToOverview(level);
}
private flyToOverview(level: ViewLevel): void {
if (!this.rig) {
return;
}
const pose = level === 'galactic' ? galacticOverviewPose() : { position: GALAXY_OVERVIEW_POSITION.clone(), target: GALAXY_OVERVIEW_TARGET.clone() };
// The galactic flight covers four orders of magnitude, so it gets longer than a local hop.
this.rig.flyTo(pose, level === 'galactic' ? GALACTIC_FLIGHT_SECONDS : RETURN_DURATION_SECONDS);
}
private async bootstrap(): Promise<void> {
const canvas = this.canvasRef().nativeElement;
try {
await this.engine.init(canvas);
} catch (error) {
console.error('Failed to initialize the 3D engine.', error);
return;
}
const scene = this.engine.getScene();
const camera = this.engine.getCamera();
camera.position.copy(GALAXY_OVERVIEW_POSITION);
// The perspective camera whichever one is live: it is where the depth range is reasoned,
// and the plan view re-derives its own from it every frame. Writing to the active camera
// put the astronomical-unit range on one that overwrites it, and the system clipped.
const depthCamera = this.engine.getPerspectiveCamera();
depthCamera.near = GALAXY_NEAR_PC;
depthCamera.far = GALAXY_FAR_PC;
depthCamera.updateProjectionMatrix();
this.controls = new OrbitControls(camera, canvas);
this.controls.enableDamping = true;
this.controls.minDistance = GALAXY_MIN_DISTANCE_PC;
this.controls.maxDistance = GALAXY_MAX_DISTANCE_PC;
this.controls.target.copy(GALAXY_OVERVIEW_TARGET);
this.rig = new CameraRigController(camera, this.controls);
scene.add(this.galaxyGroup, this.systemGroup);
this.systemGroup.visible = false;
applyMilkyWaySkybox(scene, MILKY_WAY_SKYBOX_PATH);
const [{ stars, positions }, bodies, exoplanets, deepSky] = await Promise.all([
this.dataLoader.loadStars(),
this.dataLoader.loadBodies(),
this.dataLoader.loadExoplanets(),
// The backdrop is decorative — if its dataset is missing or malformed the star field
// should still come up, so this one failure is swallowed rather than aborting bootstrap.
this.dataLoader.loadDeepSky().catch((error) => {
console.error('Failed to load the deep-sky backdrop; continuing without it.', error);
return [] as DeepSkyRecord[];
})
]);
this.stars = stars;
this.starsById = new Map(stars.map((star) => [star.id, star]));
this.neighbourhood = new StarNeighbourhood(stars);
this.routing = new RoutingClient(stars, positions, this.neighbourhood);
this.starsByBrightness = brightnessIndex(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.
this.enterableSystems = new Set<number>([...bodies.map((body) => body.systemStarId), ...exoplanets.map((exoplanet) => exoplanet.hostStarId)].filter((id) => id !== null)).size;
// Built once rather than per label refresh: it is a scan of every body and exoplanet, and the
// labels are recomputed whenever the camera moves.
this.starIdsWithBodies = new Set([...bodies.map((body) => body.systemStarId), ...exoplanets.map((exoplanet) => exoplanet.hostStarId)].filter(
(id): id is number => id !== null && id !== undefined
));
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search), this.starsByBrightness.order);
// It has just chosen around the Sun, which is where the view opens: the first label pass need not choose again.
this.starFieldFocus = GALAXY_OVERVIEW_TARGET.clone();
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();
const centre = galacticCentrePositionPc();
this.galacticGrid = new PolarGridPlane({
ringRadii: GALACTIC_GRID_RINGS_PC,
spokeCount: GALACTIC_GRID_SPOKES,
centre: new THREE.Vector3(centre.x, centre.y, centre.z),
emphasisRadii: [SUN_GALACTOCENTRIC_RADIUS_PC]
});
this.setLocalGridRadii(distanceRings(GALAXY_OVERVIEW_POSITION.length(), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC));
// A fixed set rather than whatever is currently labelled: a tether that appears and vanishes
// as the camera drifts reads as a glitch.
this.tethers = new TetherField(TETHERED_STAR_COUNT);
this.tethers.setTargets(
[...stars]
.sort((a, b) => a.magnitude - b.magnitude)
.slice(0, TETHERED_STAR_COUNT)
.map((star) => new THREE.Vector3(star.x, star.y, star.z)),
LOCAL_PLANE_HEIGHT_PC
);
this.galaxyGroup.add(this.milkyWay.object, this.galacticGrid.object, this.tethers.object);
if (deepSky.length > 0) {
this.deepSky = new DeepSkyRenderer(deepSky);
this.galaxyGroup.add(this.deepSky.object);
this.deepSkyLabels = this.deepSky.labelPoints(DEEP_SKY_LABEL_COUNT);
}
// A neighbour's label offers to fly there, and goes through the store like every other way
// of choosing a star — so a label click, a search hit and an in-scene click are one path.
this.labelOverlay = new StarLabelOverlay(scene, (starId) => this.navigationStore.selectStar(starId));
this.labelHostRef().nativeElement.appendChild(this.labelOverlay.domElement);
this.applyDisplay(this.display());
const { width, height } = canvas.getBoundingClientRect();
this.labelOverlay.setSize(width, height);
canvas.addEventListener('pointerdown', this.handlePointerDown);
canvas.addEventListener('click', this.handleClick);
canvas.addEventListener('pointermove', this.handlePointerMove);
this.observeResize(canvas);
// Asked for per frame rather than captured: the projection can be swapped underneath, and a
// frame computed against one camera and drawn through the other puts every label off its star.
this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(this.engine.getCamera(), deltaSeconds));
this.engine.start();
this.ready = true;
this.reconcileSelection(this.navigationStore.selectedStarId());
}
private tick(camera: SceneCamera, deltaSeconds: number): void {
this.rig?.update(deltaSeconds);
this.frameProjection(camera);
this.controls?.update();
// Gated on the galaxy group rather than on `currentStarId`, which is only assigned once the
// arrival flight finishes. In between, the scene has already swapped to system space while
// `currentStarId` is still null, so labels were being recomputed from galaxy-scale positions
// and pinned over the system — the whole point of clearing them on the swap.
if (this.galaxyGroup.visible) {
// Per-frame, unlike the labels: this is a handful of uniform writes, and it is what keeps
// the zoom continuous rather than stepping between two discrete scales.
this.updateGalacticCrossfade(camera);
}
this.labelUpdateAccumulator += deltaSeconds;
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
this.labelUpdateAccumulator = 0;
if (this.galaxyGroup.visible) {
this.refocusStarField();
this.updateLabels(camera);
} else if (this.systemGroup.visible) {
this.updateSystemLabels(camera);
}
this.updateHud(camera);
}
if (this.systemGroup.visible) {
this.systemRenderer?.update(dateToJulianDate());
}
this.updateSelectionMark(camera);
this.updateNeighbourRing(camera);
this.labelOverlay?.render(camera);
}
/**
* Blends between the two things that share parsec space: the catalogued star field with its
* local grid, and the Milky Way model with its galactic one. Driven by how far the camera
* has pulled back from the Sun, so the scale ladder reports where the view already is instead
* of switching it.
*/
private updateGalacticCrossfade(camera: SceneCamera): void {
if (!this.milkyWay) {
return;
}
// How much of the Galaxy is in frame, expressed as the distance a perspective camera would
// have to be at to show that much. Under a plan view the camera's own distance says nothing
// about the extent — the frustum does — so reading `position.length()` there would report a
// fixed scale however far the view was zoomed.
const distancePc = this.effectiveDistance(camera);
this.galacticStrength = this.milkyWay.setViewerDistancePc(distancePc);
// Layer toggles from the dock fold in here rather than as a one-off `visible = false`:
// `setStrength` rewrites visibility every frame from the strength it is given, so a hidden
// layer has to be told a strength of zero every frame too.
const display = this.display();
this.galacticGrid?.setStrength(display.grid ? this.galacticStrength : 0);
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
// from inside it, so it cannot also be the sky behind a view of the Galaxy from outside.
this.engine.getScene().backgroundIntensity = display.sky ? 1 - this.galacticStrength : 0;
this.applyGalaxyDepthRange(distancePc);
const level: ViewLevel = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD ? 'galactic' : 'galaxy';
if (this.navigationStore.viewLevel() !== level && !this.systemGroup.visible) {
this.navigationStore.setViewLevel(level);
}
}
/**
* Keeps the depth range proportional to how far out the camera is. One fixed pair cannot serve
* both ends of this view: flying into a star needs a near plane a hundredth of a parsec out,
* and holding the Galaxy needs a far plane a hundred thousand parsecs out, and a projection
* spanning both has no precision left to separate one spiral arm from the next.
*/
/**
* What "how far back is the camera" means, in either projection. Under perspective it is the
* camera's own distance from the origin; under an orthographic one it is the distance a
* perspective camera would need to frame the same extent, so everything keyed on it — the
* crossfade, the depth range, the scale ladder — goes on meaning what it meant.
*/
private effectiveDistance(camera: SceneCamera): number {
if (this.engine.currentProjection === 'perspective') {
return camera.position.length();
}
const halfHeight = this.engine.visibleHalfHeight(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET));
return halfHeight / Math.tan((this.engine.getPerspectiveCamera().fov * Math.PI) / 360);
}
private applyGalaxyDepthRange(distancePc: number): void {
const near = THREE.MathUtils.clamp(distancePc / 2000, GALAXY_NEAR_PC, GALACTIC_NEAR_PC);
const far = THREE.MathUtils.clamp(distancePc * 8, GALAXY_FAR_PC, GALACTIC_FAR_PC);
// Written to the perspective camera whichever one is live, because it is the one this range
// is reasoned in and the one `frameOrthographic` reads its own from. Skipping it under a plan
// view left the far plane wherever it was when the projection changed, so flying out to the
// Galaxy from there clipped away most of it.
const perspective = this.engine.getPerspectiveCamera();
// Only when it has drifted enough to matter, so a slow zoom isn't rebuilding the projection
// matrix on every frame of it.
if (Math.abs(near - perspective.near) > perspective.near * 0.05 || Math.abs(far - perspective.far) > perspective.far * 0.05) {
perspective.near = near;
perspective.far = far;
perspective.updateProjectionMatrix();
// The plan view's own range is symmetric about the camera and derived from this one; see
// `frameOrthographic`. It is re-derived every frame, so there is nothing to do here.
}
}
/** Swaps the local grid for one with these rings, carrying its current strength across. */
private setLocalGridRadii(radii: readonly number[]): void {
this.localGrid?.dispose();
this.localGridRadii = radii;
this.localGrid = new PolarGridPlane({ ringRadii: radii, spokeCount: LOCAL_GRID_SPOKES, emphasisRadii: [SURVEY_EDGE_PC] });
this.localGrid.setStrength(this.display().grid ? 1 - this.galacticStrength : 0);
this.galaxyGroup.add(this.localGrid.object);
}
/**
* One label per ring of the local grid, naming its distance from the Sun. Each sits on the side
* of its ring facing what the view is centred on, so the ring running under the stars being
* looked at is the one named; a label pinned to one bearing is off screen most of the time.
*/
private ringLabels(camera: SceneCamera): LabeledPoint[] {
const normal = galacticNormal();
const onPlane = (point: THREE.Vector3) => point.clone().addScaledVector(normal, -point.dot(normal));
const target = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
// Toward what the view is centred on, when that is out among the rings. Otherwise across the
// far side of the grid, the part of it in front of the eye (the near side is under the
// camera and out of frame), or toward the top of the screen for a camera looking straight down.
let bearing = onPlane(target);
if (bearing.length() < (this.localGridRadii[0] ?? 0)) {
bearing = onPlane(target.clone().sub(camera.position));
}
if (bearing.lengthSq() < 1e-12) {
bearing = onPlane(new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion));
}
if (bearing.lengthSq() < 1e-12) {
return [];
}
bearing.normalize();
return this.localGridRadii.map(
(radius): LabeledPoint => ({
id: `ring-${radius}`,
name: formatRoundLength(radius, 'pc'),
...(radius === SURVEY_EDGE_PC ? { kind: 'Survey edge' } : {}),
tone: 'ghost',
x: bearing.x * radius,
y: bearing.y * radius,
z: bearing.z * radius
})
);
}
/** The scale bar for the current zoom, measured at the depth the view is centred on. */
private scaleBarFor(camera: SceneCamera, unit: LengthUnit): ScaleBar | null {
const heightPx = this.canvasRef().nativeElement.clientHeight;
if (heightPx === 0) {
return null;
}
const halfHeight = this.engine.visibleHalfHeight(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET));
return scaleBar((2 * halfHeight) / heightPx, SCALE_BAR_MAX_PX, unit);
}
/**
* Keeps the drawn stars those around what the view is centred on, and the ones the map is
* pointing at: the selected star and the stars of a plotted route. Re-chosen only once the
* centre has moved far enough to matter, so any star within `FOCUS_RADIUS_PC - STAR_FIELD_REFOCUS_PC`
* of it is always drawn, however faint.
*/
private refocusStarField(): void {
// At galactic scale the whole catalogue is a smudge a few pixels across, and the view's centre
// sweeps hundreds of parsecs a pass across empty space: nothing to choose, and nothing to see.
if (!this.starField || !this.neighbourhood || this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) {
return;
}
const centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
const selectedId = this.navigationStore.selectedStarId();
const pinnedIds = [...(selectedId === null ? [] : [selectedId]), ...(this.routeResult()?.stars.map((star) => star.id) ?? [])];
const pins = pinnedIds.join();
if (this.starFieldFocus && this.starFieldFocus.distanceTo(centre) <= STAR_FIELD_REFOCUS_PC && pins === this.starFieldPins) {
return;
}
// By catalogue index, through the lookup the neighbourhood already holds: building a second
// one of 423 651 entries on the first pin stalled the first flight of a session for 50-140 ms.
const neighbourhood = this.neighbourhood;
const pinned = pinnedIds.map((id) => neighbourhood.indexOf(id)).filter((index): index is number => index !== undefined);
this.starField.refocus({ centre, pinned });
this.starFieldFocus = centre.clone();
this.starFieldPins = pins;
}
private updateLabels(camera: SceneCamera): void {
const radii = distanceRings(this.effectiveDistance(camera), LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC);
if (radii.join() !== this.localGridRadii.join()) {
this.setLocalGridRadii(radii);
}
const selectedId = this.navigationStore.selectedStarId();
// Measured from what the camera is looking at, not from where it is. Those differ by the
// orbit distance, so a camera-relative rule names the stars closest to the near edge of the
// view — a ring of labels around the outside of the thing the user is actually looking at.
const target = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
const orbitDistance = (this.controls ? this.effectiveDistance(camera) : GALAXY_OVERVIEW_POSITION.length()) * LABEL_RADIUS_TO_ORBIT_DISTANCE;
const labelRadius = THREE.MathUtils.clamp(orbitDistance, MIN_LABEL_RADIUS_PC, MAX_LABEL_RADIUS_PC);
// Individual star names mean nothing once the whole Galaxy is in frame — at that range the
// entire catalogue is inside one pixel — so the labels hand over to the structural ones.
const isGalactic = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD;
// Brightest first, not nearest first. Proximity was the right ranking when the catalogue was
// a 50 pc bubble and everything in it was equally worth naming; across 250 pc it labels a
// clump of whatever happens to be closest to the middle of the screen and never names the
// stars that are actually prominent. Brightness is what makes a star worth a name.
//
// Walked lazily, and only as far as it takes to place the labels. "System" rather than "Star"
// for anything with catalogued bodies: it is the one distinction the second line can draw that
// the map cannot otherwise show, since it says which of these points is somewhere you can go.
const starIdsWithBodies = this.starIdsWithBodies;
const candidates = function* (stars: readonly StarRecord[], index: BrightnessIndex): Generator<LabeledPoint> {
for (const star of brightestWithin(stars, index, target, labelRadius, selectedId)) {
yield { id: star.id, name: star.name, kind: starIdsWithBodies.has(star.id) ? 'System' : 'Star', x: star.x, y: star.y, z: star.z };
}
};
const starLabels: LabeledPoint[] = isGalactic ? [] : this.spreadLabels(candidates(this.stars, this.starsByBrightness), camera, selectedId);
const backdropLabels = isGalactic ? this.galacticLabels : this.deepSkyLabels;
const ringLabels = isGalactic || !this.display().grid ? [] : this.ringLabels(camera);
this.labelOverlay?.update([...starLabels, ...ringLabels, ...backdropLabels]);
}
/**
* Takes candidate labels in priority order and keeps only those that land clear of the labels
* already placed, dropping the rest.
*
* Priority alone is not enough at either scale. The Sun's fifteen nearest neighbours are all
* inside four parsecs, so from anything but point-blank range their names print on top of each
* other in a single unreadable clump; the inner four planets do exactly the same thing when a
* system is framed out to Pluto. Rejecting on screen separation rather than on distance means
* the set naturally opens up as the camera closes in, and stays legible when it pulls back.
*
* `keepId` is exempt from both tests — it is the selection, which is about to be flown to, and
* its label going missing mid-flight reads as the target having been lost.
*/
/** The frame's shape, from the canvas rather than the camera: only one of the two has it. */
private viewportAspect(): number {
const canvas = this.canvasRef().nativeElement;
return canvas.clientHeight > 0 ? canvas.clientWidth / canvas.clientHeight : 1;
}
private spreadLabels(candidates: Iterable<LabeledPoint>, camera: SceneCamera, keepId: number | string | null): LabeledPoint[] {
const placed: THREE.Vector2[] = [];
const chosen: LabeledPoint[] = [];
const projected = new THREE.Vector3();
for (const candidate of candidates) {
projected.set(candidate.x, candidate.y, candidate.z).project(camera);
const isKept = candidate.id === keepId;
// Offscreen or behind the camera.
if (!isKept && (projected.z < -1 || projected.z > 1 || Math.abs(projected.x) > 1 || Math.abs(projected.y) > 1)) {
continue;
}
const point = new THREE.Vector2(projected.x * this.viewportAspect(), projected.y);
if (!isKept && placed.some((other) => other.distanceTo(point) < LABEL_MIN_SEPARATION_NDC)) {
continue;
}
// Text hangs on the right unless it would run off the view there, or into the space a
// label already placed to the right is using; then it hangs on the left, unless *that*
// is off the view. A crowded centre still gets right-hand labels — the separation test
// above already keeps them apart.
const crowdedRight = placed.some(
(other) => other.x > point.x && other.x - point.x < LABEL_REACH_NDC && Math.abs(other.y - point.y) < LABEL_MIN_SEPARATION_NDC
);
// The body the card is about hangs its label on the left regardless: the leader line to
// the card leaves its right, and would otherwise run straight through the text.
const side: LabelSide =
candidate.id === this.cardBodyId || ((projected.x > LABEL_EDGE_NDC || crowdedRight) && projected.x > -LABEL_EDGE_NDC) ? 'left' : 'right';
placed.push(point);
chosen.push({ ...candidate, side });
// Here rather than at the top of the loop: there, taking the fifteenth label asked the
// candidates for a sixteenth first, and near the Sun finding one walks most of the catalogue.
if (chosen.length >= LABEL_MAX_COUNT) {
break;
}
}
return chosen;
}
/**
* Brackets the body the card is about with the selection arcs, and draws the leader from
* their rim to the card's near edge. Screen-space work done here, once per frame, because the
* body moves every frame and the card's height depends on its content.
*/
private updateSelectionMark(camera: SceneCamera): void {
const leader = this.leaderRef().nativeElement;
const member = this.systemGroup.visible && this.cardBodyId !== null ? this.systemRenderer?.members.find((candidate) => candidate.id === this.cardBodyId) : undefined;
if (!member) {
this.labelOverlay?.setSelection(null);
leader.setAttribute('visibility', 'hidden');
return;
}
const world = member.marker.getWorldPosition(new THREE.Vector3());
this.labelOverlay?.setSelection(world);
const card = this.objectCardElement();
const canvas = this.canvasRef().nativeElement;
const projected = world.clone().project(camera);
if (!card || projected.z > 1 || projected.z < -1) {
leader.setAttribute('visibility', 'hidden');
return;
}
const canvasRect = canvas.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
const fromX = ((projected.x + 1) / 2) * canvas.clientWidth;
const fromY = ((1 - projected.y) / 2) * canvas.clientHeight;
// The card is top-right: meet its left edge, at the body's height where the edge allows.
const toX = cardRect.left - canvasRect.left;
const toY = Math.min(Math.max(fromY, cardRect.top - canvasRect.top + 12), cardRect.bottom - canvasRect.top - 12);
const dx = toX - fromX;
const dy = toY - fromY;
const length = Math.hypot(dx, dy);
if (length <= SELECTION_RADIUS_PX) {
leader.setAttribute('visibility', 'hidden');
return;
}
// Start on the arcs' rim, not the body's centre.
leader.setAttribute('x1', String(fromX + (dx / length) * SELECTION_RADIUS_PX));
leader.setAttribute('y1', String(fromY + (dy / length) * SELECTION_RADIUS_PX));
leader.setAttribute('x2', String(toX));
leader.setAttribute('y2', String(toY));
leader.setAttribute('visibility', 'visible');
}
/** Resolves the current system's neighbours once, on arrival. Cleared outside a system. */
private resolveNeighbours(): void {
const origin = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId);
if (!origin || !this.neighbourhood) {
this.neighbours = [];
return;
}
this.neighbours = this.neighbourhood
// Asked wide and cut back, because a catalogue holds binary companions as two rows at one
// position: a neighbour whose separation rounds to what no separation prints as is not a
// place to go, it is the same place. Compared through the formatter rather than against a
// hand-picked epsilon, so the rule stays "would print as zero" whatever the formatter does.
//
// Named stars first, survey designations only where fewer than that are in reach: a ring
// that exists to say where you are is not helped by "Gaia DR3 5853498713190525696" —
// least of all when that is the same star as the Proxima Centauri printed beside it.
.nearestPreferring(origin.id, NEIGHBOUR_COUNT * 2, (point) => {
const star = this.starsById.get(point.id);
return star !== undefined && !isDesignation(star);
})
.filter((neighbour) => formatParsecs(neighbour.distancePc) !== formatParsecs(0))
.slice(0, NEIGHBOUR_COUNT)
.flatMap((neighbour) => {
const star = this.starsById.get(neighbour.id);
return star
? [
{
star,
distancePc: neighbour.distancePc,
// A unit vector in the catalogue's parsec frame, which is the same direction in
// the system's AU frame: only the scale between the two differs.
direction: new THREE.Vector3(star.x - origin.x, star.y - origin.y, star.z - origin.z).normalize()
}
]
: [];
});
}
/** Re-reads the HUD surfaces the ring has to print around, as boxes relative to the canvas. */
private refreshReservedBoxes(): void {
const canvas = this.canvasRef().nativeElement.getBoundingClientRect();
const panels = [
this.dockRef()?.nativeElement.querySelector('[role="tabpanel"]'),
this.dockRef()?.nativeElement.querySelector('[role="tablist"]')?.parentElement,
this.objectCardRef()?.nativeElement.querySelector('[data-testid="object-card"]')
];
this.reserved = panels.flatMap((panel) => {
if (!panel) {
return [];
}
const box = panel.getBoundingClientRect();
return [{ left: box.left - canvas.left, top: box.top - canvas.top, right: box.right - canvas.left, bottom: box.bottom - canvas.top }];
});
}
/**
* Where a neighbour's name sits: on the ring, at the bearing its own direction lands on —
* moved along the ring where a HUD panel already holds that place. `null` where the whole
* neighbourhood of that bearing is covered.
*/
private neighbourRingPosition(camera: SceneCamera, direction: THREE.Vector3): THREE.Vector3 | null {
const bearing = this.ringBearing.copy(direction).applyQuaternion(this.ringInverse.copy(camera.quaternion).invert());
// A neighbour behind the camera keeps the side it is on, which is still the way to turn to
// bring it round.
const angle = Math.atan2(bearing.y, bearing.x);
const canvas = this.canvasRef().nativeElement;
const placed = ringPlacement(angle, NEIGHBOUR_RING_NDC, { width: canvas.clientWidth, height: canvas.clientHeight }, this.reserved);
if (!placed) {
return null;
}
if (this.engine.currentProjection === 'orthographic') {
// A parallel projection has no vanishing point to walk towards: every ray through the
// frame is the view direction, so treating the unprojected offset as one and stepping
// along it throws the sideways part away and pulls the whole ring into the middle. The
// unprojected point is already where the name goes.
return this.ringPoint.set(placed.x, placed.y, 0).unproject(camera);
}
const along = this.ringPoint.set(placed.x, placed.y, 0.5).unproject(camera).sub(camera.position).normalize();
return along.multiplyScalar(NEIGHBOUR_DEPTH_AU).add(camera.position);
}
/**
* Names the stars nearest the one the camera is inside, each on the side of the view its own
* lies on. It is the one thing a system view cannot otherwise say: which way its neighbours
* are, and how far. Each is a button that flies there, so a chain of neighbours can be walked
* without pulling back out to the field between hops.
*
* These are bearings, not sky positions, and are drawn as such: a ring of names at a fixed
* radius from the centre of the frame, which reads as instrument rather than as scene. The
* true position cannot be drawn — the nearest star to the Sun is 268 000 AU away, thirteen
* times the far plane — and a true *direction* is worse than useless here: at this field of
* view three neighbours in four fall outside the frame, so the view would name whichever
* happened to be in front and stay silent about the rest. What survives is the half of the
* direction a viewer can act on: which way to turn to face it.
*/
private neighbourLabels(camera: SceneCamera): LabeledPoint[] {
this.refreshReservedBoxes();
return this.neighbours.flatMap(({ star, distancePc, direction }) => {
const position = this.neighbourRingPosition(camera, direction);
if (!position) {
return [];
}
return [{
// Namespaced, so a star's ghost and the same star's own label in the galaxy view are
// never the one DOM node being asked to be two different things.
id: `neighbour:${star.id}`,
name: star.name,
kind: formatParsecs(distancePc),
tone: 'ghost' as const,
selectStarId: star.id,
x: position.x,
y: position.y,
z: position.z
}];
});
}
/**
* Holds the ring still. The names are placed relative to the camera, so between label passes
* — five a second — any camera movement would drag them off the ring and snap them back. This
* runs every frame and costs four vector operations.
*/
private updateNeighbourRing(camera: SceneCamera): void {
if (!this.systemGroup.visible || this.neighbours.length === 0) {
return;
}
for (const { star, direction } of this.neighbours) {
const position = this.neighbourRingPosition(camera, direction);
if (position) {
this.labelOverlay?.moveLabel(`neighbour:${star.id}`, position.x, position.y, position.z);
}
}
}
/**
* Names the bodies of the system the view is inside.
*
* Outermost first, because that is the order that survives the separation test usefully: with
* the whole system in frame the outer planets are the ones far enough apart to label, and the
* inner four are a single clump around the star. Closing in reverses it on its own — the outer
* orbits leave the frame and their labels drop out, freeing the space for the inner planets.
*
* Moons are left out entirely: they sit within a marker's width of their planet at system
* framing, so their labels could only ever print on top of it.
*/
private updateSystemLabels(camera: SceneCamera): void {
const renderer = this.systemRenderer;
if (!renderer) {
this.labelOverlay?.update([]);
return;
}
const records = new Map<string, { name: string; semiMajorAxisAu: number }>([
...this.bodies.map((body): [string, { name: string; semiMajorAxisAu: number }] => [
body.id,
{ name: body.name, semiMajorAxisAu: body.orbit.semiMajorAxisAu }
]),
...this.exoplanets.map((exoplanet): [string, { name: string; semiMajorAxisAu: number }] => [
exoplanet.id,
{ name: exoplanet.name, semiMajorAxisAu: exoplanet.orbit?.semiMajorAxisAu ?? 0 }
])
]);
const position = new THREE.Vector3();
const points: Array<LabeledPoint & { semiMajorAxisAu: number }> = [];
for (const member of renderer.members) {
if (member.kind === 'moon') {
continue;
}
const record = records.get(member.id);
member.marker.getWorldPosition(position);
points.push({
id: member.id,
name: record?.name ?? member.id,
kind: member.kind === 'exoplanet' ? 'Exoplanet' : member.kind === 'dwarf' ? 'Dwarf Planet' : 'Planet',
semiMajorAxisAu: record?.semiMajorAxisAu ?? 0,
x: position.x,
y: position.y,
z: position.z
});
}
points.sort((a, b) => b.semiMajorAxisAu - a.semiMajorAxisAu);
// Bodies first, so a neighbour's name never takes the space one of this system's own would
// have had: `spreadLabels` keeps whichever candidate it reaches first.
this.labelOverlay?.update(this.spreadLabels([...points, ...this.neighbourLabels(camera)], camera, null));
}
/** Refreshes the readout panel for whichever scale the view is currently at. */
/** A zoom carried from one unit space into the other would frame nothing recognisable. */
private resetZoom(): void {
const camera = this.engine.getCamera();
if ((camera as THREE.OrthographicCamera).isOrthographicCamera) {
(camera as THREE.OrthographicCamera).zoom = 1;
}
}
/**
* Keeps the plan view's frame, its zoom limits and its star sizes in step with the camera.
*
* All three are functions of how far the camera is orbiting from its target, which is the one
* thing the flights already animate — so a system entered, left or flown between reframes
* itself under this projection with the easing the perspective flights have, and no camera
* rig knows anything about it.
*/
private frameProjection(camera: SceneCamera): void {
if (!this.controls) {
return;
}
if (this.engine.currentProjection !== 'orthographic') {
this.starField?.setProjection(null);
this.hostRings?.setProjection(null);
return;
}
const distance = camera.position.distanceTo(this.controls.target);
this.engine.frameOrthographic(distance);
// Zoom is what a wheel moves under this projection, so the orbit clamps have to be restated
// as the zoom levels that frame the same extents.
// A plain multiplier on the frame the distance already sets, bounded by a factor rather than
// by the orbit limits: those are in whichever unit space the view is in, and reading them on
// the frame the scene swaps from parsecs to astronomical units pins the zoom at the ratio
// between the two — which is how leaving a system used to land the view three kiloparsecs out.
this.controls.minZoom = 1 / PLAN_ZOOM_SPAN;
this.controls.maxZoom = PLAN_ZOOM_SPAN;
const halfHeight = this.engine.visibleHalfHeight(distance);
this.starField?.setProjection(halfHeight);
this.hostRings?.setProjection(halfHeight);
}
/**
* Switches between the perspective view and the plan: an orthographic projection looking down
* the plane the current scale is read against — the galactic plane out here, this system's own
* orbital plane inside one.
*
* Both halves matter and neither alone is "2D". The projection is what makes a circle a circle
* wherever it sits in the frame instead of an ellipse that leans away from the centre; the
* swing to face the plane is what makes that worth looking at. Orbiting still works afterwards,
* so the plan is where a plan view starts, not a cage.
*/
private applyProjection(plan: boolean): void {
if (!this.controls || !this.rig || this.engine.currentProjection === (plan ? 'orthographic' : 'perspective')) {
return;
}
const camera = this.engine.getCamera();
const target = this.controls.target.clone();
const distance = camera.position.distanceTo(target);
this.engine.setProjection(plan ? 'orthographic' : 'perspective', distance);
const next = this.engine.getCamera();
// OrbitControls holds one camera for the lifetime of the gesture state it keeps; handing it
// the other one keeps the target, the damping and the pointer bindings it already has.
this.controls.object = next;
this.rig = new CameraRigController(next, this.controls);
next.position.copy(camera.position);
next.up.copy(camera.up);
if (plan) {
// Straight down the plane's normal, from where the camera already was.
// Down the normal of the plane this scale is actually read against. Inside a system that
// is the system's own orbital plane; outside it, the galactic plane — whose normal is the
// north galactic pole, not the celestial one. Defaulting to the scene's own z would have
// looked down the Earth's rotation axis and called it the plane of the Galaxy.
const galactic = GALACTIC_BASIS_EQUATORIAL;
const inSystem = this.systemGroup.visible && this.systemRenderer;
const normal = inSystem
? new THREE.Vector3(0, 0, 1).applyQuaternion(this.systemRenderer!.referenceFrame)
: new THREE.Vector3(galactic.z.x, galactic.z.y, galactic.z.z);
next.position.copy(target).add(normal.multiplyScalar(distance));
if (inSystem) {
next.up.set(0, 1, 0).applyQuaternion(this.systemRenderer!.referenceFrame);
} else {
// Towards the galactic centre, so the plan is oriented the way the model is described.
next.up.set(galactic.x.x, galactic.x.y, galactic.x.z);
}
}
next.lookAt(target);
this.controls.update();
this.applyDisplay(this.display());
}
/**
* Shows or hides the layers that hold still between frames: the label layer and the system
* view's orbits and grid. The galaxy grids and deep-sky shell are crossfaded every frame
* instead, so their toggles live in `updateGalacticCrossfade`. The skybox is both: the
* crossfade rewrites its intensity while the galaxy is up, but the crossfade is parked in
* system view, where the sky is still on screen — so it is also set here, once, on toggle.
*/
private applyDisplay(display: HudDisplay): void {
if (this.labelOverlay) {
this.labelOverlay.domElement.style.display = display.labels ? '' : 'none';
}
this.systemRenderer?.setLayerVisibility({ orbits: display.orbits, grid: display.grid });
// The effect that calls this fires once at construction, before the engine has a scene.
if (this.engine.isInitialized) {
this.engine.getScene().backgroundIntensity = display.sky ? 1 - this.galacticStrength : 0;
}
}
private updateHud(camera: SceneCamera): void {
const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId);
if (this.systemGroup.visible && star) {
const planetCount = this.bodies.filter((body) => body.systemStarId === star.id && !body.parentBodyId).length + this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id).length;
const moonCount = this.bodies.filter((body) => body.systemStarId === star.id && body.parentBodyId).length;
const distancePc = Math.hypot(star.x, star.y, star.z);
const luminosity = luminosityOf(star);
this.hudEyebrow.set('System');
this.hudTitle.set(star.name);
this.hudSubtitle.set(star.spectralType ? `Spectral type ${star.spectralType}` : '');
this.hudReadouts.set([
{ label: 'Bodies', value: moonCount > 0 ? `${planetCount} + ${moonCount} moons` : `${planetCount}` },
// Suppressed for the Sun rather than printed as `0.00 pc`, which is arithmetically right
// and reads as a bug: the distance from here to here is not a measurement.
...(distancePc > 0 ? [{ label: 'Distance', value: formatParsecs(distancePc) }] : []),
{ label: 'Magnitude', value: star.magnitude.toFixed(2) },
...(luminosity !== null ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : [])
]);
this.hudNote.set('Orbits propagated from published elements to the current date.');
this.hudRange.set(formatAu(this.engine.visibleHalfHeight(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)) / Math.tan((this.engine.getPerspectiveCamera().fov * Math.PI) / 360)));
this.hudScale.set(this.scaleBarFor(camera, 'AU'));
return;
}
this.hudRange.set(formatParsecs(this.effectiveDistance(camera)));
this.hudScale.set(this.scaleBarFor(camera, 'pc'));
if (this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) {
this.hudEyebrow.set('Galactic Scale');
this.hudTitle.set('Milky Way');
this.hudSubtitle.set('Barred spiral galaxy · our own');
this.hudReadouts.set([
{ label: 'Sun to centre', value: `${(SUN_GALACTOCENTRIC_RADIUS_PC / 1000).toFixed(2)} kpc` },
{ label: 'Arms modelled', value: `${MILKY_WAY_ARMS.length}` },
{ label: 'Catalogued', value: `${this.stars.length} stars` }
]);
// Quotes the catalogue's own reach rather than a figure that has already been raised once.
this.hudNote.set(`Galactic structure is an illustrative model built on measured arm geometry — no catalogue holds the Galaxy’s stars. The ${this.stars.length} catalogued stars are real.`);
return;
}
this.hudEyebrow.set('Solar Neighbourhood');
this.hudTitle.set('Local Stars');
this.hudSubtitle.set('Hipparcos · Yale Bright Star · Gliese');
this.hudReadouts.set([
// Both numbers, because they differ: the catalogue is what the map knows and the first is
// what it draws. See `STAR_RENDER_BUDGET`.
{ label: 'Stars', value: this.starField && this.starField.drawnCount < this.stars.length ? `${this.starField.drawnCount} / ${this.stars.length}` : `${this.stars.length}` },
// The radius Gaia is surveyed to, not the edge of the map: the Hipparcos stars Gaia places
// further out are drawn where it places them.
{ label: 'Survey radius', value: `${SURVEY_EDGE_PC} pc` },
{ label: 'Exoplanets', value: `${this.exoplanets.length}` },
// The one thing the field itself cannot show: which of those points can be flown into.
{ label: 'Systems', value: `${this.enterableSystems}` }
]);
this.hudNote.set('Positions from measured parallaxes. Grid marks the galactic plane through the Sun.');
}
/** Where the current press started, so a drag can be told apart from a click. */
private readonly handlePointerDown = (event: PointerEvent): void => {
this.pointerDownAt = { x: event.clientX, y: event.clientY };
};
private readonly handleClick = (event: MouseEvent): void => {
if (this.rig?.isAnimating) {
return;
}
// The browser fires `click` on release however far the pointer travelled, and OrbitControls
// does not suppress it — so without this every drag-to-rotate that happens to finish over a
// star would launch a camera flight into its system.
const pressedAt = this.pointerDownAt;
this.pointerDownAt = null;
if (pressedAt && Math.hypot(event.clientX - pressedAt.x, event.clientY - pressedAt.y) > CLICK_DRAG_SLOP_PX) {
return;
}
const canvas = this.canvasRef().nativeElement;
const camera = this.engine.getCamera();
const rect = canvas.getBoundingClientRect();
const pointerNdc = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
this.raycaster.setFromCamera(pointerNdc, camera);
if (this.currentStarId === null) {
this.handleGalaxyClick(pointerNdc, camera);
} else {
this.handleSystemClick();
}
};
private handleGalaxyClick(pointerNdc: THREE.Vector2, camera: SceneCamera): void {
if (!this.starField) {
return;
}
// Screen-space rather than a raycast: the star field billboards in the vertex shader, so
// its CPU-side geometry is a single quad at the origin. See `StarFieldRenderer.pickAt`.
const starId = this.starField.pickAt(pointerNdc, camera, this.viewportAspect());
if (starId !== undefined) {
this.navigationStore.selectStar(starId);
}
}
/**
* Picks a body in the system view. Clicking one pins its card; clicking empty space unpins,
* which is also how the card is dismissed without aiming for its close control.
*
* This used to navigate straight to `/body/:id`. That tore down the system scene and the camera
* with it, so comparing two planets meant flying back into the system between each — the card
* shows the same numbers over the live view instead, and `Full view` still opens the route.
*/
private handleSystemClick(): void {
if (!this.systemRenderer) {
return;
}
const [hit] = this.raycaster.intersectObjects(this.systemRenderer.pickableObjects);
const member = hit ? this.systemRenderer.memberForObject(hit.object) : undefined;
this.pinnedBodyId = member ? member.id : null;
if (member) {
this.navigationStore.selectBody(member.id);
}
this.refreshObjectCard();
}
/**
* Hover preview, so a body's figures can be read without committing a click.
*
* The raycast is against the system's own handful of pickable meshes rather than the star field,
* so it stays cheap even on a software rasterizer — it is the rendering that is slow in that
* environment, not the picking. Skipped outside the system view and during a camera flight.
*/
private readonly handlePointerMove = (event: PointerEvent): void => {
if (!this.systemRenderer || !this.systemGroup.visible || this.rig?.isAnimating) {
return;
}
const canvas = this.canvasRef().nativeElement;
const rect = canvas.getBoundingClientRect();
const pointerNdc = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
this.raycaster.setFromCamera(pointerNdc, this.engine.getCamera());
const [hit] = this.raycaster.intersectObjects(this.systemRenderer.pickableObjects);
const hoveredId = (hit ? this.systemRenderer.memberForObject(hit.object) : undefined)?.id ?? null;
if (hoveredId === this.hoveredBodyId) {
return;
}
this.hoveredBodyId = hoveredId;
canvas.style.cursor = hoveredId ? 'pointer' : '';
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. Both run in a worker: a route
* to a star 236 pc away, or the range one would need, can take seconds, and on this thread the
* map would stop for as long. Only the latest request is shown; an earlier one still running
* when a new one is made is answered into the void.
*/
onRouteRequested({ fromId, toId, rangePc }: RouteRequest): void {
if (!this.routing) {
return;
}
const request = ++this.routeRequest;
this.routePending.set(true);
void this.routing.route(fromId, toId, rangePc, ROUTE_RANGE_CEILING_PC).then(
({ route, neededRangePc }) => {
if (request !== this.routeRequest) {
return;
}
this.routePending.set(false);
this.routeResult.set({
stars: route ? route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })) : [],
totalPc: route?.totalPc ?? 0,
neededRangePc
});
this.jumpLinks?.setRoute(route?.stars ?? [], (id) => this.starsById.get(id));
},
(error: unknown) => {
// A request replaced by a newer one is settled this way too; only the latest matters.
if (request !== this.routeRequest) {
return;
}
// Released rather than left saying "Plotting…" with the button held, so it can be tried again.
this.routePending.set(false);
console.error('Route could not be plotted.', error);
}
);
}
/**
* Rebuilds the drawn graph, which is the expensive half: every star's neighbours, once, and 3.7
* million links at 8 pc, so it is built in the worker. Only when the layer is on and the range
* has actually moved — the control emits per pixel dragged — and only the graph for the range
* last asked for is drawn, in whatever order the answers arrive.
*/
private refreshJumpLinks(): void {
if (!this.jumpLinks || !this.routing) {
return;
}
const rangePc = this.jumpRangePc();
if (!this.display().jumpLinks) {
if (this.drawnJumpRangePc !== null) {
this.jumpLinks.setSegments(new Float32Array(0));
this.drawnJumpRangePc = null;
}
return;
}
if (this.drawnJumpRangePc === rangePc) {
return;
}
this.drawnJumpRangePc = rangePc;
void this.routing.links(rangePc).then(
(segments) => {
if (this.drawnJumpRangePc === rangePc) {
this.jumpLinks?.setSegments(segments);
}
},
() => {
// Replaced by a newer range, or failed. Either way this range is not drawn, and must not be
// remembered as if it were, or asking for it again would be skipped.
if (this.drawnJumpRangePc === rangePc) {
this.drawnJumpRangePc = null;
}
}
);
}
/** 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;
this.cardBodyId = id;
this.objectCard.set(id === null ? undefined : buildBodyViewModel(id, { bodies: this.bodies, exoplanets: this.exoplanets, stars: this.stars }));
}
/** Clears the card and everything that would bring it straight back. */
private clearObjectCard(): void {
this.pinnedBodyId = null;
this.hoveredBodyId = null;
this.cardBodyId = null;
this.objectCard.set(undefined);
this.canvasRef().nativeElement.style.cursor = '';
}
dismissObjectCard(): void {
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);
void this.router.navigate(['/body', id]);
}
/** Reacts to `NavigationStore.selectedStarId` changes coming from any source (click/search). */
private reconcileSelection(selectedStarId: number | null): void {
if (this.busy || selectedStarId === this.currentStarId) {
return;
}
// An id the catalogue no longer holds — a bookmark to a Gaia row that a refresh renumbered
// or folded into a named star — has nowhere to fly to. It is cleared here rather than left
// to `enterSystem` to decline, because declining completes the transition, and completing
// re-reads the same id: the two would call each other until the stack ran out.
if (selectedStarId !== null && !this.starsById.has(selectedStarId)) {
this.navigationStore.selectStar(null);
return;
}
this.busy = true;
if (selectedStarId === null) {
this.exitToGalaxy(() => this.finishTransition());
} else if (this.currentStarId === null) {
this.enterSystem(selectedStarId, () => this.finishTransition());
} else {
// Star-to-star: exit the current system (short outward hop) then fly into the new one.
this.exitToGalaxy(() => this.enterSystem(selectedStarId, () => this.finishTransition()), true);
}
}
/** Re-checks the store in case the selection changed again while a transition was in flight. */
private finishTransition(): void {
this.busy = false;
this.reconcileSelection(this.navigationStore.selectedStarId());
// Only once the scene is settled back in parsec space can a scale request be honoured.
const pending = this.pendingLevel;
this.pendingLevel = null;
if (pending && !this.busy && this.currentStarId === null) {
this.flyToOverview(pending);
}
}
private enterSystem(starId: number, onComplete: () => void): void {
const star = this.starsById.get(starId);
if (!star || !this.rig) {
onComplete();
return;
}
const camera = this.engine.getCamera();
const starPc = new THREE.Vector3(star.x, star.y, star.z);
const direction = camera.position.clone().sub(this.controls!.target).normalize();
if (!Number.isFinite(direction.x) || direction.lengthSq() === 0) {
direction.set(0, 0.3, 1).normalize();
}
const approachPosition = starPc.clone().add(direction.clone().multiplyScalar(GALAXY_APPROACH_DISTANCE_PC));
this.rig.flyTo({ position: approachPosition, target: starPc }, APPROACH_DURATION_SECONDS, () => {
this.swapToSystemSpace(star, direction, onComplete);
});
}
private swapToSystemSpace(star: StarRecord, direction: THREE.Vector3, onComplete: () => void): void {
const camera = this.engine.getCamera();
this.systemRenderer?.dispose();
if (this.starMarker) {
this.systemGroup.remove(this.starMarker);
(this.starMarker.material as THREE.Material).dispose();
}
if (this.starGlow) {
this.systemGroup.remove(this.starGlow);
(this.starGlow.material as THREE.SpriteMaterial).dispose();
this.starGlow = undefined;
}
const systemBodies = this.bodies.filter((body) => body.systemStarId === star.id);
const systemExoplanets = this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id);
// The star's own position is the line of sight to it, which is the plane the archive
// measures exoplanet inclinations against. The Sun sits at the origin and has no
// exoplanets, so it has no meaningful direction and the renderer falls back.
// The star's luminosity, derived from its own catalogued magnitude and distance, is what
// decides how hot each body in the system is — and so what each of them looks like.
const hostLuminosity = luminosityOf(star);
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity);
this.systemGroup.add(this.systemRenderer.object);
this.applyDisplay(this.display());
// Framed against the grid's outer ring rather than the outermost orbit — the ring is always
// the wider of the two — and against the camera this scene actually has, so the margin holds
// whatever the window shape. Computed before the star, because how far away the star will be
// seen from is what decides how big its halo has to be to stay visible.
// Framed against the perspective camera whichever is active: the framing distance is what
// the orthographic frustum is then sized from, so both projections show the same extent.
const framingCamera = this.engine.getPerspectiveCamera();
const viewport = { fovDegrees: framingCamera.fov, aspect: framingCamera.aspect };
const framingDistance = systemFramingDistanceAu(this.systemRenderer.gridOuterRadiusAu, viewport);
const frameRadiusAu = systemFrameRadiusAu(framingDistance, viewport);
// Sized against this system's innermost orbit, so the star never swallows its own planets.
const starRadiusAu = starMarkerRadiusAu(this.systemRenderer.minTopLevelSemiMajorAxisAu);
this.starMarkerGeometry?.dispose();
this.starMarkerGeometry = new THREE.SphereGeometry(starRadiusAu, 24, 16);
const starMarkerMaterial = this.starMarkerMaterial.clone();
const starColor = colorIndexToRgb(star.colorIndex, star.spectralType);
if (star.id === SOL_STAR_ID) {
// The Sun is the only star we have (and could ever have) a real photograph of; every
// other point in the galaxy view is far too distant to be resolved as a disk.
starMarkerMaterial.map = loadCachedTexture(SUN_TEXTURE_PATH);
starMarkerMaterial.color.set(0xffffff);
this.starGlow = createGlowSprite(0xfff2c0, starGlowExtentAu(starRadiusAu, frameRadiusAu));
} else {
starMarkerMaterial.color.copy(starColor);
this.starGlow = createGlowSprite(starColor, starGlowExtentAu(starRadiusAu, frameRadiusAu, DIM_STAR_GLOW_SCALE));
}
this.starMarker = new THREE.Mesh(this.starMarkerGeometry, starMarkerMaterial);
this.systemGroup.add(this.starMarker, this.starGlow);
this.galaxyGroup.visible = false;
this.systemGroup.visible = true;
// Labels are CSS2D objects parented to the scene, not to galaxyGroup, so hiding the group
// does not hide them: without this the galaxy-scale star names stay pinned on screen,
// clumped over the system's star.
this.labelOverlay?.update([]);
// The perspective camera whichever one is live: it is where the depth range is reasoned,
// and the plan view re-derives its own from it every frame. Writing to the active camera
// put the astronomical-unit range on one that overwrites it, and the system clipped.
const depthCamera = this.engine.getPerspectiveCamera();
depthCamera.near = SYSTEM_NEAR_AU;
depthCamera.far = SYSTEM_FAR_AU;
depthCamera.updateProjectionMatrix();
this.controls!.minDistance = SYSTEM_MIN_DISTANCE_AU;
this.controls!.maxDistance = SYSTEM_MAX_DISTANCE_AU;
this.resetZoom();
this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) });
// Arrives along whichever direction the approach came from, then swings round to look down
// on this system's own orbital plane as it settles — so the swap stays continuous but the
// system is not presented edge-on. See `systemViewDirection`.
const viewDirection = systemViewDirection(this.systemRenderer.referenceFrame);
this.rig!.flyTo({ position: viewDirection.multiplyScalar(framingDistance), target: new THREE.Vector3(0, 0, 0) }, SETTLE_DURATION_SECONDS, () => {
this.currentStarId = star.id;
this.resolveNeighbours();
this.navigationStore.setViewLevel('system');
onComplete();
});
}
private exitToGalaxy(onComplete: () => void, isSwitchingSystems = false): void {
if (this.currentStarId === null || !this.rig) {
onComplete();
return;
}
const camera = this.engine.getCamera();
const direction = camera.position.clone().sub(this.controls!.target).normalize();
if (!Number.isFinite(direction.x) || direction.lengthSq() === 0) {
direction.set(0, 0.3, 1).normalize();
}
const exitingStarId = this.currentStarId;
this.rig.flyTo({ position: direction.clone().multiplyScalar(SYSTEM_EXIT_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) }, EXIT_DURATION_SECONDS, () => {
this.swapToGalaxySpace(exitingStarId, direction, isSwitchingSystems, onComplete);
});
}
private swapToGalaxySpace(exitingStarId: number, direction: THREE.Vector3, isSwitchingSystems: boolean, onComplete: () => void): void {
const camera = this.engine.getCamera();
const star = this.starsById.get(exitingStarId);
const starPc = star ? new THREE.Vector3(star.x, star.y, star.z) : GALAXY_OVERVIEW_TARGET.clone();
this.systemGroup.visible = false;
this.galaxyGroup.visible = true;
// The bodies it described are no longer on screen, and a stale pin would otherwise survive
// into the next system entered.
this.clearObjectCard();
// The perspective camera whichever one is live: it is where the depth range is reasoned,
// and the plan view re-derives its own from it every frame. Writing to the active camera
// put the astronomical-unit range on one that overwrites it, and the system clipped.
const depthCamera = this.engine.getPerspectiveCamera();
depthCamera.near = GALAXY_NEAR_PC;
depthCamera.far = GALAXY_FAR_PC;
depthCamera.updateProjectionMatrix();
this.controls!.minDistance = GALAXY_MIN_DISTANCE_PC;
this.controls!.maxDistance = GALAXY_MAX_DISTANCE_PC;
this.resetZoom();
this.rig!.setImmediate({ position: starPc.clone().add(direction.clone().multiplyScalar(GALAXY_APPROACH_DISTANCE_PC)), target: starPc });
if (isSwitchingSystems) {
this.currentStarId = null;
this.resolveNeighbours();
onComplete();
return;
}
this.rig!.flyTo({ position: GALAXY_OVERVIEW_POSITION.clone(), target: GALAXY_OVERVIEW_TARGET.clone() }, RETURN_DURATION_SECONDS, () => {
this.currentStarId = null;
this.resolveNeighbours();
this.navigationStore.setViewLevel('galaxy');
onComplete();
});
}
private observeResize(canvas: HTMLCanvasElement): void {
this.resizeObserver = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
this.engine.resize(width, height);
this.labelOverlay?.setSize(width, height);
});
this.resizeObserver.observe(canvas);
}
}