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, VIEW_MARGIN } from './star-field-renderer'; import { BrightnessIndex, brightestWithin, brightnessIndex } from '../../shared/astro/brightest'; import { LinkBudget } from '../../shared/astro/jump-links'; 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, since a drag * emits per pixel; and how often at most a view on the move gets a graph for its new drawn stars. */ const JUMP_LINK_REBUILD_DELAY_MS = 250; /** * Whether a graph asked for with one budget still serves another: the same, unless the view has * zoomed by more than half its margin or its centre has moved by more than a fifth of the * neighbourhood drawn whole. */ function servesTheSame(asked: LinkBudget | undefined, now: LinkBudget | undefined): boolean { if (!asked || !now) { return asked === now; } const moved = Math.hypot(now.centre.x - asked.centre.x, now.centre.y - asked.centre.y, now.centre.z - asked.centre.z); return Math.abs(now.lengthPc / asked.lengthPc - 1) <= VIEW_MARGIN / 2 && moved <= STAR_FIELD_REFOCUS_PC; } /** * How much jump-link line the layer draws, in pixels of length on screen: about a million, measured * where lines are longest. * * What a graph costs to draw is its length on screen, not its number of links: every pixel of it is * blended over whatever is already there. On the Ryzen 7700X's integrated Radeon, standing in for an * entry-level laptop, at 1920 × 1080 with the range at 8 pc: * - near the Sun, about 10 ms a frame per million pixels. At 30 pc from it, 25 000 links were 4.8 * million pixels and 60 ms; 5 000 were 0.9 million and 18 ms, about 55 frames a second; * - at the opening view, where the links are short, 100 000 links were 1.8 million pixels and 12 ms. * * So a count could not serve both: the budget is a length, turned into parsecs at the depth the view * is centred on, and spent on the links nearest that centre. The RTX 4080 draws every graph in the * same 6 ms, but the budget is the same everywhere, like the stars'. */ const JUMP_LINK_PIXEL_BUDGET = 1_000_000; /** * 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; /** * The furthest 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. Closer in, half the frame's margin is the tighter limit. See `refocusStarField`. */ 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: `
@if (objectCard(); as card) { }
` }) export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { private readonly canvasRef = viewChild.required>('canvas'); private readonly labelHostRef = viewChild.required>('labelHost'); private readonly leaderRef = viewChild.required>('leader'); private readonly objectCardRef = viewChild>(SystemObjectCardComponent, { read: ElementRef }); private readonly dockRef = viewChild>(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 hudNote = signal(''); readonly hudRange = signal(''); readonly hudScale = signal(null); /** Which layers are drawn, as toggled from the dock. Applied by `applyDisplay`. */ readonly display = signal(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(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; /** * The view the star field last chose its stars for, and which it was told to keep. See * `refocusStarField`. `undefined` chooses again on the next pass; `null` means the last choice * was made at galactic scale, for the whole sky. */ private starFieldCamera: SceneCamera | null | undefined; private readonly starFieldQuaternion = new THREE.Quaternion(); private readonly starFieldFocus = new THREE.Vector3(); private starFieldHalfHeight = 0; private starFieldPins = ''; private readonly starFieldView = new THREE.Matrix4(); /** 1 for each catalogue index with known planets, which the star field draws ahead of the rest in view. */ private hostStars = new Uint8Array(0); 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(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(() => { 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 and the stars the drawn graph was last asked for, so a rebuild is skipped when neither moved. */ private drawnJumpRangePc: number | null = null; private linkedStars: Uint32Array | null = null; private linkedBudget: LinkBudget | undefined; /** Counts graph requests, so a rejection can tell whether it is for the latest one. */ private linkRequest = 0; 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 }[] = []; /** * 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(); /** 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([]); 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(); 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. effect(() => { this.jumpRangePc(); this.display().jumpLinks; this.scheduleJumpLinks(); }); } 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 { 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([...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); this.hostStars = Uint8Array.from(stars, (star) => (this.starIdsWithBodies.has(star.id) ? 1 : 0)); 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); // A flight turns and zooms far faster than a label pass: the return from a system zooms out // forty-fold in a second. So while one is under way the drawn stars are checked every frame, // and chosen again whenever the view has used up half the margin. if (this.rig?.isAnimating) { this.refocusStarField(camera); } } this.labelUpdateAccumulator += deltaSeconds; if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) { this.labelUpdateAccumulator = 0; if (this.galaxyGroup.visible) { this.refocusStarField(camera); 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 the camera shows: the ones the map is pointing at wherever they * are, then of what is in frame, the planet hosts, the neighbourhoods of the view's centre and of * the Sun, and the brightest. See `selectDrawnStars`. * * Chosen for a frame widened by `VIEW_MARGIN`, and chosen again, at the label cadence, once the * view could have used up half that margin: turned, zoomed or moved by half of it, switched * projection or resized. So a turn slower than a margin every two passes, about 25° a second, * brings no empty edge into view. Two things still outrun it, measured and accepted: stars much * nearer the camera than the view's centre, which an orbit sweeps across the frame faster than it * turns, and deep stars under a zoomed-in plan view, which a turn moves by their depth. Flights are * checked every frame instead of every pass; the galactic scale gets the whole sky. */ private refocusStarField(camera: SceneCamera): void { if (!this.starField || !this.neighbourhood) { return; } const selectedId = this.navigationStore.selectedStarId(); const pinnedIds = [...(selectedId === null ? [] : [selectedId]), ...(this.routeResult()?.stars.map((star) => star.id) ?? [])]; const pins = pinnedIds.join(); // 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); const centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET; let chose = false; // At galactic scale the whole catalogue is a smudge a few pixels across, and the view sweeps // hundreds of parsecs a pass: chosen once for the whole sky on the way out, then left alone, // rather than frozen on whatever narrow frame the zoom-out last passed through. if (this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) { if (this.starFieldCamera !== null || pins !== this.starFieldPins) { this.starField.refocus({ centre, pinned: pinned(), hosts: this.hostStars }); this.starFieldCamera = null; this.starFieldPins = pins; chose = true; } } else { const halfHeight = this.engine.visibleHalfHeight(camera.position.distanceTo(centre)); const perspective = this.engine.getPerspectiveCamera(); // The narrower of the frame's two half-extents: on a portrait screen the width, where the // same share of margin is the fewest degrees and the fewest parsecs. const narrowing = Math.min(1, perspective.aspect); const marginPc = (VIEW_MARGIN / 2) * halfHeight * narrowing; // The turn that moves a star at the frame's edge half the margin further out. Under a plan // view a turn moves a star by its depth times the angle instead; the survey edge stands in // for the depth of the stars drawn. const tanHalfFov = Math.tan((perspective.fov * Math.PI) / 360) * narrowing; let turnLimit = (Math.atan((1 + VIEW_MARGIN) * tanHalfFov) - Math.atan(tanHalfFov)) / 2; if (this.engine.currentProjection === 'orthographic') { turnLimit = Math.min(turnLimit, marginPc / (SURVEY_EDGE_PC + centre.length())); } const held = camera === this.starFieldCamera && pins === this.starFieldPins && camera.quaternion.angleTo(this.starFieldQuaternion) <= turnLimit && Math.abs(halfHeight / this.starFieldHalfHeight - 1) <= VIEW_MARGIN / 2 && this.starFieldFocus.distanceTo(centre) <= Math.min(STAR_FIELD_REFOCUS_PC, marginPc); if (!held) { // The tick runs before the frame is drawn, so the camera's matrices can still be last frame's. camera.updateMatrixWorld(); this.starFieldView.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); this.starField.refocus({ centre, pinned: pinned(), hosts: this.hostStars, view: this.starFieldView }); this.starFieldCamera = camera; this.starFieldQuaternion.copy(camera.quaternion); this.starFieldFocus.copy(centre); this.starFieldHalfHeight = halfHeight; this.starFieldPins = pins; chose = true; } } // The graph links the drawn stars, and spends its budget around the view's centre, so a view that // has moved may want a new one; `refreshJumpLinks` asks only if the stars or the budget changed. // Not one per pass while the view keeps moving, and not one pushed back by every pass either, or // an orbit would never get one: at most one every `JUMP_LINK_REBUILD_DELAY_MS`. if (chose && this.jumpLinkRebuild === undefined) { this.scheduleJumpLinks(); } } 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 { 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, 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([ ...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 = []; 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(() => { 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 graph once the range and the drawn stars have held still. */ private scheduleJumpLinks(): void { clearTimeout(this.jumpLinkRebuild); this.jumpLinkRebuild = setTimeout(() => { this.jumpLinkRebuild = undefined; this.refreshJumpLinks(); }, JUMP_LINK_REBUILD_DELAY_MS); } /** * Rebuilds the drawn graph: the links between the stars the field is drawing, so what is linked * is what can be seen and clicked. Hundreds of thousands of links at 8 pc, so it is built in the * worker, and only when the layer is on and the range or the drawn stars have actually changed. * * An answer is drawn if it is for the range last asked for, even when the drawn stars have moved * on since: the client answers in the order it was asked, so it is never older than the graph on * screen, and holding out for the latest set would draw nothing while the view keeps moving. */ private refreshJumpLinks(): void { if (!this.jumpLinks || !this.routing || !this.starField) { return; } const rangePc = this.jumpRangePc(); if (!this.display().jumpLinks) { if (this.drawnJumpRangePc !== null) { this.jumpLinks.setSegments(new Float32Array(0)); this.drawnJumpRangePc = null; this.linkedStars = null; this.linkedBudget = undefined; } return; } // Asked for in parsec space only: inside a system the camera and its centre are in astronomical // units about the system's own origin, which would make a budget of the wrong size in the wrong // place. The flight back out chooses the drawn stars again, and that asks. if (!this.galaxyGroup.visible) { return; } const drawn = this.starField.drawnStars; const budget = this.jumpLinkBudget(); if (this.drawnJumpRangePc === rangePc && this.linkedStars === drawn && servesTheSame(this.linkedBudget, budget)) { return; } this.drawnJumpRangePc = rangePc; this.linkedStars = drawn; this.linkedBudget = budget; const request = ++this.linkRequest; void this.routing.links(rangePc, drawn, budget).then( (segments) => { if (this.drawnJumpRangePc === rangePc) { this.jumpLinks?.setSegments(segments); } }, () => { // Replaced by a newer request, or failed. Only the latest request's rejection means no graph // is on its way; then nothing is remembered as drawn, so asking again is not skipped. An older // one's says nothing about the request that replaced it, which may ask the same thing. if (request === this.linkRequest) { this.drawnJumpRangePc = null; this.linkedStars = null; this.linkedBudget = undefined; } } ); } /** * How much of the graph to draw: the links nearest the view's centre, up to the length that * `JUMP_LINK_PIXEL_BUDGET` pixels of line make at that depth. None without a canvas to measure. */ private jumpLinkBudget(): LinkBudget | undefined { // In the pixels the lines are drawn in, not in CSS pixels: a scaled or HiDPI screen draws more of them. const heightPx = this.canvasRef().nativeElement.clientHeight * this.engine.getRenderer().getPixelRatio(); if (heightPx === 0) { return undefined; } const centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET; const halfHeight = this.engine.visibleHalfHeight(this.engine.getCamera().position.distanceTo(centre)); return { centre: { x: centre.x, y: centre.y, z: centre.z }, lengthPc: (JUMP_LINK_PIXEL_BUDGET * 2 * halfHeight) / heightPx }; } /** 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); // A new shape of frame: the stars chosen for the old one no longer fill it. this.starFieldCamera = undefined; }); this.resizeObserver.observe(canvas); } }