Merge main, carrying the observatory instrument onto its rebuilt chrome
Two HUD redesigns happened in parallel: this branch restyled the old chrome as a precision observatory instrument, while main rebuilt the chrome against screenshots of the Star Citizen starmap — a scale ladder, a nameplate, a readout panel, and two-line labels that say what a thing is, not just what it is called. This merge keeps everything main's rebuild learned and says it in this branch's voice. Kept: the ladder's reachability semantics and test hooks, the two-line name/kind labels and their shared declutter logic, system-view body labels, every readout. Restyled: chamfered clip-path panels become hairline frames with corner-tick brackets; Orbitron is gone and one readout face carries the hierarchy; the hexagon reticle becomes the same circle-and-ticks mark the search field wears; focus states move to real focus-visible outlines; the long uppercase caveat notes drop to sentence case so they read as sentences. Two merge-borne fixes along the way: the labels' translate offset moved into .map-label as a margin (CSS2DRenderer rewrites the inline transform every frame, the margin is the offset it cannot touch), and the info panel's new Derived section picked up the horizontal padding it lost when the panel moved to per-block padding. Verified: build clean, 496/496 unit, 6/6 e2e (on a free port — 4300 is occupied on this machine), design detector clean, screenshots reviewed at 1440x900 and 390x844 across all four views plus search states. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -6,8 +6,12 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||
import { EngineService } from '../../core/engine/engine.service';
|
||||
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
||||
import { EARTH_RADIUS_KM } from '../../shared/astro/planet-appearance';
|
||||
import { luminositySolar } from '../../shared/astro/stellar';
|
||||
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
|
||||
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, proceduralBodyTexture, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
||||
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
||||
import { BodyRecord } from '../../shared/models/body.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
@@ -15,15 +19,9 @@ import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
import { BodyDetailViewModel } from './body-detail.model';
|
||||
import { InfoPanelComponent } from './info-panel.component';
|
||||
|
||||
const KIND_COLORS: Record<BodyDetailViewModel['kind'], THREE.ColorRepresentation> = {
|
||||
planet: 0x8cbfff,
|
||||
moon: 0xbfbfbf,
|
||||
dwarf: 0xccb28c,
|
||||
exoplanet: 0xd966d9
|
||||
};
|
||||
|
||||
/** Gas giants read as smoother/less rocky than terrestrial bodies under the same lighting rig. */
|
||||
const GAS_GIANT_IDS = new Set(['jupiter', 'saturn', 'uranus', 'neptune']);
|
||||
/** The body is drawn at unit radius here, so the halo's extent is its multiple directly. */
|
||||
const GLOW_SCALE = 2.6;
|
||||
|
||||
/**
|
||||
@@ -140,19 +138,24 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
kind: body.kind,
|
||||
hostStarName: hostStar?.name ?? 'Unknown star',
|
||||
radiusKm: body.radiusKm,
|
||||
orbit: body.orbit
|
||||
orbit: body.orbit,
|
||||
appearance: appearanceForBody(body, this.bodies, this.luminosityOf(hostStar)),
|
||||
hasPhotography: bodyTexturePath(body.id) !== undefined
|
||||
});
|
||||
this.navigationStore.selectStar(body.systemStarId);
|
||||
} else if (exoplanet) {
|
||||
const hostStar = this.stars.find((star) => star.id === exoplanet.hostStarId);
|
||||
this.viewModel.set({
|
||||
id: exoplanet.id,
|
||||
name: exoplanet.name,
|
||||
kind: 'exoplanet',
|
||||
hostStarName: exoplanet.hostStarName,
|
||||
radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * 6371 : undefined,
|
||||
radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined,
|
||||
massEarth: exoplanet.massEarth,
|
||||
discoveryYear: exoplanet.discoveryYear,
|
||||
orbit: exoplanet.orbit
|
||||
orbit: exoplanet.orbit,
|
||||
appearance: appearanceForExoplanet(exoplanet, this.luminosityOf(hostStar)),
|
||||
hasPhotography: bodyTexturePath(exoplanet.id) !== undefined
|
||||
});
|
||||
if (exoplanet.hostStarId !== null) {
|
||||
this.navigationStore.selectStar(exoplanet.hostStarId);
|
||||
@@ -170,20 +173,33 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The host star's luminosity in solar units, from its own catalogued magnitude and distance.
|
||||
* `null` for an exoplanet whose host never cross-referenced to the star catalogue, which
|
||||
* leaves its planets with no derived temperature rather than a guessed one.
|
||||
*/
|
||||
private luminosityOf(star: StarRecord | undefined): number | null {
|
||||
if (!star) {
|
||||
return null;
|
||||
}
|
||||
return luminositySolar({ magnitude: star.magnitude, distancePc: Math.hypot(star.x, star.y, star.z), spectralType: star.spectralType });
|
||||
}
|
||||
|
||||
private applyViewModelToScene(): void {
|
||||
const viewModel = this.viewModel();
|
||||
if (!viewModel || !this.planetMaterial) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Real photography wherever it exists, and a surface derived from the body's own measured
|
||||
// properties wherever it does not — which is every exoplanet, since none has ever been
|
||||
// imaged, and the handful of moons no probe returned a usable map of.
|
||||
const realTexturePath = bodyTexturePath(viewModel.id);
|
||||
const texture = realTexturePath ? loadCachedTexture(realTexturePath) : proceduralBodyTexture(KIND_COLORS[viewModel.kind]);
|
||||
this.planetMaterial.map = texture ?? null;
|
||||
// A texture (real photo or procedural stand-in) supplies its own color; a plain white base
|
||||
// keeps that color true instead of tinting it through `KIND_COLORS` a second time. If no
|
||||
// texture is available at all (e.g. canvas rendering unsupported), fall back to the flat kind color.
|
||||
this.planetMaterial.color.set(texture ? 0xffffff : KIND_COLORS[viewModel.kind]);
|
||||
this.planetMaterial.roughness = GAS_GIANT_IDS.has(viewModel.id) ? 0.55 : 0.85;
|
||||
this.planetMaterial.map = realTexturePath ? loadCachedTexture(realTexturePath) : planetTexture(viewModel.appearance);
|
||||
// The texture supplies its own colour, so the base stays white rather than tinting it twice.
|
||||
this.planetMaterial.color.set(0xffffff);
|
||||
// A fluid envelope scatters light more evenly than a solid surface does.
|
||||
this.planetMaterial.roughness = GAS_GIANT_IDS.has(viewModel.id) || viewModel.appearance.palette.structure === 'banded' ? 0.55 : 0.85;
|
||||
this.planetMaterial.needsUpdate = true;
|
||||
|
||||
this.disposeRing();
|
||||
@@ -195,7 +211,7 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
const atmosphereColor = atmosphereColorFor(viewModel.id);
|
||||
if (atmosphereColor !== undefined) {
|
||||
this.glow = createGlowSprite(atmosphereColor, 1, GLOW_SCALE);
|
||||
this.glow = createGlowSprite(atmosphereColor, GLOW_SCALE);
|
||||
this.scene.add(this.glow);
|
||||
}
|
||||
}
|
||||
@@ -284,7 +300,9 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||
const geometry = new THREE.SphereGeometry(1, 64, 48);
|
||||
const viewModel = this.viewModel();
|
||||
this.planetMaterial = new THREE.MeshStandardMaterial({
|
||||
color: viewModel ? KIND_COLORS[viewModel.kind] : 0xffffff,
|
||||
// White, always: the map that arrives a moment later carries the colour, whether it is a
|
||||
// photograph or a surface derived from the body's own measurements.
|
||||
color: 0xffffff,
|
||||
roughness: 0.85,
|
||||
metalness: 0.05
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
||||
import { OrbitalElements } from '../../shared/models/body.model';
|
||||
|
||||
export type BodyDetailKind = 'planet' | 'moon' | 'dwarf' | 'exoplanet';
|
||||
@@ -16,4 +17,13 @@ export interface BodyDetailViewModel {
|
||||
massEarth?: number;
|
||||
discoveryYear?: number;
|
||||
orbit: Partial<OrbitalElements>;
|
||||
/**
|
||||
* What this world is inferred to look like, and the quantities that inference rests on. Always
|
||||
* present — every body has measurements enough to place it somewhere — but its individual
|
||||
* fields are nullable, since a body whose host star is not in the catalogue has no derived
|
||||
* temperature.
|
||||
*/
|
||||
appearance: PlanetAppearance;
|
||||
/** True when a real photograph is being shown rather than the derived surface. */
|
||||
hasPhotography: boolean;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DecimalPipe } from '@angular/common';
|
||||
import { Component, input } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { PLANET_CLASS_LABELS } from '../../shared/astro/planet-appearance';
|
||||
import { BodyDetailViewModel } from './body-detail.model';
|
||||
|
||||
const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
|
||||
@@ -78,6 +79,28 @@ const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">Derived</p>
|
||||
<dl class="divide-y divide-border/25">
|
||||
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
||||
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Class</dt>
|
||||
<dd class="text-sm">{{ classLabel() }}</dd>
|
||||
</div>
|
||||
@if (body().appearance.equilibriumTemperatureK !== null) {
|
||||
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
||||
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Equilibrium temp.</dt>
|
||||
<dd class="text-sm tabular-nums">{{ body().appearance.equilibriumTemperatureK | number: '1.0-0' }} <span class="text-muted">K</span></dd>
|
||||
</div>
|
||||
}
|
||||
@if (body().appearance.bulkDensityGramsPerCm3 !== null) {
|
||||
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
||||
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Bulk density</dt>
|
||||
<dd class="text-sm tabular-nums">{{ body().appearance.bulkDensityGramsPerCm3 | number: '1.0-2' }} <span class="text-muted">g/cm³</span></dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<p class="border-t border-border/40 px-4 py-3 text-[10px] leading-relaxed text-muted">{{ surfaceProvenance() }}</p>
|
||||
</div>
|
||||
`,
|
||||
imports: [DecimalPipe]
|
||||
@@ -91,6 +114,25 @@ export class InfoPanelComponent {
|
||||
return KIND_LABELS[this.body().kind];
|
||||
}
|
||||
|
||||
classLabel(): string {
|
||||
return PLANET_CLASS_LABELS[this.body().appearance.planetClass];
|
||||
}
|
||||
|
||||
/**
|
||||
* Says plainly which of the two the viewer is looking at. The derived surface is a reasoned
|
||||
* illustration, and a panel of real measurements sitting next to it is exactly the context in
|
||||
* which it could be mistaken for another one.
|
||||
*/
|
||||
surfaceProvenance(): string {
|
||||
if (this.body().hasPhotography) {
|
||||
return 'Surface: NASA/ESA/USGS photography.';
|
||||
}
|
||||
const temperature = this.body().appearance.equilibriumTemperatureK;
|
||||
return temperature === null
|
||||
? 'Surface illustrated from this body’s measured size and mass. Its host star is not in the catalogue, so no temperature could be derived. Not an observation — no image of this world exists.'
|
||||
: 'Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — no image of this world exists.';
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
void this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
||||
import { backdropPosition, backdropSpriteSizePc, BACKDROP_RADIUS_PC, brightnessBandIndex, deepSkyLabelPoints, DeepSkyRenderer } from './deep-sky-renderer';
|
||||
|
||||
function record(overrides: Partial<DeepSkyRecord> = {}): DeepSkyRecord {
|
||||
return {
|
||||
id: 'NGC0224',
|
||||
name: 'Andromeda Galaxy',
|
||||
kind: 'galaxy',
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
angularSizeDeg: 2.96,
|
||||
magnitude: 3.44,
|
||||
distancePc: null,
|
||||
distanceMethod: null,
|
||||
constellation: 'And',
|
||||
messier: 'M31',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('backdropPosition', () => {
|
||||
it('pushes the direction out to the shell radius', () => {
|
||||
const position = backdropPosition(record({ x: 0, y: 0, z: 1 }));
|
||||
expect(position.z).toBeCloseTo(BACKDROP_RADIUS_PC, 9);
|
||||
expect(position.length()).toBeCloseTo(BACKDROP_RADIUS_PC, 9);
|
||||
});
|
||||
|
||||
it('preserves direction for an off-axis object', () => {
|
||||
const direction = new THREE.Vector3(0.3, -0.5, 0.81).normalize();
|
||||
const position = backdropPosition(record({ x: direction.x, y: direction.y, z: direction.z }));
|
||||
|
||||
expect(position.length()).toBeCloseTo(BACKDROP_RADIUS_PC, 6);
|
||||
expect(position.clone().normalize().dot(direction)).toBeCloseTo(1, 9);
|
||||
});
|
||||
|
||||
it('honours an explicit radius', () => {
|
||||
expect(backdropPosition(record(), 100).length()).toBeCloseTo(100, 9);
|
||||
});
|
||||
|
||||
it('lands inside the galaxy camera frustum from anywhere on its orbit', () => {
|
||||
// The camera orbits at most 2000 pc out and its far plane is 5000 pc, so the far side of
|
||||
// the shell has to stay within reach or the backdrop would be clipped away.
|
||||
expect(BACKDROP_RADIUS_PC).toBeGreaterThan(2000);
|
||||
expect(BACKDROP_RADIUS_PC + 2000).toBeLessThan(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backdropSpriteSizePc', () => {
|
||||
it('scales with true angular size', () => {
|
||||
const small = backdropSpriteSizePc(record({ angularSizeDeg: 1 }));
|
||||
const large = backdropSpriteSizePc(record({ angularSizeDeg: 2 }));
|
||||
expect(large).toBeGreaterThan(small);
|
||||
});
|
||||
|
||||
it('reproduces the real angular size in the unclamped range', () => {
|
||||
// 2 degrees at the shell radius: r * theta.
|
||||
const expected = BACKDROP_RADIUS_PC * 2 * (Math.PI / 180);
|
||||
expect(backdropSpriteSizePc(record({ angularSizeDeg: 2 }))).toBeCloseTo(expected, 6);
|
||||
});
|
||||
|
||||
it('floors sub-arcminute objects so they stay visible', () => {
|
||||
const tiny = backdropSpriteSizePc(record({ angularSizeDeg: 0 }));
|
||||
expect(tiny).toBeGreaterThan(0);
|
||||
expect(tiny).toBe(backdropSpriteSizePc(record({ angularSizeDeg: 0.001 })));
|
||||
});
|
||||
|
||||
it('caps very extended objects so they cannot blanket the view', () => {
|
||||
const huge = backdropSpriteSizePc(record({ angularSizeDeg: 90 }));
|
||||
const larger = backdropSpriteSizePc(record({ angularSizeDeg: 180 }));
|
||||
expect(huge).toBe(larger);
|
||||
});
|
||||
});
|
||||
|
||||
describe('brightnessBandIndex', () => {
|
||||
it('puts the brightest objects in the most opaque band', () => {
|
||||
expect(brightnessBandIndex(3.44)).toBe(0);
|
||||
});
|
||||
|
||||
it('separates mid and faint objects into later bands', () => {
|
||||
expect(brightnessBandIndex(6)).toBe(1);
|
||||
expect(brightnessBandIndex(9)).toBe(2);
|
||||
});
|
||||
|
||||
it('is monotonic in magnitude', () => {
|
||||
const bands = [0, 3, 5, 6, 7.5, 9, 14].map(brightnessBandIndex);
|
||||
expect([...bands].sort((a, b) => a - b)).toEqual(bands);
|
||||
});
|
||||
|
||||
it('treats an unphotometered object as faintest rather than brightest', () => {
|
||||
expect(brightnessBandIndex(null)).toBe(brightnessBandIndex(99));
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepSkyLabelPoints', () => {
|
||||
const records = [record({ id: 'a', name: 'A' }), record({ id: 'b', name: 'B' }), record({ id: 'c', name: 'C' })];
|
||||
|
||||
it('takes a prefix of the (magnitude-sorted) records', () => {
|
||||
expect(deepSkyLabelPoints(records, 2).map((point) => point.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('anchors each label on the backdrop shell', () => {
|
||||
const [point] = deepSkyLabelPoints(records, 1);
|
||||
expect(Math.hypot(point.x, point.y, point.z)).toBeCloseTo(BACKDROP_RADIUS_PC, 6);
|
||||
});
|
||||
|
||||
it('carries the display name and the catalog id', () => {
|
||||
const [point] = deepSkyLabelPoints(records, 1);
|
||||
expect(point).toMatchObject({ id: 'a', name: 'A' });
|
||||
});
|
||||
|
||||
it('never returns more labels than there are records', () => {
|
||||
expect(deepSkyLabelPoints(records, 99)).toHaveLength(3);
|
||||
expect(deepSkyLabelPoints([], 5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeepSkyRenderer', () => {
|
||||
it('adds one sprite per record', () => {
|
||||
const renderer = new DeepSkyRenderer([record({ id: 'a' }), record({ id: 'b' })]);
|
||||
expect(renderer.object.children).toHaveLength(2);
|
||||
expect(renderer.object.children.every((child) => child instanceof THREE.Sprite)).toBe(true);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('positions and scales each sprite from its record', () => {
|
||||
const only = record({ angularSizeDeg: 2 });
|
||||
const renderer = new DeepSkyRenderer([only]);
|
||||
const sprite = renderer.object.children[0] as THREE.Sprite;
|
||||
|
||||
expect(sprite.position.length()).toBeCloseTo(BACKDROP_RADIUS_PC, 6);
|
||||
expect(sprite.scale.x).toBeCloseTo(backdropSpriteSizePc(only), 6);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('shares one material across objects of the same kind and brightness', () => {
|
||||
const renderer = new DeepSkyRenderer([
|
||||
record({ id: 'a', kind: 'galaxy', magnitude: 3 }),
|
||||
record({ id: 'b', kind: 'galaxy', magnitude: 4 })
|
||||
]);
|
||||
const [first, second] = renderer.object.children as THREE.Sprite[];
|
||||
|
||||
expect(first.material).toBe(second.material);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('gives different kinds different materials', () => {
|
||||
const renderer = new DeepSkyRenderer([
|
||||
record({ id: 'a', kind: 'galaxy', magnitude: 3 }),
|
||||
record({ id: 'b', kind: 'nebula', magnitude: 3 }),
|
||||
record({ id: 'c', kind: 'cluster', magnitude: 3 })
|
||||
]);
|
||||
const materials = new Set((renderer.object.children as THREE.Sprite[]).map((sprite) => sprite.material));
|
||||
|
||||
expect(materials.size).toBe(3);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('gives different brightness bands different materials', () => {
|
||||
const renderer = new DeepSkyRenderer([
|
||||
record({ id: 'a', kind: 'galaxy', magnitude: 3 }),
|
||||
record({ id: 'b', kind: 'galaxy', magnitude: 9 })
|
||||
]);
|
||||
const [bright, faint] = renderer.object.children as THREE.Sprite[];
|
||||
|
||||
expect(bright.material).not.toBe(faint.material);
|
||||
expect(bright.material.opacity).toBeGreaterThan(faint.material.opacity);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('keeps the material count bounded no matter how many objects there are', () => {
|
||||
const many = Array.from({ length: 200 }, (_, index) =>
|
||||
record({ id: `obj-${index}`, kind: (['galaxy', 'nebula', 'cluster'] as const)[index % 3], magnitude: index % 12 })
|
||||
);
|
||||
const renderer = new DeepSkyRenderer(many);
|
||||
const materials = new Set((renderer.object.children as THREE.Sprite[]).map((sprite) => sprite.material));
|
||||
|
||||
expect(renderer.object.children).toHaveLength(200);
|
||||
// Three kinds x three brightness bands.
|
||||
expect(materials.size).toBeLessThanOrEqual(9);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('renders behind the star field', () => {
|
||||
const renderer = new DeepSkyRenderer([record()]);
|
||||
expect(renderer.object.renderOrder).toBeLessThan(0);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('disposes its materials and empties the group', () => {
|
||||
const renderer = new DeepSkyRenderer([record({ id: 'a' }), record({ id: 'b', kind: 'nebula' })]);
|
||||
const materials = (renderer.object.children as THREE.Sprite[]).map((sprite) => sprite.material);
|
||||
const disposed = materials.map((material) => {
|
||||
let seen = false;
|
||||
material.addEventListener('dispose', () => (seen = true));
|
||||
return () => seen;
|
||||
});
|
||||
|
||||
renderer.dispose();
|
||||
|
||||
expect(renderer.object.children).toHaveLength(0);
|
||||
expect(disposed.every((wasDisposed) => wasDisposed())).toBe(true);
|
||||
});
|
||||
|
||||
it('handles an empty catalog', () => {
|
||||
const renderer = new DeepSkyRenderer([]);
|
||||
expect(renderer.object.children).toHaveLength(0);
|
||||
expect(renderer.labelPoints(5)).toEqual([]);
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
|
||||
import { DeepSkyKind, DeepSkyRecord } from '../../shared/models/deepsky.model';
|
||||
import { createGlowTexture } from '../../shared/rendering/skybox';
|
||||
import { LabeledPoint } from './star-label-overlay';
|
||||
|
||||
/**
|
||||
* Radius (parsecs) of the shell the backdrop is painted on.
|
||||
*
|
||||
* Chosen to sit clear of the local star field it is a backdrop for: well outside its 50 pc
|
||||
* radius, and close enough that the far side of the shell stays inside the local view's far
|
||||
* plane rather than being clipped away.
|
||||
*
|
||||
* The shell only makes sense from inside it — it is the sky as seen from the Sun, with every
|
||||
* object's true distance flattened onto one radius. The camera can now pull back far past it to
|
||||
* the galactic scale, so {@link DeepSkyRenderer.setStrength} fades it out on the way rather than
|
||||
* letting the view fly through a wall of nebulae.
|
||||
*/
|
||||
export const BACKDROP_RADIUS_PC = 2500;
|
||||
|
||||
/**
|
||||
* Apparent-size clamps (parsecs at {@link BACKDROP_RADIUS_PC}) for a backdrop sprite. The floor
|
||||
* is generous — most catalog objects are a few arcminutes across, and at this shell radius that
|
||||
* is a pixel or two — so they read as haze rather than as another star.
|
||||
*/
|
||||
const MIN_SPRITE_SIZE_PC = 70;
|
||||
const MAX_SPRITE_SIZE_PC = 340;
|
||||
|
||||
const DEGREES_TO_RADIANS = Math.PI / 180;
|
||||
|
||||
/** Loosely evocative of each class's real appearance in long-exposure photography. */
|
||||
const KIND_COLORS: Readonly<Record<DeepSkyKind, number>> = {
|
||||
galaxy: 0xffd9a0,
|
||||
nebula: 0xff86b0,
|
||||
cluster: 0xa8c8ff
|
||||
};
|
||||
|
||||
/**
|
||||
* Opacity bands by apparent magnitude. Sprites share a material per (kind, band), so
|
||||
* brightness is quantised rather than continuous — nine materials instead of one per object,
|
||||
* which keeps 400-odd backdrop sprites cheap to build and dispose.
|
||||
*/
|
||||
const BRIGHTNESS_BANDS: readonly { maxMagnitude: number; opacity: number }[] = [
|
||||
{ maxMagnitude: 5, opacity: 0.5 },
|
||||
{ maxMagnitude: 7.5, opacity: 0.3 },
|
||||
{ maxMagnitude: Infinity, opacity: 0.16 }
|
||||
];
|
||||
|
||||
/**
|
||||
* Where a deep-sky object lands on the backdrop shell. The record stores a unit direction,
|
||||
* so this is just that direction pushed out to the shell radius.
|
||||
*/
|
||||
export function backdropPosition(record: DeepSkyRecord, radiusPc = BACKDROP_RADIUS_PC): THREE.Vector3 {
|
||||
return new THREE.Vector3(record.x, record.y, record.z).multiplyScalar(radiusPc);
|
||||
}
|
||||
|
||||
/**
|
||||
* On-shell size for an object, from its true angular size — so the backdrop reproduces the
|
||||
* real sky, where the Andromeda Galaxy is six times wider than the full Moon.
|
||||
*
|
||||
* Clamped at both ends: without a floor, the many sub-arcminute objects would be invisible
|
||||
* specks, and without a ceiling a handful of very extended objects would blanket the view.
|
||||
*/
|
||||
export function backdropSpriteSizePc(record: DeepSkyRecord, radiusPc = BACKDROP_RADIUS_PC): number {
|
||||
const trueSize = radiusPc * record.angularSizeDeg * DEGREES_TO_RADIANS;
|
||||
return THREE.MathUtils.clamp(trueSize, MIN_SPRITE_SIZE_PC, MAX_SPRITE_SIZE_PC);
|
||||
}
|
||||
|
||||
/** Index into {@link BRIGHTNESS_BANDS}; unphotometered objects fall into the faintest band. */
|
||||
export function brightnessBandIndex(magnitude: number | null): number {
|
||||
if (magnitude === null) {
|
||||
return BRIGHTNESS_BANDS.length - 1;
|
||||
}
|
||||
const index = BRIGHTNESS_BANDS.findIndex((band) => magnitude <= band.maxMagnitude);
|
||||
return index === -1 ? BRIGHTNESS_BANDS.length - 1 : index;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `limit` most prominent objects, as label anchors on the backdrop shell. Prominence is
|
||||
* apparent magnitude, which is the order the ETL already writes, so this is a prefix of the
|
||||
* records that actually have a name worth showing.
|
||||
*/
|
||||
export function deepSkyLabelPoints(
|
||||
records: readonly DeepSkyRecord[],
|
||||
limit: number,
|
||||
radiusPc = BACKDROP_RADIUS_PC
|
||||
): LabeledPoint[] {
|
||||
return records.slice(0, limit).map((record) => {
|
||||
const position = backdropPosition(record, radiusPc);
|
||||
return { id: record.id, name: record.name, kind: record.kind.toUpperCase(), x: position.x, y: position.y, z: position.z };
|
||||
});
|
||||
}
|
||||
|
||||
/** Material keys are `kind:band`; the band is what fixes an object's base opacity. */
|
||||
function bandIndexFromKey(key: string): number {
|
||||
return Number(key.slice(key.indexOf(':') + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints the notable deep-sky objects from `deepsky.json` onto a fixed shell around the star
|
||||
* field, as soft additive billboards coloured by kind and sized by real angular extent.
|
||||
*
|
||||
* Billboards rather than a single `THREE.Points` cloud: the WebGPU backend caps point
|
||||
* primitives at one pixel (see `StarFieldRenderer`), which would reduce the Orion Nebula to a
|
||||
* dot. Sprites cost one draw call each, so materials are shared across all of them and the
|
||||
* catalog is pre-filtered by the ETL to the few hundred objects actually worth drawing.
|
||||
*/
|
||||
export class DeepSkyRenderer {
|
||||
readonly object = new THREE.Group();
|
||||
|
||||
private readonly materials = new Map<string, THREE.SpriteMaterial>();
|
||||
|
||||
constructor(
|
||||
private readonly records: readonly DeepSkyRecord[],
|
||||
private readonly radiusPc = BACKDROP_RADIUS_PC
|
||||
) {
|
||||
// Drawn before the star field so the stars composite on top of the glow.
|
||||
this.object.renderOrder = -1;
|
||||
|
||||
for (const record of records) {
|
||||
const sprite = new THREE.Sprite(this.materialFor(record));
|
||||
sprite.position.copy(backdropPosition(record, this.radiusPc));
|
||||
sprite.scale.setScalar(backdropSpriteSizePc(record, this.radiusPc));
|
||||
this.object.add(sprite);
|
||||
}
|
||||
}
|
||||
|
||||
/** Label anchors for the brightest `limit` objects on this backdrop. */
|
||||
labelPoints(limit: number): LabeledPoint[] {
|
||||
return deepSkyLabelPoints(this.records, limit, this.radiusPc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scales the whole backdrop's opacity, keeping each object's brightness band relative to the
|
||||
* others. Used to dissolve the shell as the camera leaves the neighbourhood it belongs to.
|
||||
*/
|
||||
setStrength(strength: number): void {
|
||||
const clamped = THREE.MathUtils.clamp(strength, 0, 1);
|
||||
for (const [key, material] of this.materials) {
|
||||
material.opacity = BRIGHTNESS_BANDS[bandIndexFromKey(key)].opacity * clamped;
|
||||
}
|
||||
this.object.visible = clamped > 0;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const material of this.materials.values()) {
|
||||
material.dispose();
|
||||
}
|
||||
this.materials.clear();
|
||||
this.object.clear();
|
||||
}
|
||||
|
||||
private materialFor(record: DeepSkyRecord): THREE.SpriteMaterial {
|
||||
const band = brightnessBandIndex(record.magnitude);
|
||||
const key = `${record.kind}:${band}`;
|
||||
|
||||
let material = this.materials.get(key);
|
||||
if (!material) {
|
||||
const color = KIND_COLORS[record.kind];
|
||||
material = new THREE.SpriteMaterial({
|
||||
map: createGlowTexture(color, 'diffuse'),
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: BRIGHTNESS_BANDS[band].opacity,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
this.materials.set(key, material);
|
||||
}
|
||||
return material;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
|
||||
import { EngineService, EngineTickCallback } 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 { StarRecord } from '../../shared/models/star.model';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
@@ -26,6 +27,21 @@ const PROXIMA: StarRecord = { id: 2, name: 'Proxima Centauri', x: 0, y: 1.3, z:
|
||||
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
|
||||
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
||||
|
||||
const DEEP_SKY_OBJECT: DeepSkyRecord = {
|
||||
id: 'NGC0224',
|
||||
name: 'Andromeda Galaxy',
|
||||
kind: 'galaxy',
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
angularSizeDeg: 2.96,
|
||||
magnitude: 3.44,
|
||||
distancePc: null,
|
||||
distanceMethod: null,
|
||||
constellation: 'And',
|
||||
messier: 'M31'
|
||||
};
|
||||
|
||||
const EARTH: BodyRecord = {
|
||||
id: 'earth',
|
||||
systemStarId: SUN.id,
|
||||
@@ -99,6 +115,10 @@ class FakeDataLoaderService {
|
||||
loadExoplanets(): Promise<ExoplanetRecord[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
loadDeepSky(): Promise<DeepSkyRecord[]> {
|
||||
return Promise.resolve([DEEP_SKY_OBJECT]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits out several macrotask turns so chained promises (bootstrap's awaits) settle. */
|
||||
@@ -191,7 +211,13 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
|
||||
expect(component.galaxyGroup.visible).toBe(true);
|
||||
expect(component.systemGroup.visible).toBe(false);
|
||||
expect(engine.getCamera().near).toBeCloseTo(0.01, 9);
|
||||
// Parsec-scale rather than an exact figure: in galaxy space the depth range scales with how
|
||||
// far the camera has pulled back, so what identifies it is the far plane it settles on
|
||||
// (5000 pc) versus the AU-space one (20000 AU), not a fixed near plane.
|
||||
expect(engine.getCamera().far).toBeCloseTo(5000, 6);
|
||||
// The near plane tracks how far back the camera is rather than sitting at a constant, so
|
||||
// what identifies galaxy space is that it is a small fraction of that far plane.
|
||||
expect(engine.getCamera().near).toBeLessThan(engine.getCamera().far / 1000);
|
||||
expect(navigationStore.viewLevel()).toBe('galaxy');
|
||||
});
|
||||
|
||||
@@ -210,6 +236,59 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
expect(component.currentStarId).toBe(ALPHA_CENTAURI.id);
|
||||
});
|
||||
|
||||
it('reports the galactic scale once the camera has pulled back far enough, and comes back', async () => {
|
||||
const camera = engine.getCamera();
|
||||
|
||||
camera.position.set(0, 0, 30000);
|
||||
await advanceFrames(engine, 0.3);
|
||||
expect(navigationStore.viewLevel()).toBe('galactic');
|
||||
|
||||
camera.position.set(0, 15, 30);
|
||||
await advanceFrames(engine, 0.3);
|
||||
expect(navigationStore.viewLevel()).toBe('galaxy');
|
||||
});
|
||||
|
||||
it('widens the depth range as the camera pulls back, instead of holding one range for both scales', async () => {
|
||||
const camera = engine.getCamera();
|
||||
|
||||
await advanceFrames(engine, 0.3);
|
||||
const localFar = camera.far;
|
||||
|
||||
camera.position.set(0, 0, 30000);
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
expect(camera.far).toBeGreaterThan(localFar);
|
||||
// A near plane a hundredth of a parsec out has no precision left to spare at this range.
|
||||
expect(camera.near).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('flies out to the Galaxy when the scale ladder asks for it', async () => {
|
||||
const camera = engine.getCamera();
|
||||
fixture.componentInstance.goToLevel('galactic');
|
||||
await advanceFrames(engine, 3);
|
||||
|
||||
expect(camera.position.length()).toBeGreaterThan(10000);
|
||||
expect(navigationStore.viewLevel()).toBe('galactic');
|
||||
});
|
||||
|
||||
it('leaves the system first when the scale ladder is used from inside one', async () => {
|
||||
navigationStore.selectStar(SUN.id);
|
||||
await flushAsync();
|
||||
await advanceFrames(engine, 2.5);
|
||||
expect(navigationStore.viewLevel()).toBe('system');
|
||||
|
||||
fixture.componentInstance.goToLevel('galactic');
|
||||
await flushAsync();
|
||||
// Exit leg, then the return leg, then the galactic flight: the request has to wait out the
|
||||
// unit-space unwind rather than firing a parsec-scale flight while the scene is in AU.
|
||||
await advanceFrames(engine, 6);
|
||||
|
||||
const component = fixture.componentInstance as unknown as { currentStarId: number | null; systemGroup: THREE.Group };
|
||||
expect(component.currentStarId).toBeNull();
|
||||
expect(component.systemGroup.visible).toBe(false);
|
||||
expect(navigationStore.viewLevel()).toBe('galactic');
|
||||
});
|
||||
|
||||
it('ignores a new selection while a transition is already in flight, then resolves to the latest requested star once idle', async () => {
|
||||
navigationStore.selectStar(SUN.id);
|
||||
await flushAsync();
|
||||
|
||||
@@ -1,44 +1,115 @@
|
||||
import { AfterViewInit, Component, effect, ElementRef, OnDestroy, viewChild } from '@angular/core';
|
||||
import { AfterViewInit, Component, 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 { galacticCentrePositionPc, galacticToEquatorial, MILKY_WAY_ARMS, SUN_GALACTOCENTRIC_RADIUS_PC } from '../../shared/astro/galaxy';
|
||||
import { luminositySolar } from '../../shared/astro/stellar';
|
||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||
import { EngineService } 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 { StarRecord } from '../../shared/models/star.model';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
import { NavigationStore, ViewLevel } from '../../shared/state/navigation.store';
|
||||
import { CameraRigController } from './camera-rig-controller';
|
||||
import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
|
||||
import { StarLabelOverlay } from './star-label-overlay';
|
||||
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 { HudReadout, StarmapHudComponent } from './starmap-hud.component';
|
||||
import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
|
||||
import { LabeledPoint, 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;
|
||||
const SUN_GLOW_SCALE = 3.2;
|
||||
/** Stars drawn from a colour rather than a photograph get a more restrained halo. */
|
||||
const DIM_STAR_GLOW_SCALE = 0.6;
|
||||
|
||||
/** Stars closer than this to the camera get a name label (always includes the selection). */
|
||||
const LABEL_MAX_DISTANCE_PC = 20;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/** Raycast pick tolerance around each star point, in parsecs. */
|
||||
const PICK_THRESHOLD_PC = 1.2;
|
||||
/** Pointer travel (px) above which a press counts as an orbit drag rather than a selection. */
|
||||
const CLICK_DRAG_SLOP_PX = 5;
|
||||
|
||||
const GALAXY_OVERVIEW_POSITION = new THREE.Vector3(0, 15, 30);
|
||||
/**
|
||||
* 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;
|
||||
const GALAXY_MAX_DISTANCE_PC = 2000;
|
||||
/** 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;
|
||||
|
||||
/** Rings for the local grid (parsecs from the Sun), with the catalogue's edge called out. */
|
||||
const LOCAL_GRID_RINGS_PC = [50, 100, 150, 200, 250];
|
||||
const LOCAL_GRID_SPOKES = 12;
|
||||
/** 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;
|
||||
@@ -47,15 +118,34 @@ const SYSTEM_MAX_DISTANCE_AU = 5000;
|
||||
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 MIN_SYSTEM_FRAMING_DISTANCE_AU = 3;
|
||||
const MAX_SYSTEM_FRAMING_DISTANCE_AU = 80;
|
||||
|
||||
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;
|
||||
|
||||
const STAR_MARKER_RADIUS_AU = 0.2;
|
||||
/** Camera range for the readout panel, in the unit that suits the distance. */
|
||||
function formatParsecs(distancePc: number): string {
|
||||
return distancePc >= 1000 ? `${(distancePc / 1000).toFixed(1)} kpc` : `${distancePc.toFixed(distancePc < 10 ? 2 : 0)} pc`;
|
||||
}
|
||||
|
||||
function formatAu(distanceAu: number): string {
|
||||
return distanceAu >= 100 ? `${distanceAu.toFixed(0)} AU` : `${distanceAu.toFixed(2)} AU`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -67,22 +157,21 @@ const STAR_MARKER_RADIUS_AU = 0.2;
|
||||
@Component({
|
||||
selector: 'app-galaxy-system-scene',
|
||||
providers: [EngineService],
|
||||
imports: [StarmapHudComponent],
|
||||
template: `
|
||||
<div class="relative h-full w-full">
|
||||
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
|
||||
<div #labelHost class="absolute inset-0 overflow-hidden pointer-events-none"></div>
|
||||
@if (navigationStore.viewLevel() === 'system') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="exitSystem()"
|
||||
class="hud-brackets hud-acquire absolute top-20 left-4 flex items-center gap-2 border border-border/60 bg-panel/85 px-3 py-2 font-body text-[10px] tracking-[0.16em] text-muted uppercase backdrop-blur-md transition-colors hover:border-accent/70 hover:text-accent focus-visible:border-accent/70 focus-visible:text-accent focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-accent sm:top-4"
|
||||
>
|
||||
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 6l-6 6 6 6" />
|
||||
</svg>
|
||||
Galaxy
|
||||
</button>
|
||||
}
|
||||
<app-starmap-hud
|
||||
[level]="navigationStore.viewLevel()"
|
||||
[eyebrow]="hudEyebrow()"
|
||||
[title]="hudTitle()"
|
||||
[subtitle]="hudSubtitle()"
|
||||
[readouts]="hudReadouts()"
|
||||
[note]="hudNote()"
|
||||
[range]="hudRange()"
|
||||
(levelSelected)="goToLevel($event)"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
@@ -94,11 +183,31 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly galaxyGroup = new THREE.Group();
|
||||
private readonly systemGroup = new THREE.Group();
|
||||
private readonly starMarkerMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||
private readonly starMarkerGeometry = new THREE.SphereGeometry(STAR_MARKER_RADIUS_AU, 24, 16);
|
||||
/** 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('');
|
||||
|
||||
private controls?: OrbitControls;
|
||||
private rig?: CameraRigController;
|
||||
private starField?: StarFieldRenderer;
|
||||
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>();
|
||||
private milkyWay?: MilkyWayRenderer;
|
||||
private galacticLabels: readonly LabeledPoint[] = [];
|
||||
private galacticGrid?: PolarGridPlane;
|
||||
private localGrid?: PolarGridPlane;
|
||||
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>();
|
||||
@@ -107,8 +216,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
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;
|
||||
@@ -137,20 +249,52 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeTick?.();
|
||||
this.resizeObserver?.disconnect();
|
||||
this.canvasRef().nativeElement.removeEventListener('pointerdown', this.handlePointerDown);
|
||||
this.canvasRef().nativeElement.removeEventListener('click', this.handleClick);
|
||||
this.controls?.dispose();
|
||||
this.starField?.dispose();
|
||||
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.starMarkerGeometry?.dispose();
|
||||
this.starMarkerMaterial.dispose();
|
||||
this.engine.dispose();
|
||||
}
|
||||
|
||||
exitSystem(): void {
|
||||
this.navigationStore.selectStar(null);
|
||||
/**
|
||||
* 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> {
|
||||
@@ -182,25 +326,68 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.systemGroup.visible = false;
|
||||
applyMilkyWaySkybox(scene, MILKY_WAY_SKYBOX_PATH);
|
||||
|
||||
const [{ stars, positions }, bodies, exoplanets] = await Promise.all([
|
||||
const [{ stars, positions }, bodies, exoplanets, deepSky] = await Promise.all([
|
||||
this.dataLoader.loadStars(),
|
||||
this.dataLoader.loadBodies(),
|
||||
this.dataLoader.loadExoplanets()
|
||||
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.bodies = bodies;
|
||||
this.exoplanets = exoplanets;
|
||||
// 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);
|
||||
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search));
|
||||
this.galaxyGroup.add(this.starField.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.localGrid = new PolarGridPlane({
|
||||
ringRadii: LOCAL_GRID_RINGS_PC,
|
||||
spokeCount: LOCAL_GRID_SPOKES,
|
||||
emphasisRadii: [LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]]
|
||||
});
|
||||
// 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.localGrid.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);
|
||||
}
|
||||
|
||||
this.labelOverlay = new StarLabelOverlay(scene);
|
||||
this.labelHostRef().nativeElement.appendChild(this.labelOverlay.domElement);
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
this.labelOverlay.setSize(width, height);
|
||||
|
||||
this.raycaster.params.Points!.threshold = PICK_THRESHOLD_PC;
|
||||
canvas.addEventListener('pointerdown', this.handlePointerDown);
|
||||
canvas.addEventListener('click', this.handleClick);
|
||||
this.observeResize(canvas);
|
||||
|
||||
@@ -215,22 +402,91 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.rig?.update(deltaSeconds);
|
||||
this.controls?.update();
|
||||
|
||||
if (this.currentStarId === null) {
|
||||
this.labelUpdateAccumulator += deltaSeconds;
|
||||
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
|
||||
this.labelUpdateAccumulator = 0;
|
||||
this.updateLabels(camera);
|
||||
}
|
||||
// Gated on the galaxy group rather than on `currentStarId`, which is only assigned once the
|
||||
// arrival flight finishes. In between, the scene has already swapped to system space while
|
||||
// `currentStarId` is still null, so labels were being recomputed from galaxy-scale positions
|
||||
// and pinned over the system — the whole point of clearing them on the swap.
|
||||
if (this.galaxyGroup.visible) {
|
||||
// Per-frame, unlike the labels: this is a handful of uniform writes, and it is what keeps
|
||||
// the zoom continuous rather than stepping between two discrete scales.
|
||||
this.updateGalacticCrossfade(camera);
|
||||
}
|
||||
|
||||
this.systemRenderer?.update(dateToJulianDate());
|
||||
this.labelUpdateAccumulator += deltaSeconds;
|
||||
if (this.labelUpdateAccumulator >= LABEL_UPDATE_INTERVAL_SECONDS) {
|
||||
this.labelUpdateAccumulator = 0;
|
||||
if (this.galaxyGroup.visible) {
|
||||
this.updateLabels(camera);
|
||||
} else if (this.systemGroup.visible) {
|
||||
this.updateSystemLabels(camera);
|
||||
}
|
||||
this.updateHud(camera);
|
||||
}
|
||||
|
||||
if (this.systemGroup.visible) {
|
||||
this.systemRenderer?.update(dateToJulianDate());
|
||||
}
|
||||
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: THREE.PerspectiveCamera): void {
|
||||
if (!this.milkyWay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distancePc = camera.position.length();
|
||||
this.galacticStrength = this.milkyWay.setViewerDistancePc(distancePc);
|
||||
|
||||
this.galacticGrid?.setStrength(this.galacticStrength);
|
||||
this.localGrid?.setStrength(1 - this.galacticStrength);
|
||||
this.tethers?.setStrength(1 - this.galacticStrength);
|
||||
// The backdrop shell is the sky as seen from here; from outside it, it is a wall.
|
||||
this.deepSky?.setStrength(1 - this.galacticStrength);
|
||||
// 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 = 1 - this.galacticStrength;
|
||||
|
||||
this.applyGalaxyDepthRange(camera, 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.
|
||||
*/
|
||||
private applyGalaxyDepthRange(camera: THREE.PerspectiveCamera, 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);
|
||||
// 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 - camera.near) > camera.near * 0.05 || Math.abs(far - camera.far) > camera.far * 0.05) {
|
||||
camera.near = near;
|
||||
camera.far = far;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
|
||||
private updateLabels(camera: THREE.PerspectiveCamera): void {
|
||||
const selectedId = this.navigationStore.selectedStarId();
|
||||
const { x: cx, y: cy, z: cz } = camera.position;
|
||||
const maxDistanceSq = LABEL_MAX_DISTANCE_PC * LABEL_MAX_DISTANCE_PC;
|
||||
// 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 { x: cx, y: cy, z: cz } = target;
|
||||
const orbitDistance = (this.controls ? camera.position.distanceTo(target) : GALAXY_OVERVIEW_POSITION.length()) * LABEL_RADIUS_TO_ORBIT_DISTANCE;
|
||||
const labelRadius = THREE.MathUtils.clamp(orbitDistance, MIN_LABEL_RADIUS_PC, MAX_LABEL_RADIUS_PC);
|
||||
const maxDistanceSq = labelRadius * labelRadius;
|
||||
|
||||
const candidates: Array<{ star: StarRecord; distanceSq: number }> = [];
|
||||
for (const star of this.stars) {
|
||||
@@ -243,15 +499,196 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => a.distanceSq - b.distanceSq);
|
||||
this.labelOverlay?.update(candidates.slice(0, LABEL_MAX_COUNT).map((candidate) => candidate.star));
|
||||
// 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.
|
||||
candidates.sort((a, b) => a.star.magnitude - b.star.magnitude);
|
||||
// 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;
|
||||
// "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 actually go.
|
||||
const starLabels: LabeledPoint[] = isGalactic
|
||||
? []
|
||||
: this.spreadLabels(
|
||||
candidates.map(({ star }) => ({
|
||||
id: star.id,
|
||||
name: star.name,
|
||||
kind: this.starIdsWithBodies.has(star.id) ? 'System' : 'Star',
|
||||
x: star.x,
|
||||
y: star.y,
|
||||
z: star.z
|
||||
})),
|
||||
camera,
|
||||
selectedId
|
||||
);
|
||||
const backdropLabels = isGalactic ? this.galacticLabels : this.deepSkyLabels;
|
||||
this.labelOverlay?.update([...starLabels, ...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.
|
||||
*/
|
||||
private spreadLabels(candidates: readonly LabeledPoint[], camera: THREE.PerspectiveCamera, keepId: number | string | null): LabeledPoint[] {
|
||||
const placed: THREE.Vector2[] = [];
|
||||
const chosen: LabeledPoint[] = [];
|
||||
const projected = new THREE.Vector3();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (chosen.length >= LABEL_MAX_COUNT) {
|
||||
break;
|
||||
}
|
||||
|
||||
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 * camera.aspect, projected.y);
|
||||
if (!isKept && placed.some((other) => other.distanceTo(point) < LABEL_MIN_SEPARATION_NDC)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
placed.push(point);
|
||||
chosen.push(candidate);
|
||||
}
|
||||
|
||||
return chosen;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: THREE.PerspectiveCamera): 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);
|
||||
this.labelOverlay?.update(this.spreadLabels(points, camera, null));
|
||||
}
|
||||
|
||||
/** Refreshes the readout panel for whichever scale the view is currently at. */
|
||||
private updateHud(camera: THREE.PerspectiveCamera): 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;
|
||||
this.hudEyebrow.set('System');
|
||||
this.hudTitle.set(star.name);
|
||||
this.hudSubtitle.set(star.spectralType ? `Spectral type ${star.spectralType}` : '');
|
||||
this.hudReadouts.set([
|
||||
{ label: 'Bodies', value: `${planetCount}` },
|
||||
{ label: 'Distance', value: `${Math.hypot(star.x, star.y, star.z).toFixed(2)} pc` },
|
||||
{ label: 'Magnitude', value: star.magnitude.toFixed(2) }
|
||||
]);
|
||||
this.hudNote.set('Orbits propagated from published elements to the current date.');
|
||||
this.hudRange.set(formatAu(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)));
|
||||
return;
|
||||
}
|
||||
|
||||
this.hudRange.set(formatParsecs(camera.position.length()));
|
||||
|
||||
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 within ${LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]} pc 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}` },
|
||||
{ label: 'Radius', value: `${LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]} pc` },
|
||||
{ label: 'Exoplanets', value: `${this.exoplanets.length}` }
|
||||
]);
|
||||
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();
|
||||
@@ -259,18 +696,19 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.raycaster.setFromCamera(pointerNdc, camera);
|
||||
|
||||
if (this.currentStarId === null) {
|
||||
this.handleGalaxyClick();
|
||||
this.handleGalaxyClick(pointerNdc, camera);
|
||||
} else {
|
||||
this.handleSystemClick();
|
||||
}
|
||||
};
|
||||
|
||||
private handleGalaxyClick(): void {
|
||||
private handleGalaxyClick(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): void {
|
||||
if (!this.starField) {
|
||||
return;
|
||||
}
|
||||
const [hit] = this.raycaster.intersectObject(this.starField.object);
|
||||
const starId = hit?.index !== undefined ? this.starField.starIdAt(hit.index) : undefined;
|
||||
// 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);
|
||||
if (starId !== undefined) {
|
||||
this.navigationStore.selectStar(starId);
|
||||
}
|
||||
@@ -309,6 +747,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
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 {
|
||||
@@ -347,26 +792,49 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
const systemBodies = this.bodies.filter((body) => body.systemStarId === star.id);
|
||||
const systemExoplanets = this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id);
|
||||
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets);
|
||||
// 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 = luminositySolar({ magnitude: star.magnitude, distancePc: Math.hypot(star.x, star.y, star.z), spectralType: star.spectralType });
|
||||
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity);
|
||||
this.systemGroup.add(this.systemRenderer.object);
|
||||
|
||||
// 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.
|
||||
const viewport = { fovDegrees: camera.fov, aspect: camera.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);
|
||||
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, STAR_MARKER_RADIUS_AU, SUN_GLOW_SCALE);
|
||||
this.starGlow = createGlowSprite(0xfff2c0, starGlowExtentAu(starRadiusAu, frameRadiusAu));
|
||||
} else {
|
||||
starMarkerMaterial.color.copy(starColor);
|
||||
this.starGlow = createGlowSprite(starColor, STAR_MARKER_RADIUS_AU, SUN_GLOW_SCALE * 0.6);
|
||||
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([]);
|
||||
|
||||
camera.near = SYSTEM_NEAR_AU;
|
||||
camera.far = SYSTEM_FAR_AU;
|
||||
@@ -376,13 +844,12 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) });
|
||||
|
||||
const framingDistance = THREE.MathUtils.clamp(
|
||||
this.systemRenderer.maxTopLevelSemiMajorAxisAu * 2.4 || MIN_SYSTEM_FRAMING_DISTANCE_AU,
|
||||
MIN_SYSTEM_FRAMING_DISTANCE_AU,
|
||||
MAX_SYSTEM_FRAMING_DISTANCE_AU
|
||||
);
|
||||
// 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: direction.clone().multiplyScalar(framingDistance), target: new THREE.Vector3(0, 0, 0) }, SETTLE_DURATION_SECONDS, () => {
|
||||
this.rig!.flyTo({ position: viewDirection.multiplyScalar(framingDistance), target: new THREE.Vector3(0, 0, 0) }, SETTLE_DURATION_SECONDS, () => {
|
||||
this.currentStarId = star.id;
|
||||
this.navigationStore.setViewLevel('system');
|
||||
onComplete();
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { galacticCentrePositionPc, SUN_HEIGHT_ABOVE_MIDPLANE_PC } from '../../shared/astro/galaxy';
|
||||
import { galacticFrameQuaternion, galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
|
||||
|
||||
const SEGMENTS_PER_RING = 180;
|
||||
|
||||
function vertexAt(geometry: THREE.BufferGeometry, index: number): THREE.Vector3 {
|
||||
const position = geometry.getAttribute('position');
|
||||
return new THREE.Vector3(position.getX(index), position.getY(index), position.getZ(index));
|
||||
}
|
||||
|
||||
describe('galacticFrameQuaternion', () => {
|
||||
it('carries the local +Z onto the galactic normal, so a flat grid lands in the galactic plane', () => {
|
||||
const rotated = new THREE.Vector3(0, 0, 1).applyQuaternion(galacticFrameQuaternion());
|
||||
const normal = galacticNormal();
|
||||
expect(rotated.x).toBeCloseTo(normal.x, 9);
|
||||
expect(rotated.y).toBeCloseTo(normal.y, 9);
|
||||
expect(rotated.z).toBeCloseTo(normal.z, 9);
|
||||
});
|
||||
|
||||
it('tilts that plane the real angle away from the celestial equator', () => {
|
||||
// The galactic and celestial poles are 62.9 degrees apart, so the planes are too.
|
||||
const normal = galacticNormal();
|
||||
expect((Math.acos(Math.abs(normal.z)) * 180) / Math.PI).toBeCloseTo(62.87, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PolarGridPlane', () => {
|
||||
const rings = [10, 20, 50];
|
||||
const spokes = 8;
|
||||
const grid = new PolarGridPlane({ ringRadii: rings, spokeCount: spokes, emphasisRadii: [50] });
|
||||
|
||||
it('draws every ring segment and every spoke', () => {
|
||||
expect(grid.object.geometry.getAttribute('position').count).toBe(rings.length * SEGMENTS_PER_RING * 2 + spokes * 2);
|
||||
});
|
||||
|
||||
it('starts hidden, so a view that never zooms out never draws it', () => {
|
||||
expect(grid.object.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('fades in and out with strength, and disappears outright at zero', () => {
|
||||
grid.setStrength(1);
|
||||
expect(grid.object.visible).toBe(true);
|
||||
const full = (grid.object.material as THREE.LineBasicMaterial).opacity;
|
||||
|
||||
grid.setStrength(0.5);
|
||||
expect((grid.object.material as THREE.LineBasicMaterial).opacity).toBeCloseTo(full / 2, 6);
|
||||
|
||||
grid.setStrength(0);
|
||||
expect(grid.object.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('clamps strength rather than letting opacity run past one', () => {
|
||||
grid.setStrength(4);
|
||||
expect((grid.object.material as THREE.LineBasicMaterial).opacity).toBeLessThanOrEqual(1);
|
||||
grid.setStrength(-1);
|
||||
expect(grid.object.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('lies in the galactic plane through its centre once placed in the scene', () => {
|
||||
grid.object.updateMatrixWorld(true);
|
||||
const normal = galacticNormal();
|
||||
|
||||
for (const index of [0, 100, 1000, grid.object.geometry.getAttribute('position').count - 1]) {
|
||||
const world = vertexAt(grid.object.geometry, index).applyMatrix4(grid.object.matrixWorld);
|
||||
expect(world.dot(normal)).toBeCloseTo(0, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('sits on the galactic centre when given it, still in the plane', () => {
|
||||
const centre = galacticCentrePositionPc();
|
||||
const galacticGrid = new PolarGridPlane({
|
||||
ringRadii: [2500, 8178],
|
||||
spokeCount: 4,
|
||||
centre: new THREE.Vector3(centre.x, centre.y, centre.z)
|
||||
});
|
||||
galacticGrid.object.updateMatrixWorld(true);
|
||||
|
||||
const normal = galacticNormal();
|
||||
const world = vertexAt(galacticGrid.object.geometry, 0).applyMatrix4(galacticGrid.object.matrixWorld);
|
||||
// The centre is one Sun-height below the Sun's own plane, and the grid follows it there.
|
||||
expect(world.dot(normal)).toBeCloseTo(-SUN_HEIGHT_ABOVE_MIDPLANE_PC, 4);
|
||||
|
||||
galacticGrid.dispose();
|
||||
});
|
||||
|
||||
it('lies in whatever plane it is oriented into, for a system read against its own', () => {
|
||||
// The system view passes the frame its orbital elements were measured in, which has nothing
|
||||
// to do with the Galaxy's plane.
|
||||
const orientation = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2);
|
||||
const systemGrid = new PolarGridPlane({ ringRadii: [1, 2, 3], spokeCount: 6, orientation });
|
||||
systemGrid.object.updateMatrixWorld(true);
|
||||
|
||||
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(orientation);
|
||||
for (const index of [0, 200, systemGrid.object.geometry.getAttribute('position').count - 1]) {
|
||||
const world = vertexAt(systemGrid.object.geometry, index).applyMatrix4(systemGrid.object.matrixWorld);
|
||||
expect(world.dot(normal)).toBeCloseTo(0, 6);
|
||||
}
|
||||
// ...and it is genuinely a different plane from the default.
|
||||
expect(Math.abs(normal.dot(galacticNormal()))).toBeLessThan(0.99);
|
||||
|
||||
systemGrid.dispose();
|
||||
});
|
||||
|
||||
it('honours an explicit peak opacity, for a grid that has to sit under other rings', () => {
|
||||
const quiet = new PolarGridPlane({ ringRadii: [1, 2], spokeCount: 4, opacity: 0.2 });
|
||||
quiet.setStrength(1);
|
||||
expect((quiet.object.material as THREE.LineBasicMaterial).opacity).toBeCloseTo(0.2, 6);
|
||||
quiet.dispose();
|
||||
});
|
||||
|
||||
it('keeps the emphasised ring brighter than the rest', () => {
|
||||
const colors = grid.object.geometry.getAttribute('color');
|
||||
// Vertices are written ring by ring, in the order they were listed: 10 pc first, 50 pc last.
|
||||
const innerBrightness = colors.getX(0) + colors.getY(0) + colors.getZ(0);
|
||||
const emphasisIndex = 2 * SEGMENTS_PER_RING * 2;
|
||||
const emphasisBrightness = colors.getX(emphasisIndex) + colors.getY(emphasisIndex) + colors.getZ(emphasisIndex);
|
||||
expect(emphasisBrightness).toBeGreaterThan(innerBrightness);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TetherField', () => {
|
||||
it('drops each point onto the plane, straight down the galactic normal', () => {
|
||||
const field = new TetherField(4);
|
||||
const point = new THREE.Vector3(12, -7, 30);
|
||||
field.setTargets([point]);
|
||||
|
||||
const geometry = field.object.geometry;
|
||||
const top = vertexAt(geometry, 0);
|
||||
const foot = vertexAt(geometry, 1);
|
||||
const normal = galacticNormal();
|
||||
|
||||
expect(top.distanceTo(point)).toBeCloseTo(0, 4);
|
||||
// The foot is in the plane...
|
||||
expect(foot.dot(normal)).toBeCloseTo(0, 4);
|
||||
// ...and directly below the point: the drop has no sideways component.
|
||||
const drop = top.clone().sub(foot);
|
||||
expect(drop.clone().cross(normal).length()).toBeCloseTo(0, 4);
|
||||
|
||||
field.dispose();
|
||||
});
|
||||
|
||||
it('drops onto an offset plane when asked, for a grid on the true midplane', () => {
|
||||
const field = new TetherField(2);
|
||||
field.setTargets([new THREE.Vector3(0, 0, 100)], -SUN_HEIGHT_ABOVE_MIDPLANE_PC);
|
||||
|
||||
const foot = vertexAt(field.object.geometry, 1);
|
||||
expect(foot.dot(galacticNormal())).toBeCloseTo(-SUN_HEIGHT_ABOVE_MIDPLANE_PC, 4);
|
||||
|
||||
field.dispose();
|
||||
});
|
||||
|
||||
it('draws two vertices per tether and nothing for the ones it was not given', () => {
|
||||
const field = new TetherField(8);
|
||||
field.setTargets([new THREE.Vector3(1, 2, 3), new THREE.Vector3(4, 5, 6)]);
|
||||
expect(field.object.geometry.drawRange.count).toBe(4);
|
||||
|
||||
field.setTargets([]);
|
||||
expect(field.object.geometry.drawRange.count).toBe(0);
|
||||
|
||||
field.dispose();
|
||||
});
|
||||
|
||||
it('drops points past its capacity rather than overrunning the buffer', () => {
|
||||
const field = new TetherField(2);
|
||||
const points = [new THREE.Vector3(1, 0, 5), new THREE.Vector3(2, 0, 5), new THREE.Vector3(3, 0, 5), new THREE.Vector3(4, 0, 5)];
|
||||
expect(() => field.setTargets(points)).not.toThrow();
|
||||
expect(field.object.geometry.drawRange.count).toBe(4);
|
||||
expect(field.object.geometry.getAttribute('position').count).toBe(4);
|
||||
|
||||
field.dispose();
|
||||
});
|
||||
|
||||
it('drops down whatever normal it was built with, not always the galactic one', () => {
|
||||
const normal = new THREE.Vector3(0, 1, 0);
|
||||
const field = new TetherField(2, { normal });
|
||||
field.setTargets([new THREE.Vector3(3, 7, 5)]);
|
||||
|
||||
const foot = vertexAt(field.object.geometry, 1);
|
||||
// The foot keeps the in-plane components and loses only the height along the normal.
|
||||
expect(foot.x).toBeCloseTo(3, 6);
|
||||
expect(foot.y).toBeCloseTo(0, 6);
|
||||
expect(foot.z).toBeCloseTo(5, 6);
|
||||
|
||||
field.dispose();
|
||||
});
|
||||
|
||||
it('normalises the normal it is given, so an unnormalised frame axis still lands on the plane', () => {
|
||||
const field = new TetherField(2, { normal: new THREE.Vector3(0, 0, 4) });
|
||||
field.setTargets([new THREE.Vector3(1, 1, 9)]);
|
||||
expect(vertexAt(field.object.geometry, 1).z).toBeCloseTo(0, 6);
|
||||
field.dispose();
|
||||
});
|
||||
|
||||
it('stays hidden until it is given a strength', () => {
|
||||
const field = new TetherField(2);
|
||||
expect(field.object.visible).toBe(false);
|
||||
|
||||
field.setStrength(1);
|
||||
expect(field.object.visible).toBe(true);
|
||||
field.setStrength(0);
|
||||
expect(field.object.visible).toBe(false);
|
||||
|
||||
field.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
|
||||
import { GALACTIC_BASIS_EQUATORIAL, SUN_HEIGHT_ABOVE_MIDPLANE_PC } from '../../shared/astro/galaxy';
|
||||
|
||||
const SEGMENTS_PER_RING = 180;
|
||||
|
||||
/**
|
||||
* The rotation that carries the galactic frame's axes onto the scene's equatorial ones, as a
|
||||
* quaternion — so a grid built flat in XY comes out lying in the galactic plane, tilted the
|
||||
* real 63 degrees against the celestial equator rather than parked on an arbitrary plane.
|
||||
*/
|
||||
export function galacticFrameQuaternion(): THREE.Quaternion {
|
||||
const { x, y, z } = GALACTIC_BASIS_EQUATORIAL;
|
||||
const basis = new THREE.Matrix4().makeBasis(new THREE.Vector3(x.x, x.y, x.z), new THREE.Vector3(y.x, y.y, y.z), new THREE.Vector3(z.x, z.y, z.z));
|
||||
return new THREE.Quaternion().setFromRotationMatrix(basis);
|
||||
}
|
||||
|
||||
/** The galactic plane's unit normal, in the equatorial frame. */
|
||||
export function galacticNormal(): THREE.Vector3 {
|
||||
const { z } = GALACTIC_BASIS_EQUATORIAL;
|
||||
return new THREE.Vector3(z.x, z.y, z.z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distances here carry no unit of their own: they are whatever the group the grid is added to
|
||||
* works in — parsecs in the galaxy view, AU in the system view.
|
||||
*/
|
||||
export interface PolarGridOptions {
|
||||
/** Ring radii to draw, innermost first. */
|
||||
readonly ringRadii: readonly number[];
|
||||
/** Radial spokes drawn from the innermost to the outermost ring. */
|
||||
readonly spokeCount: number;
|
||||
/**
|
||||
* Rotation from the grid's own XY plane onto the plane it should lie in. Defaults to the
|
||||
* galactic plane; the system view passes the frame its orbital elements were measured in.
|
||||
*/
|
||||
readonly orientation?: THREE.Quaternion;
|
||||
/**
|
||||
* Centre of the grid, which also fixes the plane it lies in. Defaults to the origin — note
|
||||
* that in the galaxy view the origin is the Sun, whose own plane is
|
||||
* {@link SUN_HEIGHT_ABOVE_MIDPLANE_PC} above the Galaxy's midplane; that matters at the local
|
||||
* scale and is invisible at the galactic one.
|
||||
*/
|
||||
readonly centre?: THREE.Vector3;
|
||||
readonly color?: THREE.ColorRepresentation;
|
||||
/** Rings listed here are drawn at full strength — used to call out a meaningful radius. */
|
||||
readonly emphasisRadii?: readonly number[];
|
||||
/** Peak opacity, for a grid that should read louder or quieter than the default. */
|
||||
readonly opacity?: number;
|
||||
/**
|
||||
* Breaks the rings into dashes. Worth it where the grid shares a plane with real curves it
|
||||
* could be mistaken for — the system view draws orbit ellipses in the same plane, and a solid
|
||||
* ring there is indistinguishable at a glance from a circular orbit. Dashed reads as
|
||||
* "reference", solid as "something is actually there".
|
||||
*/
|
||||
readonly dashed?: boolean;
|
||||
}
|
||||
|
||||
/** Ring segments per dash and per gap when {@link PolarGridOptions.dashed} is set. */
|
||||
const DASH_SEGMENTS = 2;
|
||||
|
||||
/**
|
||||
* A polar grid lying in a reference plane: concentric rings and radial spokes, fading out with
|
||||
* radius.
|
||||
*
|
||||
* This is the one piece of chrome that makes a 3D map readable. Without a reference plane a
|
||||
* cloud of points has no depth at all — two stars a thousand parsecs apart look like neighbours,
|
||||
* and a planet above its system's plane looks like one inside it. With a plane under them, and a
|
||||
* tether from each down to it, the eye reads height directly. It is also the signature of the
|
||||
* map this view is modelled on.
|
||||
*/
|
||||
export class PolarGridPlane {
|
||||
readonly object: THREE.LineSegments;
|
||||
|
||||
private readonly geometry = new THREE.BufferGeometry();
|
||||
private readonly material: THREE.LineBasicMaterial;
|
||||
private readonly baseOpacity: number;
|
||||
|
||||
constructor(options: PolarGridOptions) {
|
||||
const color = new THREE.Color(options.color ?? 0x4dd7ff);
|
||||
const emphasis = new Set(options.emphasisRadii ?? []);
|
||||
const outerRadius = Math.max(...options.ringRadii);
|
||||
const innerRadius = Math.min(...options.ringRadii);
|
||||
|
||||
const vertices: number[] = [];
|
||||
const colors: number[] = [];
|
||||
|
||||
const push = (x: number, y: number, brightness: number): void => {
|
||||
vertices.push(x, y, 0);
|
||||
colors.push(color.r * brightness, color.g * brightness, color.b * brightness);
|
||||
};
|
||||
|
||||
for (const radius of options.ringRadii) {
|
||||
// Rings dim toward the edge of the grid so it dissolves into the void instead of ending.
|
||||
const brightness = emphasis.has(radius) ? 1 : 0.55 * (1 - (0.6 * radius) / outerRadius);
|
||||
for (let segment = 0; segment < SEGMENTS_PER_RING; segment++) {
|
||||
// Dashes are cut by dropping whole segments rather than by a dashed material: the ring is
|
||||
// already built from independent segment pairs, so a material's dash pattern would
|
||||
// restart at each one. Skipping segments also keeps the dash angular, so every ring is
|
||||
// dashed at the same rate however large it is.
|
||||
if (options.dashed && segment % (DASH_SEGMENTS * 2) >= DASH_SEGMENTS) {
|
||||
continue;
|
||||
}
|
||||
const a = (segment / SEGMENTS_PER_RING) * Math.PI * 2;
|
||||
const b = ((segment + 1) / SEGMENTS_PER_RING) * Math.PI * 2;
|
||||
push(Math.cos(a) * radius, Math.sin(a) * radius, brightness);
|
||||
push(Math.cos(b) * radius, Math.sin(b) * radius, brightness);
|
||||
}
|
||||
}
|
||||
|
||||
for (let spoke = 0; spoke < options.spokeCount; spoke++) {
|
||||
const angle = (spoke / options.spokeCount) * Math.PI * 2;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
push(cos * innerRadius, sin * innerRadius, 0.4);
|
||||
push(cos * outerRadius, sin * outerRadius, 0.05);
|
||||
}
|
||||
|
||||
this.geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
|
||||
this.geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
|
||||
|
||||
// Deliberately restrained: the grid is the reference the map is read against, not the map.
|
||||
this.baseOpacity = options.opacity ?? 0.55;
|
||||
this.material = new THREE.LineBasicMaterial({
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
|
||||
this.object = new THREE.LineSegments(this.geometry, this.material);
|
||||
// Built flat in its own XY plane, then rotated onto the reference plane and slid to centre.
|
||||
this.object.quaternion.copy(options.orientation ?? galacticFrameQuaternion());
|
||||
this.object.position.copy(options.centre ?? new THREE.Vector3());
|
||||
this.object.visible = false;
|
||||
this.object.renderOrder = -1;
|
||||
}
|
||||
|
||||
/** Crossfades the grid. Zero hides it outright rather than drawing a fully transparent pass. */
|
||||
setStrength(strength: number): void {
|
||||
const clamped = Math.max(0, Math.min(1, strength));
|
||||
this.material.opacity = clamped * this.baseOpacity;
|
||||
this.object.visible = clamped > 0;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.object.removeFromParent();
|
||||
this.geometry.dispose();
|
||||
this.material.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The vertical lines dropped from objects onto the reference plane — the other half of what
|
||||
* makes the grid work. A point floating over a grid still has ambiguous height; a point with a
|
||||
* line down to a marked spot on the grid does not.
|
||||
*
|
||||
* Drawn as one `LineSegments` with a fixed-capacity buffer and a draw range, so following a
|
||||
* changing set of stars costs a buffer write rather than a rebuild.
|
||||
*/
|
||||
export class TetherField {
|
||||
readonly object: THREE.LineSegments;
|
||||
|
||||
private readonly geometry = new THREE.BufferGeometry();
|
||||
private readonly material: THREE.LineBasicMaterial;
|
||||
private readonly positions: Float32Array;
|
||||
private readonly maxCount: number;
|
||||
private readonly normal: THREE.Vector3;
|
||||
private readonly peakOpacity: number;
|
||||
|
||||
constructor(maxCount: number, options: { color?: THREE.ColorRepresentation; normal?: THREE.Vector3; opacity?: number } = {}) {
|
||||
this.maxCount = maxCount;
|
||||
this.normal = (options.normal ?? galacticNormal()).clone().normalize();
|
||||
this.peakOpacity = options.opacity ?? 0.45;
|
||||
this.positions = new Float32Array(maxCount * 6);
|
||||
const color = options.color ?? 0x4dd7ff;
|
||||
this.geometry.setAttribute('position', new THREE.BufferAttribute(this.positions, 3));
|
||||
this.geometry.setDrawRange(0, 0);
|
||||
|
||||
this.material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending });
|
||||
this.object = new THREE.LineSegments(this.geometry, this.material);
|
||||
// The buffer is rewritten in place as the visible set changes, so its bounds are stale by
|
||||
// construction; culling on those bounds would blink the whole field in and out.
|
||||
this.object.frustumCulled = false;
|
||||
this.object.visible = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a tether from each point onto the plane through the origin with this field's normal,
|
||||
* offset along that normal by `planeOffset`.
|
||||
*
|
||||
* The offset is `0` for a plane through the origin — the Sun in the galaxy view, the host star
|
||||
* in the system view — and `-SUN_HEIGHT_ABOVE_MIDPLANE_PC` for a grid on the Galaxy's true
|
||||
* midplane. Points past the field's capacity are dropped.
|
||||
*/
|
||||
setTargets(points: readonly THREE.Vector3[], planeOffset = 0): void {
|
||||
const normal = this.normal;
|
||||
const count = Math.min(points.length, this.maxCount);
|
||||
|
||||
for (let index = 0; index < count; index++) {
|
||||
const point = points[index];
|
||||
const height = point.dot(normal) - planeOffset;
|
||||
this.positions.set(
|
||||
[point.x, point.y, point.z, point.x - normal.x * height, point.y - normal.y * height, point.z - normal.z * height],
|
||||
index * 6
|
||||
);
|
||||
}
|
||||
|
||||
this.geometry.setDrawRange(0, count * 2);
|
||||
this.geometry.getAttribute('position').needsUpdate = true;
|
||||
}
|
||||
|
||||
/** Crossfades the tethers, matching whichever grid they are dropping onto. */
|
||||
setStrength(strength: number): void {
|
||||
const clamped = Math.max(0, Math.min(1, strength));
|
||||
this.material.opacity = clamped * this.peakOpacity;
|
||||
this.object.visible = clamped > 0;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.object.removeFromParent();
|
||||
this.geometry.dispose();
|
||||
this.material.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DISC_RADIUS_PC, equatorialToGalactic, SUN_GALACTOCENTRIC_RADIUS_PC, SUN_HEIGHT_ABOVE_MIDPLANE_PC } from '../../shared/astro/galaxy';
|
||||
import { createRandom, DEFAULT_PARTICLE_COUNTS, generateMilkyWayParticles } from './milky-way-model';
|
||||
|
||||
/** A small, fast budget — the shape of the model does not depend on how many particles trace it. */
|
||||
const TEST_COUNTS = { arms: 3000, disc: 1500, bulge: 1200, halo: 200 };
|
||||
const TEST_BUDGET = TEST_COUNTS.arms + TEST_COUNTS.disc + TEST_COUNTS.bulge + TEST_COUNTS.halo;
|
||||
|
||||
/** Galactocentric radius and height above the midplane of the i-th particle, in parsecs. */
|
||||
function galactocentric(positions: Float32Array, index: number): { radiusPc: number; heightPc: number } {
|
||||
const galactic = equatorialToGalactic({ x: positions[index * 3], y: positions[index * 3 + 1], z: positions[index * 3 + 2] });
|
||||
return {
|
||||
radiusPc: Math.hypot(SUN_GALACTOCENTRIC_RADIUS_PC - galactic.x, galactic.y),
|
||||
heightPc: galactic.z + SUN_HEIGHT_ABOVE_MIDPLANE_PC
|
||||
};
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.floor(sorted.length / 2)];
|
||||
}
|
||||
|
||||
describe('createRandom', () => {
|
||||
it('is deterministic for a given seed', () => {
|
||||
const a = createRandom(7);
|
||||
const b = createRandom(7);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(a()).toBe(b());
|
||||
}
|
||||
});
|
||||
|
||||
it('stays inside the unit interval', () => {
|
||||
const random = createRandom(99);
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
const value = random();
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('produces different streams for different seeds', () => {
|
||||
expect(createRandom(1)()).not.toBe(createRandom(2)());
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMilkyWayParticles', () => {
|
||||
const particles = generateMilkyWayParticles(1234, TEST_COUNTS);
|
||||
|
||||
it('is the same Galaxy on every run, so the map does not reshuffle on reload', () => {
|
||||
const again = generateMilkyWayParticles(1234, TEST_COUNTS);
|
||||
expect(again.count).toBe(particles.count);
|
||||
expect(Array.from(again.positions.slice(0, 300))).toEqual(Array.from(particles.positions.slice(0, 300)));
|
||||
});
|
||||
|
||||
it('places most of the requested budget, rejecting only the samples that miss', () => {
|
||||
expect(particles.count).toBeLessThanOrEqual(TEST_BUDGET);
|
||||
expect(particles.count).toBeGreaterThan(TEST_BUDGET * 0.75);
|
||||
});
|
||||
|
||||
it('emits finite positions, sizes and alphas throughout', () => {
|
||||
for (let index = 0; index < particles.count; index++) {
|
||||
expect(Number.isFinite(particles.positions[index * 3])).toBe(true);
|
||||
expect(Number.isFinite(particles.positions[index * 3 + 1])).toBe(true);
|
||||
expect(Number.isFinite(particles.positions[index * 3 + 2])).toBe(true);
|
||||
expect(particles.sizes[index]).toBeGreaterThan(0);
|
||||
expect(particles.alphas[index]).toBeGreaterThan(0);
|
||||
expect(particles.alphas[index]).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every colour channel inside the displayable range', () => {
|
||||
for (let index = 0; index < particles.count * 3; index++) {
|
||||
expect(particles.colors[index]).toBeGreaterThanOrEqual(0);
|
||||
expect(particles.colors[index]).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every particle inside the modelled galaxy, halo included', () => {
|
||||
for (let index = 0; index < particles.count; index++) {
|
||||
expect(galactocentric(particles.positions, index).radiusPc).toBeLessThan(DISC_RADIUS_PC * 1.3);
|
||||
}
|
||||
});
|
||||
|
||||
it('builds a disc rather than a ball: half the particles sit within 300 pc of the midplane', () => {
|
||||
const heights: number[] = [];
|
||||
for (let index = 0; index < particles.count; index++) {
|
||||
heights.push(Math.abs(galactocentric(particles.positions, index).heightPc));
|
||||
}
|
||||
expect(median(heights)).toBeLessThan(300);
|
||||
});
|
||||
|
||||
it('leaves the centre denser than the outskirts', () => {
|
||||
let inner = 0;
|
||||
let outer = 0;
|
||||
for (let index = 0; index < particles.count; index++) {
|
||||
const { radiusPc } = galactocentric(particles.positions, index);
|
||||
if (radiusPc < 4000) {
|
||||
inner++;
|
||||
} else if (radiusPc > 12000) {
|
||||
outer++;
|
||||
}
|
||||
}
|
||||
expect(inner).toBeGreaterThan(outer);
|
||||
});
|
||||
|
||||
it('puts the Sun in the disc, not off its edge', () => {
|
||||
// The whole point of the placement: the local star field has to sit inside the model, about
|
||||
// half way out, rather than floating beside it.
|
||||
let neighbours = 0;
|
||||
for (let index = 0; index < particles.count; index++) {
|
||||
const x = particles.positions[index * 3];
|
||||
const y = particles.positions[index * 3 + 1];
|
||||
const z = particles.positions[index * 3 + 2];
|
||||
if (Math.hypot(x, y, z) < 2000) {
|
||||
neighbours++;
|
||||
}
|
||||
}
|
||||
expect(neighbours).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('spans a full turn in azimuth, so the arms wrap rather than forming a fan', () => {
|
||||
const quadrants = new Set<number>();
|
||||
for (let index = 0; index < particles.count; index++) {
|
||||
const galactic = equatorialToGalactic({
|
||||
x: particles.positions[index * 3],
|
||||
y: particles.positions[index * 3 + 1],
|
||||
z: particles.positions[index * 3 + 2]
|
||||
});
|
||||
const angle = Math.atan2(galactic.y, SUN_GALACTOCENTRIC_RADIUS_PC - galactic.x);
|
||||
quadrants.add(Math.floor(((angle + Math.PI) / (Math.PI / 2)) % 4));
|
||||
}
|
||||
expect(quadrants.size).toBe(4);
|
||||
});
|
||||
|
||||
it('defaults to a budget big enough to read as a galaxy', () => {
|
||||
expect(DEFAULT_PARTICLE_COUNTS.arms).toBeGreaterThan(DEFAULT_PARTICLE_COUNTS.disc);
|
||||
expect(DEFAULT_PARTICLE_COUNTS.halo).toBeLessThan(DEFAULT_PARTICLE_COUNTS.bulge);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
import {
|
||||
armRadiusPc,
|
||||
BAR_HALF_LENGTH_PC,
|
||||
BAR_HALF_THICKNESS_PC,
|
||||
BAR_HALF_WIDTH_PC,
|
||||
BAR_POSITION_ANGLE_DEG,
|
||||
DISC_RADIUS_PC,
|
||||
DISC_SCALE_HEIGHT_PC,
|
||||
DISC_SCALE_LENGTH_PC,
|
||||
galacticToEquatorial,
|
||||
MILKY_WAY_ARMS,
|
||||
ORION_SPUR,
|
||||
SpiralArm,
|
||||
SUN_GALACTOCENTRIC_RADIUS_PC,
|
||||
SUN_HEIGHT_ABOVE_MIDPLANE_PC
|
||||
} from '../../shared/astro/galaxy';
|
||||
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/**
|
||||
* Particle budget per population. Between them these are ~46k instanced quads, which is the
|
||||
* same order as the star field and draws in a single call.
|
||||
*/
|
||||
export interface GalaxyParticleCounts {
|
||||
readonly arms: number;
|
||||
readonly disc: number;
|
||||
readonly bulge: number;
|
||||
readonly halo: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PARTICLE_COUNTS: GalaxyParticleCounts = {
|
||||
arms: 24000,
|
||||
disc: 12000,
|
||||
bulge: 9000,
|
||||
halo: 1600
|
||||
};
|
||||
|
||||
/** Vertical scale height of the star-forming ridge in an arm — much thinner than the disc. */
|
||||
const ARM_SCALE_HEIGHT_PC = 130;
|
||||
/** Fraction of arm particles drawn as bright star-forming knots rather than ordinary field. */
|
||||
const HII_REGION_FRACTION = 0.05;
|
||||
|
||||
/** Particle diameters in parsecs. These are cloud-sized on purpose: the model is haze, not stars. */
|
||||
const ARM_SIZE_PC = { min: 70, max: 240 } as const;
|
||||
const DISC_SIZE_PC = { min: 120, max: 420 } as const;
|
||||
const BULGE_SIZE_PC = { min: 90, max: 300 } as const;
|
||||
const HALO_SIZE_PC = { min: 110, max: 260 } as const;
|
||||
const HII_SIZE_MULTIPLIER = 1.9;
|
||||
|
||||
/**
|
||||
* The palette. Young blue-white stars trace the arms, star formation lights them pink, the
|
||||
* bar and bulge are old and red, and the smooth disc between the arms is a dim yellow-white.
|
||||
*/
|
||||
const ARM_INNER_COLOR = [0.62, 0.74, 1.0] as const;
|
||||
const ARM_OUTER_COLOR = [0.78, 0.86, 1.0] as const;
|
||||
const HII_COLOR = [1.0, 0.48, 0.66] as const;
|
||||
const BULGE_CORE_COLOR = [1.0, 0.87, 0.64] as const;
|
||||
const BULGE_EDGE_COLOR = [1.0, 0.68, 0.38] as const;
|
||||
const DISC_COLOR = [0.72, 0.74, 0.82] as const;
|
||||
const HALO_COLOR = [0.55, 0.6, 0.78] as const;
|
||||
|
||||
export interface GalaxyParticles {
|
||||
readonly count: number;
|
||||
/** Equatorial-frame positions in parsecs from the Sun, packed xyz. */
|
||||
readonly positions: Float32Array;
|
||||
readonly colors: Float32Array;
|
||||
/** World-space diameter, in parsecs. */
|
||||
readonly sizes: Float32Array;
|
||||
readonly alphas: Float32Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small, fast, seedable PRNG (mulberry32). `Math.random` would do visually, but the model would
|
||||
* then be different on every reload and untestable — this way the Galaxy is the same Galaxy
|
||||
* every time, and a test can assert on where its particles land.
|
||||
*/
|
||||
export function createRandom(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** Standard normal sample, by the polar form of Box-Muller. */
|
||||
function gaussian(random: () => number): number {
|
||||
let u = 0;
|
||||
let v = 0;
|
||||
let s = 0;
|
||||
do {
|
||||
u = random() * 2 - 1;
|
||||
v = random() * 2 - 1;
|
||||
s = u * u + v * v;
|
||||
} while (s === 0 || s >= 1);
|
||||
return u * Math.sqrt((-2 * Math.log(s)) / s);
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function lerpColor(a: readonly number[], b: readonly number[], t: number): [number, number, number] {
|
||||
return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a galactocentric offset in the plane (heliocentric-parallel axes: +X toward the centre,
|
||||
* +Y toward longitude 90) plus a height above the midplane into a heliocentric galactic
|
||||
* position. The polar form of the same mapping lives in `galaxy.ts`; the bar is easier to write
|
||||
* in Cartesian, so it gets this one.
|
||||
*/
|
||||
function fromCentreOffset(dx: number, dy: number, heightPc: number): { x: number; y: number; z: number } {
|
||||
return { x: SUN_GALACTOCENTRIC_RADIUS_PC + dx, y: dy, z: heightPc - SUN_HEIGHT_ABOVE_MIDPLANE_PC };
|
||||
}
|
||||
|
||||
interface Writer {
|
||||
push(galactic: { x: number; y: number; z: number }, color: readonly number[], sizePc: number, alpha: number): void;
|
||||
}
|
||||
|
||||
function createWriter(capacity: number): Writer & GalaxyParticles & { finish(): GalaxyParticles } {
|
||||
const positions = new Float32Array(capacity * 3);
|
||||
const colors = new Float32Array(capacity * 3);
|
||||
const sizes = new Float32Array(capacity);
|
||||
const alphas = new Float32Array(capacity);
|
||||
let count = 0;
|
||||
|
||||
return {
|
||||
positions,
|
||||
colors,
|
||||
sizes,
|
||||
alphas,
|
||||
get count() {
|
||||
return count;
|
||||
},
|
||||
push(galactic, color, sizePc, alpha) {
|
||||
// Everything is modelled in galactic coordinates and rotated once, here, into the frame
|
||||
// the star field and orbits already share.
|
||||
const equatorial = galacticToEquatorial(galactic);
|
||||
positions[count * 3] = equatorial.x;
|
||||
positions[count * 3 + 1] = equatorial.y;
|
||||
positions[count * 3 + 2] = equatorial.z;
|
||||
colors[count * 3] = color[0];
|
||||
colors[count * 3 + 1] = color[1];
|
||||
colors[count * 3 + 2] = color[2];
|
||||
sizes[count] = sizePc;
|
||||
alphas[count] = alpha;
|
||||
count++;
|
||||
},
|
||||
finish() {
|
||||
return { count, positions, colors, sizes, alphas };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Picks an arm, weighted, so the two grand-design arms dominate the minor ones. */
|
||||
function pickArm(random: () => number, arms: readonly SpiralArm[]): SpiralArm {
|
||||
const total = arms.reduce((sum, arm) => sum + arm.weight, 0);
|
||||
let roll = random() * total;
|
||||
for (const arm of arms) {
|
||||
roll -= arm.weight;
|
||||
if (roll <= 0) {
|
||||
return arm;
|
||||
}
|
||||
}
|
||||
return arms[arms.length - 1];
|
||||
}
|
||||
|
||||
function addArmParticles(writer: Writer, random: () => number, count: number): void {
|
||||
const arms = [...MILKY_WAY_ARMS, ORION_SPUR];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const arm = pickArm(random, arms);
|
||||
// Biased toward the start of the sweep, which is the inner, denser end of every arm.
|
||||
const t = Math.pow(random(), 1.4);
|
||||
const beta = lerp(arm.fromAzimuthDeg, arm.toAzimuthDeg, t);
|
||||
const spineRadius = armRadiusPc(arm, beta);
|
||||
if (spineRadius > DISC_RADIUS_PC || spineRadius < BAR_HALF_LENGTH_PC * 0.5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const offset = gaussian(random) * arm.widthPc;
|
||||
const radius = spineRadius + offset;
|
||||
if (radius <= 0) {
|
||||
continue;
|
||||
}
|
||||
const height = gaussian(random) * ARM_SCALE_HEIGHT_PC;
|
||||
const angle = beta * DEG_TO_RAD;
|
||||
const galactic = fromCentreOffset(-radius * Math.cos(angle), radius * Math.sin(angle), height);
|
||||
|
||||
const radialT = Math.min(radius / DISC_RADIUS_PC, 1);
|
||||
const isHii = random() < HII_REGION_FRACTION;
|
||||
const color = isHii ? HII_COLOR : lerpColor(ARM_INNER_COLOR, ARM_OUTER_COLOR, radialT);
|
||||
const size = lerp(ARM_SIZE_PC.min, ARM_SIZE_PC.max, random()) * (isHii ? HII_SIZE_MULTIPLIER : 1);
|
||||
// Fades with radius (the arms thin out) and with distance off the spine (they have edges).
|
||||
const ridgeFalloff = Math.exp(-(offset * offset) / (2 * arm.widthPc * arm.widthPc));
|
||||
const alpha = (isHii ? 0.85 : 0.4) * ridgeFalloff * lerp(1, 0.35, radialT);
|
||||
|
||||
writer.push(galactic, color, size, alpha);
|
||||
}
|
||||
}
|
||||
|
||||
function addDiscParticles(writer: Writer, random: () => number, count: number): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Inverse-transform sample of an exponential disc, rejected past the visible edge.
|
||||
const radius = -DISC_SCALE_LENGTH_PC * Math.log(1 - random());
|
||||
// The inner cut is where the bulge takes over, not a hole: set it at the bar's short axis
|
||||
// rather than its long one, or the model has a visible gap either side of the bar.
|
||||
if (radius > DISC_RADIUS_PC || radius < BAR_HALF_WIDTH_PC) {
|
||||
continue;
|
||||
}
|
||||
const angle = random() * Math.PI * 2;
|
||||
const height = gaussian(random) * DISC_SCALE_HEIGHT_PC;
|
||||
const galactic = fromCentreOffset(-radius * Math.cos(angle), radius * Math.sin(angle), height);
|
||||
|
||||
const radialT = Math.min(radius / DISC_RADIUS_PC, 1);
|
||||
writer.push(galactic, DISC_COLOR, lerp(DISC_SIZE_PC.min, DISC_SIZE_PC.max, random()), lerp(0.16, 0.03, radialT));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share of the bulge budget spent on the bar rather than on the rounder spheroid it sits inside.
|
||||
* Both are needed: the bar alone leaves a void either side of its short axis, between it and the
|
||||
* radius the arms and disc start at.
|
||||
*/
|
||||
const BAR_SHARE_OF_BULGE = 0.6;
|
||||
/** Gaussian width of the inner spheroid, and how much it is flattened toward the disc. */
|
||||
const SPHEROID_SIGMA_PC = 1150;
|
||||
const SPHEROID_FLATTENING = 0.62;
|
||||
|
||||
function addBulgeParticles(writer: Writer, random: () => number, count: number): void {
|
||||
const phi = BAR_POSITION_ANGLE_DEG * DEG_TO_RAD;
|
||||
const alongX = -Math.cos(phi);
|
||||
const alongY = Math.sin(phi);
|
||||
const acrossX = Math.sin(phi);
|
||||
const acrossY = Math.cos(phi);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
let dx: number;
|
||||
let dy: number;
|
||||
let height: number;
|
||||
let distance: number;
|
||||
|
||||
if (random() < BAR_SHARE_OF_BULGE) {
|
||||
// A triaxial Gaussian: long down the bar, narrow across it, flattened vertically.
|
||||
const along = gaussian(random) * (BAR_HALF_LENGTH_PC / 2);
|
||||
const across = gaussian(random) * (BAR_HALF_WIDTH_PC / 2);
|
||||
height = gaussian(random) * (BAR_HALF_THICKNESS_PC / 2);
|
||||
dx = along * alongX + across * acrossX;
|
||||
dy = along * alongY + across * acrossY;
|
||||
distance = Math.hypot(along, across, height);
|
||||
} else {
|
||||
dx = gaussian(random) * SPHEROID_SIGMA_PC;
|
||||
dy = gaussian(random) * SPHEROID_SIGMA_PC;
|
||||
height = gaussian(random) * SPHEROID_SIGMA_PC * SPHEROID_FLATTENING;
|
||||
distance = Math.hypot(dx, dy, height);
|
||||
}
|
||||
|
||||
const coreT = Math.min(distance / BAR_HALF_LENGTH_PC, 1);
|
||||
writer.push(
|
||||
fromCentreOffset(dx, dy, height),
|
||||
lerpColor(BULGE_CORE_COLOR, BULGE_EDGE_COLOR, coreT),
|
||||
lerp(BULGE_SIZE_PC.min, BULGE_SIZE_PC.max, random()),
|
||||
lerp(0.3, 0.06, coreT)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function addHaloParticles(writer: Writer, random: () => number, count: number): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
// A thin spherical scatter (globular clusters and halo field) so the disc is not a bare
|
||||
// plate floating in the void.
|
||||
const radius = DISC_RADIUS_PC * (0.35 + 0.85 * Math.pow(random(), 0.7));
|
||||
const cosTheta = random() * 2 - 1;
|
||||
const sinTheta = Math.sqrt(1 - cosTheta * cosTheta);
|
||||
const angle = random() * Math.PI * 2;
|
||||
const galactic = fromCentreOffset(radius * sinTheta * Math.cos(angle), radius * sinTheta * Math.sin(angle), radius * cosTheta);
|
||||
|
||||
writer.push(galactic, HALO_COLOR, lerp(HALO_SIZE_PC.min, HALO_SIZE_PC.max, random()), 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scatters the Milky Way's particle cloud around the structural model in `galaxy.ts` and returns
|
||||
* it packed for instanced rendering, already rotated into the scene's equatorial frame.
|
||||
*
|
||||
* Some samples are rejected (an arm particle that lands inside the bar, a disc particle past the
|
||||
* visible edge), so the returned `count` is a little below the requested budget — the arrays are
|
||||
* allocated at capacity and the count says how much of them is live.
|
||||
*/
|
||||
export function generateMilkyWayParticles(seed = 20260804, counts: GalaxyParticleCounts = DEFAULT_PARTICLE_COUNTS): GalaxyParticles {
|
||||
const random = createRandom(seed);
|
||||
const writer = createWriter(counts.arms + counts.disc + counts.bulge + counts.halo);
|
||||
|
||||
addBulgeParticles(writer, random, counts.bulge);
|
||||
addArmParticles(writer, random, counts.arms);
|
||||
addDiscParticles(writer, random, counts.disc);
|
||||
addHaloParticles(writer, random, counts.halo);
|
||||
|
||||
return writer.finish();
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GALACTIC_LANDMARKS } from '../../shared/astro/galaxy';
|
||||
import { GALAXY_FADE_FAR_PC, GALAXY_FADE_NEAR_PC, MilkyWayRenderer } from './milky-way-renderer';
|
||||
|
||||
const SMALL_COUNTS = { arms: 400, disc: 200, bulge: 200, halo: 50 };
|
||||
|
||||
describe('MilkyWayRenderer', () => {
|
||||
it('draws one instance per particle the model placed', () => {
|
||||
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||
expect(renderer.particleCount).toBeGreaterThan(0);
|
||||
expect((renderer.object.geometry as THREE.InstancedBufferGeometry).instanceCount).toBe(renderer.particleCount);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('stays hidden until the camera has pulled back, so the local view pays nothing for it', () => {
|
||||
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||
expect(renderer.object.visible).toBe(false);
|
||||
|
||||
expect(renderer.setViewerDistancePc(50)).toBe(0);
|
||||
expect(renderer.object.visible).toBe(false);
|
||||
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('fades in across the crossfade band and holds at full strength beyond it', () => {
|
||||
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||
|
||||
expect(renderer.setViewerDistancePc(GALAXY_FADE_NEAR_PC)).toBe(0);
|
||||
expect(renderer.setViewerDistancePc((GALAXY_FADE_NEAR_PC + GALAXY_FADE_FAR_PC) / 2)).toBeCloseTo(0.5, 6);
|
||||
expect(renderer.setViewerDistancePc(GALAXY_FADE_FAR_PC)).toBe(1);
|
||||
expect(renderer.setViewerDistancePc(80000)).toBe(1);
|
||||
expect(renderer.object.visible).toBe(true);
|
||||
expect(renderer.strength).toBe(1);
|
||||
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('never culls itself, since its instances are nowhere near the geometry it was built from', () => {
|
||||
// The billboard quad sits at the origin and the particles are placed in the vertex shader,
|
||||
// so the mesh's own bounds say nothing about where it is drawn.
|
||||
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||
expect(renderer.object.frustumCulled).toBe(false);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('offers a label anchor for every structural landmark, namespaced away from the star ids', () => {
|
||||
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||
const labels = renderer.labelPoints();
|
||||
|
||||
expect(labels).toHaveLength(GALACTIC_LANDMARKS.length);
|
||||
expect(labels.map((label) => label.name)).toContain('Sagittarius A*');
|
||||
for (const label of labels) {
|
||||
expect(String(label.id).startsWith('galactic:')).toBe(true);
|
||||
expect(Number.isFinite(label.x + label.y + label.z)).toBe(true);
|
||||
}
|
||||
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('detaches itself on dispose, so a torn-down scene does not keep it alive', () => {
|
||||
const renderer = new MilkyWayRenderer(5, SMALL_COUNTS);
|
||||
new THREE.Group().add(renderer.object);
|
||||
|
||||
renderer.dispose();
|
||||
expect(renderer.object.parent).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { instancedBufferAttribute, smoothstep, uniform, uv, vec2 } from 'three/tsl';
|
||||
|
||||
import { GALACTIC_LANDMARKS, landmarkPositionPc } from '../../shared/astro/galaxy';
|
||||
import { LabeledPoint } from './star-label-overlay';
|
||||
import { generateMilkyWayParticles, GalaxyParticleCounts } from './milky-way-model';
|
||||
|
||||
/**
|
||||
* Camera distances (parsecs from the Sun) between which the Galaxy model fades in. Below the
|
||||
* near figure the view is the real, measured star field and the model is entirely hidden; above
|
||||
* the far figure the model is at full strength and the 50 pc catalogue bubble is a single point.
|
||||
*/
|
||||
export const GALAXY_FADE_NEAR_PC = 400;
|
||||
export const GALAXY_FADE_FAR_PC = 2500;
|
||||
|
||||
/** A unit quad centred on the origin — the billboard every particle is drawn on. */
|
||||
function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometry {
|
||||
const geometry = new THREE.InstancedBufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]), 3));
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), 2));
|
||||
geometry.setIndex([0, 1, 2, 0, 2, 3]);
|
||||
geometry.instanceCount = instanceCount;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the Milky Way itself: the bar and bulge, five spiral arms, the smooth disc between them
|
||||
* and a thin halo, as one instanced cloud of soft camera-facing billboards.
|
||||
*
|
||||
* The particles are **illustrative**. Their skeleton is not — arm radii, pitch angles, the
|
||||
* Sun's galactocentric distance and the tilt of the disc against the sky are all measured
|
||||
* quantities, and the model is built from them in `galaxy.ts`. What no catalogue can supply is
|
||||
* the position of each star in the disc, because dust hides most of it from us, so the cloud
|
||||
* around that skeleton is scattered rather than observed. The UI says so on the galactic level.
|
||||
*
|
||||
* Sizes are world-space here, unlike the star field's angular ones: these particles stand for
|
||||
* clouds hundreds of parsecs across, so they should grow as the camera closes on them.
|
||||
*/
|
||||
export class MilkyWayRenderer {
|
||||
readonly object: THREE.Mesh;
|
||||
/** How many instances the model actually placed, after rejected samples. */
|
||||
readonly particleCount: number;
|
||||
|
||||
private readonly geometry: THREE.InstancedBufferGeometry;
|
||||
private readonly material: THREE.SpriteNodeMaterial;
|
||||
private readonly fade = uniform(0);
|
||||
private fadeValue = 0;
|
||||
|
||||
constructor(seed?: number, counts?: GalaxyParticleCounts) {
|
||||
const particles = generateMilkyWayParticles(seed, counts);
|
||||
this.particleCount = particles.count;
|
||||
this.geometry = createQuadGeometry(particles.count);
|
||||
|
||||
const positionAttribute = new THREE.InstancedBufferAttribute(particles.positions, 3);
|
||||
const colorAttribute = new THREE.InstancedBufferAttribute(particles.colors, 3);
|
||||
const sizeAttribute = new THREE.InstancedBufferAttribute(particles.sizes, 1);
|
||||
const alphaAttribute = new THREE.InstancedBufferAttribute(particles.alphas, 1);
|
||||
|
||||
this.material = new THREE.SpriteNodeMaterial({
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
this.material.positionNode = instancedBufferAttribute(positionAttribute, 'vec3');
|
||||
this.material.scaleNode = instancedBufferAttribute(sizeAttribute, 'float');
|
||||
this.material.colorNode = instancedBufferAttribute(colorAttribute, 'vec3');
|
||||
// A gentler falloff than the star field's: these are clouds, and the tight curve that makes
|
||||
// a star read as a bright point makes a cloud read as a solid ball.
|
||||
const radius = uv().sub(vec2(0.5)).length();
|
||||
const falloff = smoothstep(0.0, 0.5, radius).oneMinus().pow(1.6);
|
||||
this.material.opacityNode = falloff.mul(instancedBufferAttribute(alphaAttribute, 'float')).mul(this.fade);
|
||||
|
||||
this.object = new THREE.Mesh(this.geometry, this.material);
|
||||
// The quad's own bounds sit at the origin and say nothing about where the instances are.
|
||||
this.object.frustumCulled = false;
|
||||
this.object.visible = false;
|
||||
// Behind everything else: the model is a backdrop for the real data, never in front of it.
|
||||
this.object.renderOrder = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crossfades the model against how far the camera has pulled back, and returns the resulting
|
||||
* strength (0-1). The mesh is skipped outright at zero so the local view pays nothing for it.
|
||||
*/
|
||||
setViewerDistancePc(distancePc: number): number {
|
||||
const t = (distancePc - GALAXY_FADE_NEAR_PC) / (GALAXY_FADE_FAR_PC - GALAXY_FADE_NEAR_PC);
|
||||
this.fadeValue = Math.max(0, Math.min(1, t));
|
||||
this.fade.value = this.fadeValue;
|
||||
this.object.visible = this.fadeValue > 0;
|
||||
return this.fadeValue;
|
||||
}
|
||||
|
||||
get strength(): number {
|
||||
return this.fadeValue;
|
||||
}
|
||||
|
||||
/** Named structural landmarks — the centre, the Sun, and one label per arm. */
|
||||
labelPoints(): readonly LabeledPoint[] {
|
||||
return GALACTIC_LANDMARKS.map((landmark) => {
|
||||
const position = landmarkPositionPc(landmark);
|
||||
return { id: `galactic:${landmark.id}`, name: landmark.name, kind: landmark.kind, x: position.x, y: position.y, z: position.z };
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.object.removeFromParent();
|
||||
this.geometry.dispose();
|
||||
this.material.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { colorIndexToRgb, magnitudeToPointSize, selectDrawnStars, StarFieldRenderer } from './star-field-renderer';
|
||||
|
||||
function star(overrides: Partial<StarRecord> = {}): StarRecord {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Test Star',
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
magnitude: 5,
|
||||
spectralType: 'G2V',
|
||||
colorIndex: 0.65,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function packPositions(stars: readonly StarRecord[]): Float32Array {
|
||||
return new Float32Array(stars.flatMap((s) => [s.x, s.y, s.z]));
|
||||
}
|
||||
|
||||
/** A camera looking down -Z from the origin, framing everything in front of it. */
|
||||
function testCamera(): THREE.PerspectiveCamera {
|
||||
const camera = new THREE.PerspectiveCamera(55, 16 / 9, 0.01, 5000);
|
||||
camera.position.set(0, 0, 0);
|
||||
camera.lookAt(0, 0, -1);
|
||||
camera.updateMatrixWorld(true);
|
||||
camera.updateProjectionMatrix();
|
||||
return camera;
|
||||
}
|
||||
|
||||
describe('colorIndexToRgb', () => {
|
||||
it('tints a hot, low-index star blue-white', () => {
|
||||
const color = colorIndexToRgb(-0.3);
|
||||
expect(color.b).toBeGreaterThan(color.r);
|
||||
});
|
||||
|
||||
it('tints a cool, high-index star orange-red', () => {
|
||||
const color = colorIndexToRgb(1.8);
|
||||
expect(color.r).toBeGreaterThan(color.b);
|
||||
});
|
||||
|
||||
it('moves monotonically from blue toward red as the index rises', () => {
|
||||
const blueness = [-0.3, 0.2, 0.65, 1.2, 1.9].map((index) => {
|
||||
const color = colorIndexToRgb(index);
|
||||
return color.b - color.r;
|
||||
});
|
||||
expect([...blueness].sort((a, b) => b - a)).toEqual(blueness);
|
||||
});
|
||||
|
||||
describe('when the catalog has no photometry', () => {
|
||||
// ~10% of nearby HYG stars have a blank colour-index cell. Reading that as 0 (which is a
|
||||
// real index, meaning a hot A-type star) painted several hundred red dwarfs blue-white.
|
||||
it('falls back to the spectral type rather than to zero', () => {
|
||||
const fromNull = colorIndexToRgb(null, 'M4');
|
||||
const asIfZero = colorIndexToRgb(0);
|
||||
|
||||
expect(fromNull.r).toBeGreaterThan(fromNull.b);
|
||||
expect(asIfZero.b).toBeGreaterThan(asIfZero.r);
|
||||
});
|
||||
|
||||
it('matches the colour the same spectral type would give explicitly', () => {
|
||||
// K5 sits halfway between the K anchor (0.81) and the M anchor (1.40).
|
||||
const derived = colorIndexToRgb(null, 'K5');
|
||||
const explicit = colorIndexToRgb(1.105);
|
||||
|
||||
expect(derived.r).toBeCloseTo(explicit.r, 6);
|
||||
expect(derived.g).toBeCloseTo(explicit.g, 6);
|
||||
expect(derived.b).toBeCloseTo(explicit.b, 6);
|
||||
});
|
||||
|
||||
it('handles the bare lowercase classes HYG ships', () => {
|
||||
const color = colorIndexToRgb(null, 'm');
|
||||
expect(color.r).toBeGreaterThan(color.b);
|
||||
});
|
||||
|
||||
it('falls back to neutral when the star is unclassified too', () => {
|
||||
const color = colorIndexToRgb(null, 'Unknown');
|
||||
expect(color.r).toBeCloseTo(1, 6);
|
||||
expect(color.g).toBeCloseTo(1, 6);
|
||||
expect(color.b).toBeCloseTo(1, 6);
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers a measured index over the spectral type', () => {
|
||||
const measured = colorIndexToRgb(-0.3, 'M5');
|
||||
expect(measured.b).toBeGreaterThan(measured.r);
|
||||
});
|
||||
});
|
||||
|
||||
describe('magnitudeToPointSize', () => {
|
||||
it('renders brighter stars larger', () => {
|
||||
expect(magnitudeToPointSize(-1)).toBeGreaterThan(magnitudeToPointSize(12));
|
||||
});
|
||||
|
||||
it('clamps outside the magnitude range rather than running away', () => {
|
||||
expect(magnitudeToPointSize(-30)).toBe(magnitudeToPointSize(-2));
|
||||
expect(magnitudeToPointSize(50)).toBe(magnitudeToPointSize(10));
|
||||
});
|
||||
});
|
||||
|
||||
describe('StarFieldRenderer', () => {
|
||||
const stars = [star({ id: 10, name: 'A' }), star({ id: 20, name: 'B', colorIndex: null, spectralType: 'M4' })];
|
||||
|
||||
it('draws one instance per star from a single shared quad', () => {
|
||||
const renderer = new StarFieldRenderer(stars, packPositions(stars));
|
||||
const geometry = renderer.object.geometry as THREE.InstancedBufferGeometry;
|
||||
|
||||
expect(geometry.instanceCount).toBe(2);
|
||||
// Four corners of one quad, reused by every instance.
|
||||
expect(geometry.getAttribute('position').count).toBe(4);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('never culls itself, since its geometry sits at the origin', () => {
|
||||
// The quad's bounds say nothing about where the instances are, so culling would drop the
|
||||
// entire field whenever the origin left the frustum.
|
||||
const renderer = new StarFieldRenderer(stars, packPositions(stars));
|
||||
expect(renderer.object.frustumCulled).toBe(false);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('maps an instance index back to its HYG star id', () => {
|
||||
const renderer = new StarFieldRenderer(stars, packPositions(stars));
|
||||
|
||||
expect(renderer.starIdAt(0)).toBe(10);
|
||||
expect(renderer.starIdAt(1)).toBe(20);
|
||||
expect(renderer.starIdAt(99)).toBeUndefined();
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('handles an empty star field', () => {
|
||||
const renderer = new StarFieldRenderer([], new Float32Array(0));
|
||||
|
||||
expect((renderer.object.geometry as THREE.InstancedBufferGeometry).instanceCount).toBe(0);
|
||||
expect(renderer.starIdAt(0)).toBeUndefined();
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
describe('pickAt', () => {
|
||||
const camera = testCamera();
|
||||
// Two stars straight ahead, one well off to the side.
|
||||
const picked = [
|
||||
star({ id: 1, name: 'Near', x: 0, y: 0, z: -10, magnitude: 1 }),
|
||||
star({ id: 2, name: 'Far', x: 0, y: 0, z: -100, magnitude: 1 }),
|
||||
star({ id: 3, name: 'Aside', x: 40, y: 0, z: -10, magnitude: 1 })
|
||||
];
|
||||
|
||||
it('finds the star under the pointer', () => {
|
||||
const renderer = new StarFieldRenderer(picked, packPositions(picked));
|
||||
// Both Near and Far project to the screen centre; either is a correct hit.
|
||||
expect([1, 2]).toContain(renderer.pickAt(new THREE.Vector2(0, 0), camera));
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('returns undefined when the pointer is on empty sky', () => {
|
||||
const renderer = new StarFieldRenderer(picked, packPositions(picked));
|
||||
expect(renderer.pickAt(new THREE.Vector2(-0.9, 0.9), camera)).toBeUndefined();
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('ignores stars behind the camera', () => {
|
||||
// `project()` mirrors points behind the camera back onto the screen, so without an
|
||||
// explicit depth guard this star would be pickable at the centre of the view.
|
||||
const behind = [star({ id: 7, x: 0, y: 0, z: 10 })];
|
||||
const renderer = new StarFieldRenderer(behind, packPositions(behind));
|
||||
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined();
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('picks the star nearest the pointer when several are in view', () => {
|
||||
const spread = [
|
||||
star({ id: 1, x: 0, y: 0, z: -10 }),
|
||||
star({ id: 2, x: 0, y: 2, z: -10 }),
|
||||
star({ id: 3, x: 0, y: -2, z: -10 })
|
||||
];
|
||||
const renderer = new StarFieldRenderer(spread, packPositions(spread));
|
||||
|
||||
// Aim at where star 2 projects, and confirm we get it rather than its neighbours.
|
||||
const target = new THREE.Vector3(0, 2, -10).project(camera);
|
||||
expect(renderer.pickAt(new THREE.Vector2(target.x, target.y), camera)).toBe(2);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('gives a brighter star a larger hit area than a faint one', () => {
|
||||
const bright = [star({ id: 1, x: 0, y: 0, z: -10, magnitude: -1 })];
|
||||
const faint = [star({ id: 2, x: 0, y: 0, z: -10, magnitude: 14 })];
|
||||
const brightRenderer = new StarFieldRenderer(bright, packPositions(bright));
|
||||
const faintRenderer = new StarFieldRenderer(faint, packPositions(faint));
|
||||
|
||||
// Walk outward from the centre until each stops being pickable.
|
||||
const reach = (renderer: StarFieldRenderer): number => {
|
||||
let offset = 0;
|
||||
while (offset < 1 && renderer.pickAt(new THREE.Vector2(0, offset), camera) !== undefined) {
|
||||
offset += 0.001;
|
||||
}
|
||||
return offset;
|
||||
};
|
||||
|
||||
expect(reach(brightRenderer)).toBeGreaterThan(reach(faintRenderer));
|
||||
brightRenderer.dispose();
|
||||
faintRenderer.dispose();
|
||||
});
|
||||
|
||||
it('keeps even the faintest star clickable', () => {
|
||||
// A magnitude-15 star is drawn under 2 px across, so without the added slop the faint end
|
||||
// of the catalogue would demand sub-pixel accuracy.
|
||||
const faint = [star({ id: 5, x: 0, y: 0, z: -10, magnitude: 15 })];
|
||||
const renderer = new StarFieldRenderer(faint, packPositions(faint));
|
||||
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0.005), camera)).toBe(5);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('finds nothing in an empty field', () => {
|
||||
const renderer = new StarFieldRenderer([], new Float32Array(0));
|
||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined();
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** A star at a given distance along +X, with a given apparent magnitude. */
|
||||
function catalogueStar(id: number, distancePc: number, magnitude: number): StarRecord {
|
||||
return { id, name: `star-${id}`, x: distancePc, y: 0, z: 0, magnitude, spectralType: 'G2V', colorIndex: 0.6 };
|
||||
}
|
||||
|
||||
describe('selectDrawnStars', () => {
|
||||
it('draws everything when the catalogue fits the budget', () => {
|
||||
const catalogue = [catalogueStar(1, 10, 5), catalogueStar(2, 20, 6)];
|
||||
expect(Array.from(selectDrawnStars(catalogue, 10))).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('never draws more than the budget', () => {
|
||||
const catalogue = Array.from({ length: 500 }, (_, i) => catalogueStar(i, 200, i));
|
||||
expect(selectDrawnStars(catalogue, 50)).toHaveLength(50);
|
||||
});
|
||||
|
||||
it('keeps the whole solar neighbourhood, however faint', () => {
|
||||
// The load-bearing case: the nearest stars are overwhelmingly faint red dwarfs, and Proxima
|
||||
// Centauri is magnitude 11. A pure brightness cut would delete the part of the map that
|
||||
// matters most and holds the nearby planets.
|
||||
const proxima = catalogueStar(999, 1.3, 11.1);
|
||||
const catalogue = [proxima, ...Array.from({ length: 200 }, (_, i) => catalogueStar(i, 240, 2))];
|
||||
const drawn = selectDrawnStars(catalogue, 20);
|
||||
|
||||
expect(Array.from(drawn)).toContain(0);
|
||||
expect(drawn).toHaveLength(20);
|
||||
});
|
||||
|
||||
it('spends what is left on the brightest stars beyond the neighbourhood', () => {
|
||||
const catalogue = [catalogueStar(0, 10, 12), catalogueStar(1, 200, 8), catalogueStar(2, 200, 2), catalogueStar(3, 200, 5)];
|
||||
const drawn = Array.from(selectDrawnStars(catalogue, 3));
|
||||
|
||||
// The nearby faint one, then the two brightest distant ones — not the magnitude-8 straggler.
|
||||
expect(drawn).toEqual([0, 2, 3]);
|
||||
});
|
||||
|
||||
it('returns catalogue indices in order, so positions can be subset alongside', () => {
|
||||
const catalogue = Array.from({ length: 100 }, (_, i) => catalogueStar(i, 150, 100 - i));
|
||||
const drawn = Array.from(selectDrawnStars(catalogue, 10));
|
||||
expect(drawn).toEqual([...drawn].sort((a, b) => a - b));
|
||||
});
|
||||
});
|
||||
|
||||
describe('StarFieldRenderer render budget', () => {
|
||||
it('draws only the budget, and reports how many that was', () => {
|
||||
const catalogue = Array.from({ length: 300 }, (_, i) => catalogueStar(i, 200, i));
|
||||
const positions = new Float32Array(catalogue.flatMap((s) => [s.x, s.y, s.z]));
|
||||
const renderer = new StarFieldRenderer(catalogue, positions, 40);
|
||||
|
||||
expect(renderer.drawnCount).toBe(40);
|
||||
expect((renderer.object.geometry as THREE.InstancedBufferGeometry).instanceCount).toBe(40);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('keeps each drawn star with its own position after subsetting', () => {
|
||||
// The subtle failure this guards: repacking positions for a subset while the colours and
|
||||
// sizes follow a different order would give every star someone else's place in the sky.
|
||||
const catalogue = [catalogueStar(0, 5, 9), catalogueStar(1, 200, 1), catalogueStar(2, 200, 7)];
|
||||
const positions = new Float32Array(catalogue.flatMap((s) => [s.x, s.y, s.z]));
|
||||
const renderer = new StarFieldRenderer(catalogue, positions, 2);
|
||||
|
||||
expect(renderer.drawnCount).toBe(2);
|
||||
expect(renderer.starIdAt(0)).toBe(0);
|
||||
expect(renderer.starIdAt(1)).toBe(1);
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,70 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { attribute } from 'three/tsl';
|
||||
import { instancedBufferAttribute, smoothstep, uv, vec2 } from 'three/tsl';
|
||||
|
||||
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
|
||||
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
|
||||
const MIN_POINT_SIZE = 1.5;
|
||||
const MAX_POINT_SIZE = 6;
|
||||
|
||||
/**
|
||||
* Star size is expressed in pixels for readability, but the material works in angular size, so
|
||||
* the two are related through the scene's vertical field of view and a reference viewport.
|
||||
* Because the size is angular, a star keeps the same share of the screen at any window size —
|
||||
* these pixel figures are exact only at this reference height.
|
||||
*/
|
||||
const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
|
||||
const REFERENCE_FOV_DEGREES = 55;
|
||||
const PIXELS_TO_ANGULAR_SIZE =
|
||||
(2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
|
||||
|
||||
/**
|
||||
* Extra click forgiveness added to a star's drawn radius, in NDC — roughly 4 px on the
|
||||
* reference viewport.
|
||||
*
|
||||
* Added rather than used as a floor. Stars are drawn 1.5-6 px across, so any floor generous
|
||||
* enough to make the faintest ones clickable would also exceed the brightest one's radius and
|
||||
* flatten every star to the same hit area. Adding keeps the ordering intact: a brighter star is
|
||||
* always the easier target, which is what the eye expects.
|
||||
*/
|
||||
const PICK_NDC_SLOP = 0.01;
|
||||
|
||||
/**
|
||||
* How many stars the field draws at once, however many the catalogue holds.
|
||||
*
|
||||
* The catalogue reaches as far as its parallaxes do — 68388 stars at 250 pc — but drawing all of
|
||||
* them is a cost paid every frame by every machine, and most of that cost buys 1.5-pixel dots.
|
||||
* So the *data* is the catalogue and the *drawing* is a budget, and the two are allowed to
|
||||
* differ. Everything still exists for search, for flying to, and for hosting planets.
|
||||
*
|
||||
* Currently set to the whole catalogue, which is what a GPU should be asked to do — this is one
|
||||
* instanced draw call, and a discrete card will not notice it. The budget still exists because
|
||||
* the catalogue is meant to grow past what any machine should draw at once: Gaia alone could
|
||||
* contribute a million stars, and at that point the selection below is what keeps the field
|
||||
* legible rather than a grey wash.
|
||||
*
|
||||
* Machines without a GPU do feel it. A software rasterizer measured here lost about a third of
|
||||
* its frame rate per 12000 stars drawn; if that matters for a deployment, this is the one number
|
||||
* to turn down.
|
||||
*/
|
||||
export const STAR_RENDER_BUDGET = 68388;
|
||||
|
||||
/**
|
||||
* Radius (parsecs) inside which every star is drawn regardless of brightness.
|
||||
*
|
||||
* A pure brightness cut would be defensible — apparent magnitude is exactly "how visible this
|
||||
* is" — but it would drop the solar neighbourhood, because the nearest stars are overwhelmingly
|
||||
* faint red dwarfs. Proxima Centauri is magnitude 11. Those are the stars this map is most about
|
||||
* and the ones that hold the nearby planets, so the neighbourhood is kept whole and the budget
|
||||
* is spent on the brightest of everything beyond it.
|
||||
*
|
||||
* Kept deliberately small against the catalogue's 250 pc reach. The guaranteed core occupies a
|
||||
* thousandth of that volume, so a generous radius spends most of the budget inside it and draws
|
||||
* a dense knot surrounded by nothing — which is a worse picture than the smaller catalogue was.
|
||||
*/
|
||||
export const ALWAYS_DRAWN_RADIUS_PC = 25;
|
||||
|
||||
const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0);
|
||||
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
|
||||
const WARM_STAR_COLOR = new THREE.Color(1.0, 0.6, 0.35);
|
||||
@@ -13,66 +72,203 @@ const WARM_STAR_COLOR = new THREE.Color(1.0, 0.6, 0.35);
|
||||
/**
|
||||
* Crude but effective B-V color-index -> RGB tint: hot/blue stars (low/negative index) skew
|
||||
* blue-white, cool/red stars (high index) skew orange-red, matching real spectral colors.
|
||||
*
|
||||
* `colorIndex` is `null` for the ~10% of stars HYG never photometered. Those fall back to a
|
||||
* value derived from `spectralType`, and to neutral white only when the catalog records no
|
||||
* classification at all — never to 0, which is itself a real color index meaning "hot A-type"
|
||||
* and would paint several hundred red dwarfs blue-white.
|
||||
*/
|
||||
export function colorIndexToRgb(colorIndex: number): THREE.Color {
|
||||
const t = THREE.MathUtils.clamp((colorIndex + 0.4) / 2.4, 0, 1);
|
||||
export function colorIndexToRgb(colorIndex: number | null, spectralType?: string): THREE.Color {
|
||||
const resolved = colorIndex ?? spectralTypeToColorIndex(spectralType);
|
||||
const color = new THREE.Color();
|
||||
if (resolved === null) {
|
||||
return color.copy(NEUTRAL_STAR_COLOR);
|
||||
}
|
||||
|
||||
const t = THREE.MathUtils.clamp((resolved + 0.4) / 2.4, 0, 1);
|
||||
return t < 0.5 ? color.lerpColors(COLD_STAR_COLOR, NEUTRAL_STAR_COLOR, t * 2) : color.lerpColors(NEUTRAL_STAR_COLOR, WARM_STAR_COLOR, (t - 0.5) * 2);
|
||||
}
|
||||
|
||||
/** Brighter stars (lower apparent magnitude) render as bigger points. */
|
||||
function magnitudeToPointSize(magnitude: number): number {
|
||||
export function magnitudeToPointSize(magnitude: number): number {
|
||||
const t = THREE.MathUtils.clamp(1 - (magnitude + 2) / 12, 0, 1);
|
||||
return MIN_POINT_SIZE + t * (MAX_POINT_SIZE - MIN_POINT_SIZE);
|
||||
}
|
||||
|
||||
/** A unit quad centred on the origin — the billboard every star instance is drawn on. */
|
||||
function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometry {
|
||||
const geometry = new THREE.InstancedBufferGeometry();
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]), 3)
|
||||
);
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), 2));
|
||||
geometry.setIndex([0, 1, 2, 0, 2, 3]);
|
||||
geometry.instanceCount = instanceCount;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a `THREE.Points` field from the ETL-generated star positions/index, using a TSL
|
||||
* `PointsNodeMaterial` whose color/size are driven by per-vertex attributes derived from
|
||||
* each star's spectral color index and magnitude.
|
||||
* Builds the galaxy-scale star field as instanced camera-facing billboards, one per HYG star,
|
||||
* coloured by spectral index and sized by magnitude.
|
||||
*
|
||||
* Note: per the Three.js WebGPU backend, point primitives are capped at 1px on native
|
||||
* WebGPU — `sizeNode` only has a visible effect when `WebGPURenderer` has fallen back to
|
||||
* its WebGL2 backend. Color variation works on both backends.
|
||||
* **Why billboards and not `THREE.Points`.** Point primitives are capped at a single pixel on
|
||||
* the WebGPU backend — which is the renderer this app targets — so a points cloud rendered
|
||||
* every star as an identical 1 px dot no matter what `sizeNode` said, discarding both the
|
||||
* magnitude sizing and any glow. Instanced quads render identically on both backends.
|
||||
*
|
||||
* `SpriteNodeMaterial` takes each instance's centre from `positionNode` rather than from an
|
||||
* instance matrix (see its own documentation), so the per-star data rides on instanced buffer
|
||||
* attributes and the mesh itself never moves.
|
||||
*
|
||||
* Sizes are angular (`sizeAttenuation = false`), so a star holds the same apparent size however
|
||||
* close the camera gets. That is deliberate and physically right: real stars are unresolvable
|
||||
* point sources, and their apparent size on screen is a function of brightness, not distance.
|
||||
*/
|
||||
/**
|
||||
* Chooses which stars to draw when the catalogue is larger than the budget: everything inside
|
||||
* the neighbourhood radius, then the brightest of the rest until the budget is spent.
|
||||
*
|
||||
* Returns indices into the original list, so the caller can subset the positions that go with
|
||||
* them. Returns them in catalogue order rather than in selection order, purely so the drawn set
|
||||
* is stable and inspectable.
|
||||
*/
|
||||
/**
|
||||
* Reads a render budget override off the page URL (`?stars=20000`), falling back to the default.
|
||||
*
|
||||
* Two uses, one real and one incidental. The real one is a deployment or a machine that cannot
|
||||
* draw the whole catalogue — a number in a URL beats a rebuild. The incidental one is the
|
||||
* end-to-end suite, which runs against a software rasterizer whose frame rate is two orders of
|
||||
* magnitude below a real GPU's: those tests are checking navigation and state, and making them
|
||||
* wait on a rasterizer measures nothing about the app.
|
||||
*/
|
||||
export function starRenderBudgetFromUrl(search: string, fallback = STAR_RENDER_BUDGET): number {
|
||||
const requested = Number(new URLSearchParams(search).get('stars'));
|
||||
return Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : fallback;
|
||||
}
|
||||
|
||||
export function selectDrawnStars(stars: readonly StarRecord[], budget = STAR_RENDER_BUDGET): Uint32Array {
|
||||
if (stars.length <= budget) {
|
||||
return Uint32Array.from(stars.keys());
|
||||
}
|
||||
|
||||
const near: number[] = [];
|
||||
const far: number[] = [];
|
||||
stars.forEach((star, index) => {
|
||||
(Math.hypot(star.x, star.y, star.z) <= ALWAYS_DRAWN_RADIUS_PC ? near : far).push(index);
|
||||
});
|
||||
|
||||
far.sort((a, b) => stars[a].magnitude - stars[b].magnitude);
|
||||
const selected = near.concat(far.slice(0, Math.max(0, budget - near.length)));
|
||||
selected.sort((a, b) => a - b);
|
||||
return Uint32Array.from(selected);
|
||||
}
|
||||
|
||||
export class StarFieldRenderer {
|
||||
readonly object: THREE.Points;
|
||||
readonly object: THREE.Mesh;
|
||||
/** How many of the catalogue's stars this field actually draws. */
|
||||
readonly drawnCount: number;
|
||||
|
||||
private readonly geometry: THREE.BufferGeometry;
|
||||
private readonly material: THREE.PointsNodeMaterial;
|
||||
private readonly geometry: THREE.InstancedBufferGeometry;
|
||||
private readonly material: THREE.SpriteNodeMaterial;
|
||||
/** The subset of the catalogue that is drawn, and so the only set that can be clicked. */
|
||||
private readonly stars: readonly StarRecord[];
|
||||
/** Angular diameter per drawn star, in the same order as `stars` — reused for picking. */
|
||||
private readonly angularSizes: Float32Array;
|
||||
|
||||
constructor(private readonly stars: readonly StarRecord[], positions: Float32Array) {
|
||||
this.geometry = new THREE.BufferGeometry();
|
||||
this.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
constructor(catalogue: readonly StarRecord[], cataloguePositions: Float32Array, budget = STAR_RENDER_BUDGET) {
|
||||
const drawn = selectDrawnStars(catalogue, budget);
|
||||
this.stars = drawn.length === catalogue.length ? catalogue : Array.from(drawn, (index) => catalogue[index]);
|
||||
this.drawnCount = this.stars.length;
|
||||
|
||||
const stars = this.stars;
|
||||
this.geometry = createQuadGeometry(stars.length);
|
||||
|
||||
const colors = new Float32Array(stars.length * 3);
|
||||
const sizes = new Float32Array(stars.length);
|
||||
this.angularSizes = new Float32Array(stars.length);
|
||||
// Repacked only when the drawn set is a subset; otherwise the ETL's buffer is used as-is.
|
||||
const positions =
|
||||
drawn.length === catalogue.length
|
||||
? cataloguePositions
|
||||
: Float32Array.from({ length: drawn.length * 3 }, (_, i) => cataloguePositions[drawn[(i / 3) | 0] * 3 + (i % 3)]);
|
||||
|
||||
stars.forEach((star, index) => {
|
||||
const color = colorIndexToRgb(star.colorIndex);
|
||||
const color = colorIndexToRgb(star.colorIndex, star.spectralType);
|
||||
colors[index * 3] = color.r;
|
||||
colors[index * 3 + 1] = color.g;
|
||||
colors[index * 3 + 2] = color.b;
|
||||
sizes[index] = magnitudeToPointSize(star.magnitude);
|
||||
this.angularSizes[index] = magnitudeToPointSize(star.magnitude) * PIXELS_TO_ANGULAR_SIZE;
|
||||
});
|
||||
|
||||
this.geometry.setAttribute('starColor', new THREE.BufferAttribute(colors, 3));
|
||||
this.geometry.setAttribute('starSize', new THREE.BufferAttribute(sizes, 1));
|
||||
const positionAttribute = new THREE.InstancedBufferAttribute(positions, 3);
|
||||
const colorAttribute = new THREE.InstancedBufferAttribute(colors, 3);
|
||||
const sizeAttribute = new THREE.InstancedBufferAttribute(this.angularSizes, 1);
|
||||
|
||||
this.material = new THREE.PointsNodeMaterial({
|
||||
colorNode: attribute('starColor', 'vec3'),
|
||||
sizeNode: attribute('starSize', 'float'),
|
||||
sizeAttenuation: true,
|
||||
this.material = new THREE.SpriteNodeMaterial({
|
||||
transparent: true,
|
||||
depthWrite: false
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
this.material.sizeAttenuation = false;
|
||||
this.material.positionNode = instancedBufferAttribute(positionAttribute, 'vec3');
|
||||
this.material.scaleNode = instancedBufferAttribute(sizeAttribute, 'float');
|
||||
this.material.colorNode = instancedBufferAttribute(colorAttribute, 'vec3');
|
||||
// Soft radial falloff so each star is a small bright core inside a halo, rather than a
|
||||
// hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre.
|
||||
const radius = uv().sub(vec2(0.5)).length();
|
||||
this.material.opacityNode = smoothstep(0.0, 0.5, radius).oneMinus().pow(2.0);
|
||||
|
||||
this.object = new THREE.Points(this.geometry, this.material);
|
||||
this.object = new THREE.Mesh(this.geometry, this.material);
|
||||
// The quad's own bounds sit at the origin and say nothing about where the instances are,
|
||||
// so leaving culling on would drop the whole field whenever the origin left the frustum.
|
||||
this.object.frustumCulled = false;
|
||||
}
|
||||
|
||||
/** Looks up the HYG star id for a given geometry vertex index (e.g. from a raycast hit). */
|
||||
starIdAt(vertexIndex: number): number | undefined {
|
||||
return this.stars[vertexIndex]?.id;
|
||||
/** Looks up the HYG star id for a given instance index. */
|
||||
starIdAt(instanceIndex: number): number | undefined {
|
||||
return this.stars[instanceIndex]?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The star under `pointerNdc`, or `undefined`.
|
||||
*
|
||||
* Billboarding happens in the vertex shader, so the CPU-side geometry is a single quad at the
|
||||
* origin and `Raycaster` cannot see the star field at all. Picking is therefore done in screen
|
||||
* space, which is also strictly better than the fixed world-space radius the points cloud
|
||||
* needed: each star is tested against the size it is actually drawn at, so the hit area matches
|
||||
* what the user sees at every zoom level instead of being over-permissive up close and
|
||||
* sub-pixel at the far end of the camera's range.
|
||||
*/
|
||||
pickAt(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): number | undefined {
|
||||
const tanHalfFov = Math.tan((camera.fov * Math.PI) / 360);
|
||||
const projected = new THREE.Vector3();
|
||||
|
||||
let bestIndex: number | undefined;
|
||||
let bestScore = Infinity;
|
||||
|
||||
for (let index = 0; index < this.stars.length; index++) {
|
||||
const star = this.stars[index];
|
||||
projected.set(star.x, star.y, star.z).project(camera);
|
||||
// Outside the depth range means behind the camera or beyond the far plane; `project`
|
||||
// mirrors points behind the camera onto the screen, so this guard is load-bearing.
|
||||
if (projected.z < -1 || projected.z > 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
|
||||
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
|
||||
const ndcRadius = (0.5 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP;
|
||||
const dx = (projected.x - pointerNdc.x) * camera.aspect;
|
||||
const dy = projected.y - pointerNdc.y;
|
||||
const score = Math.hypot(dx, dy) / ndcRadius;
|
||||
|
||||
if (score <= 1 && score < bestScore) {
|
||||
bestScore = score;
|
||||
bestIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex === undefined ? undefined : this.stars[bestIndex].id;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { StarLabelOverlay } from './star-label-overlay';
|
||||
|
||||
describe('StarLabelOverlay', () => {
|
||||
let scene: THREE.Scene;
|
||||
let overlay: StarLabelOverlay;
|
||||
let camera: THREE.PerspectiveCamera;
|
||||
|
||||
/**
|
||||
* Every label element currently in the layer, in DOM order.
|
||||
*
|
||||
* `CSS2DRenderer` only attaches an element to its layer when it projects it, so the labels do
|
||||
* not exist in the DOM until something has been rendered — which is why this draws a frame
|
||||
* rather than reading straight off `domElement`.
|
||||
*/
|
||||
function labels(): HTMLElement[] {
|
||||
overlay.render(camera);
|
||||
return [...overlay.domElement.querySelectorAll<HTMLElement>('.map-label')];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new THREE.Scene();
|
||||
overlay = new StarLabelOverlay(scene);
|
||||
overlay.setSize(800, 600);
|
||||
camera = new THREE.PerspectiveCamera(50, 800 / 600, 0.1, 1000);
|
||||
camera.position.set(0, 0, 20);
|
||||
camera.updateMatrixWorld();
|
||||
});
|
||||
|
||||
it('prints what a thing is under what it is called', () => {
|
||||
overlay.update([{ id: 1, name: 'Sirius', kind: 'System', x: 1, y: 2, z: 3 }]);
|
||||
|
||||
const label = labels()[0];
|
||||
expect(label.querySelector('.map-label-name')?.textContent).toBe('Sirius');
|
||||
expect(label.querySelector('.map-label-kind')?.textContent).toBe('System');
|
||||
});
|
||||
|
||||
it('prints the name alone when there is no type to give', () => {
|
||||
// The second line is optional, and an empty one would still cost its line height — which
|
||||
// over a screen of labels shifts every name off the point it is anchored to.
|
||||
overlay.update([{ id: 1, name: 'Sirius', x: 1, y: 2, z: 3 }]);
|
||||
|
||||
expect(labels()[0].querySelector('.map-label-name')?.textContent).toBe('Sirius');
|
||||
expect(labels()[0].querySelector('.map-label-kind')).toBeNull();
|
||||
});
|
||||
|
||||
it('places the label at the point it belongs to', () => {
|
||||
overlay.update([{ id: 7, name: 'Vega', kind: 'Star', x: 4, y: -5, z: 6 }]);
|
||||
|
||||
const object = scene.children.find((child) => child.type === 'Object3D');
|
||||
expect(object?.position.toArray()).toEqual([4, -5, 6]);
|
||||
});
|
||||
|
||||
it('touches the DOM only for labels that actually changed', () => {
|
||||
overlay.update([
|
||||
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
||||
{ id: 2, name: 'Vega', kind: 'Star', x: 0, y: 1, z: 0 }
|
||||
]);
|
||||
const sirius = labels()[0];
|
||||
|
||||
overlay.update([
|
||||
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
||||
{ id: 3, name: 'Altair', kind: 'Star', x: 0, y: 0, z: 1 }
|
||||
]);
|
||||
|
||||
// Same element instance: the label that stayed was not torn down and rebuilt.
|
||||
expect(labels()[0]).toBe(sirius);
|
||||
expect(labels().map((label) => label.querySelector('.map-label-name')?.textContent)).toEqual(['Sirius', 'Altair']);
|
||||
});
|
||||
|
||||
it('drops every label when given none', () => {
|
||||
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 }]);
|
||||
overlay.update([]);
|
||||
|
||||
expect(labels()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves nothing behind when disposed', () => {
|
||||
overlay.update([
|
||||
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
||||
{ id: 'ngc:224', name: 'Andromeda', kind: 'GALAXY', x: 0, y: 1, z: 0 }
|
||||
]);
|
||||
overlay.dispose();
|
||||
|
||||
expect(labels()).toHaveLength(0);
|
||||
expect(scene.children).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,19 @@ import * as THREE from 'three/webgpu';
|
||||
import { CSS2DObject, CSS2DRenderer } from 'three/addons/renderers/CSS2DRenderer.js';
|
||||
|
||||
export interface LabeledPoint {
|
||||
id: number;
|
||||
/** Numeric for HYG stars, string for catalog designations such as deep-sky objects. */
|
||||
id: number | string;
|
||||
name: string;
|
||||
/**
|
||||
* What sort of thing this is — `STAR`, `PLANET`, `NEBULA`, `ARM`. Printed under the name in
|
||||
* smaller, dimmer, wider-tracked capitals.
|
||||
*
|
||||
* A name on its own is ambiguous in a map that mixes scales: "Orion" is an arm, a nebula and a
|
||||
* constellation, and at a glance nothing distinguishes the label on one from the label on
|
||||
* another. The second line is what makes a label say what it is pointing at, not just what it
|
||||
* is called.
|
||||
*/
|
||||
kind?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
@@ -19,7 +30,7 @@ export class StarLabelOverlay {
|
||||
readonly domElement: HTMLElement;
|
||||
|
||||
private readonly cssRenderer = new CSS2DRenderer();
|
||||
private readonly labelObjects = new Map<number, CSS2DObject>();
|
||||
private readonly labelObjects = new Map<number | string, CSS2DObject>();
|
||||
|
||||
constructor(private readonly scene: THREE.Scene) {
|
||||
this.cssRenderer.domElement.classList.add('star-label-layer');
|
||||
@@ -30,7 +41,13 @@ export class StarLabelOverlay {
|
||||
this.cssRenderer.setSize(width, height);
|
||||
}
|
||||
|
||||
/** Shows exactly these labels, adding/removing DOM elements only for a changed set. */
|
||||
/**
|
||||
* Shows exactly these labels, adding/removing DOM elements only for a changed set.
|
||||
*
|
||||
* A label that is already up is repositioned rather than left where it was: stars never move,
|
||||
* but planets do, and a system's labels would otherwise stay pinned to wherever each body
|
||||
* happened to be when its label first appeared.
|
||||
*/
|
||||
update(points: readonly LabeledPoint[]): void {
|
||||
const idsToShow = new Set(points.map((point) => point.id));
|
||||
|
||||
@@ -41,7 +58,10 @@ export class StarLabelOverlay {
|
||||
}
|
||||
|
||||
for (const point of points) {
|
||||
if (!this.labelObjects.has(point.id)) {
|
||||
const existing = this.labelObjects.get(point.id);
|
||||
if (existing) {
|
||||
existing.position.set(point.x, point.y, point.z);
|
||||
} else {
|
||||
this.addLabel(point);
|
||||
}
|
||||
}
|
||||
@@ -59,16 +79,23 @@ export class StarLabelOverlay {
|
||||
|
||||
private addLabel(point: LabeledPoint): void {
|
||||
const element = document.createElement('div');
|
||||
// Tailwind utility classes assigned directly since this element lives outside Angular's
|
||||
// view encapsulation (see the class comment above) rather than through a component template.
|
||||
//
|
||||
// The offset is a margin rather than a `translate-*` utility because `CSS2DRenderer` writes
|
||||
// its own `transform` to the inline style every frame, which would win over any class. The
|
||||
// `::before` hairline is the leader line back to the star the name belongs to — without it a
|
||||
// margin-shifted label just floats near a point instead of pointing at it.
|
||||
element.className =
|
||||
"relative ml-3 whitespace-nowrap font-body text-[11px] tracking-[0.08em] text-accent uppercase [text-shadow:0_0_6px_var(--color-void),0_0_3px_var(--color-void)] before:absolute before:top-1/2 before:-left-3 before:h-px before:w-2.5 before:bg-accent/60 before:content-['']";
|
||||
element.textContent = point.name;
|
||||
// Classes assigned directly since this element lives outside Angular's view encapsulation
|
||||
// (see the class comment above). The offset and leader line live in `.map-label` itself:
|
||||
// CSS2DRenderer rewrites this element's inline transform every frame, so a translate here
|
||||
// would be overwritten — the margin is the offset it cannot touch.
|
||||
element.className = 'map-label whitespace-nowrap font-body';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'map-label-name';
|
||||
name.textContent = point.name;
|
||||
element.appendChild(name);
|
||||
|
||||
if (point.kind) {
|
||||
const kind = document.createElement('span');
|
||||
kind.className = 'map-label-kind';
|
||||
kind.textContent = point.kind;
|
||||
element.appendChild(kind);
|
||||
}
|
||||
|
||||
const object = new CSS2DObject(element);
|
||||
object.position.set(point.x, point.y, point.z);
|
||||
@@ -76,7 +103,7 @@ export class StarLabelOverlay {
|
||||
this.labelObjects.set(point.id, object);
|
||||
}
|
||||
|
||||
private removeLabel(id: number, object: CSS2DObject): void {
|
||||
private removeLabel(id: number | string, object: CSS2DObject): void {
|
||||
this.scene.remove(object);
|
||||
object.element.remove();
|
||||
this.labelObjects.delete(id);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ViewLevel } from '../../shared/state/navigation.store';
|
||||
import { StarmapHudComponent } from './starmap-hud.component';
|
||||
|
||||
describe('StarmapHudComponent', () => {
|
||||
let fixture: ComponentFixture<StarmapHudComponent>;
|
||||
|
||||
function render(level: ViewLevel): HTMLElement {
|
||||
fixture.componentRef.setInput('level', level);
|
||||
fixture.detectChanges();
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
}
|
||||
|
||||
function buttonLabels(host: HTMLElement): string[] {
|
||||
return [...host.querySelectorAll('nav button')].map((button) => button.textContent?.trim() ?? '');
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({ imports: [StarmapHudComponent] }).compileComponents();
|
||||
fixture = TestBed.createComponent(StarmapHudComponent);
|
||||
});
|
||||
|
||||
it('offers the way back down from the outermost scale', () => {
|
||||
// The two outer scales share one space, so the ladder goes both ways between them.
|
||||
expect(buttonLabels(render('galactic'))).toEqual(['Solar Neighbourhood']);
|
||||
});
|
||||
|
||||
it('offers the Milky Way from the solar neighbourhood', () => {
|
||||
expect(buttonLabels(render('galaxy'))).toEqual(['Milky Way']);
|
||||
});
|
||||
|
||||
it('offers both wider scales from inside a system', () => {
|
||||
expect(buttonLabels(render('system'))).toEqual(['Milky Way', 'Solar Neighbourhood']);
|
||||
});
|
||||
|
||||
it('never offers the system scale, which needs a star picked first', () => {
|
||||
for (const level of ['galactic', 'galaxy', 'system'] as const) {
|
||||
expect(buttonLabels(render(level))).not.toContain('System');
|
||||
}
|
||||
});
|
||||
|
||||
it('marks the current scale rather than making it a button that goes nowhere', () => {
|
||||
// Load-bearing beyond tidiness: this marker is how the end-to-end tests tell which scale the
|
||||
// view has actually settled at.
|
||||
const host = render('galaxy');
|
||||
const current = host.querySelector('[data-testid="hud-current-level"]');
|
||||
expect(current?.textContent?.trim()).toBe('Solar Neighbourhood');
|
||||
expect(current?.getAttribute('aria-current')).toBe('step');
|
||||
expect(buttonLabels(host)).not.toContain('Solar Neighbourhood');
|
||||
});
|
||||
|
||||
it('marks exactly one scale as current', () => {
|
||||
for (const level of ['galactic', 'galaxy', 'system'] as const) {
|
||||
expect(render(level).querySelectorAll('[data-testid="hud-current-level"]')).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('emits the scale that was asked for', () => {
|
||||
const host = render('system');
|
||||
const emitted: ViewLevel[] = [];
|
||||
fixture.componentInstance.levelSelected.subscribe((level) => emitted.push(level));
|
||||
|
||||
host.querySelectorAll('nav button').forEach((button) => (button as HTMLButtonElement).click());
|
||||
|
||||
expect(emitted).toEqual(['galactic', 'galaxy']);
|
||||
});
|
||||
|
||||
it('renders the readout panel from its inputs', () => {
|
||||
fixture.componentRef.setInput('level', 'galactic');
|
||||
fixture.componentRef.setInput('eyebrow', 'Galactic Scale');
|
||||
fixture.componentRef.setInput('title', 'Milky Way');
|
||||
fixture.componentRef.setInput('subtitle', 'Barred spiral');
|
||||
fixture.componentRef.setInput('readouts', [{ label: 'Arms', value: '5' }]);
|
||||
fixture.componentRef.setInput('note', 'Illustrative model.');
|
||||
fixture.componentRef.setInput('range', '21.5 kpc');
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||
for (const expected of ['Galactic Scale', 'Milky Way', 'Barred spiral', 'Arms', '5', 'Illustrative model.', '21.5 kpc']) {
|
||||
expect(text).toContain(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves out the optional lines it was given nothing for', () => {
|
||||
const host = render('galaxy');
|
||||
expect(host.querySelector('dl')).toBeNull();
|
||||
expect(host.textContent).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('names what the view is holding on the banner across the top', () => {
|
||||
fixture.componentRef.setInput('level', 'system');
|
||||
fixture.componentRef.setInput('title', 'Sol');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.hud-banner')?.textContent?.trim()).toBe('Sol');
|
||||
});
|
||||
|
||||
it('shows no banner when the view is holding nothing', () => {
|
||||
// An empty nameplate is worse than none: it reads as a selection that failed to resolve.
|
||||
expect(render('galaxy').querySelector('.hud-banner')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
|
||||
import { ViewLevel } from '../../shared/state/navigation.store';
|
||||
|
||||
export interface HudReadout {
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
interface LadderStep {
|
||||
readonly level: ViewLevel;
|
||||
readonly label: string;
|
||||
/** Above the active step, on it, or below it — which is what the step is styled from. */
|
||||
readonly state: 'above' | 'current' | 'below';
|
||||
/** Whether this step is somewhere the view can be sent right now. */
|
||||
readonly reachable: boolean;
|
||||
}
|
||||
|
||||
/** Outermost first — the order the ladder is drawn in, and the order levels nest. */
|
||||
const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
||||
{ level: 'galactic', label: 'Milky Way' },
|
||||
{ level: 'galaxy', label: 'Solar Neighbourhood' },
|
||||
{ level: 'system', label: 'System' }
|
||||
];
|
||||
|
||||
/**
|
||||
* The map's heads-up display: the scale ladder down the left, the readout panel across the
|
||||
* bottom, a centre reticle on whatever the camera is holding, and the frame brackets around
|
||||
* the whole viewport.
|
||||
*
|
||||
* Purely presentational — every value arrives as an input and the only thing it emits is a
|
||||
* request to move to another scale. The scene owns the camera and decides what that means.
|
||||
*
|
||||
* Reachable levels render as buttons and the current one renders as a static marker rather than
|
||||
* a button that does nothing. The galactic and neighbourhood scales are always reachable, in
|
||||
* both directions: they are one continuous space and the Sun is always in it. The system scale
|
||||
* is not, since there is no system to go to until a star has been picked.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-starmap-hud',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'pointer-events-none absolute inset-0 block select-none' },
|
||||
template: `
|
||||
<div class="hud-vignette absolute inset-0"></div>
|
||||
|
||||
@if (showReticle()) {
|
||||
<!-- The same circle-and-ticks reticle the search field wears, scaled up: one lock mark
|
||||
for the whole instrument, whether it is holding a query or a body. -->
|
||||
<svg class="absolute top-1/2 left-1/2 h-14 w-14 -translate-x-1/2 -translate-y-1/2 text-accent/70" viewBox="0 0 56 56" fill="none" stroke="currentColor" stroke-width="1" aria-hidden="true">
|
||||
<circle cx="28" cy="28" r="15" />
|
||||
<path d="M28 6v7M28 43v7M6 28h7M43 28h7" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
<!-- Top rail: which scale the view is at, and what it is holding. Both sit on one line
|
||||
across the top of the display, clear of the search field above them. -->
|
||||
<nav aria-label="Map scale" class="hud-brackets pointer-events-auto absolute top-16 left-6 flex items-stretch divide-x divide-border/40 border border-border/60 bg-panel/85 backdrop-blur-md">
|
||||
@for (step of ladder(); track step.level) {
|
||||
@if (step.reachable) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="levelSelected.emit(step.level)"
|
||||
class="px-4 py-1.5 text-[10px] tracking-[0.18em] text-muted uppercase transition-colors hover:bg-accent/10 hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
>
|
||||
{{ step.label }}
|
||||
</button>
|
||||
} @else {
|
||||
<span
|
||||
[attr.aria-current]="step.state === 'current' ? 'step' : null"
|
||||
[attr.data-testid]="step.state === 'current' ? 'hud-current-level' : null"
|
||||
class="px-4 py-1.5 text-[10px] tracking-[0.18em] uppercase"
|
||||
[class]="step.state === 'current' ? 'bg-accent/15 text-accent' : 'text-muted/40'"
|
||||
>{{ step.label }}</span
|
||||
>
|
||||
}
|
||||
}
|
||||
</nav>
|
||||
|
||||
@if (title()) {
|
||||
<!-- Hidden on phones: the readout panel names the same thing, and at this width the
|
||||
nameplate would sit on top of the scale rail. -->
|
||||
<div class="absolute top-16 left-1/2 hidden -translate-x-1/2 sm:block">
|
||||
<div class="hud-banner hud-brackets hud-acquire flex items-center gap-2.5 border border-border/60 bg-panel/85 px-6 py-1.5 backdrop-blur-md">
|
||||
<svg class="h-3 w-3 shrink-0 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
|
||||
</svg>
|
||||
<span class="text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="absolute right-6 bottom-6 left-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div class="hud-brackets hud-acquire max-w-lg border border-border/60 bg-panel/92 px-4 py-3 backdrop-blur-md">
|
||||
<p class="text-[10px] tracking-[0.16em] text-muted uppercase">{{ eyebrow() }}</p>
|
||||
<p data-testid="hud-title" class="mt-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
|
||||
@if (subtitle()) {
|
||||
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
|
||||
}
|
||||
@if (readouts().length) {
|
||||
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
||||
@for (readout of readouts(); track readout.label) {
|
||||
<div>
|
||||
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">{{ readout.label }}</dt>
|
||||
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
@if (note()) {
|
||||
<p class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted">{{ note() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="hud-brackets hud-acquire border border-border/60 bg-panel/92 px-4 py-3 text-right backdrop-blur-md">
|
||||
<p class="text-[10px] tracking-[0.16em] text-muted uppercase">Range</p>
|
||||
<p class="mt-1 text-lg text-accent tabular-nums">{{ range() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class StarmapHudComponent {
|
||||
readonly level = input.required<ViewLevel>();
|
||||
/** Headline for the readout panel — the selected star, or the name of the current scale. */
|
||||
readonly title = input('');
|
||||
readonly subtitle = input('');
|
||||
readonly eyebrow = input('');
|
||||
readonly readouts = input<readonly HudReadout[]>([]);
|
||||
/** Standing caveat for the current view, e.g. that galactic structure is a model. */
|
||||
readonly note = input('');
|
||||
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
|
||||
readonly range = input('');
|
||||
readonly showReticle = input(true);
|
||||
|
||||
readonly levelSelected = output<ViewLevel>();
|
||||
|
||||
readonly ladder = computed<readonly LadderStep[]>(() => {
|
||||
const activeIndex = LADDER.findIndex((step) => step.level === this.level());
|
||||
return LADDER.map((step, index) => ({
|
||||
...step,
|
||||
state: index < activeIndex ? 'above' : index === activeIndex ? 'current' : 'below',
|
||||
// Every scale is somewhere the camera can be sent except the current one and the system
|
||||
// level, which needs a star picked first — there is no "the system" without a selection.
|
||||
reachable: index !== activeIndex && step.level !== 'system'
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||
import {
|
||||
bodyMarkerRadiusAu,
|
||||
DEFAULT_STAR_MARKER_RADIUS_AU,
|
||||
starGlowExtentAu,
|
||||
starMarkerRadiusAu,
|
||||
systemFrameRadiusAu,
|
||||
systemFramingDistanceAu,
|
||||
systemGridRingsAu,
|
||||
SystemViewport,
|
||||
SYSTEM_VIEW_DIRECTION_IN_PLANE,
|
||||
systemViewDirection
|
||||
} from './system-framing';
|
||||
|
||||
/** Real systems spanning the range the view has to cope with. */
|
||||
const TRAPPIST_1 = { innermost: 0.01154, outermost: 0.06189 };
|
||||
const GL_357 = { innermost: 0.035, outermost: 0.204 };
|
||||
const SOLAR = { innermost: 0.387, outermost: 30.07 };
|
||||
|
||||
describe('starMarkerRadiusAu', () => {
|
||||
it('never reaches the innermost orbit', () => {
|
||||
for (const { innermost } of [TRAPPIST_1, GL_357, SOLAR]) {
|
||||
expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost);
|
||||
}
|
||||
});
|
||||
|
||||
it('shrinks to fit a compact system whose orbits were all inside the old fixed radius', () => {
|
||||
// Every TRAPPIST-1 orbit is inside 0.2 AU, so the star used to swallow the entire system.
|
||||
expect(starMarkerRadiusAu(TRAPPIST_1.innermost)).toBeLessThan(TRAPPIST_1.outermost);
|
||||
expect(starMarkerRadiusAu(GL_357.innermost)).toBeLessThan(GL_357.outermost);
|
||||
});
|
||||
|
||||
it('never grows beyond the default, however wide the system', () => {
|
||||
expect(starMarkerRadiusAu(SOLAR.innermost)).toBeLessThanOrEqual(DEFAULT_STAR_MARKER_RADIUS_AU);
|
||||
expect(starMarkerRadiusAu(500)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU);
|
||||
});
|
||||
|
||||
it('scales in proportion to the innermost orbit', () => {
|
||||
expect(starMarkerRadiusAu(0.02) / starMarkerRadiusAu(0.01)).toBeCloseTo(2, 9);
|
||||
});
|
||||
|
||||
it('falls back to the default when there are no planets to scale against', () => {
|
||||
for (const innermost of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
expect(starMarkerRadiusAu(innermost)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU);
|
||||
}
|
||||
});
|
||||
|
||||
it('stays positive for an extremely tight orbit', () => {
|
||||
expect(starMarkerRadiusAu(0.0001)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('systemFramingDistanceAu', () => {
|
||||
it('fits the radius it is given in view, with room around it', () => {
|
||||
for (const radius of [TRAPPIST_1.outermost, GL_357.outermost, SOLAR.outermost]) {
|
||||
expect(systemFrameRadiusAu(systemFramingDistanceAu(radius))).toBeGreaterThan(radius);
|
||||
}
|
||||
});
|
||||
|
||||
it('closes right in on a compact system instead of hanging back at a fixed floor', () => {
|
||||
// The old floor was 3 AU — some 48x the width of the entire TRAPPIST-1 system.
|
||||
expect(systemFramingDistanceAu(TRAPPIST_1.outermost)).toBeLessThan(1);
|
||||
expect(systemFramingDistanceAu(GL_357.outermost)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('scales in proportion to the radius it has to frame', () => {
|
||||
expect(systemFramingDistanceAu(0.2) / systemFramingDistanceAu(0.1)).toBeCloseTo(2, 9);
|
||||
});
|
||||
|
||||
it('backs off further for a narrower field of view, which a fixed multiple could not', () => {
|
||||
// The bug this replaced: the multiple was tuned by eye against a 55-degree field and the
|
||||
// engine's camera is 50, so everything sat that much too close.
|
||||
const wide = systemFramingDistanceAu(1, { fovDegrees: 70, aspect: 1.78 });
|
||||
const narrow = systemFramingDistanceAu(1, { fovDegrees: 30, aspect: 1.78 });
|
||||
expect(narrow).toBeGreaterThan(wide);
|
||||
});
|
||||
|
||||
it('backs off further for a portrait window, where the horizontal axis is the tighter one', () => {
|
||||
const landscape = systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 1.78 });
|
||||
const portrait = systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 0.6 });
|
||||
expect(portrait).toBeCloseTo(landscape / 0.6, 6);
|
||||
});
|
||||
|
||||
it('ignores aspect once the window is landscape, since the vertical binds there', () => {
|
||||
const square = systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 1 });
|
||||
expect(systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 2.5 })).toBeCloseTo(square, 9);
|
||||
});
|
||||
|
||||
it('caps the distance so a far-flung companion cannot shrink the star to nothing', () => {
|
||||
expect(systemFramingDistanceAu(1000)).toBe(systemFramingDistanceAu(5000));
|
||||
});
|
||||
|
||||
it('reaches far enough to frame the solar system out to Pluto', () => {
|
||||
// The old 80 AU ceiling could not: at the camera's real field of view this needs 120.
|
||||
const rings = systemGridRingsAu(39.288);
|
||||
const distance = systemFramingDistanceAu(rings[rings.length - 1]);
|
||||
expect(distance).toBeLessThan(200);
|
||||
expect(systemFrameRadiusAu(distance)).toBeGreaterThan(39.288);
|
||||
});
|
||||
|
||||
it('stays outside the orbit controls minimum distance', () => {
|
||||
// Framing closer than the controls allow would be clamped straight back out again.
|
||||
expect(systemFramingDistanceAu(0.00001)).toBeGreaterThanOrEqual(0.05);
|
||||
});
|
||||
|
||||
it('uses a sensible default for a star with no known planets', () => {
|
||||
for (const radius of [0, -1, Number.NaN]) {
|
||||
expect(systemFramingDistanceAu(radius)).toBe(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('starGlowExtentAu', () => {
|
||||
/** A typical viewport, so a screen-space claim can be made in pixels rather than in ratios. */
|
||||
const REFERENCE_VIEWPORT_HALF_HEIGHT_PX = 450;
|
||||
|
||||
/** The halo's visual radius, in AU, at the distance this system is framed from. */
|
||||
function haloRadiusAu(innermostAu: number, outermostAu: number, glowScale = 1): number {
|
||||
// The sprite's extent is its full width, so half of it is what reaches out from the star.
|
||||
return starGlowExtentAu(starMarkerRadiusAu(innermostAu), frameRadiusFor(outermostAu), glowScale) / 2;
|
||||
}
|
||||
|
||||
function frameRadiusFor(outermostAu: number): number {
|
||||
const rings = systemGridRingsAu(outermostAu);
|
||||
return systemFrameRadiusAu(systemFramingDistanceAu(rings[rings.length - 1]));
|
||||
}
|
||||
|
||||
/** Apparent size on screen, as a fraction of the frame's half-height. */
|
||||
function apparentFraction(innermostAu: number, outermostAu: number, glowScale = 1): number {
|
||||
return haloRadiusAu(innermostAu, outermostAu, glowScale) / frameRadiusFor(outermostAu);
|
||||
}
|
||||
|
||||
function apparentPixels(innermostAu: number, outermostAu: number): number {
|
||||
return apparentFraction(innermostAu, outermostAu) * REFERENCE_VIEWPORT_HALF_HEIGHT_PX;
|
||||
}
|
||||
|
||||
it('scales with the star for a compact system, where the star is already big enough', () => {
|
||||
// A tight frame relative to the star, so the star's own multiple is what decides.
|
||||
const marker = 0.02;
|
||||
const tightFrame = 0.5;
|
||||
expect(starGlowExtentAu(marker, tightFrame)).toBeCloseTo(marker * 3.2, 9);
|
||||
expect(starGlowExtentAu(marker * 2, tightFrame)).toBeCloseTo(marker * 2 * 3.2, 9);
|
||||
});
|
||||
|
||||
it('floors against the frame once the star would otherwise vanish into it', () => {
|
||||
// A star sized against a close-in orbit, framed from far enough out to hold a wide system:
|
||||
// the multiple of the star is nothing, so the frame decides instead.
|
||||
const tinyStar = 0.001;
|
||||
const wideFrame = 56;
|
||||
expect(starGlowExtentAu(tinyStar, wideFrame)).toBeGreaterThan(tinyStar * 3.2 * 100);
|
||||
});
|
||||
|
||||
it('keeps the Sun visible at the distance that frames the solar system', () => {
|
||||
// The case that prompted this: the solar system spans a factor of a hundred from Mercury to
|
||||
// Pluto, so a disc that stays clear of Mercury is about a pixel across once Pluto is in view.
|
||||
expect(apparentPixels(0.387, 39.288)).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('leaves the inner orbits clear of the halo', () => {
|
||||
// The other half of the same trade. Venus and Earth have to stay legible as rings around the
|
||||
// star, which bounds the halo from above just as visibility bounds it from below.
|
||||
const halo = haloRadiusAu(0.387, 39.288);
|
||||
const VENUS_AU = 0.723;
|
||||
const EARTH_AU = 1;
|
||||
expect(halo).toBeLessThan(VENUS_AU);
|
||||
expect(halo).toBeLessThan(EARTH_AU);
|
||||
});
|
||||
|
||||
it('cannot clear Mercury as well, and does not pretend to', () => {
|
||||
// Mercury's orbit is 0.7% of the framed radius — about three pixels — so it is inside any
|
||||
// halo big enough to see. Pinned so the trade is a decision rather than an oversight.
|
||||
expect(haloRadiusAu(0.387, 39.288)).toBeGreaterThan(0.387);
|
||||
});
|
||||
|
||||
it('holds the floor across every system scale the datasets contain', () => {
|
||||
// A compact system's star is genuinely large relative to its own system and keeps the bigger
|
||||
// halo; the floor is not there to equalise them, only to stop the wide ones disappearing.
|
||||
for (const [innermost, outermost] of [
|
||||
[0.387, 39.288],
|
||||
[0.035, 0.204],
|
||||
[0.01154, 0.06189],
|
||||
[1.2, 12.4]
|
||||
]) {
|
||||
expect(apparentPixels(innermost, outermost)).toBeGreaterThan(4);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not blot out the system it sits in', () => {
|
||||
for (const [innermost, outermost] of [
|
||||
[0.387, 39.288],
|
||||
[0.035, 0.204],
|
||||
[0.01154, 0.06189]
|
||||
]) {
|
||||
expect(apparentFraction(innermost, outermost)).toBeLessThan(0.2);
|
||||
}
|
||||
});
|
||||
|
||||
it('dims for a star drawn from a colour rather than a photograph, but never below the floor', () => {
|
||||
// Above the floor the multiplier applies...
|
||||
expect(starGlowExtentAu(1, 10, 0.6)).toBeLessThan(starGlowExtentAu(1, 10, 1));
|
||||
// ...and at the floor it cannot dim a star into invisibility.
|
||||
expect(starGlowExtentAu(0.001, 56, 0.6)).toBe(starGlowExtentAu(0.001, 56, 1));
|
||||
});
|
||||
|
||||
it('falls back to the star alone when there is no frame to measure against', () => {
|
||||
for (const frame of [0, -1, Number.NaN]) {
|
||||
expect(starGlowExtentAu(0.2, frame)).toBeCloseTo(0.2 * 3.2, 9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the grid and the framing together', () => {
|
||||
/** What the scene actually composes: rings from the orbits, then a distance from the rings. */
|
||||
function fit(outermostOrbitAu: number, viewport?: SystemViewport): { ring: number; frame: number } {
|
||||
const rings = systemGridRingsAu(outermostOrbitAu);
|
||||
const ring = rings[rings.length - 1];
|
||||
return { ring, frame: systemFrameRadiusAu(systemFramingDistanceAu(ring, viewport), viewport) };
|
||||
}
|
||||
|
||||
const VIEWPORTS: SystemViewport[] = [
|
||||
{ fovDegrees: 50, aspect: 1.78 },
|
||||
{ fovDegrees: 50, aspect: 1 },
|
||||
{ fovDegrees: 50, aspect: 0.6 }
|
||||
];
|
||||
|
||||
it('leaves the outermost ring clear of the frame edge at every scale and window shape', () => {
|
||||
// The whole point of framing against the grid rather than the orbits: before this, 368 of
|
||||
// the 371 systems in the datasets drew a grid wider than the view that was meant to hold it.
|
||||
for (const viewport of VIEWPORTS) {
|
||||
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) {
|
||||
const { ring, frame } = fit(outermost, viewport);
|
||||
expect(ring).toBeLessThan(frame);
|
||||
expect(ring / frame).toBeLessThan(0.93);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('still encloses the outermost orbit, so no planet sits off the edge of the grid', () => {
|
||||
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 1 }, { outermost: 12.4 }]) {
|
||||
expect(fit(outermost).ring).toBeGreaterThan(outermost);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not overshoot either: the grid still fills most of the frame', () => {
|
||||
// A margin is not the same as framing a system from orbit. Half the frame empty would be as
|
||||
// wrong as none of it.
|
||||
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR]) {
|
||||
const { ring, frame } = fit(outermost);
|
||||
expect(ring / frame).toBeGreaterThan(0.6);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('star and framing together', () => {
|
||||
it('gives compact and wide systems a comparable apparent star size', () => {
|
||||
// Both scale with the system, so the star subtends a similar angle either way — the point
|
||||
// of deriving them from the same measurements rather than fixing them.
|
||||
const apparent = ({ innermost, outermost }: { innermost: number; outermost: number }) =>
|
||||
starMarkerRadiusAu(innermost) / systemFramingDistanceAu(outermost);
|
||||
|
||||
const compact = apparent(TRAPPIST_1);
|
||||
const midRange = apparent(GL_357);
|
||||
|
||||
expect(compact).toBeGreaterThan(0);
|
||||
expect(compact / midRange).toBeGreaterThan(0.25);
|
||||
expect(compact / midRange).toBeLessThan(4);
|
||||
});
|
||||
|
||||
it('always leaves the innermost orbit outside the star, at every scale', () => {
|
||||
for (const innermost of [0.005, 0.01, 0.05, 0.2, 1, 5, 40]) {
|
||||
expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('bodyMarkerRadiusAu', () => {
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const SOLAR_SPAN_AU = 30.07;
|
||||
|
||||
it('scales in proportion to the system span', () => {
|
||||
const wide = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU);
|
||||
const compact = bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU / 100);
|
||||
|
||||
expect(compact / wide).toBeCloseTo(0.01, 6);
|
||||
});
|
||||
|
||||
it('keeps a marker far smaller than the orbits it sits on, at any scale', () => {
|
||||
// A fixed 0.09 AU marker inside Gl 357's 0.204 AU system was wider than the orbits, so one
|
||||
// planet swallowed the whole view.
|
||||
for (const span of [0.06, 0.204, 1, 30.07, 800]) {
|
||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeLessThan(span / 5);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives compact and wide systems the same apparent marker size', () => {
|
||||
const apparent = (span: number) => bodyMarkerRadiusAu(EARTH_RADIUS_KM, span) / systemFramingDistanceAu(span);
|
||||
|
||||
expect(apparent(0.204)).toBeCloseTo(apparent(10), 6);
|
||||
});
|
||||
|
||||
it('still renders a bigger body as a bigger marker', () => {
|
||||
const jupiter = bodyMarkerRadiusAu(69911, SOLAR_SPAN_AU);
|
||||
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU);
|
||||
|
||||
expect(jupiter).toBeGreaterThan(pluto);
|
||||
});
|
||||
|
||||
it('falls back to the smallest marker for a body with no known radius', () => {
|
||||
const unknown = bodyMarkerRadiusAu(undefined, SOLAR_SPAN_AU);
|
||||
const pluto = bodyMarkerRadiusAu(1188, SOLAR_SPAN_AU);
|
||||
|
||||
expect(unknown).toBeGreaterThan(0);
|
||||
expect(unknown).toBeLessThanOrEqual(pluto);
|
||||
});
|
||||
|
||||
it('treats a missing span as the reference scale rather than collapsing to zero', () => {
|
||||
for (const span of [0, -5, Number.NaN]) {
|
||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, span)).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the solar system essentially as it was before scaling', () => {
|
||||
// The constants were tuned at this span, so the scale factor here is ~1.
|
||||
expect(bodyMarkerRadiusAu(EARTH_RADIUS_KM, SOLAR_SPAN_AU)).toBeCloseTo(0.09, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('systemGridRingsAu', () => {
|
||||
it('reaches past the outermost orbit, so no planet sits off the edge of the grid', () => {
|
||||
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR]) {
|
||||
const rings = systemGridRingsAu(outermost);
|
||||
expect(rings.length).toBeGreaterThan(0);
|
||||
expect(rings[rings.length - 1]).toBeGreaterThan(outermost);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives a legible handful of rings at every scale, four orders of magnitude apart', () => {
|
||||
for (const { outermost } of [TRAPPIST_1, GL_357, SOLAR, { outermost: 650 }]) {
|
||||
const rings = systemGridRingsAu(outermost);
|
||||
expect(rings.length).toBeGreaterThanOrEqual(3);
|
||||
expect(rings.length).toBeLessThanOrEqual(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('spaces them evenly, on a round number', () => {
|
||||
const rings = systemGridRingsAu(SOLAR.outermost);
|
||||
// The solar system reads in 5 AU steps: 5, 10, ... out past Neptune at 30.07.
|
||||
expect(rings).toEqual([5, 10, 15, 20, 25, 30, 35]);
|
||||
});
|
||||
|
||||
it('scales the step down to the system rather than defaulting to whole AU', () => {
|
||||
// TRAPPIST-1's outermost planet orbits at 0.062 AU. Whole-AU rings would put the entire
|
||||
// system inside the first one.
|
||||
const rings = systemGridRingsAu(TRAPPIST_1.outermost);
|
||||
expect(rings[0]).toBeLessThan(TRAPPIST_1.outermost / 2);
|
||||
for (const radius of rings) {
|
||||
expect(Number.isFinite(radius)).toBe(true);
|
||||
expect(radius).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the step free of floating-point drift, so labels would read cleanly', () => {
|
||||
for (const radius of systemGridRingsAu(TRAPPIST_1.outermost)) {
|
||||
// Multiplying the step out rather than accumulating it keeps these exact to 1e-12.
|
||||
expect(Math.abs(radius * 1000 - Math.round(radius * 1000))).toBeLessThan(1e-9);
|
||||
}
|
||||
});
|
||||
|
||||
it('draws no grid for a system with nothing to measure against', () => {
|
||||
for (const outermost of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
expect(systemGridRingsAu(outermost)).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('systemViewDirection', () => {
|
||||
const RAD_TO_DEG = 180 / Math.PI;
|
||||
const ECLIPTIC_FRAME = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), (OBLIQUITY_J2000_DEG * Math.PI) / 180);
|
||||
|
||||
/** Angle between the camera direction and the plane's own normal, in degrees. */
|
||||
function angleFromNormalDeg(frame: THREE.Quaternion): number {
|
||||
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(frame);
|
||||
return Math.acos(Math.abs(systemViewDirection(frame).dot(normal))) * RAD_TO_DEG;
|
||||
}
|
||||
|
||||
it('returns a unit direction', () => {
|
||||
expect(systemViewDirection(ECLIPTIC_FRAME).length()).toBeCloseTo(1, 12);
|
||||
});
|
||||
|
||||
it('holds the same three-quarter angle to the plane whatever plane that is', () => {
|
||||
// The whole point: one fixed direction in the scene's frame would be face-on for the solar
|
||||
// system and edge-on for an exoplanet system measured against the plane of the sky.
|
||||
const skyPlanes = [
|
||||
new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0.3, -0.5, 0.81).normalize()),
|
||||
new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(-1, 0, 0)),
|
||||
new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 1, 0))
|
||||
];
|
||||
|
||||
// atan(0.6 / 0.8) — the angle the in-plane direction was chosen at, held exactly.
|
||||
const expected = Math.atan2(SYSTEM_VIEW_DIRECTION_IN_PLANE.y, SYSTEM_VIEW_DIRECTION_IN_PLANE.z) * RAD_TO_DEG;
|
||||
for (const frame of [ECLIPTIC_FRAME, ...skyPlanes]) {
|
||||
expect(angleFromNormalDeg(frame)).toBeCloseTo(expected, 9);
|
||||
}
|
||||
});
|
||||
|
||||
it('is well clear of edge-on in every case, which is what it exists to prevent', () => {
|
||||
for (const axis of [new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 1, 0), new THREE.Vector3(0.2, 0.9, -0.4).normalize()]) {
|
||||
const frame = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), axis);
|
||||
expect(angleFromNormalDeg(frame)).toBeLessThan(60);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the solar system framed exactly as the ecliptic conversion used to frame it', () => {
|
||||
// The previous behaviour was correct for the one system whose elements are ecliptic; this
|
||||
// pins that it did not move while the other systems were fixed.
|
||||
const previous = eclipticToEquatorial(SYSTEM_VIEW_DIRECTION_IN_PLANE);
|
||||
const current = systemViewDirection(ECLIPTIC_FRAME);
|
||||
const length = Math.hypot(previous.x, previous.y, previous.z);
|
||||
|
||||
expect(current.x).toBeCloseTo(previous.x / length, 12);
|
||||
expect(current.y).toBeCloseTo(previous.y / length, 12);
|
||||
expect(current.z).toBeCloseTo(previous.z / length, 12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
|
||||
import { CartesianCoordinates } from '../../shared/astro/coordinates';
|
||||
|
||||
/**
|
||||
* How the system view sizes itself to whatever system it is showing.
|
||||
*
|
||||
* Real planetary systems span four orders of magnitude: TRAPPIST-1's outermost planet orbits
|
||||
* closer than Mercury by a factor of six, while some directly-imaged companions sit hundreds of
|
||||
* AU out. A single fixed star size and camera distance cannot serve both, and the fixed pair
|
||||
* that used to be hard-coded served only the wide end — 29% of systems had *every* orbit inside
|
||||
* the star marker, so they rendered as a lone sphere with nothing around it, and 52% were
|
||||
* framed from a distance floor far larger than the system itself.
|
||||
*
|
||||
* Both quantities are therefore derived from the system's own scale. Because the star and the
|
||||
* camera scale together, a compact system ends up looking like a wide one: same apparent star,
|
||||
* same apparent spread of orbits.
|
||||
*/
|
||||
|
||||
/** Star size when there are no orbits to scale against, and the ceiling everywhere else. */
|
||||
export const DEFAULT_STAR_MARKER_RADIUS_AU = 0.2;
|
||||
|
||||
/**
|
||||
* Star radius as a fraction of the innermost orbit. Comfortably below 1 so there is visible
|
||||
* space between the star's limb and the closest orbit, rather than the orbit grazing or
|
||||
* disappearing inside it.
|
||||
*/
|
||||
const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.45;
|
||||
|
||||
/**
|
||||
* Halo extent as a multiple of the star's own radius, and the floor on that extent as a
|
||||
* fraction of the framed radius.
|
||||
*
|
||||
* The floor is what keeps a star visible. A system's star is sized against its *innermost*
|
||||
* orbit — it must never swallow its closest planet — while the camera is placed to frame the
|
||||
* *outermost* ring, and those differ by a factor of a hundred in the solar system. At the
|
||||
* distance that fits Pluto in view, a disc that stays clear of Mercury is about one pixel
|
||||
* across; there is no radius that satisfies both, because the information genuinely does not
|
||||
* fit on one screen at that zoom.
|
||||
*
|
||||
* The halo resolves it, because light is not a surface: a glow that reaches past the innermost
|
||||
* orbit does not claim the star is that large, it claims the star is bright. So the disc stays
|
||||
* honest to the orbits and the halo is floored against the frame.
|
||||
*
|
||||
* The floor is set by what it must not cover. Its visual radius is half the extent, so a floor
|
||||
* of `f` puts the halo's edge at `f / 2` of the frame radius — and the orbits it has to leave
|
||||
* legible sit at their own fraction of that same radius. In the solar system, framed to hold
|
||||
* Pluto, Venus's orbit is at 1.3% of the frame radius and Earth's at 1.8%, so a floor of 2%
|
||||
* leaves both of them outside the halo. Mercury's, at 0.7%, is inside it — and would be at any
|
||||
* halo large enough to see, since the orbit itself is only a few pixels wide there.
|
||||
*/
|
||||
const STAR_GLOW_TO_MARKER = 3.2;
|
||||
const MIN_STAR_GLOW_TO_FRAME = 0.02;
|
||||
|
||||
/**
|
||||
* Clear space left around the framed radius, as a fraction of it. The camera backs off this
|
||||
* much further than the geometry strictly needs, so the outermost ring sits inside the frame
|
||||
* with room around it rather than grazing the edge.
|
||||
*/
|
||||
const FRAME_MARGIN = 0.12;
|
||||
|
||||
/**
|
||||
* The camera the system view is framed for. The vertical field of view is what
|
||||
* `EngineService` creates its camera with; the aspect decides which screen axis is the tighter
|
||||
* one, since a perspective camera's `fov` is vertical and the horizontal extent scales with the
|
||||
* aspect. Anything landscape is bound by the vertical, anything portrait by the horizontal.
|
||||
*/
|
||||
export interface SystemViewport {
|
||||
fovDegrees: number;
|
||||
aspect: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SYSTEM_VIEWPORT: SystemViewport = { fovDegrees: 50, aspect: 1 };
|
||||
|
||||
/** Half-angle tangent along whichever screen axis is the tighter of the two. */
|
||||
function tightHalfExtent(viewport: SystemViewport): number {
|
||||
return Math.tan((viewport.fovDegrees * Math.PI) / 360) * Math.min(1, viewport.aspect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Floor on the framing distance. Only guards the degenerate case — it sits just above the
|
||||
* orbit controls' own minimum distance, so for any real system the fit above decides.
|
||||
*/
|
||||
const MIN_FRAMING_DISTANCE_AU = 0.06;
|
||||
|
||||
/**
|
||||
* Ceiling on the framing distance, so a distant companion does not push the star to a dot.
|
||||
*
|
||||
* Generous enough to frame the solar system out to Pluto in any window shape, which needs 120 AU
|
||||
* on a landscape display and 140 on a portrait one once the camera's real field of view is
|
||||
* accounted for. Only genuinely pathological systems reach it now — the handful with
|
||||
* directly-imaged companions hundreds of AU out — and those still arrive framed on their inner
|
||||
* region, with the orbit controls reaching far enough to pull back to the rest.
|
||||
*/
|
||||
const MAX_FRAMING_DISTANCE_AU = 200;
|
||||
|
||||
/** Framing for a star with no known planets, where there is nothing to fit. */
|
||||
const EMPTY_SYSTEM_FRAMING_DISTANCE_AU = 3;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the camera settles when arriving at a system, as a unit direction from the star —
|
||||
* expressed in the system's *own* reference plane, before that plane is rotated into the scene.
|
||||
*
|
||||
* A three-quarter view, about 37 degrees off the plane's normal, so a system reads as a disc
|
||||
* rather than as a line.
|
||||
*/
|
||||
export const SYSTEM_VIEW_DIRECTION_IN_PLANE: CartesianCoordinates = { x: 0, y: 0.6, z: 0.8 };
|
||||
|
||||
/**
|
||||
* That direction carried into the scene's equatorial frame by the system's own reference frame.
|
||||
*
|
||||
* The scene is equatorial so that orbits and stars share one frame, but no system's orbits lie
|
||||
* in the equatorial plane: the solar system's are measured against the ecliptic, 23.4 degrees
|
||||
* out of it, and an exoplanet system's against the plane of the sky, which depends on where its
|
||||
* host star happens to be. Left to the equatorial axes — or to any single fixed direction — some
|
||||
* systems come out edge-on.
|
||||
*
|
||||
* Rather than rotate the world into a comfortable pose, which would put the orbits back at odds
|
||||
* with the sky, the camera is placed relative to whichever plane the system was measured in. So
|
||||
* every system reads as a disc while staying exactly where it truly sits.
|
||||
*/
|
||||
export function systemViewDirection(referenceFrame: THREE.Quaternion): THREE.Vector3 {
|
||||
const { x, y, z } = SYSTEM_VIEW_DIRECTION_IN_PLANE;
|
||||
return new THREE.Vector3(x, y, z).normalize().applyQuaternion(referenceFrame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Radius (AU) to draw the system's star at, given its innermost orbit.
|
||||
*
|
||||
* Never larger than {@link DEFAULT_STAR_MARKER_RADIUS_AU}, and never large enough to reach the
|
||||
* closest orbit. Falls back to that default when the system has no planets, since there is
|
||||
* then nothing for the star to crowd.
|
||||
*/
|
||||
export function starMarkerRadiusAu(innermostOrbitAu: number): number {
|
||||
if (!Number.isFinite(innermostOrbitAu) || innermostOrbitAu <= 0) {
|
||||
return DEFAULT_STAR_MARKER_RADIUS_AU;
|
||||
}
|
||||
return Math.min(DEFAULT_STAR_MARKER_RADIUS_AU, innermostOrbitAu * STAR_RADIUS_TO_INNERMOST_ORBIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Radius, in AU, that the camera can see at the star's own distance — the half-height of the
|
||||
* view frustum where the system sits, along whichever screen axis is tighter.
|
||||
*/
|
||||
export function systemFrameRadiusAu(distanceAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT): number {
|
||||
return distanceAu * tightHalfExtent(viewport);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extent (AU) of the star's glow sprite — how wide it is drawn, not its radius.
|
||||
*
|
||||
* Normally a multiple of the star's own radius, so a compact system keeps the corona it has.
|
||||
* Floored against the framed radius, so a star framed from far enough out to hold its whole
|
||||
* system still reads as a bright point rather than disappearing into it. `glowScale` lets a
|
||||
* caller dim the halo for stars drawn without a real photograph.
|
||||
*/
|
||||
export function starGlowExtentAu(markerRadiusAu: number, frameRadiusAu: number, glowScale = 1): number {
|
||||
const fromStar = markerRadiusAu * STAR_GLOW_TO_MARKER * glowScale;
|
||||
const fromFrame = Number.isFinite(frameRadiusAu) && frameRadiusAu > 0 ? frameRadiusAu * MIN_STAR_GLOW_TO_FRAME : 0;
|
||||
return Math.max(fromStar, fromFrame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance (AU) to settle the camera at so that `framedRadiusAu` fits in view with a margin
|
||||
* around it.
|
||||
*
|
||||
* Derived from the camera's actual field of view rather than from a multiple of the outermost
|
||||
* orbit. A plain multiple cannot be right: what has to fit is a *radius* on screen, and how much
|
||||
* radius a given distance buys depends entirely on the lens. The multiple that used to be here
|
||||
* was tuned by eye against a 55-degree field, and the engine's camera is 50 — which left the
|
||||
* grid overflowing the frame in 368 of the 371 systems the datasets contain.
|
||||
*
|
||||
* Callers pass the outermost thing actually drawn, which is the reference grid's outer ring
|
||||
* rather than the outermost orbit — the ring is always the wider of the two, by construction.
|
||||
*/
|
||||
export function systemFramingDistanceAu(framedRadiusAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT): number {
|
||||
if (!Number.isFinite(framedRadiusAu) || framedRadiusAu <= 0) {
|
||||
return EMPTY_SYSTEM_FRAMING_DISTANCE_AU;
|
||||
}
|
||||
const required = (framedRadiusAu * (1 + FRAME_MARGIN)) / tightHalfExtent(viewport);
|
||||
return clamp(required, MIN_FRAMING_DISTANCE_AU, MAX_FRAMING_DISTANCE_AU);
|
||||
}
|
||||
|
||||
/** Roughly how many rings the system grid aims for, and how far past the outermost orbit it runs. */
|
||||
const TARGET_GRID_RING_COUNT = 8;
|
||||
const GRID_EXTENT_TO_OUTERMOST_ORBIT = 1.15;
|
||||
/** Ring spacings are always one of these times a power of ten, so the numbers stay readable. */
|
||||
const RING_STEP_MANTISSAS = [1, 2, 5, 10];
|
||||
|
||||
/**
|
||||
* Ring radii (AU) for the system view's reference grid, given the system's outermost orbit.
|
||||
*
|
||||
* Snapped to a 1-2-5 ladder rather than evenly dividing the system, because the point of the
|
||||
* grid is to put a number on a distance: rings at 5, 10, 15 AU can be read off at a glance, and
|
||||
* rings at 4.34, 8.68, 13.02 AU cannot. That holds across the four orders of magnitude real
|
||||
* systems span — the solar system gets 5 AU rings, TRAPPIST-1 gets 0.01 AU ones.
|
||||
*
|
||||
* Empty for a system with no orbits to scale against; there is no distance to mark out.
|
||||
*/
|
||||
export function systemGridRingsAu(outermostOrbitAu: number): number[] {
|
||||
if (!Number.isFinite(outermostOrbitAu) || outermostOrbitAu <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const extent = outermostOrbitAu * GRID_EXTENT_TO_OUTERMOST_ORBIT;
|
||||
const target = extent / TARGET_GRID_RING_COUNT;
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(target)));
|
||||
const step = magnitude * (RING_STEP_MANTISSAS.find((mantissa) => magnitude * mantissa >= target) ?? 10);
|
||||
|
||||
// Rounded up, not truncated: the last ring has to enclose the outermost orbit rather than fall
|
||||
// just inside it, or the outermost planet spends its year outside the grid meant to measure it.
|
||||
const count = Math.ceil(extent / step);
|
||||
const rings: number[] = [];
|
||||
// Multiplied rather than accumulated, so a step of 0.01 does not drift into 0.060000000000000005.
|
||||
for (let index = 1; index <= count; index++) {
|
||||
rings.push(index * step);
|
||||
}
|
||||
return rings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Span of the solar system, in AU, used as the reference every other system's marker sizes are
|
||||
* scaled against. The marker constants below were tuned by eye at this scale.
|
||||
*/
|
||||
const REFERENCE_SYSTEM_SPAN_AU = 30;
|
||||
|
||||
/** Exaggerated (non-physical) marker sizes at the reference scale, so planets stay visible. */
|
||||
const MIN_MARKER_RADIUS_AU = 0.012;
|
||||
const MAX_MARKER_RADIUS_AU = 0.09;
|
||||
/** Physical radius (km) that maps to one AU of marker radius before clamping. */
|
||||
const MARKER_RADIUS_KM_PER_AU = 18000;
|
||||
|
||||
/**
|
||||
* Radius (AU) to draw a planet, moon or exoplanet marker at, scaled to the system it sits in.
|
||||
*
|
||||
* Marker sizes are deliberately exaggerated — a true-scale Earth would be invisible next to its
|
||||
* own orbit — but the exaggeration has to be relative to the system, not absolute. Fixed AU
|
||||
* sizes tuned against the solar system's 30 AU span become grotesque in a system a hundredth
|
||||
* that size: a marker of 0.09 AU inside a 0.2 AU system is wider than the orbits it sits on, so
|
||||
* a single planet swallows the entire view.
|
||||
*
|
||||
* Scaling by the span keeps every system looking like the solar system does: orbits legible,
|
||||
* planets as small dots on them.
|
||||
*/
|
||||
export function bodyMarkerRadiusAu(radiusKm: number | undefined, systemSpanAu: number): number {
|
||||
const span = Number.isFinite(systemSpanAu) && systemSpanAu > 0 ? systemSpanAu : REFERENCE_SYSTEM_SPAN_AU;
|
||||
const atReferenceScale = radiusKm ? clamp(radiusKm / MARKER_RADIUS_KM_PER_AU, MIN_MARKER_RADIUS_AU, MAX_MARKER_RADIUS_AU) : MIN_MARKER_RADIUS_AU;
|
||||
|
||||
return atReferenceScale * (span / REFERENCE_SYSTEM_SPAN_AU);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_EPOCH_JD } from '../../shared/astro/constants';
|
||||
import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||
import { BodyRecord } from '../../shared/models/body.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
||||
|
||||
/** TRAPPIST-1 b: a real short-period planet around a 0.09 solar-mass red dwarf. */
|
||||
const TRAPPIST_1B_SEMI_MAJOR_AXIS_AU = 0.01154;
|
||||
const TRAPPIST_1B_PERIOD_DAYS = 1.51088;
|
||||
|
||||
function exoplanet(overrides: Partial<ExoplanetRecord> = {}): ExoplanetRecord {
|
||||
return {
|
||||
id: 'TRAPPIST-1 b',
|
||||
hostStarId: 1,
|
||||
hostStarName: 'TRAPPIST-1',
|
||||
name: 'TRAPPIST-1 b',
|
||||
orbit: { semiMajorAxisAu: TRAPPIST_1B_SEMI_MAJOR_AXIS_AU, eccentricity: 0 },
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/** Marker position for the system's single exoplanet at a given Julian date. */
|
||||
function positionAt(renderer: SystemOrbitsRenderer, epochJd: number): THREE.Vector3 {
|
||||
renderer.update(epochJd);
|
||||
return renderer.members[0].marker.position.clone();
|
||||
}
|
||||
|
||||
describe('SystemOrbitsRenderer exoplanet propagation', () => {
|
||||
it('completes exactly one orbit over the measured period', () => {
|
||||
// The end-to-end check that the period actually reaches the propagator: after one full
|
||||
// published period the planet must be back where it started.
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS })]);
|
||||
|
||||
const start = positionAt(renderer, DEFAULT_EPOCH_JD);
|
||||
const afterOnePeriod = positionAt(renderer, DEFAULT_EPOCH_JD + TRAPPIST_1B_PERIOD_DAYS);
|
||||
const afterHalfPeriod = positionAt(renderer, DEFAULT_EPOCH_JD + TRAPPIST_1B_PERIOD_DAYS / 2);
|
||||
|
||||
expect(afterOnePeriod.distanceTo(start)).toBeLessThan(1e-6);
|
||||
// Half an orbit of a circle is the far side, a full diameter away.
|
||||
expect(afterHalfPeriod.distanceTo(start)).toBeCloseTo(2 * TRAPPIST_1B_SEMI_MAJOR_AXIS_AU, 6);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('moves a red dwarf planet more slowly than the old solar-mass assumption did', () => {
|
||||
// Assuming a solar-mass host made TRAPPIST-1's planets orbit about 3.3x too fast, so the
|
||||
// corrected planet must have travelled less far after the same elapsed time.
|
||||
const corrected = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS })]);
|
||||
const assumingSolar = new SystemOrbitsRenderer([], [exoplanet()]);
|
||||
|
||||
const elapsed = TRAPPIST_1B_PERIOD_DAYS / 8;
|
||||
const correctedTravel = positionAt(corrected, DEFAULT_EPOCH_JD).distanceTo(positionAt(corrected, DEFAULT_EPOCH_JD + elapsed));
|
||||
const solarTravel = positionAt(assumingSolar, DEFAULT_EPOCH_JD).distanceTo(
|
||||
positionAt(assumingSolar, DEFAULT_EPOCH_JD + elapsed)
|
||||
);
|
||||
|
||||
expect(correctedTravel).toBeLessThan(solarTravel);
|
||||
corrected.dispose();
|
||||
assumingSolar.dispose();
|
||||
});
|
||||
|
||||
it('uses the host star mass when no period is published', () => {
|
||||
const fromMass = new SystemOrbitsRenderer([], [exoplanet({ hostStarMassSolar: 0.0898 })]);
|
||||
const fromPeriod = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS })]);
|
||||
|
||||
const elapsed = 0.3;
|
||||
const massTravel = positionAt(fromMass, DEFAULT_EPOCH_JD).distanceTo(positionAt(fromMass, DEFAULT_EPOCH_JD + elapsed));
|
||||
const periodTravel = positionAt(fromPeriod, DEFAULT_EPOCH_JD).distanceTo(positionAt(fromPeriod, DEFAULT_EPOCH_JD + elapsed));
|
||||
|
||||
// The published mass and the period-derived mass agree, so the two must nearly coincide.
|
||||
expect(massTravel).toBeCloseTo(periodTravel, 4);
|
||||
fromMass.dispose();
|
||||
fromPeriod.dispose();
|
||||
});
|
||||
|
||||
it('still renders an exoplanet that has neither a period nor a host mass', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet()]);
|
||||
|
||||
expect(renderer.members).toHaveLength(1);
|
||||
expect(positionAt(renderer, DEFAULT_EPOCH_JD).length()).toBeCloseTo(TRAPPIST_1B_SEMI_MAJOR_AXIS_AU, 6);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('skips an exoplanet with no semi-major axis rather than crashing', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ orbit: { eccentricity: 0 } })]);
|
||||
|
||||
expect(renderer.members).toHaveLength(0);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
describe('orbits with no published eccentricity', () => {
|
||||
// The archive publishes a semi-major axis far more often than an eccentricity. Requiring
|
||||
// both dropped 1509 otherwise drawable planets.
|
||||
it('draws a planet that has an axis but no eccentricity', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ orbit: { semiMajorAxisAu: 0.4 } })]);
|
||||
|
||||
expect(renderer.members).toHaveLength(1);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('places it on a circle of the right radius', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ orbit: { semiMajorAxisAu: 0.4 } })]);
|
||||
|
||||
for (const offset of [0, 5, 20, 60]) {
|
||||
expect(positionAt(renderer, DEFAULT_EPOCH_JD + offset).length()).toBeCloseTo(0.4, 6);
|
||||
}
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('still honours the measured period', () => {
|
||||
const renderer = new SystemOrbitsRenderer(
|
||||
[],
|
||||
[exoplanet({ orbit: { semiMajorAxisAu: TRAPPIST_1B_SEMI_MAJOR_AXIS_AU }, periodDays: TRAPPIST_1B_PERIOD_DAYS })]
|
||||
);
|
||||
|
||||
const start = positionAt(renderer, DEFAULT_EPOCH_JD);
|
||||
const afterOnePeriod = positionAt(renderer, DEFAULT_EPOCH_JD + TRAPPIST_1B_PERIOD_DAYS);
|
||||
expect(afterOnePeriod.distanceTo(start)).toBeLessThan(1e-6);
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it('skips an escape trajectory rather than emitting NaN positions', () => {
|
||||
// e >= 1 is not an ellipse; propagating it anyway yields NaN, which poisons the geometry's
|
||||
// bounding sphere and disables culling for the whole object.
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ orbit: { semiMajorAxisAu: 1, eccentricity: 1.4 } })]);
|
||||
|
||||
expect(renderer.members).toHaveLength(0);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('skips a non-positive semi-major axis', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ orbit: { semiMajorAxisAu: 0, eccentricity: 0.1 } })]);
|
||||
|
||||
expect(renderer.members).toHaveLength(0);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('keeps every propagated position finite', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS, orbit: { semiMajorAxisAu: TRAPPIST_1B_SEMI_MAJOR_AXIS_AU, eccentricity: 0.62 } })]);
|
||||
|
||||
for (const offset of [0, 0.1, 1, 10, 1000]) {
|
||||
const { x, y, z } = positionAt(renderer, DEFAULT_EPOCH_JD + offset);
|
||||
expect([x, y, z].every(Number.isFinite)).toBe(true);
|
||||
}
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
describe('reference frame', () => {
|
||||
/** Earth: inclination 0 by definition — its orbit *is* the ecliptic plane. */
|
||||
const EARTH: BodyRecord = {
|
||||
id: 'earth',
|
||||
systemStarId: 0,
|
||||
name: 'Earth',
|
||||
kind: 'planet',
|
||||
radiusKm: 6371,
|
||||
orbit: {
|
||||
semiMajorAxisAu: 1,
|
||||
eccentricity: 0.0167,
|
||||
inclinationDeg: 0,
|
||||
longitudeOfAscendingNodeDeg: 0,
|
||||
argumentOfPeriapsisDeg: 0,
|
||||
meanAnomalyAtEpochDeg: 0,
|
||||
epochJd: DEFAULT_EPOCH_JD
|
||||
}
|
||||
};
|
||||
|
||||
it('places an ecliptic orbit in the ecliptic plane of the equatorial scene', () => {
|
||||
// Horizons reports elements against the ecliptic; the scene is equatorial, to match the
|
||||
// star catalogue. So Earth's orbit must come out tilted, lying perpendicular to the
|
||||
// *ecliptic* pole rather than to the scene's own vertical.
|
||||
const renderer = new SystemOrbitsRenderer([EARTH], []);
|
||||
const eclipticPole = eclipticToEquatorial({ x: 0, y: 0, z: 1 });
|
||||
|
||||
for (const offset of [0, 40, 91, 200, 300]) {
|
||||
renderer.update(DEFAULT_EPOCH_JD + offset);
|
||||
const p = renderer.members[0].marker.position;
|
||||
const outOfPlane = p.x * eclipticPole.x + p.y * eclipticPole.y + p.z * eclipticPole.z;
|
||||
expect(Math.abs(outOfPlane)).toBeLessThan(1e-9);
|
||||
}
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('tilts that orbit away from the celestial equator by the obliquity', () => {
|
||||
// The discriminating check: before the frames were reconciled, the orbit sat flat in the
|
||||
// scene and this angle was zero.
|
||||
const renderer = new SystemOrbitsRenderer([EARTH], []);
|
||||
renderer.update(DEFAULT_EPOCH_JD + 91); // a quarter orbit on, well away from the equinox
|
||||
|
||||
const p = renderer.members[0].marker.position;
|
||||
const latitudeDeg = (Math.asin(p.z / p.length()) * 180) / Math.PI;
|
||||
|
||||
expect(Math.abs(latitudeDeg)).toBeGreaterThan(1);
|
||||
expect(Math.abs(latitudeDeg)).toBeLessThanOrEqual(OBLIQUITY_J2000_DEG + 1e-6);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('keeps the vernal equinox direction shared between the two frames', () => {
|
||||
// A body at ecliptic longitude 0 sits on the +X axis in both frames, so it must not move.
|
||||
const atEquinox: BodyRecord = { ...EARTH, orbit: { ...EARTH.orbit, eccentricity: 0 } };
|
||||
const renderer = new SystemOrbitsRenderer([atEquinox], []);
|
||||
renderer.update(DEFAULT_EPOCH_JD);
|
||||
|
||||
const p = renderer.members[0].marker.position;
|
||||
expect(p.x).toBeCloseTo(1, 6);
|
||||
expect(p.y).toBeCloseTo(0, 9);
|
||||
expect(p.z).toBeCloseTo(0, 9);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('reads the solar system against the ecliptic and everything else against the sky plane', () => {
|
||||
const solar = new SystemOrbitsRenderer([EARTH], []);
|
||||
const eclipticPole = eclipticToEquatorial({ x: 0, y: 0, z: 1 });
|
||||
const solarNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(solar.referenceFrame);
|
||||
expect(solarNormal.dot(new THREE.Vector3(eclipticPole.x, eclipticPole.y, eclipticPole.z))).toBeCloseTo(1, 9);
|
||||
solar.dispose();
|
||||
|
||||
const lineOfSight = { x: 0.3, y: -0.5, z: 0.81 };
|
||||
const exo = new SystemOrbitsRenderer([], [exoplanet()], lineOfSight);
|
||||
const exoNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(exo.referenceFrame);
|
||||
const expected = new THREE.Vector3(lineOfSight.x, lineOfSight.y, lineOfSight.z).normalize();
|
||||
expect(exoNormal.dot(expected)).toBeCloseTo(1, 9);
|
||||
exo.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reference grid', () => {
|
||||
/** A body far enough out to give the grid something to measure. */
|
||||
const JUPITER: BodyRecord = {
|
||||
id: 'jupiter',
|
||||
systemStarId: 0,
|
||||
name: 'Jupiter',
|
||||
kind: 'planet',
|
||||
radiusKm: 69911,
|
||||
orbit: { semiMajorAxisAu: 5.2, eccentricity: 0.048, inclinationDeg: 1.3, longitudeOfAscendingNodeDeg: 100, argumentOfPeriapsisDeg: 275, meanAnomalyAtEpochDeg: 20, epochJd: DEFAULT_EPOCH_JD }
|
||||
};
|
||||
|
||||
/** The grid and the tethers are the only line objects the renderer adds outside a pivot. */
|
||||
function planeObjects(renderer: SystemOrbitsRenderer): THREE.LineSegments[] {
|
||||
return renderer.object.children.filter((child): child is THREE.LineSegments => child instanceof THREE.LineSegments);
|
||||
}
|
||||
|
||||
it('lays a grid and tethers in the system plane', () => {
|
||||
const renderer = new SystemOrbitsRenderer([JUPITER], []);
|
||||
expect(planeObjects(renderer)).toHaveLength(2);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('drops a tether from every top-level body onto that plane, and follows them', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet({ periodDays: TRAPPIST_1B_PERIOD_DAYS })]);
|
||||
renderer.update(DEFAULT_EPOCH_JD);
|
||||
|
||||
// The tether field is the one with an explicit draw range; the grid leaves it at Infinity.
|
||||
const tethers = planeObjects(renderer).find((object) => Number.isFinite(object.geometry.drawRange.count))!;
|
||||
const readTop = (): THREE.Vector3 => {
|
||||
const position = tethers.geometry.getAttribute('position');
|
||||
return new THREE.Vector3(position.getX(0), position.getY(0), position.getZ(0));
|
||||
};
|
||||
|
||||
// The tether's top is the marker, wherever the marker currently is.
|
||||
expect(readTop().distanceTo(renderer.members[0].marker.position)).toBeCloseTo(0, 9);
|
||||
const before = readTop();
|
||||
|
||||
renderer.update(DEFAULT_EPOCH_JD + TRAPPIST_1B_PERIOD_DAYS / 2);
|
||||
expect(readTop().distanceTo(renderer.members[0].marker.position)).toBeCloseTo(0, 9);
|
||||
expect(readTop().distanceTo(before)).toBeGreaterThan(0);
|
||||
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('draws no grid for a star with no known planets', () => {
|
||||
// Nothing to measure, and a bare ring around a lone star would imply a scale it does not
|
||||
// have.
|
||||
const renderer = new SystemOrbitsRenderer([], []);
|
||||
expect(planeObjects(renderer)).toHaveLength(0);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('detaches the grid on dispose along with everything else', () => {
|
||||
const renderer = new SystemOrbitsRenderer([JUPITER], []);
|
||||
const [grid] = planeObjects(renderer);
|
||||
renderer.dispose();
|
||||
expect(grid.parent).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('exoplanet inclination is measured from the plane of the sky', () => {
|
||||
// A host somewhere off all three axes, so nothing can pass by coincidence.
|
||||
const LINE_OF_SIGHT = new THREE.Vector3(0.37, -0.62, 0.69).normalize();
|
||||
|
||||
function circular(inclinationDeg: number): ExoplanetRecord {
|
||||
return exoplanet({ orbit: { semiMajorAxisAu: 0.5, eccentricity: 0, inclinationDeg } });
|
||||
}
|
||||
|
||||
/** Normal of the plane the rendered orbit actually lies in. */
|
||||
function orbitNormal(renderer: SystemOrbitsRenderer): THREE.Vector3 {
|
||||
const a = positionAt(renderer, DEFAULT_EPOCH_JD);
|
||||
const b = positionAt(renderer, DEFAULT_EPOCH_JD + 20);
|
||||
return new THREE.Vector3().crossVectors(a, b).normalize();
|
||||
}
|
||||
|
||||
it('tilts the orbit by the published inclination away from the line of sight', () => {
|
||||
// The definition: inclination is the angle between the orbital axis and our line of
|
||||
// sight to the star. Reading it as an ecliptic inclination instead tips the orbit against
|
||||
// a plane it was never measured against.
|
||||
for (const inclinationDeg of [0, 30, 60, 88.9, 90]) {
|
||||
const renderer = new SystemOrbitsRenderer([], [circular(inclinationDeg)], LINE_OF_SIGHT);
|
||||
const angleDeg = (Math.acos(Math.abs(orbitNormal(renderer).dot(LINE_OF_SIGHT))) * 180) / Math.PI;
|
||||
|
||||
expect(angleDeg).toBeCloseTo(inclinationDeg <= 90 ? inclinationDeg : 180 - inclinationDeg, 4);
|
||||
renderer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('makes an edge-on planet actually transit its star as seen from Earth', () => {
|
||||
// 90 degrees means edge-on to us, which is why transiting planets cluster there. So some
|
||||
// point on the orbit must lie along the line of sight — in front of or behind the star.
|
||||
const renderer = new SystemOrbitsRenderer([], [circular(90)], LINE_OF_SIGHT);
|
||||
|
||||
let closestToLineOfSight = 0;
|
||||
for (let day = 0; day < 120; day++) {
|
||||
const p = positionAt(renderer, DEFAULT_EPOCH_JD + day).normalize();
|
||||
closestToLineOfSight = Math.max(closestToLineOfSight, Math.abs(p.dot(LINE_OF_SIGHT)));
|
||||
}
|
||||
|
||||
expect(closestToLineOfSight).toBeGreaterThan(0.99);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('keeps a face-on planet in the plane of the sky, never transiting', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [circular(0)], LINE_OF_SIGHT);
|
||||
|
||||
for (let day = 0; day < 120; day += 7) {
|
||||
const p = positionAt(renderer, DEFAULT_EPOCH_JD + day).normalize();
|
||||
expect(Math.abs(p.dot(LINE_OF_SIGHT))).toBeLessThan(1e-9);
|
||||
}
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('places identical elements differently for hosts in different directions', () => {
|
||||
// Each system is oriented against its own line of sight, so the same elements around two
|
||||
// stars in different parts of the sky do not land in the same place.
|
||||
//
|
||||
// Note this checks position, not the plane's normal. With no published node angle the
|
||||
// rotation about the line of sight is arbitrary, so two planes can come out near-parallel
|
||||
// by coincidence while each still sits at its correct inclination to its own host — which
|
||||
// is the property the test above pins.
|
||||
const here = new SystemOrbitsRenderer([], [circular(88.9)], new THREE.Vector3(1, 0, 0));
|
||||
const there = new SystemOrbitsRenderer([], [circular(88.9)], new THREE.Vector3(0, 0, 1));
|
||||
|
||||
expect(positionAt(here, DEFAULT_EPOCH_JD).distanceTo(positionAt(there, DEFAULT_EPOCH_JD))).toBeGreaterThan(0.1);
|
||||
here.dispose();
|
||||
there.dispose();
|
||||
});
|
||||
|
||||
it('falls back to the ecliptic frame when the host direction is unknown', () => {
|
||||
const withoutHost = new SystemOrbitsRenderer([], [circular(0)]);
|
||||
const eclipticPole = eclipticToEquatorial({ x: 0, y: 0, z: 1 });
|
||||
|
||||
expect(Math.abs(orbitNormal(withoutHost).dot(new THREE.Vector3(eclipticPole.x, eclipticPole.y, eclipticPole.z)))).toBeCloseTo(1, 9);
|
||||
withoutHost.dispose();
|
||||
});
|
||||
|
||||
it('ignores a zero-length host direction rather than producing NaN', () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [circular(45)], new THREE.Vector3(0, 0, 0));
|
||||
const p = positionAt(renderer, DEFAULT_EPOCH_JD);
|
||||
|
||||
expect([p.x, p.y, p.z].every(Number.isFinite)).toBe(true);
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
|
||||
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
||||
import { gmForParent } from '../../shared/astro/constants';
|
||||
import { orbitEllipsePoints, propagateOrbit, resolveOrbitalElements } from '../../shared/astro/kepler';
|
||||
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
||||
import { MARKER_TEXTURE_HEIGHT, MARKER_TEXTURE_WIDTH, planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||
import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
|
||||
import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
||||
import { bodyMarkerRadiusAu, systemGridRingsAu } from './system-framing';
|
||||
import { PolarGridPlane, TetherField } from './grid-plane';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
|
||||
export type SystemMemberKind = 'planet' | 'moon' | 'dwarf' | 'exoplanet';
|
||||
@@ -27,15 +33,46 @@ const ORBIT_LINE_OPACITY_BY_KIND: Record<SystemMemberKind, number> = {
|
||||
};
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const MIN_MARKER_RADIUS_AU = 0.012;
|
||||
const MAX_MARKER_RADIUS_AU = 0.09;
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
|
||||
/** Exaggerated (non-physical) marker radius so planets stay visible at AU scale. */
|
||||
function markerRadiusAu(radiusKm: number | undefined): number {
|
||||
if (!radiusKm) {
|
||||
return MIN_MARKER_RADIUS_AU;
|
||||
/** Spokes on the system's reference grid, and how loudly it is drawn against the orbits. */
|
||||
const SYSTEM_GRID_SPOKES = 12;
|
||||
const SYSTEM_GRID_OPACITY = 0.28;
|
||||
const SYSTEM_TETHER_OPACITY = 0.3;
|
||||
|
||||
/**
|
||||
* Rotation carrying the **ecliptic** frame into the scene's equatorial one — a turn of the
|
||||
* obliquity about the shared vernal-equinox axis. Solar-system elements come from Horizons
|
||||
* against the ecliptic, so this is their frame.
|
||||
*/
|
||||
const ECLIPTIC_FRAME = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), OBLIQUITY_J2000_DEG * DEG_TO_RAD);
|
||||
|
||||
/**
|
||||
* Rotation carrying the frame an **exoplanet's** elements are measured in into the scene.
|
||||
*
|
||||
* The Exoplanet Archive measures inclination from the *plane of the sky* — the plane
|
||||
* perpendicular to our line of sight to the host star — not from the ecliptic. 90 degrees means
|
||||
* edge-on as seen from Earth, which is why transiting planets cluster there: 1643 of the 2061
|
||||
* published inclinations are within 5 degrees of 90. Treating that as an ecliptic inclination
|
||||
* tips every transiting system on its side against a plane it was never measured against.
|
||||
*
|
||||
* Carrying the elements' +Z onto the line of sight fixes it: an inclination of `i` then means
|
||||
* the orbit's normal sits `i` from our line of sight, which is exactly the definition. The
|
||||
* rotation about that axis is the node's position angle on the sky, which the archive does not
|
||||
* publish, so the shortest arc from +Z is used — deterministic, and no less arbitrary than any
|
||||
* other choice given no data.
|
||||
*
|
||||
* Falls back to the ecliptic frame when there is no direction to work with.
|
||||
*/
|
||||
function skyPlaneFrame(lineOfSight: CartesianCoordinates | undefined): THREE.Quaternion {
|
||||
if (!lineOfSight) {
|
||||
return ECLIPTIC_FRAME.clone();
|
||||
}
|
||||
return THREE.MathUtils.clamp(radiusKm / 18000, MIN_MARKER_RADIUS_AU, MAX_MARKER_RADIUS_AU);
|
||||
const direction = new THREE.Vector3(lineOfSight.x, lineOfSight.y, lineOfSight.z);
|
||||
if (direction.lengthSq() === 0) {
|
||||
return ECLIPTIC_FRAME.clone();
|
||||
}
|
||||
return new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), direction.normalize());
|
||||
}
|
||||
|
||||
function colorForKind(kind: SystemMemberKind): THREE.Color {
|
||||
@@ -51,13 +88,17 @@ function colorForKind(kind: SystemMemberKind): THREE.Color {
|
||||
}
|
||||
}
|
||||
|
||||
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind): THREE.Line {
|
||||
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame: THREE.Quaternion): THREE.Line {
|
||||
const points = orbitEllipsePoints(elements);
|
||||
const positions = new Float32Array(points.length * 3);
|
||||
const scratch = new THREE.Vector3();
|
||||
points.forEach((point, index) => {
|
||||
positions[index * 3] = point.x;
|
||||
positions[index * 3 + 1] = point.z; // AU "up" (ecliptic normal) maps to scene Y.
|
||||
positions[index * 3 + 2] = point.y;
|
||||
// Elements are measured against their source's own reference plane; `frame` rotates that
|
||||
// plane into the scene's equatorial one.
|
||||
const { x, y, z } = scratch.set(point.x, point.y, point.z).applyQuaternion(frame);
|
||||
positions[index * 3] = x;
|
||||
positions[index * 3 + 1] = y;
|
||||
positions[index * 3 + 2] = z;
|
||||
});
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
@@ -72,9 +113,19 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind): THRE
|
||||
return new THREE.Line(geometry, material);
|
||||
}
|
||||
|
||||
function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined): THREE.Mesh {
|
||||
const geometry = new THREE.SphereGeometry(markerRadiusAu(radiusKm), 16, 12);
|
||||
const material = new THREE.MeshBasicMaterial({ color: colorForKind(kind) });
|
||||
/**
|
||||
* A marker sphere, surfaced with the body's own derived appearance rather than a flat category
|
||||
* colour — so a system reads as a set of distinct worlds at a glance, and the colour of each is
|
||||
* a consequence of its measurements rather than of which list it came from.
|
||||
*
|
||||
* The texture is tiny (see `MARKER_TEXTURE_WIDTH`): a marker is a few pixels across, so what
|
||||
* survives is essentially its average colour, and generating it costs well under a millisecond.
|
||||
*/
|
||||
function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, systemSpanAu: number, appearance: PlanetAppearance | undefined): THREE.Mesh {
|
||||
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm, systemSpanAu), 16, 12);
|
||||
const material = appearance
|
||||
? new THREE.MeshBasicMaterial({ map: planetTexture(appearance, { width: MARKER_TEXTURE_WIDTH, height: MARKER_TEXTURE_HEIGHT }) })
|
||||
: new THREE.MeshBasicMaterial({ color: colorForKind(kind) });
|
||||
return new THREE.Mesh(geometry, material);
|
||||
}
|
||||
|
||||
@@ -84,6 +135,8 @@ interface TrackedTopLevelBody {
|
||||
elements: OrbitalElements;
|
||||
gmAu3PerDay2: number;
|
||||
marker: THREE.Mesh;
|
||||
/** Rotation from this body's own element frame into the scene's equatorial one. */
|
||||
frame: THREE.Quaternion;
|
||||
/** AU position last computed for this body; moons read their parent's here. */
|
||||
position: THREE.Vector3;
|
||||
}
|
||||
@@ -93,6 +146,7 @@ interface TrackedMoon {
|
||||
elements: OrbitalElements;
|
||||
gmAu3PerDay2: number;
|
||||
marker: THREE.Mesh;
|
||||
frame: THREE.Quaternion;
|
||||
pivot: THREE.Group;
|
||||
parentId: string;
|
||||
}
|
||||
@@ -108,15 +162,54 @@ export class SystemOrbitsRenderer {
|
||||
readonly members: readonly SystemMember[];
|
||||
/** Largest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
|
||||
readonly maxTopLevelSemiMajorAxisAu: number;
|
||||
/** Smallest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
|
||||
readonly minTopLevelSemiMajorAxisAu: number;
|
||||
/**
|
||||
* The plane this system is read against, as a rotation from XY into the scene's equatorial
|
||||
* frame: the ecliptic for the solar system, the plane of the sky for everything else.
|
||||
*/
|
||||
readonly referenceFrame: THREE.Quaternion;
|
||||
/**
|
||||
* Outer radius (AU) of the reference grid, or 0 where there is none. This — not the outermost
|
||||
* orbit — is the widest thing the system draws, so it is what the camera has to frame.
|
||||
*/
|
||||
readonly gridOuterRadiusAu: number;
|
||||
|
||||
private readonly topLevelBodies: TrackedTopLevelBody[] = [];
|
||||
private readonly moons: TrackedMoon[] = [];
|
||||
private readonly disposables: Array<{ geometry: THREE.BufferGeometry; material: THREE.Material }> = [];
|
||||
private readonly grid?: PolarGridPlane;
|
||||
private readonly tethers?: TetherField;
|
||||
/**
|
||||
* Aliases of the tracked bodies' own position vectors, which `update` writes in place — so
|
||||
* following them each tick costs no allocation at all.
|
||||
*/
|
||||
private tetherPoints: readonly THREE.Vector3[] = [];
|
||||
|
||||
constructor(bodies: readonly BodyRecord[], exoplanets: readonly ExoplanetRecord[]) {
|
||||
constructor(
|
||||
bodies: readonly BodyRecord[],
|
||||
exoplanets: readonly ExoplanetRecord[],
|
||||
/** Direction from the Sun to this system's host star, equatorial — the exoplanet line of sight. */
|
||||
hostStarDirection?: CartesianCoordinates,
|
||||
/**
|
||||
* The host star's luminosity in solar units, which is what sets how hot each body in the
|
||||
* system is and therefore what it looks like. Omitted for a host that is not in the star
|
||||
* catalogue, leaving its bodies classified on size and density alone.
|
||||
*/
|
||||
hostLuminositySolar?: number | null
|
||||
) {
|
||||
const members: SystemMember[] = [];
|
||||
const topLevelBodiesById = new Map<string, BodyRecord>();
|
||||
|
||||
// Measured before anything is built, because marker sizes are scaled against the span and
|
||||
// the markers are created as the bodies are added.
|
||||
const topLevelAxes = [
|
||||
...bodies.filter((body) => !body.parentBodyId).map((body) => body.orbit.semiMajorAxisAu),
|
||||
...exoplanets.filter((exoplanet) => isPropagatableOrbit(exoplanet.orbit)).map((exoplanet) => exoplanet.orbit.semiMajorAxisAu!)
|
||||
].filter((axis) => Number.isFinite(axis) && axis > 0);
|
||||
this.maxTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.max(...topLevelAxes) : 0;
|
||||
this.minTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.min(...topLevelAxes) : 0;
|
||||
|
||||
for (const body of bodies) {
|
||||
if (!body.parentBodyId) {
|
||||
topLevelBodiesById.set(body.id, body);
|
||||
@@ -129,7 +222,7 @@ export class SystemOrbitsRenderer {
|
||||
}
|
||||
// A body reaches here only when it has no parentBodyId, so `kind` is 'planet' or 'dwarf'.
|
||||
const kind: SystemMemberKind = body.kind;
|
||||
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm);
|
||||
const tracked = this.addTopLevelBody(body.id, kind, body.orbit, gmForParent(undefined), body.radiusKm, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar));
|
||||
members.push({ id: body.id, kind, marker: tracked.marker });
|
||||
}
|
||||
|
||||
@@ -142,37 +235,72 @@ export class SystemOrbitsRenderer {
|
||||
if (!parentTracked) {
|
||||
continue; // orphaned moon reference; skip rather than crash.
|
||||
}
|
||||
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked);
|
||||
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked, ECLIPTIC_FRAME, appearanceForBody(body, bodies, hostLuminositySolar));
|
||||
members.push({ id: body.id, kind: 'moon', marker: moon.marker });
|
||||
}
|
||||
|
||||
// Every exoplanet in a system shares the same line of sight, so the frame is built once.
|
||||
const exoplanetFrame = skyPlaneFrame(hostStarDirection);
|
||||
|
||||
for (const exoplanet of exoplanets) {
|
||||
if (!exoplanet.orbit.semiMajorAxisAu || exoplanet.orbit.eccentricity === undefined) {
|
||||
continue; // not enough data to place on an orbit.
|
||||
// Only a semi-major axis is genuinely required; resolveOrbitalElements defaults the rest,
|
||||
// eccentricity included. Demanding a published eccentricity as well used to drop 1509
|
||||
// otherwise drawable planets, so a user could open one's detail page, jump to its system,
|
||||
// and find it missing from the very system it belongs to.
|
||||
if (!isPropagatableOrbit(exoplanet.orbit)) {
|
||||
continue;
|
||||
}
|
||||
const elements = resolveOrbitalElements({
|
||||
semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu,
|
||||
eccentricity: exoplanet.orbit.eccentricity,
|
||||
inclinationDeg: exoplanet.orbit.inclinationDeg,
|
||||
longitudeOfAscendingNodeDeg: exoplanet.orbit.longitudeOfAscendingNodeDeg,
|
||||
argumentOfPeriapsisDeg: exoplanet.orbit.argumentOfPeriapsisDeg,
|
||||
meanAnomalyAtEpochDeg: exoplanet.orbit.meanAnomalyAtEpochDeg,
|
||||
epochJd: exoplanet.orbit.epochJd
|
||||
});
|
||||
const elements = resolveOrbitalElements(exoplanet.orbit);
|
||||
const radiusKm = exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined;
|
||||
const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gmForParent(undefined), radiusKm);
|
||||
// Not `gmForParent(undefined)`: that assumes a solar-mass host for every system, and
|
||||
// most exoplanet hosts are red dwarfs a fraction of the Sun's mass.
|
||||
const gm = resolveGravitationalParameter({
|
||||
semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu,
|
||||
periodDays: exoplanet.periodDays,
|
||||
hostStarMassSolar: exoplanet.hostStarMassSolar
|
||||
});
|
||||
const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gm, radiusKm, exoplanetFrame, appearanceForExoplanet(exoplanet, hostLuminositySolar));
|
||||
members.push({ id: exoplanet.id, kind: 'exoplanet', marker: tracked.marker });
|
||||
}
|
||||
|
||||
this.members = members;
|
||||
this.maxTopLevelSemiMajorAxisAu = this.topLevelBodies.reduce((max, body) => Math.max(max, body.elements.semiMajorAxisAu), 0);
|
||||
|
||||
// Which plane the system is read against follows from where its elements came from. Only the
|
||||
// Sun has Horizons bodies and no system has both, so this is a choice between the two rather
|
||||
// than a compromise: the ecliptic if there are solar-system bodies, the sky plane otherwise.
|
||||
this.referenceFrame = bodies.some((body) => !body.parentBodyId) ? ECLIPTIC_FRAME.clone() : exoplanetFrame;
|
||||
|
||||
const rings = systemGridRingsAu(this.maxTopLevelSemiMajorAxisAu);
|
||||
this.gridOuterRadiusAu = rings.length > 0 ? rings[rings.length - 1] : 0;
|
||||
if (rings.length > 0) {
|
||||
this.grid = new PolarGridPlane({
|
||||
ringRadii: rings,
|
||||
spokeCount: SYSTEM_GRID_SPOKES,
|
||||
orientation: this.referenceFrame,
|
||||
// Quieter and dashed, unlike the galaxy view's: here the grid shares a plane with the
|
||||
// orbit ellipses, which are themselves rings, and it must not be mistaken for one.
|
||||
opacity: SYSTEM_GRID_OPACITY,
|
||||
dashed: true,
|
||||
emphasisRadii: [rings[rings.length - 1]]
|
||||
});
|
||||
this.grid.setStrength(1);
|
||||
|
||||
this.tethers = new TetherField(this.topLevelBodies.length, {
|
||||
normal: new THREE.Vector3(0, 0, 1).applyQuaternion(this.referenceFrame),
|
||||
opacity: SYSTEM_TETHER_OPACITY
|
||||
});
|
||||
this.tethers.setStrength(1);
|
||||
this.tetherPoints = this.topLevelBodies.map((body) => body.position);
|
||||
|
||||
this.object.add(this.grid.object, this.tethers.object);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recomputes every marker's position for the given Julian date. Call once per tick. */
|
||||
update(epochJd: number): void {
|
||||
for (const body of this.topLevelBodies) {
|
||||
const { x, y, z } = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd);
|
||||
body.position.set(x, z, y); // AU "up" maps to scene Y, matching buildOrbitLine.
|
||||
const orbital = propagateOrbit(body.elements, body.gmAu3PerDay2, epochJd);
|
||||
body.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(body.frame);
|
||||
body.marker.position.copy(body.position);
|
||||
}
|
||||
|
||||
@@ -182,9 +310,13 @@ export class SystemOrbitsRenderer {
|
||||
continue;
|
||||
}
|
||||
moon.pivot.position.copy(parent.position);
|
||||
const { x, y, z } = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
|
||||
moon.marker.position.set(x, z, y);
|
||||
const orbital = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
|
||||
moon.marker.position.set(orbital.x, orbital.y, orbital.z).applyQuaternion(moon.frame);
|
||||
}
|
||||
|
||||
// Moons are left out: their tether would land within a marker's width of their planet's and
|
||||
// say nothing the planet's has not already said.
|
||||
this.tethers?.setTargets(this.tetherPoints);
|
||||
}
|
||||
|
||||
/** Looks up which system member a marker object belongs to (e.g. from a raycast hit). */
|
||||
@@ -198,34 +330,58 @@ export class SystemOrbitsRenderer {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.grid?.dispose();
|
||||
this.tethers?.dispose();
|
||||
for (const { geometry, material } of this.disposables) {
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
}
|
||||
// Detach as well as dispose. A star-to-star hop builds a new renderer and drops the old
|
||||
// one, but without this the old orbit lines and markers stay parented to the system group
|
||||
// forever — still traversed and re-uploaded every frame despite their geometries being
|
||||
// disposed, and drawn over the new system while being unpickable.
|
||||
this.object.removeFromParent();
|
||||
this.object.clear();
|
||||
}
|
||||
|
||||
private addTopLevelBody(id: string, kind: SystemMemberKind, elements: OrbitalElements, gmAu3PerDay2: number, radiusKm: number | undefined): TrackedTopLevelBody {
|
||||
const orbitLine = buildOrbitLine(elements, kind);
|
||||
const marker = buildMarker(kind, radiusKm);
|
||||
private addTopLevelBody(
|
||||
id: string,
|
||||
kind: SystemMemberKind,
|
||||
elements: OrbitalElements,
|
||||
gmAu3PerDay2: number,
|
||||
radiusKm: number | undefined,
|
||||
frame: THREE.Quaternion,
|
||||
appearance?: PlanetAppearance
|
||||
): TrackedTopLevelBody {
|
||||
const orbitLine = buildOrbitLine(elements, kind, frame);
|
||||
const marker = buildMarker(kind, radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance);
|
||||
this.object.add(orbitLine, marker);
|
||||
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
||||
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
||||
|
||||
const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, position: new THREE.Vector3() };
|
||||
const tracked: TrackedTopLevelBody = { id, kind, elements, gmAu3PerDay2, marker, frame, position: new THREE.Vector3() };
|
||||
this.topLevelBodies.push(tracked);
|
||||
return tracked;
|
||||
}
|
||||
|
||||
private addMoon(id: string, elements: OrbitalElements, gmAu3PerDay2: number, radiusKm: number | undefined, parent: TrackedTopLevelBody): TrackedMoon {
|
||||
private addMoon(
|
||||
id: string,
|
||||
elements: OrbitalElements,
|
||||
gmAu3PerDay2: number,
|
||||
radiusKm: number | undefined,
|
||||
parent: TrackedTopLevelBody,
|
||||
frame: THREE.Quaternion,
|
||||
appearance?: PlanetAppearance
|
||||
): TrackedMoon {
|
||||
const pivot = new THREE.Group();
|
||||
const orbitLine = buildOrbitLine(elements, 'moon');
|
||||
const marker = buildMarker('moon', radiusKm);
|
||||
const orbitLine = buildOrbitLine(elements, 'moon', frame);
|
||||
const marker = buildMarker('moon', radiusKm, this.maxTopLevelSemiMajorAxisAu, appearance);
|
||||
pivot.add(orbitLine, marker);
|
||||
this.object.add(pivot);
|
||||
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
||||
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
||||
|
||||
const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, pivot, parentId: parent.id };
|
||||
const moon: TrackedMoon = { id, elements, gmAu3PerDay2, marker, frame, pivot, parentId: parent.id };
|
||||
this.moons.push(moon);
|
||||
return moon;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSearchIndex, rankSearchResults, scoreSearchMatch, SearchEntry } from './search-ranking';
|
||||
|
||||
function star(name: string): SearchEntry {
|
||||
return { kind: 'star', name, subtitle: 'G2V', starId: name.length };
|
||||
}
|
||||
function body(name: string): SearchEntry {
|
||||
return { kind: 'body', name, subtitle: 'moon', bodyId: name };
|
||||
}
|
||||
function exoplanet(name: string): SearchEntry {
|
||||
return { kind: 'exoplanet', name, subtitle: 'host', bodyId: name };
|
||||
}
|
||||
|
||||
function rank(entries: readonly SearchEntry[], query: string, limit = 8): string[] {
|
||||
return rankSearchResults(buildSearchIndex(entries), query, limit).map((entry) => entry.name);
|
||||
}
|
||||
|
||||
describe('scoreSearchMatch', () => {
|
||||
it('ranks an exact match above a prefix, a prefix above a word start, and that above a substring', () => {
|
||||
const exact = scoreSearchMatch('Io', 'Io');
|
||||
const prefix = scoreSearchMatch('Iot Cas', 'Io');
|
||||
const wordStart = scoreSearchMatch('Alpha Ionis', 'Io');
|
||||
const substring = scoreSearchMatch('Bellion', 'io');
|
||||
|
||||
expect(exact).toBeGreaterThan(prefix);
|
||||
expect(prefix).toBeGreaterThan(wordStart);
|
||||
expect(wordStart).toBeGreaterThan(substring);
|
||||
expect(substring).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('ignores case', () => {
|
||||
expect(scoreSearchMatch('Sirius', 'sirius')).toBe(scoreSearchMatch('Sirius', 'Sirius'));
|
||||
});
|
||||
|
||||
it('ignores punctuation and spacing for an exact match', () => {
|
||||
// HYG writes "Gl 357"; a user may well type "gl357".
|
||||
expect(scoreSearchMatch('Gl 357', 'gl357')).toBe(scoreSearchMatch('Gl 357', 'Gl 357'));
|
||||
});
|
||||
|
||||
it('treats a hyphenated part as its own word', () => {
|
||||
// "Kepler-9 c" should be reachable by its designation as well as its catalogue name.
|
||||
expect(scoreSearchMatch('Kepler-9 c', '9')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not match an unrelated name', () => {
|
||||
expect(scoreSearchMatch('Sirius', 'zzz')).toBe(0);
|
||||
});
|
||||
|
||||
it('does not match an empty query', () => {
|
||||
expect(scoreSearchMatch('Sirius', '')).toBe(0);
|
||||
expect(scoreSearchMatch('Sirius', ' ')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankSearchResults', () => {
|
||||
it('surfaces an exact match that the old index-order scan could never reach', () => {
|
||||
// The moon Io sits behind 8750 stars in the index, so a scan that stopped at the first
|
||||
// 8 substring hits returned only stars named "Iot ..." and never reached it.
|
||||
const entries = [...Array.from({ length: 30 }, (_, i) => star(`Iot Star ${i}`)), body('Io')];
|
||||
|
||||
expect(rank(entries, 'Io')[0]).toBe('Io');
|
||||
});
|
||||
|
||||
it('keeps the rest of the matches after the exact one', () => {
|
||||
const entries = [star('Iot Cas'), body('Io'), star('Iot Boo')];
|
||||
expect(rank(entries, 'Io')).toEqual(['Io', 'Iot Boo', 'Iot Cas']);
|
||||
});
|
||||
|
||||
it('prefers a prefix match over a mid-name one', () => {
|
||||
expect(rank([star('Bellion'), star('Ionis')], 'io')).toEqual(['Ionis', 'Bellion']);
|
||||
});
|
||||
|
||||
it('orders equally-scored matches by kind, bodies first then stars then exoplanets', () => {
|
||||
const entries = [exoplanet('Cen x'), star('Cen y'), body('Cen z')];
|
||||
expect(rank(entries, 'Cen')).toEqual(['Cen z', 'Cen y', 'Cen x']);
|
||||
});
|
||||
|
||||
it('puts the host star ahead of its own planets', () => {
|
||||
const entries = [exoplanet('Proxima Cen b'), exoplanet('Proxima Cen d'), star('Proxima Centauri')];
|
||||
expect(rank(entries, 'Proxima')[0]).toBe('Proxima Centauri');
|
||||
});
|
||||
|
||||
it('breaks remaining ties by name length, then alphabetically', () => {
|
||||
const entries = [exoplanet('Kepler-1292 b'), exoplanet('Kepler-9 c'), exoplanet('Kepler-9 b'), exoplanet('Kepler-15 b')];
|
||||
expect(rank(entries, 'Kepler')).toEqual(['Kepler-9 b', 'Kepler-9 c', 'Kepler-15 b', 'Kepler-1292 b']);
|
||||
});
|
||||
|
||||
it('is independent of the order entries were indexed in', () => {
|
||||
const entries = [star('Iot Cas'), body('Io'), exoplanet('Iota b')];
|
||||
expect(rank(entries, 'Io')).toEqual(rank([...entries].reverse(), 'Io'));
|
||||
});
|
||||
|
||||
it('respects the limit', () => {
|
||||
const entries = Array.from({ length: 50 }, (_, i) => star(`Test ${i}`));
|
||||
expect(rank(entries, 'Test', 8)).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('returns nothing for a limit of zero or less', () => {
|
||||
expect(rank([star('Sirius')], 'Sirius', 0)).toEqual([]);
|
||||
expect(rank([star('Sirius')], 'Sirius', -1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing for an empty query rather than everything', () => {
|
||||
expect(rank([star('Sirius'), body('Io')], '')).toEqual([]);
|
||||
expect(rank([star('Sirius'), body('Io')], ' ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing when there is no match', () => {
|
||||
expect(rank([star('Sirius')], 'zzz')).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles an empty index', () => {
|
||||
expect(rank([], 'anything')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps both stars that genuinely share a name', () => {
|
||||
// 17 HYG names are shared by two records — binary components are separate, visitable stars.
|
||||
const entries = [star('Iot Pic'), star('Iot Pic')];
|
||||
expect(rank(entries, 'Iot Pic')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('carries the full entry through, not just the name', () => {
|
||||
const [result] = rankSearchResults(buildSearchIndex([body('Io')]), 'Io', 1);
|
||||
expect(result).toMatchObject({ kind: 'body', name: 'Io', bodyId: 'Io' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSearchIndex', () => {
|
||||
it('preserves every entry', () => {
|
||||
const entries = [star('A'), body('B'), exoplanet('C')];
|
||||
expect(buildSearchIndex(entries).map((indexed) => indexed.entry)).toEqual(entries);
|
||||
});
|
||||
|
||||
it('precomputes the forms matching needs', () => {
|
||||
const [indexed] = buildSearchIndex([star('Alpha Cen-B')]);
|
||||
|
||||
expect(indexed.normalizedName).toBe('alpha cen-b');
|
||||
expect(indexed.compactName).toBe('alphacenb');
|
||||
expect(indexed.words).toEqual(['alpha', 'cen', 'b']);
|
||||
});
|
||||
|
||||
it('handles an empty list', () => {
|
||||
expect(buildSearchIndex([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
export type SearchResultKind = 'star' | 'body' | 'exoplanet';
|
||||
|
||||
export interface SearchEntry {
|
||||
kind: SearchResultKind;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
/** HYG star id, for `kind: 'star'` results. */
|
||||
starId?: number;
|
||||
/** `bodies.json`/`exoplanets.json` id, for `kind: 'body' | 'exoplanet'` results. */
|
||||
bodyId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How well a name matches, best first. The gaps are what matter: any exact match outranks every
|
||||
* prefix match, and so on, so a better kind of match can never be crowded out by a worse one.
|
||||
*/
|
||||
const MATCH_EXACT = 4;
|
||||
const MATCH_PREFIX = 3;
|
||||
const MATCH_WORD_START = 2;
|
||||
const MATCH_SUBSTRING = 1;
|
||||
const NO_MATCH = 0;
|
||||
|
||||
/**
|
||||
* Order for results that match equally well. Solar-system bodies are eighteen famous objects
|
||||
* and win ties outright; a star outranks an exoplanet because searching a name like "Proxima"
|
||||
* is usually an attempt to reach the system rather than one particular planet in it.
|
||||
*/
|
||||
const KIND_PRIORITY: Readonly<Record<SearchResultKind, number>> = {
|
||||
body: 0,
|
||||
star: 1,
|
||||
exoplanet: 2
|
||||
};
|
||||
|
||||
/** Catalogue names separate their parts with spaces, hyphens, underscores and slashes. */
|
||||
const WORD_SEPARATORS = /[\s\-_/]+/;
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/** Strips everything but letters and digits, so "gj581" can match "GJ 581". */
|
||||
function compact(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* A search entry with its name pre-broken into the forms matching needs.
|
||||
*
|
||||
* Built once via {@link buildSearchIndex} rather than derived per keystroke. Normalising 15,000
|
||||
* names on every character typed costs about 11 ms — most of a frame — and the search runs on
|
||||
* the same thread as the render loop, so doing it live visibly stutters the scene.
|
||||
*/
|
||||
export interface IndexedSearchEntry {
|
||||
readonly entry: SearchEntry;
|
||||
readonly normalizedName: string;
|
||||
readonly compactName: string;
|
||||
readonly words: readonly string[];
|
||||
}
|
||||
|
||||
export function buildSearchIndex(entries: readonly SearchEntry[]): IndexedSearchEntry[] {
|
||||
return entries.map((entry) => {
|
||||
const normalizedName = normalize(entry.name);
|
||||
return {
|
||||
entry,
|
||||
normalizedName,
|
||||
compactName: compact(entry.name),
|
||||
words: normalizedName.split(WORD_SEPARATORS)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function scoreIndexed(indexed: IndexedSearchEntry, normalizedQuery: string, compactQuery: string): number {
|
||||
// Punctuation-insensitive as well as case-insensitive, because the catalogues are
|
||||
// inconsistent about it: HYG writes "Gl 357" where a user may well type "gl357".
|
||||
if (indexed.normalizedName === normalizedQuery || indexed.compactName === compactQuery) {
|
||||
return MATCH_EXACT;
|
||||
}
|
||||
if (indexed.normalizedName.startsWith(normalizedQuery)) {
|
||||
return MATCH_PREFIX;
|
||||
}
|
||||
if (indexed.words.some((word) => word.startsWith(normalizedQuery))) {
|
||||
return MATCH_WORD_START;
|
||||
}
|
||||
if (indexed.normalizedName.includes(normalizedQuery)) {
|
||||
return MATCH_SUBSTRING;
|
||||
}
|
||||
return NO_MATCH;
|
||||
}
|
||||
|
||||
/**
|
||||
* How well `name` matches `query`, or {@link NO_MATCH}. The readable, allocation-per-call form
|
||||
* of {@link scoreIndexed}, kept for tests and for callers scoring a single name.
|
||||
*/
|
||||
export function scoreSearchMatch(name: string, query: string): number {
|
||||
const normalizedQuery = normalize(query);
|
||||
if (normalizedQuery === '') {
|
||||
return NO_MATCH;
|
||||
}
|
||||
return scoreIndexed(buildSearchIndex([{ kind: 'star', name, subtitle: '' }])[0], normalizedQuery, compact(query));
|
||||
}
|
||||
|
||||
/**
|
||||
* The `limit` best matches for `query`, best first.
|
||||
*
|
||||
* Replaces a scan that took the first `limit` substring matches in index order — stars, then
|
||||
* bodies, then exoplanets. With 8750 stars ahead of 18 bodies, that let a worse match hide a
|
||||
* better one *and* an exact match: searching "Io" returned eight stars named "Iot ..." and
|
||||
* never reached the moon Io at all, because the scan had already filled up.
|
||||
*
|
||||
* Everything is scored before anything is taken, so the best matches win regardless of where
|
||||
* they sit in the index. Ties break on kind, then on name length — a shorter name containing
|
||||
* the query is the closer match — and finally alphabetically, so the order is fully determined
|
||||
* rather than dependent on the input order.
|
||||
*/
|
||||
export function rankSearchResults(index: readonly IndexedSearchEntry[], query: string, limit: number): SearchEntry[] {
|
||||
const normalizedQuery = normalize(query);
|
||||
if (limit <= 0 || normalizedQuery === '') {
|
||||
return [];
|
||||
}
|
||||
const compactQuery = compact(query);
|
||||
|
||||
const scored: { entry: SearchEntry; score: number }[] = [];
|
||||
for (const indexed of index) {
|
||||
const score = scoreIndexed(indexed, normalizedQuery, compactQuery);
|
||||
if (score > NO_MATCH) {
|
||||
scored.push({ entry: indexed.entry, score });
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
KIND_PRIORITY[a.entry.kind] - KIND_PRIORITY[b.entry.kind] ||
|
||||
a.entry.name.length - b.entry.name.length ||
|
||||
a.entry.name.localeCompare(b.entry.name)
|
||||
);
|
||||
|
||||
return scored.slice(0, limit).map((match) => match.entry);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
|
||||
import { BodyRecord } from '../../shared/models/body.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
import { SearchComponent } from './search.component';
|
||||
|
||||
function starRecord(id: number, name: string): StarRecord {
|
||||
return { id, name, x: 0, y: 0, z: 0, magnitude: 5, spectralType: 'G2V', colorIndex: 0.65 };
|
||||
}
|
||||
|
||||
/** Enough "Iot ..." stars to fill the result list ahead of the moon Io, as the real index does. */
|
||||
const STARS: StarRecord[] = [
|
||||
...Array.from({ length: 12 }, (_, i) => starRecord(100 + i, `Iot Star ${i}`)),
|
||||
starRecord(1, 'Proxima Centauri')
|
||||
];
|
||||
|
||||
const IO: BodyRecord = {
|
||||
id: 'io',
|
||||
systemStarId: 0,
|
||||
name: 'Io',
|
||||
kind: 'moon',
|
||||
parentBodyId: 'jupiter',
|
||||
radiusKm: 1821,
|
||||
orbit: {
|
||||
semiMajorAxisAu: 0.002819,
|
||||
eccentricity: 0.004,
|
||||
inclinationDeg: 0,
|
||||
longitudeOfAscendingNodeDeg: 0,
|
||||
argumentOfPeriapsisDeg: 0,
|
||||
meanAnomalyAtEpochDeg: 0,
|
||||
epochJd: 2451545.0
|
||||
}
|
||||
};
|
||||
|
||||
const PROXIMA_B: ExoplanetRecord = {
|
||||
id: 'Proxima Cen b',
|
||||
hostStarId: 1,
|
||||
hostStarName: 'Proxima Centauri',
|
||||
name: 'Proxima Cen b',
|
||||
orbit: { semiMajorAxisAu: 0.0485, eccentricity: 0.02 }
|
||||
};
|
||||
|
||||
class FakeDataLoaderService {
|
||||
loadStars(): Promise<StarField> {
|
||||
return Promise.resolve({ stars: STARS, positions: new Float32Array(STARS.length * 3) });
|
||||
}
|
||||
loadBodies(): Promise<BodyRecord[]> {
|
||||
return Promise.resolve([IO]);
|
||||
}
|
||||
loadExoplanets(): Promise<ExoplanetRecord[]> {
|
||||
return Promise.resolve([PROXIMA_B]);
|
||||
}
|
||||
}
|
||||
|
||||
describe('SearchComponent', () => {
|
||||
let fixture: ComponentFixture<SearchComponent>;
|
||||
let element: HTMLElement;
|
||||
let navigationStore: NavigationStore;
|
||||
let router: { navigate: ReturnType<typeof vi.fn> };
|
||||
|
||||
async function type(query: string): Promise<void> {
|
||||
fixture.componentInstance.query.set(query);
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
function resultNames(): string[] {
|
||||
return [...element.querySelectorAll('[data-testid="search-results"] button')].map((button) =>
|
||||
(button.querySelector('span')?.textContent ?? '').trim()
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
router = { navigate: vi.fn().mockResolvedValue(true) };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SearchComponent],
|
||||
providers: [
|
||||
{ provide: DataLoaderService, useClass: FakeDataLoaderService },
|
||||
{ provide: Router, useValue: router }
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SearchComponent);
|
||||
navigationStore = TestBed.inject(NavigationStore);
|
||||
await fixture.whenStable();
|
||||
element = fixture.nativeElement as HTMLElement;
|
||||
});
|
||||
|
||||
it('shows no results until the query is long enough', async () => {
|
||||
await type('I');
|
||||
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('finds an exact match that sits behind thousands of stars in the index', async () => {
|
||||
// The regression this ranking exists for: the moon Io is indexed after every star, so the
|
||||
// old first-8-substring-hits scan filled up on "Iot ..." names and never reached it.
|
||||
await type('Io');
|
||||
expect(resultNames()[0]).toBe('Io');
|
||||
});
|
||||
|
||||
it('puts a host star ahead of its own planets', async () => {
|
||||
await type('Proxima');
|
||||
expect(resultNames()[0]).toBe('Proxima Centauri');
|
||||
expect(resultNames()).toContain('Proxima Cen b');
|
||||
});
|
||||
|
||||
it('shows nothing for a query that matches nothing', async () => {
|
||||
await type('zzzzz');
|
||||
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('selects a star and returns to the galaxy route', async () => {
|
||||
await type('Proxima Centauri');
|
||||
element.querySelector<HTMLButtonElement>('[data-testid="search-results"] button')!.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(navigationStore.selectedStarId()).toBe(1);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||
});
|
||||
|
||||
it('navigates straight to a body detail route', async () => {
|
||||
await type('Io');
|
||||
element.querySelector<HTMLButtonElement>('[data-testid="search-results"] button')!.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/body', 'io']);
|
||||
});
|
||||
|
||||
it('clears the query after a selection, so the list closes', async () => {
|
||||
await type('Io');
|
||||
element.querySelector<HTMLButtonElement>('[data-testid="search-results"] button')!.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(fixture.componentInstance.query()).toBe('');
|
||||
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears the query on demand', async () => {
|
||||
await type('Io');
|
||||
fixture.componentInstance.clear();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,18 +3,7 @@ import { Router } from '@angular/router';
|
||||
|
||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
|
||||
type SearchResultKind = 'star' | 'body' | 'exoplanet';
|
||||
|
||||
interface SearchEntry {
|
||||
kind: SearchResultKind;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
/** HYG star id, for `kind: 'star'` results. */
|
||||
starId?: number;
|
||||
/** `bodies.json`/`exoplanets.json` id, for `kind: 'body' | 'exoplanet'` results. */
|
||||
bodyId?: string;
|
||||
}
|
||||
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking';
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
@@ -76,27 +65,19 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
|
||||
})
|
||||
export class SearchComponent {
|
||||
readonly query = signal('');
|
||||
private readonly index = signal<SearchEntry[]>([]);
|
||||
/** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */
|
||||
private readonly index = signal<IndexedSearchEntry[]>([]);
|
||||
|
||||
/** True once the query is long enough to have been searched — so "no matches" is only ever
|
||||
* reported about a search that actually ran, never about a half-typed word. */
|
||||
readonly hasQuery = computed(() => this.query().trim().length >= MIN_QUERY_LENGTH);
|
||||
|
||||
readonly results = computed(() => {
|
||||
const query = this.query().trim().toLowerCase();
|
||||
const query = this.query().trim();
|
||||
if (query.length < MIN_QUERY_LENGTH) {
|
||||
return [];
|
||||
}
|
||||
const matches: SearchEntry[] = [];
|
||||
for (const entry of this.index()) {
|
||||
if (entry.name.toLowerCase().includes(query)) {
|
||||
matches.push(entry);
|
||||
if (matches.length >= MAX_RESULTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
return rankSearchResults(this.index(), query, MAX_RESULTS);
|
||||
});
|
||||
|
||||
constructor(
|
||||
@@ -143,7 +124,7 @@ export class SearchComponent {
|
||||
...exoplanets.map((exoplanet): SearchEntry => ({ kind: 'exoplanet', name: exoplanet.name, subtitle: exoplanet.hostStarName, bodyId: exoplanet.id }))
|
||||
];
|
||||
|
||||
this.index.set(entries);
|
||||
this.index.set(buildSearchIndex(entries));
|
||||
} catch (error) {
|
||||
console.error('Failed to build the search index.', error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user