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