import { AfterViewInit, Component, computed, effect, ElementRef, OnDestroy, signal, viewChild, } from '@angular/core'; import { Router } from '@angular/router'; import * as THREE from 'three/webgpu'; import { normalView, positionViewDirection, texture, uniform } from 'three/tsl'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { GALACTIC_BASIS_EQUATORIAL, MILKY_WAY_ARMS, SUN_GALACTOCENTRIC_RADIUS_PC, galacticCentrePositionPc, galacticToEquatorial, } from '../../shared/astro/galaxy'; import { spectralClassification } from '../../shared/astro/spectral'; import { blackbodyColor } from '../../shared/astro/stellar'; import { DataLoaderService } from '../../core/data/data-loader.service'; import { EngineService, SceneCamera } from '../../core/engine/engine.service'; import { BodyRecord, RotationalElements } from '../../shared/models/body.model'; import { SUN_ROTATIONAL_ELEMENTS } from '../../shared/astro/rotational-elements'; import { bodyOrientation } from '../../shared/rendering/body-orientation'; import { DeepSkyRecord } from '../../shared/models/deepsky.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { applyMilkyWaySkybox } 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, SUN_STAR_ID } from '../../shared/models/star.model'; import { Bookmark } from '../../shared/state/bookmarks.store'; import { NavigationStore, ViewLevel } from '../../shared/state/navigation.store'; import { TimeStore } from '../../shared/state/time.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 { closestApproachAu, NEIGHBOUR_RING_FRACTION, SUN_RADIUS_AU, systemFrameRadiusAu, systemFramingDistanceAu, systemViewDirection, } from './system-framing'; import { formatAu, 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, publishedTemperaturesK, starSurfaceOf, StarSurface } 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, entrySubtitle, 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 { 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'; import { catalogueCensus, positionsNote, starReadouts, starSubtitle } from './star-readouts'; /** Radius, in CSS pixels, below which a body in the system view is scaled up to be seen at all. */ const MIN_MARKER_PIXELS = 3; /** * What a star nothing gives a radius for is drawn at: 150 km, far under the pixel floor from any * distance the camera can reach, so it is the floor's point, the size of no star in particular. * Drawn at the Sun's radius, PSR J1719-1438 — a neutron star, 10 km across — swallowed the planet * it holds at 0.0044 AU, and Procyon B, a white dwarf of 0.012 R☉, was drawn 81 times too wide. */ const UNMEASURED_STAR_RADIUS_AU = 1e-6; /** * The linear limb-darkening coefficient: a star's surface is I(μ) = I(1) (1 − u (1 − μ)) bright, * where μ is the cosine of the angle between the line of sight and the surface normal. The Sun's * is about 0.6 in the visible, so its limb is 40 % as bright as its centre. Taken for every star, * although a hotter star's limb is somewhat brighter and a cooler one's darker. */ const LIMB_DARKENING = 0.6; /** * The surface every star is drawn with: the Sun's photograph in grey, in `tint` — the colour of a * blackbody at the star's temperature — and darkened towards the limb. Unlit: it is the source. * * The pattern is the Sun's, standing in for a surface no telescope resolves on another star, at a * contrast turned down to the Sun's own (see `SUN_TEXTURE_PATH`). Its colour was taken out, so * the tint says what colour the star is rather than which filter the Sun was photographed in: the * Sun itself comes out the warm white of 5 772 K, not the pack's orange. * * The tint is a uniform, so every star shares one shader, compiled on the first system entry. */ function starSurfaceMaterial(tint: THREE.Node<'color'>): THREE.MeshBasicNodeMaterial { const material = new THREE.MeshBasicNodeMaterial(); const mu = normalView.dot(positionViewDirection).clamp(0, 1); material.colorNode = texture(loadCachedTexture(SUN_TEXTURE_PATH)) .rgb.mul(tint) .mul(mu.sub(1).mul(LIMB_DARKENING).add(1)); return material; } /** * 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 a ring label has to sit from a star's name, in NDC — half what two star names keep * between them, as a clearance around the anchor and as the height of the row its text occupies. * A ring label is one short line, and the rungs of its ladder are a twentieth of the screen apart, * so the full separation would have one name clear three rungs. */ const RING_LABEL_CLEARANCE_NDC = LABEL_MIN_SEPARATION_NDC / 2; /** How far right of its point a label's text reaches, in aspect-scaled NDC (~135px at 1440). */ const LABEL_REACH_NDC = 0.3; /** * The same for a ring label, which is shorter: "1.5 kpc" with "Survey edge" under it is the widest * of them, about 100px at 1440. Measuring those as a star name's width rejected rungs a hand's * breadth clear of it. */ const RING_LABEL_REACH_NDC = 0.23; /** * 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 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; /** * How many rings the local grid aims for: the step is rounded down from a fifth of how far the * frame reaches from the Sun, which makes five to fourteen of them. So 50 to 350 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_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, at least, before swapping back to galaxy/parsec space. A camera * already beyond it flies half as far again: a phone held upright frames the Sun's system from 508 AU, * and flying to 400 drew the system 21 per cent nearer while the reader was leaving it. */ const SYSTEM_EXIT_DISTANCE_AU = 400; const SYSTEM_EXIT_PULL_BACK = 1.5; 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(); /** The colour of the system's star, set on entering it; see `starSurfaceMaterial`. */ private readonly starTint = uniform(new THREE.Color(1, 1, 1)); /** One for every star, built on the first system entry, so its pipeline is compiled once. */ private starMarkerMaterial?: THREE.MeshBasicNodeMaterial; /** Rebuilt per system, since every star has its own radius. */ 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); /** The date the sky is drawn for — worth printing once the clock is no longer the world's. */ readonly hudDate = signal(''); /** 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: entrySubtitle(entry) }], ); }); 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[] = []; /** The neighbourhood's subtitle: what the catalogue holds, by the catalogue describing it. */ private catalogueCensus = ''; private positionsNote = ''; 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; /** The system's star's radius and temperature, worked out once on entering it. */ private currentStarSurface?: StarSurface; /** How the star marker is turned: the Sun's IAU elements for the Sun, nothing for any other star. */ private starRotation?: RotationalElements; constructor( private readonly engine: EngineService, private readonly dataLoader: DataLoaderService, private readonly router: Router, readonly navigationStore: NavigationStore, readonly time: TimeStore, ) { 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.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.catalogueCensus = catalogueCensus(stars); this.positionsNote = positionsNote(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, 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, publishedTemperaturesK(exoplanets), ); // Ringed, and drawn ahead of the brightness tier, only inside the survey edge. The 2 883 hosts // past it, 2 878 of them placed by the archive and 916 beyond a kiloparsec, mostly the Kepler // field's, were a band of 1 687 fixed-size rings over the opening view's lower right, on the // 250-350 pc grid labels, and read as neighbours; they still compete for the budget by brightness, and search // still enters them. const ringed = (star: StarRecord): boolean => this.starIdsWithBodies.has(star.id) && Math.hypot(star.x, star.y, star.z) <= SURVEY_EDGE_PC; this.hostStars = Uint8Array.from(stars, (star) => (ringed(star) ? 1 : 0)); this.galaxyGroup.add(this.starField.object); this.hostRings = new HostStarRings(stars.filter(ringed), 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(0, 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(this.time.julianDate()); if (this.starMarker && this.starRotation) { bodyOrientation(this.starRotation, this.time.julianDate(), this.starMarker.quaternion); } this.keepMarkersLegible(camera); } this.updateSelectionMark(camera); this.updateNeighbourRing(camera); this.labelOverlay?.render(camera); } /** * Holds every body in the system view to a minimum size on screen, by scaling the markers that * would otherwise be smaller than {@link MIN_MARKER_PIXELS}. * * A system is framed to hold its outermost orbit, and at that distance the bodies on the inner * ones are sub-pixel: at the solar system's arrival distance Jupiter projects to about a pixel * and Earth to less, so the labels and the selection arcs point at nothing. The halo used to * cover the star's half of this — a light that reached past the innermost orbit, claiming * brightness rather than size — but it covered the star only, and at a fixed extent that filled * the screen once the camera closed in. * * Sizing in pixels instead keeps the exaggeration where it is needed and takes it away where it * is not: a body whose true radius already spans more than the floor is drawn at that radius, so * zooming in walks back to the real proportions rather than away from them. */ private keepMarkersLegible(camera: SceneCamera): void { const heightPx = this.canvasRef().nativeElement.clientHeight; if (!this.systemRenderer || heightPx === 0) { return; } const world = new THREE.Vector3(); const drawnRadiusAu = new Map(); // A body's marker is a unit sphere scaled to its radius, kept in `userData.radiusAu`; the // star's is built at its own. const sphereRadius = (marker: THREE.Object3D): number | undefined => ((marker as THREE.Mesh).geometry as THREE.SphereGeometry | undefined)?.parameters?.radius; const radiusOf = (marker: THREE.Object3D): number | undefined => (marker.userData['radiusAu'] as number | undefined) ?? sphereRadius(marker); const floorFor = (marker: THREE.Object3D): number => { marker.getWorldPosition(world); return ( MIN_MARKER_PIXELS * ((2 * this.engine.visibleHalfHeight(camera.position.distanceTo(world))) / heightPx) ); }; // Parents first: a moon's ceiling is its planet's drawn radius, which has to be known by then. const members = [...this.systemRenderer.members].sort( (a, b) => Number(a.kind === 'moon') - Number(b.kind === 'moon'), ); for (const { id, marker, parentId } of this.starMarker ? [...members, { id: 'star', marker: this.starMarker, parentId: undefined }] : members) { const radiusAu = radiusOf(marker); if (!radiusAu) { continue; } // Lifted to the floor, but never past half of what it orbits: at the arrival framing every // body is sub-pixel, and floored on its own a moon comes out the size of its planet and // sitting on top of it — which is the thing true scale was adopted to stop. const ceiling = parentId !== undefined ? (drawnRadiusAu.get(parentId) ?? Number.POSITIVE_INFINITY) / 2 : Number.POSITIVE_INFINITY; const drawn = Math.min(Math.max(radiusAu, floorFor(marker)), Math.max(radiusAu, ceiling)); drawnRadiusAu.set(id, drawn); marker.scale.setScalar(drawn / sphereRadius(marker)!); } } /** * 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(); } } /** * The ring labels worth drawing: the ones on screen, and clear of the star names already placed. * * Not held apart from each other, as the star names are: they are a ladder up one ray, a few * hundredths of the screen apart, and reading them in order is the point. What they must not do * is sit on a star's name, which is worth more than a distance — or be handed to the overlay * from beside the camera, which CSS2DRenderer places past the edge of the container rather than * hiding, since all it tests is depth. * * A name is a line of text hanging to one side of its point, about 135 px of it, not the point: * two anchors a tenth of the screen apart still print one inside the other. So the test is * against the span the name occupies, with the anchors' own clearance kept for the pair whose * text runs the other way. */ private ringLabelsInTheClear( candidates: readonly LabeledPoint[], camera: SceneCamera, stars: readonly LabeledPoint[], ): LabeledPoint[] { const projected = new THREE.Vector3(); const onScreen = (label: LabeledPoint): THREE.Vector2 | null => { projected.set(label.x, label.y, label.z).project(camera); const outside = projected.z < -1 || projected.z > 1 || Math.abs(projected.x) > 1 || Math.abs(projected.y) > 1; return outside ? null : new THREE.Vector2(projected.x * this.viewportAspect(), projected.y); }; const taken = stars .map((star) => ({ at: onScreen(star), side: star.side })) .filter( (name): name is { at: THREE.Vector2; side: LabelSide | undefined } => name.at !== null, ) .map(({ at, side }) => ({ at, from: side === 'left' ? at.x - LABEL_REACH_NDC : at.x, to: side === 'left' ? at.x : at.x + LABEL_REACH_NDC, })); return candidates.filter((label) => { const point = onScreen(label); // Ring labels hang right, as `applyPresentation` leaves anything with no side of its own. return ( point !== null && !taken.some( (name) => name.at.distanceTo(point) < RING_LABEL_CLEARANCE_NDC || (Math.abs(name.at.y - point.y) < RING_LABEL_CLEARANCE_NDC && name.from < point.x + RING_LABEL_REACH_NDC && point.x < name.to), ) ); }); } private updateLabels(camera: SceneCamera): void { 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; // The rings are distances from the Sun, so what they have to cover is how far from the Sun the // frame reaches: where the view is centred, plus how far out the camera is orbiting it. Under // the plan view the orbit distance is the frame's own extent, since that is what the wheel // moves there. Read as "how far the camera is from the Sun" instead, panning away from the Sun // and flipping to the plan view left every ring off the frame. // Rebuilt only while the grid is drawn: each new set disposes the old rings and builds every // vertex of the new ones, and the set changes on any zoom that crosses a round step. if (!isGalactic && this.display().grid) { const orbitPc = this.engine.currentProjection === 'perspective' ? camera.position.distanceTo(target) : this.effectiveDistance(camera); // Half the frame's diagonal, at the depth it is centred on: how near the Sun the frame // reaches, as well as how far. A step sized to the far edge alone is no use to a frame that // does not contain the Sun — 20 pc rings for a view of a 19 pc band at 190 pc drew none of // them on screen, and the ladder of labels went with them. const frameRadiusPc = this.engine.visibleHalfHeight(orbitPc) * Math.hypot(1, this.viewportAspect()); // Measured in the plane the rings lie in, not through it: a ring of radius r passes within // `|r - p|` of the view's centre, where p is how far out the centre is *along the plane*. For // a target above it the two differ by its height, which would put the band around a radius no // ring has — and `ringLabels` compares its own in-plane bearing against the innermost. const normal = galacticNormal(); const inPlanePc = target.clone().addScaledVector(normal, -target.dot(normal)).length(); const radii = distanceRings( Math.max(0, inPlanePc - frameRadiusPc), inPlanePc + orbitPc, LOCAL_GRID_RING_COUNT, SURVEY_EDGE_PC, ); if (radii.join() !== this.localGridRadii.join()) { this.setLocalGridRadii(radii); } } // 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.ringLabelsInTheClear(this.ringLabels(camera), camera, starLabels); 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_FRACTION, { 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); // On the label cadence rather than per frame: at a month a second the date changes faster // than anyone can read it, and the HUD does not need to re-render sixty times a second to // say so. this.hudDate.set(this.time.atNow() ? '' : this.time.date().toISOString().slice(0, 10)); 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; this.hudEyebrow.set('System'); this.hudTitle.set(star.name); this.hudSubtitle.set(starSubtitle(star)); this.hudReadouts.set([ { label: 'Bodies', value: moonCount > 0 ? `${planetCount} + ${moonCount} moons` : `${planetCount}`, }, ...starReadouts(star, this.currentStarSurface), ]); // Where the orbits come from, and for the Sun how far from the present they hold: each // body's card names its own source, and for a moon or an SBDB dwarf planet how far it strays from // Horizons over the span it was checked. const source = this.bodies.some((body) => body.systemStarId === star.id) ? 'JPL mean elements, the planets’ fit for 3000 BC to AD 3000 and the moons’ checked from 1950 to 2100, and the SBDB’s osculating ones for Ceres, Eris, Haumea and Makemake, checked over the same span,' : 'published elements'; // Named to the minute, in UTC like the date field: a jump to 18:00 on a given day is a // question about that hour, and the note is where the answer says which sky it is. const drawnFor = `${this.time.date().toISOString().slice(0, 16).replace('T', ' ')} UTC`; this.hudNote.set(`Orbits propagated from ${source} to ${this.time.atNow() ? 'now, ' : ''}${drawnFor}.`); 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(this.catalogueCensus); 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(this.positionsNote); } /** 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: spectralClassification(star) } : 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, gaveUp, least }) => { 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, gaveUp, least, }); 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); } 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. // Every star at its own radius: the archive's for a planet host, otherwise derived from its // colour and brightness — or a point, for the 2 858 stars with no measured magnitude or with // neither a colour nor a type, which the card then gives no radius. Its temperature is the // colour of its disc and of the light it casts, and its luminosity — the archive's, or else // derived from its magnitude and distance — decides how hot each body in the system is, and // so what each of them looks like. this.currentStarSurface = starSurfaceOf(star, systemExoplanets); this.systemRenderer = new SystemOrbitsRenderer( systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, this.currentStarSurface.luminositySolar, this.currentStarSurface.temperatureK, ); this.systemGroup.add(this.systemRenderer.object); this.applyDisplay(this.display()); const starRadiusAu = this.currentStarSurface.radiusSolar === null ? UNMEASURED_STAR_RADIUS_AU : this.currentStarSurface.radiusSolar * SUN_RADIUS_AU; // Framed against the outermost thing drawn — the grid's outer ring, or an eccentric orbit's // aphelion where it runs past it — or against the star, for a giant wider than both; and // against the camera this scene actually has, so the margin holds whatever the window shape. // 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 canvas = this.canvasRef().nativeElement; const viewport = { fovDegrees: framingCamera.fov, aspect: framingCamera.aspect, shorterSidePx: Math.min(canvas.clientWidth, canvas.clientHeight) }; const framingDistance = systemFramingDistanceAu( this.systemRenderer.outermostRadiusAu, viewport, starRadiusAu, ); this.starMarkerGeometry?.dispose(); this.starMarkerGeometry = new THREE.SphereGeometry(starRadiusAu, 64, 32); this.starMarkerMaterial ??= starSurfaceMaterial(this.starTint); // Grey, the photograph's own, where there is no temperature: the Sun's colour would say it is one. const temperatureK = this.currentStarSurface.temperatureK; const [red, green, blue] = temperatureK === null ? [1, 1, 1] : blackbodyColor(temperatureK); this.starTint.value.setRGB(red, green, blue, THREE.LinearSRGBColorSpace); // No halo. It was a sprite sized against the arrival frame — 1.12 AU for the Sun — so it // stayed put as the camera closed in and ended up filling the screen with the flat gradient // that was meant to dress the star, over the photograph underneath it. this.starMarker = new THREE.Mesh(this.starMarkerGeometry, this.starMarkerMaterial); // Its pole 115 degrees from the one the IAU gives, and still, until it was turned like a planet. // The map's longitudes are Solar System Scope's, not Carrington's, so only the pole and the // 25.38-day turn are the Sun's own. this.starRotation = star.id === SUN_STAR_ID ? SUN_ROTATIONAL_ELEMENTS : undefined; this.systemGroup.add(this.starMarker); 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 = closestApproachAu(starRadiusAu); 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(Math.max(SYSTEM_EXIT_DISTANCE_AU, SYSTEM_EXIT_PULL_BACK * camera.position.length())), 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); } }