Add the deep-sky backdrop, the last unbuilt piece of the plan
The design doc scopes deep-sky objects as a galaxy-view backdrop and lists fetchDeepSky.ts, deepsky.json and deepsky.model.ts, but none of it existed — it was the only part of the plan with no implementation behind it. ETL: fetchDeepSky.ts pulls the OpenNGC catalog, classifies each object as a galaxy/nebula/cluster, and keeps the ~460 worth drawing (everything Messier, everything with a common name, and anything brighter than magnitude 9) out of ~12,000 mostly-anonymous rows. build.ts runs it and validates the output. Distances are the hard part: OpenNGC has no distance column, and both fallbacks fail for the best-known objects. M31, M33 and M42 are Local Group members whose redshift is negative or absent, and a galaxy's catalog parallax comes from a cross-matched foreground star — 6 mas for M31 would put a 780 kpc galaxy at 167 pc. So records store a unit direction on the celestial sphere rather than a position (the line of sight is always known precisely, and the objects are drawn on a fixed backdrop shell where true distance is unusable anyway), and distance is optional metadata carrying its own provenance. Parallax is trusted only for galactic objects, redshift only above z=0.003 where expansion outweighs peculiar velocity. 330 of 463 get a distance; the rest honestly report none. Rendering: DeepSkyRenderer paints the objects as soft additive billboards on a 2500 pc shell — clear of the 50 pc star field, beyond the camera's 2000 pc orbit limit, and inside its 5000 pc far plane. Size comes from real angular extent, so Andromeda is six times wider than the full Moon, clamped at both ends. Sprites rather than points because the WebGPU backend caps point primitives at one pixel; materials are shared per kind and brightness band, so 460 objects cost nine of them. The brightest dozen get permanent labels, which needed the label overlay to accept string ids alongside numeric star ids. The backdrop is decorative, so a failure to load its dataset is logged and the star field comes up regardless. Also documents the app in the README, which until now covered only the plugin marketplace. Tests: 112 passing, up from 54. Build, both typechecks and the Playwright suite are green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
@@ -108,12 +108,23 @@ interface ExoplanetRecord {
|
||||
orbit: Partial<OrbitalElements>;
|
||||
}
|
||||
|
||||
// Revised during implementation: OpenNGC publishes no distance column, and the redshift/
|
||||
// parallax fallbacks both fail for the best-known objects (M31/M33/M42 are Local Group
|
||||
// members with negative or absent redshift; a galaxy's catalog parallax comes from a
|
||||
// cross-matched foreground star). The line of sight is always known, so these are stored as
|
||||
// unit directions and drawn on a fixed backdrop shell, with distance as optional metadata.
|
||||
// See `shared/models/deepsky.model.ts`.
|
||||
interface DeepSkyRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'nebula' | 'galaxy' | 'cluster';
|
||||
x: number; y: number; z: number; // parsecs
|
||||
x: number; y: number; z: number; // unit vector on the celestial sphere, not a position
|
||||
angularSizeDeg: number;
|
||||
magnitude: number | null;
|
||||
distancePc: number | null;
|
||||
distanceMethod: 'parallax' | 'redshift' | null;
|
||||
constellation: string;
|
||||
messier: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,20 +1,124 @@
|
||||
# star-map
|
||||
|
||||
Personal marketplace of `cs:*` Claude Code commands, agents, and skills.
|
||||
An interactive 3D star map in the spirit of Star Citizen's in-game starmap, but populated with
|
||||
real astronomical data instead of fictional systems. Browse the solar neighbourhood, fly into a
|
||||
star's system to see its planets on their real orbits, and drill into a single body for the
|
||||
NASA figures behind it.
|
||||
|
||||
## Install (global — works in every project)
|
||||
This repo also hosts a small Claude Code plugin marketplace — see [Plugins](#plugins) below.
|
||||
|
||||
This repo is a Claude Code plugin marketplace. Adding it and installing a
|
||||
plugin defaults to **user scope**, meaning the plugin becomes available in
|
||||
*every* project on your machine, not just the one you happen to be in:
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start # dev server on http://localhost:4200
|
||||
npm run build # production bundle into dist/
|
||||
```
|
||||
|
||||
Requires the Node version in `package.json`'s Angular toolchain range (Node 22.22.3+ or 24.15+).
|
||||
|
||||
```bash
|
||||
npm test # unit/component tests (Vitest, jsdom)
|
||||
npm run e2e # end-to-end tests (Playwright + Chromium) — see e2e/README.md
|
||||
npm run etl # refresh the astronomical datasets — see below
|
||||
npm run etl:typecheck # type-check the ETL scripts (they build separately from the app)
|
||||
npm run e2e:typecheck
|
||||
```
|
||||
|
||||
## What's in it
|
||||
|
||||
**Galaxy view** — every HYG-catalogue star within 50 parsecs as a point field, positioned from
|
||||
real RA/Dec/parallax, coloured by spectral index and sized by magnitude. Names label the stars
|
||||
nearest the camera. Behind them sits a backdrop of notable deep-sky objects and a Milky Way
|
||||
panorama.
|
||||
|
||||
**System view** — selecting a star flies the camera continuously into its system rather than
|
||||
cutting to a new scene. The Sun gets the real solar-system bodies from JPL Horizons; other
|
||||
stars get their confirmed exoplanets. Orbits are drawn as ellipses and bodies are propagated
|
||||
along them by a Kepler solver against the current epoch.
|
||||
|
||||
**Body detail** — a dedicated close-up scene and info panel for one planet, moon or exoplanet,
|
||||
with real photography where NASA/ESA/USGS imagery exists.
|
||||
|
||||
**Search** — name search across stars, solar-system bodies and exoplanets, navigating to the
|
||||
same place an in-scene click would.
|
||||
|
||||
### Architecture notes
|
||||
|
||||
- **Rendering** runs on Three.js `WebGPURenderer`, which falls back to a WebGL2 backend
|
||||
automatically. The render loop runs outside Angular's change detection.
|
||||
- **Two coordinate scales.** The galaxy view works in parsecs and the system view in AU —
|
||||
about eight orders of magnitude apart, which wrecks float precision if rendered in one unit
|
||||
space. The camera rig recentres the active star to the origin ("floating origin") and swaps
|
||||
the unit scale and near/far planes at the transition point.
|
||||
- **No backend.** Every dataset is baked at build time into `src/assets/data/` and served as a
|
||||
static asset. Nothing queries an astronomy API at runtime.
|
||||
|
||||
## Data pipeline
|
||||
|
||||
`npm run etl` runs `tools/etl/build.ts`, which fetches each source, writes the static assets,
|
||||
then validates the combined output. Raw responses are cached under `tools/etl/.cache/`, so
|
||||
re-runs are cheap and offline-friendly; set `ETL_FORCE_REFRESH=1` to bypass the cache.
|
||||
|
||||
| Script | Source | Output |
|
||||
| --- | --- | --- |
|
||||
| `fetchStars.ts` | HYG database (Hipparcos/Yale/Gliese) | `stars.bin`, `stars-index.json` |
|
||||
| `fetchSolarSystem.ts` | JPL Horizons / SSD | `bodies.json` |
|
||||
| `fetchExoplanets.ts` | NASA Exoplanet Archive (TAP) | `exoplanets.json` |
|
||||
| `fetchDeepSky.ts` | OpenNGC | `deepsky.json` |
|
||||
|
||||
Star positions ship as a packed `Float32Array` (`stars.bin`) rather than JSON to keep the
|
||||
initial payload and parse cost down; `stars-index.json` carries everything else in the same
|
||||
order.
|
||||
|
||||
`ETL_STAR_DISTANCE_PC` (default `50`) sets the star-field distance cutoff.
|
||||
|
||||
### On deep-sky distances
|
||||
|
||||
OpenNGC publishes no distance column, so distance has to be inferred — and the inference fails
|
||||
for precisely the best-known objects. M31, M33 and M42 are Local Group members whose redshift
|
||||
is negative or absent, and the catalogue's parallax for a galaxy comes from a cross-matched
|
||||
foreground star (it lists 6 mas for M31, implying 167 pc for something 780,000 pc away).
|
||||
|
||||
So deep-sky records store a **unit direction** on the celestial sphere rather than a position:
|
||||
the line of sight is always known precisely, and the objects are drawn as a fixed-radius
|
||||
backdrop shell where true distance would be unusable anyway. `distancePc` is optional metadata,
|
||||
derived from parallax for galactic objects or the Hubble law for genuinely distant galaxies,
|
||||
and left `null` — with its `distanceMethod` — whenever neither is trustworthy. Roughly 330 of
|
||||
the 463 cataloged objects get a distance; the rest honestly report none.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/app/
|
||||
core/engine/ Three.js renderer, render loop, resize
|
||||
core/data/ static-asset loading and caching
|
||||
features/galaxy-system/ shared galaxy+system scene, camera rig, star field,
|
||||
deep-sky backdrop, orbits, labels
|
||||
features/body-detail/ close-up scene and info panel
|
||||
features/search/ name search across every dataset
|
||||
shared/astro/ coordinates, Kepler propagator, deep-sky classification
|
||||
shared/models/ record contracts shared by the app and the ETL
|
||||
shared/rendering/ skybox, glow sprites, texture catalog
|
||||
shared/state/ navigation store (Angular signals)
|
||||
tools/etl/ build-time data pipeline
|
||||
e2e/ Playwright end-to-end tests
|
||||
```
|
||||
|
||||
The design document behind all of this is `.junie/plans/nasa-star-map.md`.
|
||||
|
||||
## Plugins
|
||||
|
||||
This repo doubles as a Claude Code plugin marketplace. Adding it and installing a plugin
|
||||
defaults to **user scope**, meaning the plugin becomes available in *every* project on your
|
||||
machine, not just the one you happen to be in:
|
||||
|
||||
```bash
|
||||
/plugin marketplace add avalon-vanguard/star-map
|
||||
/plugin install caveman@star-map
|
||||
```
|
||||
|
||||
Scope can be overridden at install time if you want it tied to a single
|
||||
repo instead:
|
||||
Scope can be overridden at install time if you want it tied to a single repo instead:
|
||||
|
||||
```bash
|
||||
# Shared with collaborators via that repo's .claude/settings.json
|
||||
@@ -27,9 +131,15 @@ repo instead:
|
||||
See [Claude Code plugin installation scopes](https://code.claude.com/docs/en/plugins-reference)
|
||||
for details on `user` / `project` / `local` scope.
|
||||
|
||||
## Plugins
|
||||
|
||||
- **caveman** — `/cs:caveman` ultra-compressed communication mode.
|
||||
- Command: [`commands/cs/caveman.md`](commands/cs/caveman.md)
|
||||
- Agent: [`agents/cs-caveman-mode.md`](agents/cs-caveman-mode.md)
|
||||
- Skill: [`skills/caveman/SKILL.md`](skills/caveman/SKILL.md)
|
||||
- Skill: [`skills/caveman/SKILL.md`](skills/caveman/SKILL.md)
|
||||
|
||||
## Data credits
|
||||
|
||||
Star catalogue: [HYG database](https://github.com/astronexus/HYG-Database) (Hipparcos, Yale
|
||||
Bright Star, Gliese). Solar-system ephemerides: NASA/JPL Horizons. Exoplanets: NASA Exoplanet
|
||||
Archive. Deep-sky objects: [OpenNGC](https://github.com/mattiaverga/OpenNGC). Body and skybox
|
||||
imagery: NASA/JPL/USGS public domain and Solar System Scope (CC BY 4.0) — per-file provenance
|
||||
is recorded in `src/app/shared/rendering/texture-catalog.ts`.
|
||||
|
||||
+5
-3
@@ -25,6 +25,8 @@ server a developer might already have running there.
|
||||
exactly where the default galaxy-view camera looks. `camera-flight.spec.ts` relies on this to
|
||||
reliably click-select it by clicking the center of the canvas, without needing pixel-perfect
|
||||
knowledge of the star field's on-screen layout.
|
||||
- Data (bootstrap fetch of `stars.bin`/`stars-index.json`/`bodies.json`/`exoplanets.json`) loads
|
||||
asynchronously after the page loads, so tests poll (re-click/re-check) rather than assume the
|
||||
scene is interactive immediately after `page.goto()`.
|
||||
- Data (bootstrap fetch of `stars.bin`/`stars-index.json`/`bodies.json`/`exoplanets.json`/
|
||||
`deepsky.json`) loads asynchronously after the page loads, so tests poll (re-click/re-check)
|
||||
rather than assume the scene is interactive immediately after `page.goto()`.
|
||||
- `deepsky.json` is the one dataset the scene treats as optional: it only feeds the decorative
|
||||
backdrop, so a failure to load it is logged and the star field comes up regardless.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
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';
|
||||
|
||||
@@ -19,6 +20,7 @@ export class DataLoaderService {
|
||||
private starFieldPromise?: Promise<StarField>;
|
||||
private bodiesPromise?: Promise<BodyRecord[]>;
|
||||
private exoplanetsPromise?: Promise<ExoplanetRecord[]>;
|
||||
private deepSkyPromise?: Promise<DeepSkyRecord[]>;
|
||||
|
||||
loadStars(): Promise<StarField> {
|
||||
this.starFieldPromise ??= this.fetchStars();
|
||||
@@ -35,6 +37,11 @@ export class DataLoaderService {
|
||||
return this.exoplanetsPromise;
|
||||
}
|
||||
|
||||
loadDeepSky(): Promise<DeepSkyRecord[]> {
|
||||
this.deepSkyPromise ??= this.fetchJson<DeepSkyRecord[]>('assets/data/deepsky.json');
|
||||
return this.deepSkyPromise;
|
||||
}
|
||||
|
||||
private async fetchStars(): Promise<StarField> {
|
||||
const [stars, buffer] = await Promise.all([
|
||||
fetch('assets/data/stars-index.json').then((response) => response.json() as Promise<StarRecord[]>),
|
||||
|
||||
@@ -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,151 @@
|
||||
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 everything else the galaxy camera deals with: well outside the 50 pc
|
||||
* star field, beyond the camera's 2000 pc orbit limit so it can never be flown through, and
|
||||
* close enough that even the far side of the shell (2000 + 2500 = 4500 pc) stays inside the
|
||||
* 5000 pc far plane rather than being clipped away.
|
||||
*/
|
||||
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, x: position.x, y: position.y, z: position.z };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
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. */
|
||||
|
||||
@@ -7,14 +7,16 @@ import { dateToJulianDate } from '../../shared/astro/constants';
|
||||
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 { CameraRigController } from './camera-rig-controller';
|
||||
import { DeepSkyRenderer } from './deep-sky-renderer';
|
||||
import { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
|
||||
import { StarLabelOverlay } from './star-label-overlay';
|
||||
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. */
|
||||
@@ -25,6 +27,12 @@ const SUN_GLOW_SCALE = 3.2;
|
||||
const LABEL_MAX_DISTANCE_PC = 20;
|
||||
/** Caps how many labels are shown at once, to keep the DOM light. */
|
||||
const LABEL_MAX_COUNT = 15;
|
||||
/**
|
||||
* 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. */
|
||||
@@ -99,6 +107,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
private controls?: OrbitControls;
|
||||
private rig?: CameraRigController;
|
||||
private starField?: StarFieldRenderer;
|
||||
private deepSky?: DeepSkyRenderer;
|
||||
private deepSkyLabels: readonly LabeledPoint[] = [];
|
||||
private labelOverlay?: StarLabelOverlay;
|
||||
private stars: readonly StarRecord[] = [];
|
||||
private starsById = new Map<number, StarRecord>();
|
||||
@@ -140,6 +150,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.canvasRef().nativeElement.removeEventListener('click', this.handleClick);
|
||||
this.controls?.dispose();
|
||||
this.starField?.dispose();
|
||||
this.deepSky?.dispose();
|
||||
this.labelOverlay?.dispose();
|
||||
this.systemRenderer?.dispose();
|
||||
(this.starMarker?.material as THREE.Material | undefined)?.dispose();
|
||||
@@ -182,10 +193,16 @@ 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]));
|
||||
@@ -195,6 +212,12 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.starField = new StarFieldRenderer(stars, positions);
|
||||
this.galaxyGroup.add(this.starField.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();
|
||||
@@ -244,7 +267,8 @@ 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));
|
||||
const starLabels = candidates.slice(0, LABEL_MAX_COUNT).map((candidate) => candidate.star);
|
||||
this.labelOverlay?.update([...starLabels, ...this.deepSkyLabels]);
|
||||
}
|
||||
|
||||
private readonly handleClick = (event: MouseEvent): void => {
|
||||
|
||||
@@ -2,7 +2,8 @@ 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;
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -19,7 +20,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');
|
||||
@@ -70,7 +71,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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { distanceBetween, parallaxMasToParsecs, raDecDistanceToXyz, raDegDecDistanceToXyz } from './coordinates';
|
||||
import { distanceBetween, parallaxMasToParsecs, parseSexagesimal, raDecDistanceToXyz, raDecToUnitVector, raDegDecDistanceToXyz } from './coordinates';
|
||||
|
||||
// Reference values taken directly from the HYG v4.1 database (RA/Dec/dist and its own
|
||||
// precomputed x/y/z, which uses the same equatorial-Cartesian convention we implement).
|
||||
@@ -65,3 +65,74 @@ describe('distanceBetween', () => {
|
||||
expect(distanceBetween({ x: 0, y: 0, z: 0 }, { x: 3, y: 4, z: 0 })).toBeCloseTo(5, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('raDecToUnitVector', () => {
|
||||
it('always returns a unit-length vector', () => {
|
||||
for (const [ra, dec] of [
|
||||
[0, 0],
|
||||
[6, 45],
|
||||
[13.7, -62.7],
|
||||
[23.99, 89.9]
|
||||
]) {
|
||||
const { x, y, z } = raDecToUnitVector(ra, dec);
|
||||
expect(Math.hypot(x, y, z)).toBeCloseTo(1, 12);
|
||||
}
|
||||
});
|
||||
|
||||
it('points along +Z at the north celestial pole', () => {
|
||||
const { x, y, z } = raDecToUnitVector(0, 90);
|
||||
expect(x).toBeCloseTo(0, 12);
|
||||
expect(y).toBeCloseTo(0, 12);
|
||||
expect(z).toBeCloseTo(1, 12);
|
||||
});
|
||||
|
||||
it('agrees with the distance-carrying conversion, scaled', () => {
|
||||
const unit = raDecToUnitVector(6.752481, -16.716116);
|
||||
const scaled = raDecDistanceToXyz(6.752481, -16.716116, 2.6371);
|
||||
|
||||
expect(unit.x * 2.6371).toBeCloseTo(scaled.x, 12);
|
||||
expect(unit.y * 2.6371).toBeCloseTo(scaled.y, 12);
|
||||
expect(unit.z * 2.6371).toBeCloseTo(scaled.z, 12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSexagesimal', () => {
|
||||
it('parses a right ascension into decimal hours', () => {
|
||||
// 00:08:27.05 = 8/60 + 27.05/3600 hours
|
||||
expect(parseSexagesimal('00:08:27.05')).toBeCloseTo(0.140847, 6);
|
||||
});
|
||||
|
||||
it('parses a positive declination into decimal degrees', () => {
|
||||
expect(parseSexagesimal('+27:43:03.6')).toBeCloseTo(27.7176667, 6);
|
||||
});
|
||||
|
||||
it('parses a negative declination', () => {
|
||||
expect(parseSexagesimal('-12:49:22.3')).toBeCloseTo(-12.8228611, 6);
|
||||
});
|
||||
|
||||
it('keeps the sign for a negative angle inside the first degree', () => {
|
||||
// The trap: `Number('-00')` is `-0`, which is `=== 0`, so a naive implementation flips
|
||||
// this object into the northern hemisphere.
|
||||
const parsed = parseSexagesimal('-00:24:54.8');
|
||||
expect(parsed).toBeLessThan(0);
|
||||
expect(parsed).toBeCloseTo(-0.4152222, 6);
|
||||
});
|
||||
|
||||
it('treats an unsigned angle as positive', () => {
|
||||
expect(parseSexagesimal('00:24:54.8')).toBeCloseTo(0.4152222, 6);
|
||||
});
|
||||
|
||||
it('tolerates surrounding whitespace', () => {
|
||||
expect(parseSexagesimal(' +27:43:03.6 ')).toBeCloseTo(27.7176667, 6);
|
||||
});
|
||||
|
||||
it('returns null for missing or malformed values', () => {
|
||||
for (const input of ['', ' ', 'not-an-angle', '12:34', '12:34:56:78', '12;34;56', undefined, null]) {
|
||||
expect(parseSexagesimal(input)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null rather than a partial value for empty sub-fields', () => {
|
||||
expect(parseSexagesimal('12::56')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,40 @@ export function raDegDecDistanceToXyz(raDeg: number, decDeg: number, distancePc:
|
||||
return raDecDistanceToXyz(raDeg / HOURS_TO_DEG, decDeg, distancePc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction to a point on the celestial sphere as a unit vector, in the same equatorial frame
|
||||
* as {@link raDecDistanceToXyz}. Used for sources whose distance is unknown or irrelevant —
|
||||
* e.g. deep-sky objects rendered as a backdrop, where only the line of sight matters.
|
||||
*/
|
||||
export function raDecToUnitVector(raHours: number, decDeg: number): CartesianCoordinates {
|
||||
return raDecDistanceToXyz(raHours, decDeg, 1);
|
||||
}
|
||||
|
||||
const SEXAGESIMAL_PATTERN = /^([+-])?(\d+):(\d+):(\d+(?:\.\d+)?)$/;
|
||||
|
||||
/**
|
||||
* Parses a sexagesimal angle ("HH:MM:SS.ss" or "+DD:MM:SS.s", as published by OpenNGC and
|
||||
* most catalogs) into a decimal value carrying the unit of its first field — hours for right
|
||||
* ascension, degrees for declination.
|
||||
*
|
||||
* The sign is read from the string rather than from the parsed degrees field: `Number('-00')`
|
||||
* is `-0`, which compares equal to `0`, so a declination like "-00:24:54.8" would otherwise
|
||||
* come out positive and place the object in the wrong hemisphere.
|
||||
*
|
||||
* Returns `null` for anything that isn't a well-formed sexagesimal triple, including the empty
|
||||
* strings that catalogs use for missing values.
|
||||
*/
|
||||
export function parseSexagesimal(text: string | undefined | null): number | null {
|
||||
const match = SEXAGESIMAL_PATTERN.exec((text ?? '').trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, sign, degreesOrHours, minutes, seconds] = match;
|
||||
const magnitude = Number(degreesOrHours) + Number(minutes) / 60 + Number(seconds) / 3600;
|
||||
return sign === '-' ? -magnitude : magnitude;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a parallax (milliarcseconds) into a distance in parsecs.
|
||||
* Returns `Infinity` for non-positive parallax (unmeasured/negative parallax).
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
classifyOpenNgcType,
|
||||
estimateDeepSkyDistancePc,
|
||||
HUBBLE_CONSTANT_KM_S_PER_MPC,
|
||||
isNotableDeepSkyObject,
|
||||
NOTABLE_MAGNITUDE_LIMIT,
|
||||
redshiftToDistancePc,
|
||||
SPEED_OF_LIGHT_KM_S
|
||||
} from './deep-sky';
|
||||
|
||||
describe('classifyOpenNgcType', () => {
|
||||
it('groups every galaxy-ish type as a galaxy', () => {
|
||||
for (const type of ['G', 'GPair', 'GTrpl', 'GGroup']) {
|
||||
expect(classifyOpenNgcType(type)).toBe('galaxy');
|
||||
}
|
||||
});
|
||||
|
||||
it('groups nebulae, remnants and cluster-with-nebulosity as nebulae', () => {
|
||||
for (const type of ['PN', 'HII', 'EmN', 'RfN', 'Neb', 'DrkN', 'SNR', 'Cl+N']) {
|
||||
expect(classifyOpenNgcType(type)).toBe('nebula');
|
||||
}
|
||||
});
|
||||
|
||||
it('groups open, globular and stellar-association types as clusters', () => {
|
||||
for (const type of ['OCl', 'GCl', '*Ass']) {
|
||||
expect(classifyOpenNgcType(type)).toBe('cluster');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects catalog rows that are not deep-sky objects', () => {
|
||||
// Duplicates, non-existent entries, plain and double stars, novae, and the catch-all.
|
||||
for (const type of ['Dup', 'NonEx', '*', '**', 'Nova', 'Other']) {
|
||||
expect(classifyOpenNgcType(type)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects missing or unknown types', () => {
|
||||
for (const type of ['', ' ', 'wat', undefined, null]) {
|
||||
expect(classifyOpenNgcType(type)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores surrounding whitespace', () => {
|
||||
expect(classifyOpenNgcType(' G ')).toBe('galaxy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('redshiftToDistancePc', () => {
|
||||
it('applies the Hubble law', () => {
|
||||
const redshift = 0.02286;
|
||||
const expectedMpc = (SPEED_OF_LIGHT_KM_S * redshift) / HUBBLE_CONSTANT_KM_S_PER_MPC;
|
||||
expect(redshiftToDistancePc(redshift)).toBeCloseTo(expectedMpc * 1e6, 0);
|
||||
});
|
||||
|
||||
it('scales linearly with redshift', () => {
|
||||
const near = redshiftToDistancePc(0.01)!;
|
||||
const far = redshiftToDistancePc(0.02)!;
|
||||
expect(far / near).toBeCloseTo(2, 9);
|
||||
});
|
||||
|
||||
it('rejects a blueshift, which carries no distance information', () => {
|
||||
// M31 approaches us at ~300 km/s.
|
||||
expect(redshiftToDistancePc(-0.001)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects redshifts too small to be dominated by cosmological expansion', () => {
|
||||
// The Small Magellanic Cloud: a real positive redshift that yields a 33x-wrong distance.
|
||||
expect(redshiftToDistancePc(0.000527)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects null and non-finite input', () => {
|
||||
expect(redshiftToDistancePc(null)).toBeNull();
|
||||
expect(redshiftToDistancePc(Number.NaN)).toBeNull();
|
||||
expect(redshiftToDistancePc(Number.POSITIVE_INFINITY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateDeepSkyDistancePc', () => {
|
||||
it('prefers parallax for a galactic object', () => {
|
||||
// The Helix Nebula, ~200 pc away.
|
||||
const estimate = estimateDeepSkyDistancePc({ kind: 'nebula', redshift: 0.05, parallaxMas: 4.98 });
|
||||
expect(estimate?.method).toBe('parallax');
|
||||
expect(estimate?.distancePc).toBeCloseTo(200.8, 1);
|
||||
});
|
||||
|
||||
it('refuses parallax for a galaxy, because the catalog value is a foreground star', () => {
|
||||
// OpenNGC lists 6 mas for M31 — that would put a 780 kpc galaxy at 167 pc.
|
||||
const estimate = estimateDeepSkyDistancePc({ kind: 'galaxy', redshift: -0.001, parallaxMas: 6 });
|
||||
expect(estimate).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to redshift for a distant galaxy', () => {
|
||||
const estimate = estimateDeepSkyDistancePc({ kind: 'galaxy', redshift: 0.00365, parallaxMas: null });
|
||||
expect(estimate?.method).toBe('redshift');
|
||||
expect(estimate!.distancePc).toBeGreaterThan(1e7);
|
||||
});
|
||||
|
||||
it('falls back to redshift when a galactic object has no usable parallax', () => {
|
||||
for (const parallaxMas of [null, 0, -3]) {
|
||||
const estimate = estimateDeepSkyDistancePc({ kind: 'cluster', redshift: 0.01, parallaxMas });
|
||||
expect(estimate?.method).toBe('redshift');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a parallax implying a distance beyond the Milky Way', () => {
|
||||
// 0.000001 mas would imply a billion parsecs — noise, not a measurement.
|
||||
const estimate = estimateDeepSkyDistancePc({ kind: 'cluster', redshift: null, parallaxMas: 1e-6 });
|
||||
expect(estimate).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when neither source is usable', () => {
|
||||
expect(estimateDeepSkyDistancePc({ kind: 'nebula', redshift: null, parallaxMas: null })).toBeNull();
|
||||
expect(estimateDeepSkyDistancePc({ kind: 'galaxy', redshift: null, parallaxMas: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNotableDeepSkyObject', () => {
|
||||
it('keeps anything in the Messier catalog, however faint', () => {
|
||||
expect(isNotableDeepSkyObject({ messier: 'M76', commonName: null, magnitude: 12 })).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps anything with a common name', () => {
|
||||
expect(isNotableDeepSkyObject({ messier: null, commonName: 'Helix Nebula', magnitude: 20 })).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an anonymous object that is bright enough', () => {
|
||||
expect(isNotableDeepSkyObject({ messier: null, commonName: null, magnitude: NOTABLE_MAGNITUDE_LIMIT })).toBe(true);
|
||||
});
|
||||
|
||||
it('drops an anonymous object fainter than the limit', () => {
|
||||
expect(isNotableDeepSkyObject({ messier: null, commonName: null, magnitude: NOTABLE_MAGNITUDE_LIMIT + 0.1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('drops an anonymous object with no measured magnitude', () => {
|
||||
// Guards the `Number('') === 0` trap: an unphotometered object must not be treated as
|
||||
// magnitude 0, which would make it brighter than every star in the sky.
|
||||
expect(isNotableDeepSkyObject({ messier: null, commonName: null, magnitude: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { DeepSkyDistanceMethod, DeepSkyKind } from '../models/deepsky.model';
|
||||
import { parallaxMasToParsecs } from './coordinates';
|
||||
|
||||
/** Speed of light in km/s (exact, by definition of the metre). */
|
||||
export const SPEED_OF_LIGHT_KM_S = 299792.458;
|
||||
|
||||
/**
|
||||
* Hubble constant in km/s/Mpc. The measured value is contested — "Hubble tension" — with
|
||||
* ~67 from the cosmic microwave background and ~73 from the local distance ladder; 70 is the
|
||||
* conventional round middle. Distances derived from it are good to roughly 10%, which is far
|
||||
* inside the tolerance needed to place a smudge of light on a backdrop.
|
||||
*/
|
||||
export const HUBBLE_CONSTANT_KM_S_PER_MPC = 70;
|
||||
|
||||
const PARSECS_PER_MEGAPARSEC = 1e6;
|
||||
|
||||
/**
|
||||
* Minimum redshift trusted for a Hubble-law distance, ≈900 km/s of recession or ~13 Mpc.
|
||||
*
|
||||
* Below this a galaxy's measured velocity is mostly its own motion through its group rather
|
||||
* than cosmological expansion, so `cz / H0` stops being a distance at all. Galaxies have
|
||||
* peculiar velocities of a few hundred km/s in any direction: the Local Group's members are
|
||||
* blueshifted outright (M31 approaches at ~300 km/s), and the Small Magellanic Cloud's small
|
||||
* positive redshift yields 2 Mpc against a true distance of 62 kpc — a 33-fold error.
|
||||
*
|
||||
* Cutting here trades coverage for honesty. Nearby galaxies come back with a `null` distance
|
||||
* instead of a confident wrong one, which is the right answer to show a user.
|
||||
*/
|
||||
const MIN_USABLE_REDSHIFT = 0.003;
|
||||
|
||||
/**
|
||||
* Upper bound on a parallax-derived distance, in parsecs. The Milky Way's disc is ~30 kpc
|
||||
* across, so a parallax implying more than this is measurement noise rather than a real
|
||||
* distance to a galactic object.
|
||||
*/
|
||||
const MAX_PARALLAX_DISTANCE_PC = 100000;
|
||||
|
||||
/**
|
||||
* OpenNGC object-type codes grouped into the three kinds the backdrop distinguishes.
|
||||
* Codes not listed here (`Dup` duplicates, `NonEx` non-existent entries, plain stars `*`,
|
||||
* doubles `**`, `Nova`, `Other`) are not deep-sky objects and are dropped.
|
||||
*/
|
||||
const KIND_BY_OPENNGC_TYPE: Readonly<Record<string, DeepSkyKind>> = {
|
||||
// Galaxies, and multi-galaxy systems.
|
||||
G: 'galaxy',
|
||||
GPair: 'galaxy',
|
||||
GTrpl: 'galaxy',
|
||||
GGroup: 'galaxy',
|
||||
// Nebulae of every flavour, including supernova remnants and cluster-with-nebulosity.
|
||||
PN: 'nebula',
|
||||
HII: 'nebula',
|
||||
EmN: 'nebula',
|
||||
RfN: 'nebula',
|
||||
Neb: 'nebula',
|
||||
DrkN: 'nebula',
|
||||
SNR: 'nebula',
|
||||
'Cl+N': 'nebula',
|
||||
// Star clusters and associations.
|
||||
OCl: 'cluster',
|
||||
GCl: 'cluster',
|
||||
'*Ass': 'cluster'
|
||||
};
|
||||
|
||||
/** Maps an OpenNGC `Type` code to a backdrop kind, or `null` if it isn't a deep-sky object. */
|
||||
export function classifyOpenNgcType(type: string | undefined | null): DeepSkyKind | null {
|
||||
return KIND_BY_OPENNGC_TYPE[(type ?? '').trim()] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hubble-law distance for a cosmological redshift, in parsecs: `d = cz / H0`.
|
||||
* Returns `null` for a redshift too small (or negative) to be dominated by expansion —
|
||||
* see {@link MIN_USABLE_REDSHIFT}.
|
||||
*/
|
||||
export function redshiftToDistancePc(redshift: number | null): number | null {
|
||||
if (redshift === null || !Number.isFinite(redshift) || redshift < MIN_USABLE_REDSHIFT) {
|
||||
return null;
|
||||
}
|
||||
return ((SPEED_OF_LIGHT_KM_S * redshift) / HUBBLE_CONSTANT_KM_S_PER_MPC) * PARSECS_PER_MEGAPARSEC;
|
||||
}
|
||||
|
||||
export interface DeepSkyDistanceEstimate {
|
||||
distancePc: number;
|
||||
method: DeepSkyDistanceMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort distance for a deep-sky object, with its provenance.
|
||||
*
|
||||
* Parallax is preferred for galactic objects (clusters, nebulae) where it is a direct
|
||||
* geometric measurement, but is *rejected outright for galaxies*: OpenNGC's parallax column
|
||||
* for a galaxy comes from a cross-matched foreground star, not the galaxy itself, and taking
|
||||
* it at face value is badly wrong — M31 lists 6 mas, implying 167 pc for something actually
|
||||
* ~780,000 pc away. Redshift is the fallback, and the only usable option for distant galaxies.
|
||||
*
|
||||
* Returns `null` when neither source is trustworthy, which is the honest answer for Local
|
||||
* Group members and for anything OpenNGC leaves unmeasured.
|
||||
*/
|
||||
export function estimateDeepSkyDistancePc(input: {
|
||||
kind: DeepSkyKind;
|
||||
redshift: number | null;
|
||||
parallaxMas: number | null;
|
||||
}): DeepSkyDistanceEstimate | null {
|
||||
const { kind, redshift, parallaxMas } = input;
|
||||
|
||||
if (kind !== 'galaxy' && parallaxMas !== null && Number.isFinite(parallaxMas) && parallaxMas > 0) {
|
||||
const distancePc = parallaxMasToParsecs(parallaxMas);
|
||||
if (Number.isFinite(distancePc) && distancePc <= MAX_PARALLAX_DISTANCE_PC) {
|
||||
return { distancePc, method: 'parallax' };
|
||||
}
|
||||
}
|
||||
|
||||
const fromRedshift = redshiftToDistancePc(redshift);
|
||||
return fromRedshift === null ? null : { distancePc: fromRedshift, method: 'redshift' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an object is notable enough for the backdrop. The full catalog is ~12,000 objects,
|
||||
* almost all of them faint anonymous galaxies that would render as visual noise; this keeps
|
||||
* the ones a person could actually pick out — everything in the Messier catalog, everything
|
||||
* with a common name, and anything else brighter than {@link NOTABLE_MAGNITUDE_LIMIT}.
|
||||
*/
|
||||
export const NOTABLE_MAGNITUDE_LIMIT = 9;
|
||||
|
||||
export function isNotableDeepSkyObject(input: {
|
||||
messier: string | null;
|
||||
commonName: string | null;
|
||||
magnitude: number | null;
|
||||
}): boolean {
|
||||
if (input.messier || input.commonName) {
|
||||
return true;
|
||||
}
|
||||
return input.magnitude !== null && input.magnitude <= NOTABLE_MAGNITUDE_LIMIT;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Broad visual category a deep-sky object is grouped under on the galaxy-view backdrop. */
|
||||
export type DeepSkyKind = 'galaxy' | 'nebula' | 'cluster';
|
||||
|
||||
/** How a record's distance estimate was derived, when one could be derived at all. */
|
||||
export type DeepSkyDistanceMethod = 'parallax' | 'redshift';
|
||||
|
||||
/**
|
||||
* A notable deep-sky object (nebula, star cluster or galaxy) from the OpenNGC catalog,
|
||||
* rendered as the galaxy view's backdrop.
|
||||
*
|
||||
* **Why a direction and not a position.** Every other record in this app carries a Cartesian
|
||||
* position in parsecs, but deep-sky objects deliberately do not. The star field spans 50 pc;
|
||||
* the nearest object here is several hundred parsecs away and the galaxies are millions. At
|
||||
* true scale they would all sit far outside the galaxy camera's far plane, so a position in
|
||||
* parsecs would be unusable for the backdrop it exists to draw.
|
||||
*
|
||||
* More importantly the distances mostly are not knowable from this catalog: OpenNGC publishes
|
||||
* no distance column, so it has to be inferred from redshift or parallax, and that inference
|
||||
* fails for exactly the best-known objects — M31, M33 and M42 are all Local Group members whose
|
||||
* redshift is negative (they are approaching us) or absent. What *is* always known, and known
|
||||
* precisely, is the line of sight. So the position here is a unit vector on the celestial
|
||||
* sphere and {@link distancePc} is optional metadata.
|
||||
*/
|
||||
export interface DeepSkyRecord {
|
||||
/** OpenNGC designation, e.g. `NGC0224`. Stable, and unique within the catalog. */
|
||||
id: string;
|
||||
/** Best available display name: common name, else Messier number, else the designation. */
|
||||
name: string;
|
||||
kind: DeepSkyKind;
|
||||
/**
|
||||
* Unit vector toward the object, in the same equatorial J2000 frame as `StarRecord`
|
||||
* (+X toward the vernal equinox, +Z toward the north celestial pole). Not a position —
|
||||
* see the note on this interface.
|
||||
*/
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
/** Apparent major-axis size on the sky, in degrees. */
|
||||
angularSizeDeg: number;
|
||||
/** Apparent visual magnitude (falling back to blue), or `null` when unphotometered. */
|
||||
magnitude: number | null;
|
||||
/** Estimated distance in parsecs, or `null` when it could not be derived. */
|
||||
distancePc: number | null;
|
||||
/** Provenance for {@link distancePc}; `null` whenever the distance is `null`. */
|
||||
distanceMethod: DeepSkyDistanceMethod | null;
|
||||
/** IAU constellation abbreviation, e.g. `And`. */
|
||||
constellation: string;
|
||||
/** Messier designation, e.g. `M31`, when the object has one. */
|
||||
messier: string | null;
|
||||
}
|
||||
@@ -22,12 +22,41 @@ export function applyMilkyWaySkybox(scene: THREE.Scene, path: string): void {
|
||||
const glowSpriteCache = new Map<string, THREE.Texture>();
|
||||
|
||||
/**
|
||||
* A soft radial-gradient canvas texture, cached per color, used to fake atmosphere/corona glow.
|
||||
* Returns `undefined` if 2D canvas rendering isn't available (e.g. under a test/jsdom
|
||||
* environment with no canvas backend) so callers can fall back to a flat-color sprite instead.
|
||||
* Falloff shapes for {@link createGlowTexture}.
|
||||
*
|
||||
* `corona` is a tight, bright core for a star's or planet's halo, where the light really does
|
||||
* come from a small hot source. `diffuse` is a much softer, dimmer profile for deep-sky
|
||||
* objects, which are extended clouds — the same tight curve turns them into hard-edged
|
||||
* billiard balls that read as solid geometry rather than as haze.
|
||||
*/
|
||||
function glowSpriteTexture(color: THREE.ColorRepresentation): THREE.Texture | undefined {
|
||||
const key = new THREE.Color(color).getHexString();
|
||||
export type GlowProfile = 'corona' | 'diffuse';
|
||||
|
||||
const GLOW_PROFILES: Readonly<Record<GlowProfile, readonly { offset: number; alpha: number }[]>> = {
|
||||
corona: [
|
||||
{ offset: 0, alpha: 0.85 },
|
||||
{ offset: 0.4, alpha: 0.35 },
|
||||
{ offset: 1, alpha: 0 }
|
||||
],
|
||||
diffuse: [
|
||||
{ offset: 0, alpha: 0.5 },
|
||||
{ offset: 0.18, alpha: 0.34 },
|
||||
{ offset: 0.45, alpha: 0.13 },
|
||||
{ offset: 0.75, alpha: 0.03 },
|
||||
{ offset: 1, alpha: 0 }
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* A soft radial-gradient canvas texture, cached per color and profile, used to fake
|
||||
* atmosphere/corona glow and to paint deep-sky objects on the galaxy backdrop. Returns
|
||||
* `undefined` if 2D canvas rendering isn't available (e.g. under a test/jsdom environment with
|
||||
* no canvas backend) so callers can fall back to a flat-color sprite instead.
|
||||
*
|
||||
* The cache is process-wide and intentionally not disposed: there is one texture per distinct
|
||||
* color/profile pair, they are tiny, and they outlive any individual scene.
|
||||
*/
|
||||
export function createGlowTexture(color: THREE.ColorRepresentation, profile: GlowProfile = 'corona'): THREE.Texture | undefined {
|
||||
const key = `${new THREE.Color(color).getHexString()}:${profile}`;
|
||||
const cached = glowSpriteCache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
@@ -46,9 +75,9 @@ function glowSpriteTexture(color: THREE.ColorRepresentation): THREE.Texture | un
|
||||
const [r, g, b] = [Math.round(rgb.r * 255), Math.round(rgb.g * 255), Math.round(rgb.b * 255)];
|
||||
|
||||
const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
||||
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.85)`);
|
||||
gradient.addColorStop(0.4, `rgba(${r}, ${g}, ${b}, 0.35)`);
|
||||
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
|
||||
for (const stop of GLOW_PROFILES[profile]) {
|
||||
gradient.addColorStop(stop.offset, `rgba(${r}, ${g}, ${b}, ${stop.alpha})`);
|
||||
}
|
||||
context.fillStyle = gradient;
|
||||
context.fillRect(0, 0, size, size);
|
||||
|
||||
@@ -66,7 +95,7 @@ function glowSpriteTexture(color: THREE.ColorRepresentation): THREE.Texture | un
|
||||
*/
|
||||
export function createGlowSprite(color: THREE.ColorRepresentation, radius: number, scale: number): THREE.Sprite {
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: glowSpriteTexture(color),
|
||||
map: createGlowTexture(color),
|
||||
color: color,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,10 @@
|
||||
import { statSync } from 'node:fs';
|
||||
|
||||
import { BodyRecord } from '../../src/app/shared/models/body.model';
|
||||
import { DeepSkyRecord } from '../../src/app/shared/models/deepsky.model';
|
||||
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../src/app/shared/models/star.model';
|
||||
import { fetchDeepSky } from './fetchDeepSky';
|
||||
import { fetchExoplanets } from './fetchExoplanets';
|
||||
import { fetchSolarSystem } from './fetchSolarSystem';
|
||||
import { fetchStars } from './fetchStars';
|
||||
@@ -66,6 +68,42 @@ function validateExoplanets(exoplanets: ExoplanetRecord[], starIds: Set<number>)
|
||||
console.log(` ${crossReferenced}/${exoplanets.length} exoplanets cross-referenced to a HYG host star.`);
|
||||
}
|
||||
|
||||
const UNIT_VECTOR_TOLERANCE = 1e-6;
|
||||
|
||||
function validateDeepSky(objects: DeepSkyRecord[]): void {
|
||||
assertCondition(objects.length > 0, 'No deep-sky objects were produced.');
|
||||
|
||||
const ids = new Set<string>();
|
||||
for (const object of objects) {
|
||||
assertCondition(!!object.id, `Deep-sky object has no id: ${JSON.stringify(object)}`);
|
||||
assertCondition(!ids.has(object.id), `Duplicate deep-sky id: ${object.id}`);
|
||||
ids.add(object.id);
|
||||
assertCondition(!!object.name, `Deep-sky object ${object.id} has no name.`);
|
||||
|
||||
// Positions are directions, so every one of them must be a unit vector — a zero-length
|
||||
// or mis-scaled entry would silently collapse onto the origin on the backdrop shell.
|
||||
const length = Math.hypot(object.x, object.y, object.z);
|
||||
assertCondition(Math.abs(length - 1) < UNIT_VECTOR_TOLERANCE, `Deep-sky object ${object.id} has a non-unit direction (length ${length}).`);
|
||||
|
||||
assertCondition(object.angularSizeDeg >= 0, `Deep-sky object ${object.id} has a negative angular size.`);
|
||||
assertCondition(object.distancePc === null || object.distancePc > 0, `Deep-sky object ${object.id} has a non-positive distance.`);
|
||||
// The distance and its provenance have to travel together, or the UI cannot say where a
|
||||
// number came from.
|
||||
assertCondition(
|
||||
(object.distancePc === null) === (object.distanceMethod === null),
|
||||
`Deep-sky object ${object.id} has a distance/method mismatch.`
|
||||
);
|
||||
}
|
||||
|
||||
const kinds = new Set(objects.map((object) => object.kind));
|
||||
for (const kind of ['galaxy', 'nebula', 'cluster'] as const) {
|
||||
assertCondition(kinds.has(kind), `No deep-sky objects of kind "${kind}" were produced.`);
|
||||
}
|
||||
|
||||
const withDistance = objects.filter((object) => object.distancePc !== null).length;
|
||||
console.log(` ${withDistance}/${objects.length} deep-sky objects have a derived distance.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the whole ETL pipeline: fetches every source (each caches its own raw
|
||||
* responses under `tools/etl/.cache/`), writes the static assets under `src/assets/data/`,
|
||||
@@ -80,16 +118,20 @@ async function build(): Promise<void> {
|
||||
console.log();
|
||||
const exoplanets = await fetchExoplanets(stars);
|
||||
console.log();
|
||||
const deepSky = await fetchDeepSky();
|
||||
console.log();
|
||||
|
||||
console.log('Validating output...');
|
||||
validateStars(stars);
|
||||
validateBodies(bodies);
|
||||
validateExoplanets(exoplanets, new Set(stars.map((star) => star.id)));
|
||||
validateDeepSky(deepSky);
|
||||
|
||||
console.log('\nETL completed successfully:');
|
||||
console.log(` stars: ${stars.length}`);
|
||||
console.log(` bodies: ${bodies.length}`);
|
||||
console.log(` exoplanets: ${exoplanets.length}`);
|
||||
console.log(` deep sky: ${deepSky.length}`);
|
||||
}
|
||||
|
||||
build().catch((error) => {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
import { parseSexagesimal, raDecToUnitVector } from '../../src/app/shared/astro/coordinates';
|
||||
import { classifyOpenNgcType, estimateDeepSkyDistancePc, isNotableDeepSkyObject } from '../../src/app/shared/astro/deep-sky';
|
||||
import { DeepSkyRecord } from '../../src/app/shared/models/deepsky.model';
|
||||
import { parseCsvObjects } from './lib/csv';
|
||||
import { fetchTextCached } from './lib/http';
|
||||
import { dataPath, ensureDataDir } from './lib/paths';
|
||||
|
||||
const OPENNGC_CSV_URL = 'https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/NGC.csv';
|
||||
/** OpenNGC publishes semicolon-separated files, not comma-separated. */
|
||||
const OPENNGC_DELIMITER = ';';
|
||||
|
||||
const ARCMIN_PER_DEGREE = 60;
|
||||
|
||||
/**
|
||||
* Reads a numeric catalog column. Empty strings mean "not measured" and must become `null`
|
||||
* rather than `0`: `Number('')` is `0`, which would silently turn every unphotometered object
|
||||
* into a magnitude-0 blaze brighter than Sirius.
|
||||
*/
|
||||
function numericField(value: string | undefined): number | null {
|
||||
if (value === undefined || value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function textField(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/** OpenNGC stores Messier numbers zero-padded ("031"); render them as "M31". */
|
||||
function messierDesignation(value: string | undefined): string | null {
|
||||
const raw = textField(value);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const number = Number(raw);
|
||||
return Number.isFinite(number) ? `M${number}` : `M${raw}`;
|
||||
}
|
||||
|
||||
/** OpenNGC's `Common names` column is comma-separated; the first entry is the best known. */
|
||||
function primaryCommonName(value: string | undefined): string | null {
|
||||
const raw = textField(value);
|
||||
return raw ? (textField(raw.split(',')[0]) ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name, most recognisable first: a common name ("Andromeda Galaxy") beats a Messier
|
||||
* number ("M31"), which beats the raw catalog designation ("NGC0224").
|
||||
*/
|
||||
function resolveName(commonName: string | null, messier: string | null, designation: string): string {
|
||||
return commonName ?? messier ?? designation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the OpenNGC catalog, keeps the objects notable enough to be worth drawing, and
|
||||
* writes `deepsky.json` — each entry a unit direction on the celestial sphere plus its kind,
|
||||
* apparent size, magnitude and (where derivable) distance.
|
||||
*
|
||||
* See `DeepSkyRecord` for why these are stored as directions rather than positions.
|
||||
*/
|
||||
export async function fetchDeepSky(): Promise<DeepSkyRecord[]> {
|
||||
console.log('Fetching OpenNGC deep-sky catalog...');
|
||||
const csv = await fetchTextCached(OPENNGC_CSV_URL, 'openngc.csv');
|
||||
const rows = parseCsvObjects(csv, OPENNGC_DELIMITER);
|
||||
|
||||
const records: DeepSkyRecord[] = [];
|
||||
let skippedUnclassified = 0;
|
||||
let skippedNotNotable = 0;
|
||||
let skippedUnpositioned = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const kind = classifyOpenNgcType(row['Type']);
|
||||
if (!kind) {
|
||||
skippedUnclassified++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const magnitude = numericField(row['V-Mag']) ?? numericField(row['B-Mag']);
|
||||
const messier = messierDesignation(row['M']);
|
||||
const commonName = primaryCommonName(row['Common names']);
|
||||
|
||||
if (!isNotableDeepSkyObject({ messier, commonName, magnitude })) {
|
||||
skippedNotNotable++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const raHours = parseSexagesimal(row['RA']);
|
||||
const decDeg = parseSexagesimal(row['Dec']);
|
||||
if (raHours === null || decDeg === null) {
|
||||
skippedUnpositioned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const designation = textField(row['Name']) ?? '';
|
||||
if (!designation) {
|
||||
skippedUnpositioned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { x, y, z } = raDecToUnitVector(raHours, decDeg);
|
||||
const distance = estimateDeepSkyDistancePc({
|
||||
kind,
|
||||
redshift: numericField(row['Redshift']),
|
||||
parallaxMas: numericField(row['Pax'])
|
||||
});
|
||||
|
||||
records.push({
|
||||
id: designation,
|
||||
name: resolveName(commonName, messier, designation),
|
||||
kind,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
angularSizeDeg: (numericField(row['MajAx']) ?? 0) / ARCMIN_PER_DEGREE,
|
||||
magnitude,
|
||||
distancePc: distance?.distancePc ?? null,
|
||||
distanceMethod: distance?.method ?? null,
|
||||
constellation: textField(row['Const']) ?? 'Unknown',
|
||||
messier
|
||||
});
|
||||
}
|
||||
|
||||
// Brightest first, so a consumer taking a prefix gets the most prominent objects. Objects
|
||||
// with no measured magnitude sort last rather than being treated as infinitely bright.
|
||||
records.sort((a, b) => (a.magnitude ?? Infinity) - (b.magnitude ?? Infinity) || a.id.localeCompare(b.id));
|
||||
|
||||
ensureDataDir();
|
||||
writeFileSync(dataPath('deepsky.json'), JSON.stringify(records));
|
||||
|
||||
const withDistance = records.filter((record) => record.distancePc !== null).length;
|
||||
console.log(` kept ${records.length} deep-sky objects (of ${rows.length} catalog rows).`);
|
||||
console.log(` skipped: ${skippedUnclassified} not deep-sky, ${skippedNotNotable} too faint, ${skippedUnpositioned} unusable coordinates.`);
|
||||
console.log(` ${withDistance}/${records.length} have a derivable distance.`);
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
fetchDeepSky().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user