Add star-map Angular app, ETL pipeline, and caveman plugin

Angular 3D star map (galaxy/system/body views, Three.js rendering,
navigation store) plus the NASA ETL tooling that builds the star,
exoplanet and solar-system datasets, Playwright e2e suite, and the
cs:caveman Claude Code plugin (command, agent, skill).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
2026-08-03 16:50:10 +02:00
parent 1e1b58b0e9
commit d7e8ea1d4d
99 changed files with 5513 additions and 1 deletions
+28
View File
@@ -0,0 +1,28 @@
{
"name": "star-map",
"owner": {
"name": "avalon-vanguard",
"url": "https://github.com/avalon-vanguard"
},
"description": "Personal marketplace of cs:* Claude Code commands, agents, and skills.",
"plugins": [
{
"name": "caveman",
"source": "./",
"description": "Ultra-compressed communication mode. Cuts token usage ~75% by dropping filler, articles, and pleasantries while keeping full technical accuracy. Adds a cs-caveman-mode persona agent, /cs:caveman slash command, and stdlib Python tooling (compressor, token-savings estimator, lint) on top of Matt Pocock's MIT-licensed caveman skill.",
"version": "1.0.0",
"author": {
"name": "avalon-vanguard",
"url": "https://github.com/avalon-vanguard"
},
"license": "MIT",
"keywords": [
"caveman",
"communication",
"tokens",
"productivity"
],
"category": "productivity"
}
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "caveman",
"description": "Ultra-compressed communication mode. Cuts token usage ~75% by dropping filler, articles, and pleasantries while keeping full technical accuracy. Derived from Matt Pocock's MIT-licensed caveman skill (https://github.com/mattpocock/skills) with this repo's `cs-caveman-mode` persona agent + `/cs:caveman` slash command wrapper and stdlib Python tooling (compressor, token-savings estimator, lint). Use when user says \"caveman mode\", \"talk like caveman\", \"use caveman\", \"less tokens\", \"be brief\", or invokes /cs:caveman.",
"version": "1.0.0",
"author": {
"name": "avalon-vanguard",
"url": "https://github.com/avalon-vanguard"
},
"homepage": "https://github.com/avalon-vanguard/star-map",
"repository": "https://github.com/avalon-vanguard/star-map",
"license": "MIT",
"skills": [
"./skills/caveman"
],
"attribution": {
"derived_from": "https://github.com/mattpocock/skills/tree/main/skills/productivity/caveman",
"original_author": "Matt Pocock (@mattpocock)",
"original_license": "MIT",
"derivation_note": "Matt's SKILL.md content reproduced under MIT. Additions: cs-* persona agent + /cs:* command wrapper, stdlib compression/estimator/lint tooling."
}
}
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+12
View File
@@ -26,3 +26,15 @@
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
# ETL raw-response cache (re-downloadable; see tools/etl/lib/http.ts)
/tools/etl/.cache/
# Playwright
/playwright-report/
/test-results/
/blob-report/
# Python bytecode (skills/*/scripts)
__pycache__/
*.pyc
+272
View File
@@ -0,0 +1,272 @@
---
sessionId: session-260715-175938-19ek
---
# Requirements
### Overview & Goals
Build an interactive, web-based 3D star map in the visual style of Star Citizen's in-game starmap, but populated with **real NASA/astronomical data** instead of fictional systems. Users can browse a galaxy-scale view of nearby stars, drill into an individual star system to see its planets, and inspect a specific body (planet/moon/exoplanet) in detail.
This is a **greenfield project** — the current `star-map` repo only contains an unrelated Claude Code plugin marketplace (agents/commands/skills), so there is no existing app code to build on.
### Scope
**In Scope**
- Angular web app rendering a 3D scene with Three.js (`WebGPURenderer`, TSL node materials, automatic WebGL2 fallback).
- Build-time ETL pipeline that fetches NASA/astronomy data sources once and bakes them into static assets:
- Nearby stars (HYG database — combined Hipparcos/Yale/Gliese catalog) → galaxy-scale star field.
- Solar-system bodies (JPL Horizons/SSD) → orbital elements for planets/moons.
- Exoplanets (NASA Exoplanet Archive, Planetary Systems TAP table) → attached to host stars.
- Deep-sky objects (nebulae/galaxies, e.g. OpenNGC/Messier) → galaxy-view backdrop.
- Galaxy overview: node/point field of stars, pan/zoom/rotate camera, click-to-select, labels.
- System drill-down: orbit ellipses and planet/exoplanet markers computed from orbital elements, reached via a continuous camera-flight transition from the galaxy view.
- Body detail view: focused scene + info panel for a selected planet/moon/exoplanet (radius, mass, orbital data, NASA facts).
- Search/filter UI to jump directly to a star, system, or body.
**Out of Scope**
- Live/runtime querying of NASA APIs from the browser (data is pre-baked at build time).
- Gameplay mechanics (travel time, fuel, missions) — this is an exploration/visualization tool, not a game.
- User accounts, persistence, or multiplayer features.
- Full Gaia catalog (millions of stars) — HYG's curated subset is used for performance.
### User Stories
- As a space enthusiast, I want to see a 3D map of nearby real stars so I can explore the stellar neighborhood the way I'd explore Star Citizen's map.
- As a user, I want to click a star and zoom smoothly into its system to see its real planets and exoplanets.
- As a user, I want to click a planet/moon to see detailed real NASA data about it.
- As a user, I want to search for a star or planet by name and jump straight to it.
- As a user, I want visual cues (glow, size, color) that reflect real stellar/planetary properties (spectral type, magnitude, radius).
### Functional Requirements
- Galaxy view renders all HYG stars within a reasonable distance (e.g. ≤ a few hundred light-years) as an interactive point/instanced field with correct relative 3D positions derived from RA/Dec/distance.
- Selecting a star triggers a camera-flight transition into that system's view (not an instant scene swap) for a fluid, Star-Citizen-like feel.
- System view renders the star, its planets (from JPL orbital elements) and any known exoplanets, with orbit paths drawn as ellipses.
- Selecting a body opens a body-detail view/route with a dedicated scene and an info panel of real data.
- Search returns matches across stars, planets, and exoplanets by name and navigates to the correct view.
- The app must run in a modern browser without a WebGPU-capable GPU (auto-fallback to WebGL2 via `WebGPURenderer`).
### Non-Functional Requirements
- Initial star-field load should be fast; star position data is delivered as a compact binary buffer, not verbose JSON, to keep payload size and parse time low.
- Rendering must stay interactive (target 60fps on mid-range hardware) for the galaxy view's star count.
- ETL pipeline is re-runnable (idempotent) so data can be refreshed periodically without code changes.
# Technical Design
### Current Implementation
The repository currently contains only an unrelated Claude Code plugin marketplace (`agents/`, `commands/`, `skills/`, `README.md`). There is no existing frontend, backend, or data pipeline code — this design starts from a clean slate and defines the initial project structure.
### Key Decisions
1. **Rendering stack**: Angular + Three.js `WebGPURenderer` using TSL (Three.js Shading Language) node materials. `WebGPURenderer` automatically falls back to a WebGL2 backend when WebGPU is unavailable, so this is chosen over the legacy `WebGLRenderer` to be future-aligned while keeping broad compatibility. Rendering runs inside `NgZone.runOutsideAngular()` to avoid change-detection overhead on every animation frame.
2. **Data pipeline**: Build-time static ETL (chosen over a runtime backend). A standalone Node/TS script (`tools/etl`) fetches all NASA/astronomy sources once (or on a schedule) and writes optimized static assets (`src/assets/data/`) that the Angular app loads directly — no backend server required at runtime.
3. **Zoom/navigation architecture**: Hybrid model. Galaxy view and System view share **one continuous scene and camera** that flies smoothly between the two scales (matching Star Citizen's fluid zoom). Body-detail view is a **separate, focused scene/route**, since its content (a single close-up object) and camera needs are unrelated to the galaxy/system camera rig.
4. **Multi-scale coordinate handling**: Galaxy view operates in parsecs, System view in AU — roughly an 8-order-of-magnitude difference that causes floating-point precision and clipping problems if rendered naively in one Three.js unit space. Solution: maintain two independent coordinate scales (`galaxy-scale` and `system-scale`) with a re-centering ("floating origin") step on every camera-driven scale transition — the active system's star is recentered to the origin before switching the camera's near/far planes and unit scale, avoiding z-fighting/jitter at either extreme.
5. **Planet positions are computed, not fetched live**: JPL data provides orbital elements (semi-major axis, eccentricity, inclination, etc.), not per-frame positions. Positions are derived client-side via a simplified Kepler propagator against an app-level "current epoch" value, which also allows optional time-scrubbing later.
### Data Pipeline (ETL)
`tools/etl/` (Node + TypeScript, run via `npm run etl`):
- `fetchStars.ts` — downloads the HYG database (Hipparcos/Yale/Gliese combined catalog), converts RA/Dec/parallax → Cartesian XYZ (parsecs), filters by distance cutoff, packs into a binary `Float32Array` buffer (`stars.bin`) + a small `stars-index.json` (id, name, spectral type, magnitude, buffer offset).
- `fetchSolarSystem.ts` — queries JPL Horizons/SSD for planets/major moons, extracts orbital elements, writes `bodies.json`.
- `fetchExoplanets.ts` — queries the NASA Exoplanet Archive TAP service (`Planetary Systems` table) for confirmed exoplanets + host star coordinates, cross-references host stars to the HYG index by name/coordinates, writes `exoplanets.json`.
- `fetchDeepSky.ts` — pulls a nebula/galaxy catalog (OpenNGC/Messier) with RA/Dec/distance, writes `deepsky.json`.
- `build.ts` — orchestrates the above and validates output (no missing cross-references, reasonable file sizes).
- Output lands in `src/assets/data/` and is committed/regenerated like any other static asset.
### Data Models / Contracts
```ts
interface StarRecord {
id: number;
name: string;
x: number; y: number; z: number; // parsecs, galaxy-scale, Sun at origin
magnitude: number;
spectralType: string;
colorIndex: number;
}
interface OrbitalElements {
semiMajorAxisAu: number;
eccentricity: number;
inclinationDeg: number;
longitudeOfAscendingNodeDeg: number;
argumentOfPeriapsisDeg: number;
meanAnomalyAtEpochDeg: number;
epochJd: number;
}
interface BodyRecord {
id: string;
systemStarId: number;
name: string;
kind: 'planet' | 'moon' | 'dwarf';
radiusKm: number;
orbit: OrbitalElements;
}
interface ExoplanetRecord {
id: string;
hostStarId: number | null; // null if not cross-referenced
name: string;
radiusEarth?: number;
massEarth?: number;
orbit: Partial<OrbitalElements>;
}
interface DeepSkyRecord {
id: string;
name: string;
kind: 'nebula' | 'galaxy' | 'cluster';
x: number; y: number; z: number; // parsecs
angularSizeDeg: number;
}
```
### Components
- `EngineService` (`core/engine/engine.service.ts`) — owns the Three.js `WebGPURenderer`, the render loop (outside `NgZone`), and resize handling. Injected once per canvas host.
- `GalaxySystemSceneComponent` — hosts the shared continuous scene for Galaxy + System views; owns `CameraRigController` that animates between galaxy-scale and system-scale framing (the floating-origin recenter step lives here).
- `StarFieldRenderer` — builds a `THREE.Points`/instanced mesh from `stars.bin` with a TSL-based glow/color node material driven by magnitude and spectral type.
- `SystemOrbitsRenderer` — draws orbit ellipses and planet/exoplanet markers for the currently focused star system, using the Kepler propagator.
- `KeplerPropagator` (`shared/astro/kepler.ts`) — pure function(s) converting `OrbitalElements` + epoch → Cartesian position; independently unit-testable.
- `BodyDetailSceneComponent` — separate route/component with its own dedicated scene for a close-up view of one selected body, plus an `InfoPanelComponent` showing its data.
- `SearchComponent` — text search across `stars-index.json`, `bodies.json`, `exoplanets.json`; on match, dispatches a navigation action.
- `NavigationStore` (Angular signals-based) — holds `viewLevel: 'galaxy' | 'system'`, `selectedStarId`, `selectedBodyId`; consumed by scene components and routed body-detail view.
### File Structure
```
tools/
etl/
fetchStars.ts
fetchSolarSystem.ts
fetchExoplanets.ts
fetchDeepSky.ts
build.ts
src/
assets/data/
stars.bin
stars-index.json
bodies.json
exoplanets.json
deepsky.json
app/
core/
engine/
engine.service.ts
features/
galaxy-system/
galaxy-system-scene.component.ts
star-field-renderer.ts
system-orbits-renderer.ts
camera-rig-controller.ts
body-detail/
body-detail-scene.component.ts
info-panel.component.ts
search/
search.component.ts
shared/
astro/
kepler.ts
coordinates.ts
models/
star.model.ts
body.model.ts
exoplanet.model.ts
deepsky.model.ts
state/
navigation.store.ts
```
### Architecture Diagram
```mermaid
graph TD
subgraph ETL["Build-time ETL (tools/etl)"]
HYG[HYG Star Catalog] --> Fetch1[fetchStars.ts]
JPL[JPL Horizons/SSD] --> Fetch2[fetchSolarSystem.ts]
EXO[NASA Exoplanet Archive TAP] --> Fetch3[fetchExoplanets.ts]
NGC[OpenNGC/Messier] --> Fetch4[fetchDeepSky.ts]
Fetch1 --> Build[build.ts]
Fetch2 --> Build
Fetch3 --> Build
Fetch4 --> Build
Build --> Static[src/assets/data/*.bin,*.json]
end
subgraph App["Angular App"]
Static --> DataLoader[Data Loader Service]
DataLoader --> NavStore[NavigationStore]
NavStore --> GalaxyScene[GalaxySystemSceneComponent]
NavStore --> BodyScene[BodyDetailSceneComponent]
GalaxyScene --> Engine[EngineService: WebGPURenderer]
BodyScene --> Engine
Search[SearchComponent] --> NavStore
GalaxyScene -- selects star --> NavStore
GalaxyScene -- selects body --> BodyScene
end
```
### Risks
- **WebGPURenderer + TSL maturity**: the node-material pipeline is still evolving; some effects (custom shaders, post-processing) may need TSL rewrites rather than legacy `ShaderMaterial`. Mitigated by relying mostly on built-in node materials for stars/orbits.
- **Float precision at galaxy scale**: mitigated via the floating-origin recenter strategy described in Key Decisions.
- **Data cross-referencing**: exoplanets from the Exoplanet Archive must be matched to HYG host stars by name/coordinates; some may fail to match and should be flagged rather than silently dropped (`hostStarId: null`).
- **NASA API rate limits/availability**: ETL scripts should cache raw responses locally so re-runs don't always hit live endpoints.
# Testing
### Validation Approach
Since this is a new build, validation focuses on (a) correctness of the astronomical math and ETL output, and (b) the interactive scene behaving as specified.
### Key Scenarios
- ETL `build.ts` run produces `stars.bin`/`stars-index.json`/`bodies.json`/`exoplanets.json`/`deepsky.json` with no missing/undefined required fields.
- `coordinates.ts` RA/Dec/parallax → XYZ conversion matches known reference values (e.g. Sirius, Proxima Centauri positions) within a small tolerance.
- `kepler.ts` propagator reproduces expected planet positions for simple test cases (e.g. Earth's position at a known epoch) within tolerance.
- Clicking a star in the galaxy view triggers a camera-flight transition and lands in the correct system view (`NavigationStore.viewLevel === 'system'` and `selectedStarId` set).
- Selecting a body navigates to `BodyDetailSceneComponent` with the correct `selectedBodyId` and populated info panel.
- Search returns and navigates to the correct entity for star/body/exoplanet name queries.
### Edge Cases
- Exoplanets whose host star can't be matched to a HYG record (`hostStarId: null`) are excluded from system view but don't crash the app.
- Systems with zero known planets/exoplanets still render (star only, no orbit renderer errors).
- WebGPU unavailable in the test browser: renderer falls back to WebGL2 without throwing.
### Test Changes
- Unit tests (Jest/Karma per Angular defaults) for `coordinates.ts` and `kepler.ts` pure functions.
- Unit tests for ETL cross-referencing logic (`fetchExoplanets.ts` host-star matching) using mocked API fixtures.
- Component tests for `NavigationStore` state transitions (galaxy → system → body).
# Delivery Steps
### ✓ Step 1: Scaffold Angular project and Three.js WebGPU rendering foundation
An Angular app boots and renders an empty Three.js scene via WebGPURenderer with automatic WebGL2 fallback.
- Generate the Angular workspace (standalone components, routing enabled) with `src/app/core`, `features`, `shared` folders per the agreed file structure.
- Implement `EngineService` that creates a `THREE.WebGPURenderer`, a base `Scene`/`PerspectiveCamera`, and a render loop running via `NgZone.runOutsideAngular()`.
- Implement a canvas host component that attaches the renderer to a `<canvas>` and handles resize.
- Add a placeholder starfield (procedural points) just to prove the render loop, camera, and resize logic work end-to-end.
### ✓ Step 2: Build the NASA/astronomy data ETL pipeline
Running `npm run etl` produces the static data files consumed by the app.
- Implement `tools/etl/fetchStars.ts` to download the HYG catalog and convert RA/Dec/parallax to XYZ (parsecs) via `shared/astro/coordinates.ts`, packing results into `stars.bin` + `stars-index.json`.
- Implement `tools/etl/fetchSolarSystem.ts` to pull orbital elements for solar-system bodies from JPL Horizons/SSD into `bodies.json`.
- Implement `tools/etl/fetchExoplanets.ts` to query the NASA Exoplanet Archive TAP `Planetary Systems` table and cross-reference host stars to the HYG index, writing `exoplanets.json`.
- Implement `tools/etl/fetchDeepSky.ts` for nebula/galaxy catalog data into `deepsky.json`.
- Implement `tools/etl/build.ts` to orchestrate all fetch scripts, cache raw API responses, and validate output completeness.
### ✓ Step 3: Implement the galaxy view star field with selection and labels
Users can pan/zoom/rotate a real star field and click a star to select it.
- Implement `StarFieldRenderer` to load `stars.bin`/`stars-index.json` and build an instanced/points mesh with a TSL node material driving glow/color from magnitude and spectral type.
- Implement camera pan/zoom/rotate controls for the galaxy scale.
- Implement picking (raycast or GPU picking) to select a star on click, updating `NavigationStore.selectedStarId`.
- Implement label overlays (CSS2D or DOM overlay) showing star names near the camera focus.
### ✓ Step 4: Implement system view and the galaxy-to-system camera transition
Selecting a star flies the camera smoothly into that system, showing its real planets and orbits.
- Implement `shared/astro/kepler.ts` Kepler propagator converting `OrbitalElements` + epoch to Cartesian position.
- Implement `SystemOrbitsRenderer` to draw orbit ellipses and planet/exoplanet markers for the star in `NavigationStore.selectedStarId`, loading matching records from `bodies.json`/`exoplanets.json`.
- Implement `CameraRigController` with the floating-origin recenter step and animated transition between galaxy-scale and system-scale framing.
- Wire `NavigationStore.viewLevel` toggling between `'galaxy'` and `'system'` to drive `GalaxySystemSceneComponent`.
### ✓ Step 5: Implement the body detail view and info panel
Selecting a planet/moon/exoplanet opens a dedicated close-up scene with real data.
- Implement `BodyDetailSceneComponent` as a separate route with its own `EngineService`-backed scene focused on one selected body.
- Implement `InfoPanelComponent` displaying the body's real data (radius, mass, orbital elements, kind).
- Wire body selection from `SystemOrbitsRenderer` to update `NavigationStore.selectedBodyId` and navigate to the body-detail route.
### ✓ Step 6: Implement search and cross-view navigation
Users can search by name and jump directly to the matching star, system, or body.
- Implement `SearchComponent` querying `stars-index.json`, `bodies.json`, and `exoplanets.json` for name matches.
- On selecting a search result, dispatch the appropriate `NavigationStore` update (galaxy star, system body, or exoplanet) and trigger the corresponding camera transition or route change.
- Ensure consistent state across `GalaxySystemSceneComponent` and `BodyDetailSceneComponent` when navigation originates from search rather than in-scene clicks.
+5
View File
@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+42
View File
@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}
+35 -1
View File
@@ -1 +1,35 @@
# star-map # star-map
Personal marketplace of `cs:*` Claude Code commands, agents, and skills.
## Install (global — works in every project)
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:
```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:
```bash
# Shared with collaborators via that repo's .claude/settings.json
/plugin install caveman@star-map --scope project
# Just for you, in that one repo only (gitignored)
/plugin install caveman@star-map --scope local
```
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)
+86
View File
@@ -0,0 +1,86 @@
---
name: cs-caveman-mode
description: >-
Caveman-mode operator. Persistent ultra-compressed communication mode.
Drops articles, filler, pleasantries, and hedging while preserving all
technical substance. Auto-clarity exception for security warnings,
irreversible actions, multi-step sequences, and clarification requests.
Activated by user phrases ("caveman mode", "talk like caveman", "use
caveman", "less tokens", "be brief") or /cs:caveman command.
tools: []
---
# cs-caveman-mode
Operator agent for persistent caveman-mode communication. Wraps the
[`caveman`](../skills/caveman/SKILL.md) skill and keeps it active across
turns until explicit deactivation.
## Scope
Ships as part of this repo's plugin marketplace. Installing it via
`/plugin install caveman@star-map` defaults to **user scope**, so this
agent is available in every project, not scoped to a single repo. See the
[README](../README.md) for `--scope project` / `--scope local` overrides.
## Activation Triggers
- Explicit command: `/cs:caveman`
- User phrases: "caveman mode", "talk like caveman", "use caveman", "less
tokens", "be brief"
On activation: respond terse starting with the very next turn. No preamble,
no "switching mode now" confirmation. BEGIN immediately.
## Operating Rules
Drop:
- Articles (a/an/the)
- Filler (just/really/basically/actually/simply)
- Pleasantries (sure/certainly/of course/happy to)
- Hedging (might/maybe/perhaps/likely)
Abbreviate: DB, auth, config, req, res, fn, impl, env, deps, repo, docs, app.
Use `X -> Y` arrows for causality.
Pattern each reply on: `[thing] [action] [reason]. [next step].`
Never alter: code blocks, inline code, technical terms, error messages.
## Auto-Clarity Exception
Temporarily drop caveman compression for:
- Security warnings (`**Warning:** ...`)
- Irreversible action confirmations
- Multi-step sequences where order matters
- User asks "what?" / "wait" / repeats a question
Resume compressed output right after, marked explicitly with "Caveman
resume."
## Deactivation
On "stop caveman" / "normal mode": resume normal prose immediately, no
lingering abbreviations.
## Tooling
Use the skill's scripts to compress text, estimate token savings, and lint
own output against the rules above:
```bash
python ../skills/caveman/scripts/caveman_compressor.py "text"
python ../skills/caveman/scripts/token_savings_estimator.py "text" --price-per-mtok 3.00
python ../skills/caveman/scripts/caveman_lint.py "response"
```
## Related
- Command: [`/cs:caveman`](../commands/cs/caveman.md)
- Skill: [`caveman`](../skills/caveman/SKILL.md)
- Adjacent agents: `cs-grill-master`, `cs-handoff-author`
---
**Derived:** Matt Pocock's caveman (MIT) + this repo's wrapper
+84
View File
@@ -0,0 +1,84 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
"star-map": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
},
{
"glob": "**/*",
"input": "src/assets",
"output": "assets"
}
],
"styles": [
"src/styles.css"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1.5MB",
"maximumError": "2.5MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "star-map:build:production"
},
"development": {
"buildTarget": "star-map:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+77
View File
@@ -0,0 +1,77 @@
---
name: caveman
description: Activate caveman mode - terse, ultra-compressed communication with no filler, articles, or pleasantries.
---
# /cs:caveman — Caveman Mode
**Command:** `/cs:caveman`
Activate caveman mode. Stays active until explicit deactivation.
## Scope
Installed via this repo's plugin marketplace, this command defaults to
**user scope** — available in every project, not just the one it was
installed from. Override with `--scope project` (shared with collaborators
via `.claude/settings.json`) or `--scope local` (gitignored, this repo
only) if you want it scoped down. See the [README](../../README.md) for
install commands.
## Activation
Once invoked: respond terse every turn. No "OK switching mode" preamble. BEGIN immediately.
## Rules (per Matt Pocock)
Drop:
- Articles (a/an/the)
- Filler (just/really/basically/actually/simply)
- Pleasantries (sure/certainly/of course/happy to)
- Hedging (might/maybe/perhaps/likely)
Abbreviate: DB, auth, config, req, res, fn, impl, env, deps, repo, docs, app.
Arrows for causality: `X -> Y`.
Pattern: `[thing] [action] [reason]. [next step].`
Code blocks + inline code + technical terms + errors: unchanged.
## Auto-Clarity Exception
Drop caveman for:
- Security warnings (`**Warning:** ...`)
- Irreversible action confirmations
- Multi-step sequences where order matters
- User asks "what?" / "wait" / repeats question
Resume after exception with explicit "Caveman resume." marker.
## Deactivation
User types: "stop caveman" / "normal mode" → resume normal prose.
## Tooling
```bash
# Compress text
python ../skills/caveman/scripts/caveman_compressor.py "text"
# Estimate token savings at price
python ../skills/caveman/scripts/token_savings_estimator.py "text" --price-per-mtok 3.00
# Verify response follows caveman rules
python ../skills/caveman/scripts/caveman_lint.py "response"
```
## Related
- Agent: [`cs-caveman-mode`](../agents/cs-caveman-mode.md)
- Skill: [`caveman`](../skills/caveman/SKILL.md)
- Adjacent: `/cs:grill-me`, `/cs:handoff` (other Pocock-derived skills)
---
**Version:** 1.0.0
**Derived:** Matt Pocock's caveman (MIT) + this repo's wrapper
+30
View File
@@ -0,0 +1,30 @@
# End-to-end tests (Playwright)
These tests run the real Angular dev server in a real Chromium browser, covering interactions
that unit/component tests (under `src/**/*.spec.ts`, run via `npm test`) can only approximate
under jsdom — most importantly the galaxy↔system **camera-flight transitions** (real WebGPU/
WebGL2 initialization + raycaster picking) and cross-view **search navigation**.
## Running
```bash
npm run e2e # headless run against a freshly started dev server
npx playwright test --ui # interactive UI mode
npx playwright show-report
```
`playwright.config.ts` starts `npm run start -- --port=4300` automatically and waits for it to
respond before running the suite (`webServer.reuseExistingServer` is `true` outside CI, so an
already-running `ng serve` on port 4300 is reused instead of starting a second one). Port 4300
is used instead of the Angular CLI's conventional 4200 to avoid colliding with an unrelated
server a developer might already have running there.
## Notes
- The Sun (`Sol`) is always placed at the coordinate-system origin (`x=0,y=0,z=0`), which is
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()`.
+21
View File
@@ -0,0 +1,21 @@
import { expect, test } from '@playwright/test';
import { backButtonLocator, clickCanvasUntilSystemEntered } from './support/wait-for-back-button';
test.describe('Camera-flight transitions (click-to-select)', () => {
test('clicking the star at the view center flies into its system, and the back button flies back out to the galaxy overview', async ({ page }) => {
await page.goto('/');
const canvas = page.getByTestId('scene-canvas');
await expect(canvas).toBeVisible();
const backButton = backButtonLocator(page);
await expect(backButton).toHaveCount(0);
// Sol sits at the coordinate system's origin, exactly where the default galaxy-view camera
// looks, so clicking the canvas center reliably hits it once bootstrap/picking are ready.
await clickCanvasUntilSystemEntered(page, canvas, backButton);
await backButton.click();
await expect(backButton).toHaveCount(0, { timeout: 15_000 });
});
});
+11
View File
@@ -0,0 +1,11 @@
import { expect, test } from '@playwright/test';
test.describe('Galaxy view', () => {
test('boots the app, initializes the 3D scene, and starts in the galaxy overview (no system controls shown)', async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('scene-canvas')).toBeVisible();
await expect(page.getByPlaceholder('Search stars, planets, exoplanets…')).toBeVisible();
await expect(page.getByRole('button', { name: 'Galaxy' })).toHaveCount(0);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { expect, test } from '@playwright/test';
import { backButtonLocator } from './support/wait-for-back-button';
test.describe('Search-driven navigation', () => {
test('selecting a star result flies into that system and shows the back-to-galaxy control', async ({ page }) => {
await page.goto('/');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
await searchInput.fill('Proxima Centauri');
const result = page.getByRole('button', { name: /Proxima Centauri/ });
await expect(result).toBeVisible();
await result.click();
// Selecting a result clears the search query immediately (before the flight even starts).
await expect(searchInput).toHaveValue('');
await expect(backButtonLocator(page)).toBeVisible({ timeout: 15_000 });
});
test('selecting a body result navigates straight to its detail route and shows real NASA data', async ({ page }) => {
await page.goto('/');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
await searchInput.fill('Earth');
const result = page.getByText('Earth', { exact: true });
await expect(result).toBeVisible();
await result.click();
await expect(page).toHaveURL(/\/body\/earth$/);
// The body-detail route is lazy-loaded; the first navigation to it in a dev-server session
// can take a few seconds to bundle/compile, so this allows more time than the default 5s.
await expect(page.getByRole('heading', { name: 'Earth' })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('Planet · Sol')).toBeVisible();
await expect(page.getByText('Radius')).toBeVisible();
});
test('typing fewer than two characters shows no results, and Escape clears the query', async ({ page }) => {
await page.goto('/');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
await searchInput.fill('E');
await expect(page.getByTestId('search-results')).toHaveCount(0);
await searchInput.fill('Earth');
await expect(page.getByTestId('search-results').locator('li')).not.toHaveCount(0);
await searchInput.press('Escape');
await expect(searchInput).toHaveValue('');
await expect(page.getByTestId('search-results')).toHaveCount(0);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { expect, Locator, Page } from '@playwright/test';
/**
* The "Galaxy" back button is only rendered while `NavigationStore.viewLevel() === 'system'`,
* which becomes true only once a full galaxy-to-system camera-flight transition has settled.
* Bootstrap (data fetch + raycaster wiring) finishes asynchronously after `page.goto()`, so
* this repeatedly clicks the canvas (re-checking first, so it never clicks a system-view body
* marker by accident once the transition has already completed) until the button appears.
*/
export function backButtonLocator(page: Page): Locator {
return page.getByRole('button', { name: 'Galaxy' });
}
export async function clickCanvasUntilSystemEntered(page: Page, canvas: Locator, backButton: Locator): Promise<void> {
await expect
.poll(
async () => {
if (await backButton.isVisible()) {
return true;
}
await canvas.click();
return false;
},
{ timeout: 15_000, intervals: [300] }
)
.toBe(true);
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "bundler",
"rootDir": "..",
"outDir": "../out-tsc/e2e",
"types": ["node", "@playwright/test"]
},
"include": ["**/*.ts"]
}
+44
View File
@@ -0,0 +1,44 @@
{
"name": "star-map",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"etl": "tsx tools/etl/build.ts",
"etl:typecheck": "tsc -p tools/etl/tsconfig.json --noEmit",
"e2e": "playwright test",
"e2e:typecheck": "tsc -p e2e/tsconfig.json --noEmit"
},
"private": true,
"packageManager": "npm@11.12.1",
"dependencies": {
"@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0",
"@angular/forms": "^22.0.0",
"@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0",
"rxjs": "~7.8.0",
"three": "^0.185.1",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^22.0.6",
"@angular/cli": "^22.0.6",
"@angular/compiler-cli": "^22.0.0",
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.2",
"@types/node": "^26.1.1",
"@types/three": "^0.185.1",
"jsdom": "^28.0.0",
"postcss": "^8.5.19",
"prettier": "^3.8.1",
"tailwindcss": "^4.3.2",
"tsx": "^4.23.1",
"typescript": "~6.0.2",
"vitest": "^4.0.8"
}
}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig, devices } from '@playwright/test';
/**
* End-to-end tests that exercise the app in a real browser: search-driven navigation and the
* galaxy↔system camera-flight transitions (raycasting, WebGPU/WebGL2 init, DOM state) that
* unit/component tests can only approximate under jsdom. See `e2e/README.md`.
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
use: {
baseURL: 'http://localhost:4300',
trace: 'on-first-retry'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
],
webServer: {
// Uses a dedicated port (rather than the Angular CLI's conventional 4200) so this never
// collides with an unrelated `ng serve`/other server a developer already has running.
command: 'npm run start -- --port=4300',
url: 'http://localhost:4300',
reuseExistingServer: !process.env.CI,
timeout: 120_000
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+117
View File
@@ -0,0 +1,117 @@
---
name: caveman
description: >-
Ultra-compressed communication mode. Cuts token usage ~75% by dropping
filler, articles, and pleasantries while keeping full technical accuracy.
Use when user says "caveman mode", "talk like caveman", "use caveman",
"less tokens", "be brief", or invokes /caveman.
---
# Caveman
Ultra-compressed communication mode. Drop everything that doesn't carry
technical signal. Keep every fact, number, code snippet, and error message
exactly as-is.
## Scope
This skill ships via this repo's plugin marketplace
(`.claude-plugin/marketplace.json`). Installing with
`/plugin install caveman@star-map` defaults to **user scope** — global,
available in every project — instead of being tied to just one repo. Use
`--scope project` or `--scope local` to narrow it; see the
[README](../../README.md).
## Persistence
Once activated, caveman mode stays active for **every subsequent turn**,
across the whole conversation, until the user explicitly deactivates it.
Do not silently revert to normal prose after one terse reply — the
compression must persist.
Activate on:
- `/cs:caveman` command
- Phrases: "caveman mode", "talk like caveman", "use caveman", "less
tokens", "be brief"
Deactivate on:
- "stop caveman"
- "normal mode"
On activation, begin the compressed style immediately, starting with the
very next reply. No "OK, switching to caveman mode" announcement.
## Rules
Drop:
- Articles — a / an / the
- Filler — just / really / basically / actually / simply
- Pleasantries — sure / certainly / of course / happy to
- Hedging — might / maybe / perhaps / likely
Abbreviate common terms: `DB`, `auth`, `config`, `req`, `res`, `fn`, `impl`,
`env`, `deps`, `repo`, `docs`, `app`.
Use arrows for causality: `X -> Y` instead of "X causes Y" / "X leads to Y".
Prefer the pattern: `[thing] [action] [reason]. [next step].`
Never touch: code blocks, inline code, technical terms, error messages —
these stay verbatim, unabbreviated, uncompressed.
## Examples
Normal:
> Sure, I think the issue is likely that the database connection is
> basically timing out because the config file actually has the wrong
> host set. Let me go ahead and fix the configuration file for you.
Caveman:
> DB conn timeout -> config has wrong host. Fixing config now.
Normal:
> Of course! The function returns undefined because it's missing a return
> statement. I'll go ahead and add one now.
Caveman:
> `fn` returns undefined -> missing return statement. Adding one.
## Auto-Clarity Exception
Caveman compression is dropped automatically — full, normal prose used
instead — for:
- Security warnings (e.g. `**Warning:** ...`)
- Irreversible action confirmations (deletes, force-pushes, drops, prod
deploys)
- Multi-step sequences where order matters (numbered instructions,
migrations, setup steps)
- Clarification moments — user asks "what?" / "wait" / repeats their
question
After the exception is resolved, resume caveman mode and mark the
resumption explicitly with the line: `Caveman resume.`
## Tooling
Scripts live in `scripts/` next to this file.
```bash
# Compress text into caveman style
python scripts/caveman_compressor.py "text"
# Estimate token savings at a given price per million tokens
python scripts/token_savings_estimator.py "text" --price-per-mtok 3.00
# Verify a response follows caveman rules
python scripts/caveman_lint.py "response"
```
## Related
- Command: `/cs:caveman`
- Agent: `cs-caveman-mode`
- Adjacent skills: `grill-me`, `handoff` (other Pocock-derived skills)
---
**Derived:** Matt Pocock's caveman (MIT) + this repo's wrapper
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""caveman_compressor.py
Compress text into "caveman mode" style per the `caveman` skill rules:
drop articles, filler, pleasantries, and hedging; abbreviate common
technical terms; turn simple causal phrases into `X -> Y` arrows.
Code blocks (``` ... ```) and inline code (`...`) are left untouched.
Usage:
python caveman_compressor.py "text to compress"
python caveman_compressor.py --file some.md
"""
import argparse
import re
import sys
# Words/phrases dropped entirely (case-insensitive, whole-word match).
ARTICLES = ["a", "an", "the"]
FILLER = ["just", "really", "basically", "actually", "simply"]
HEDGING = ["might", "maybe", "perhaps", "likely"]
# Multi-word pleasantries dropped entirely (checked as phrases, longest first).
PLEASANTRIES = [
"of course",
"happy to",
"sure thing",
"certainly",
"sure",
]
DROP_WORDS = ARTICLES + FILLER + HEDGING
# Common abbreviations. Keys are matched case-insensitively as whole words;
# the replacement preserves the target casing shown here.
ABBREVIATIONS = {
"database": "DB",
"databases": "DBs",
"authentication": "auth",
"configuration": "config",
"configurations": "configs",
"request": "req",
"requests": "reqs",
"response": "res",
"responses": "res",
"function": "fn",
"functions": "fns",
"implementation": "impl",
"implementations": "impls",
"environment": "env",
"environments": "envs",
"dependency": "dep",
"dependencies": "deps",
"repository": "repo",
"repositories": "repos",
"documentation": "docs",
"application": "app",
"applications": "apps",
}
# Causal phrases turned into `X -> Y` arrows.
CAUSAL_PHRASES = [
"leads to",
"results in",
"causes",
"will cause",
]
# Splits text into segments, tagging fenced code blocks / inline code so
# they can be skipped during compression.
_CODE_SPLIT_RE = re.compile(r"(```.*?```|`[^`\n]*`)", re.DOTALL)
def _drop_words(text: str) -> str:
for phrase in PLEASANTRIES:
text = re.sub(
r"(?i)\b" + re.escape(phrase) + r"\b[,!]?\s*", "", text
)
for word in DROP_WORDS:
text = re.sub(r"(?i)\b" + re.escape(word) + r"\b\s*", "", text)
return text
def _abbreviate(text: str) -> str:
for long_form, short_form in ABBREVIATIONS.items():
text = re.sub(
r"(?i)\b" + re.escape(long_form) + r"\b",
short_form,
text,
)
return text
def _arrows(text: str) -> str:
for phrase in CAUSAL_PHRASES:
text = re.sub(r"(?i)\s*\b" + re.escape(phrase) + r"\b\s*", " -> ", text)
return text
def _cleanup_whitespace(text: str) -> str:
text = re.sub(r"[ \t]{2,}", " ", text)
text = re.sub(r"[ \t]+([,.!?;:])", r"\1", text)
text = re.sub(r"\n[ \t]+", "\n", text)
text = re.sub(r"^[ \t]+", "", text, flags=re.MULTILINE)
return text.strip()
def compress(text: str) -> str:
"""Compress `text` into caveman style, preserving code spans."""
segments = _CODE_SPLIT_RE.split(text)
out = []
for segment in segments:
if segment.startswith("`"):
out.append(segment)
continue
compressed = segment
compressed = _arrows(compressed)
compressed = _drop_words(compressed)
compressed = _abbreviate(compressed)
compressed = _cleanup_whitespace(compressed)
out.append(compressed)
return "".join(out)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("text", nargs="?", help="Text to compress")
parser.add_argument("--file", help="Read text to compress from a file")
args = parser.parse_args()
if args.file:
with open(args.file, "r", encoding="utf-8") as fh:
text = fh.read()
elif args.text is not None:
text = args.text
else:
text = sys.stdin.read()
print(compress(text))
return 0
if __name__ == "__main__":
sys.exit(main())
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""caveman_lint.py
Verify that a response follows the `caveman` skill rules: no articles,
filler words, pleasantries, or hedging outside of code spans.
Code blocks (``` ... ```) and inline code (`...`) are ignored by the lint,
since their contents are technical and must stay unchanged.
Exit code: 0 if no violations found, 1 otherwise.
Usage:
python caveman_lint.py "response text"
python caveman_lint.py --file some.md
"""
import argparse
import re
import sys
ARTICLES = ["a", "an", "the"]
FILLER = ["just", "really", "basically", "actually", "simply"]
HEDGING = ["might", "maybe", "perhaps", "likely"]
PLEASANTRIES = ["sure", "certainly", "of course", "happy to", "sure thing"]
RULES = {
"article": ARTICLES,
"filler": FILLER,
"hedging": HEDGING,
"pleasantry": PLEASANTRIES,
}
_CODE_SPLIT_RE = re.compile(r"(```.*?```|`[^`\n]*`)", re.DOTALL)
def _non_code_segments(text: str):
"""Yield (segment_text, start_offset_in_original_text) for every
segment of `text` that is NOT inside a fenced/inline code span."""
offset = 0
for segment in _CODE_SPLIT_RE.split(text):
if not segment.startswith("`"):
yield segment, offset
offset += len(segment)
def find_violations(text: str):
"""Return a list of violation dicts: category, word, position, line,
context (a short snippet around the match)."""
violations = []
for category, words in RULES.items():
for word in words:
pattern = re.compile(r"(?i)\b" + re.escape(word) + r"\b")
for segment, offset in _non_code_segments(text):
for match in pattern.finditer(segment):
pos = offset + match.start()
line = text.count("\n", 0, pos) + 1
start = max(0, match.start() - 20)
end = min(len(segment), match.end() + 20)
context = segment[start:end].strip().replace("\n", " ")
violations.append(
{
"category": category,
"word": match.group(0),
"position": pos,
"line": line,
"context": context,
}
)
violations.sort(key=lambda v: v["position"])
return violations
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("text", nargs="?", help="Response text to lint")
parser.add_argument("--file", help="Read response text to lint from a file")
args = parser.parse_args()
if args.file:
with open(args.file, "r", encoding="utf-8") as fh:
text = fh.read()
elif args.text is not None:
text = args.text
else:
text = sys.stdin.read()
violations = find_violations(text)
if not violations:
print("OK: no caveman-rule violations found.")
return 0
print(f"FAIL: {len(violations)} caveman-rule violation(s) found:\n")
for v in violations:
print(
f" line {v['line']} [{v['category']}] '{v['word']}' "
f"-> ...{v['context']}..."
)
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""token_savings_estimator.py
Estimate token savings (and $ cost savings) achieved by compressing text
into "caveman mode" style, using the `caveman_compressor` module.
Token counts are estimated with a simple heuristic (~4 chars/token) unless
`tiktoken` is installed, in which case it is used for a more accurate count.
Usage:
python token_savings_estimator.py "text" --price-per-mtok 3.00
python token_savings_estimator.py --file some.md --price-per-mtok 3.00
"""
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from caveman_compressor import compress # noqa: E402
CHARS_PER_TOKEN = 4.0
def count_tokens(text: str) -> int:
"""Count tokens in `text`, using tiktoken if available, else a
character-based heuristic (~4 chars/token, roughly matching common
English tokenizers)."""
try:
import tiktoken
encoding = tiktoken.get_encoding("cl100k_base")
return len(encoding.encode(text))
except ImportError:
if not text:
return 0
return max(1, round(len(text) / CHARS_PER_TOKEN))
def estimate_savings(text: str, price_per_mtok: float) -> dict:
compressed = compress(text)
original_tokens = count_tokens(text)
compressed_tokens = count_tokens(compressed)
saved_tokens = max(0, original_tokens - compressed_tokens)
pct_saved = (saved_tokens / original_tokens * 100) if original_tokens else 0.0
original_cost = original_tokens / 1_000_000 * price_per_mtok
compressed_cost = compressed_tokens / 1_000_000 * price_per_mtok
saved_cost = original_cost - compressed_cost
return {
"compressed_text": compressed,
"original_tokens": original_tokens,
"compressed_tokens": compressed_tokens,
"saved_tokens": saved_tokens,
"pct_saved": pct_saved,
"original_cost": original_cost,
"compressed_cost": compressed_cost,
"saved_cost": saved_cost,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("text", nargs="?", help="Text to analyze")
parser.add_argument("--file", help="Read text to analyze from a file")
parser.add_argument(
"--price-per-mtok",
type=float,
default=3.00,
help="Price in USD per 1,000,000 tokens (default: 3.00)",
)
args = parser.parse_args()
if args.file:
with open(args.file, "r", encoding="utf-8") as fh:
text = fh.read()
elif args.text is not None:
text = args.text
else:
text = sys.stdin.read()
result = estimate_savings(text, args.price_per_mtok)
print("--- Caveman compression ---")
print(result["compressed_text"])
print()
print("--- Token savings ---")
print(f"Original tokens: {result['original_tokens']}")
print(f"Compressed tokens: {result['compressed_tokens']}")
print(f"Saved tokens: {result['saved_tokens']} ({result['pct_saved']:.1f}%)")
print()
print(f"--- Cost @ ${args.price_per_mtok:.2f} / MTok ---")
print(f"Original cost: ${result['original_cost']:.6f}")
print(f"Compressed cost: ${result['compressed_cost']:.6f}")
print(f"Saved cost: ${result['saved_cost']:.6f}")
return 0
if __name__ == "__main__":
sys.exit(main())
+11
View File
@@ -0,0 +1,11 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes)
]
};
+2
View File
@@ -0,0 +1,2 @@
<app-search></app-search>
<router-outlet></router-outlet>
+12
View File
@@ -0,0 +1,12 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./features/galaxy-system/galaxy-system-scene.component').then((m) => m.GalaxySystemSceneComponent)
},
{
path: 'body/:id',
loadComponent: () => import('./features/body-detail/body-detail-scene.component').then((m) => m.BodyDetailSceneComponent)
}
];
+5
View File
@@ -0,0 +1,5 @@
:host {
display: block;
width: 100%;
height: 100%;
}
+22
View File
@@ -0,0 +1,22 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render a router outlet', () => {
const fixture = TestBed.createComponent(App);
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('router-outlet')).not.toBeNull();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { SearchComponent } from './features/search/search.component';
@Component({
selector: 'app-root',
imports: [RouterOutlet, SearchComponent],
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App {}
+50
View File
@@ -0,0 +1,50 @@
import { Injectable } from '@angular/core';
import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model';
export interface StarField {
stars: StarRecord[];
/** Positions in parsecs, in the same order as `stars`, packed as [x0,y0,z0,x1,y1,z1,...]. */
positions: Float32Array;
}
/**
* Loads the ETL-generated static assets (`src/assets/data/*`, served at `/assets/data/*`).
* Each dataset is fetched at most once per app session and cached in memory.
*/
@Injectable({ providedIn: 'root' })
export class DataLoaderService {
private starFieldPromise?: Promise<StarField>;
private bodiesPromise?: Promise<BodyRecord[]>;
private exoplanetsPromise?: Promise<ExoplanetRecord[]>;
loadStars(): Promise<StarField> {
this.starFieldPromise ??= this.fetchStars();
return this.starFieldPromise;
}
loadBodies(): Promise<BodyRecord[]> {
this.bodiesPromise ??= this.fetchJson<BodyRecord[]>('assets/data/bodies.json');
return this.bodiesPromise;
}
loadExoplanets(): Promise<ExoplanetRecord[]> {
this.exoplanetsPromise ??= this.fetchJson<ExoplanetRecord[]>('assets/data/exoplanets.json');
return this.exoplanetsPromise;
}
private async fetchStars(): Promise<StarField> {
const [stars, buffer] = await Promise.all([
fetch('assets/data/stars-index.json').then((response) => response.json() as Promise<StarRecord[]>),
fetch('assets/data/stars.bin').then((response) => response.arrayBuffer())
]);
return { stars, positions: new Float32Array(buffer) };
}
private fetchJson<T>(url: string): Promise<T> {
return fetch(url).then((response) => response.json() as Promise<T>);
}
}
+148
View File
@@ -0,0 +1,148 @@
import { Injectable, NgZone } from '@angular/core';
import * as THREE from 'three/webgpu';
export type EngineTickCallback = (deltaSeconds: number, elapsedSeconds: number) => void;
/**
* Owns the Three.js WebGPURenderer (with automatic WebGL2 fallback), the base scene/camera,
* and the render loop. The loop always runs outside Angular's zone so per-frame work never
* triggers change detection.
*
* Not provided at root: each canvas host component provides its own instance
* (via its `providers` array) so independent scenes (e.g. galaxy/system vs. body detail)
* never share a renderer.
*/
@Injectable()
export class EngineService {
private readonly clock = new THREE.Clock(false);
private readonly tickCallbacks = new Set<EngineTickCallback>();
private canvas?: HTMLCanvasElement;
private renderer?: THREE.WebGPURenderer;
private scene?: THREE.Scene;
private camera?: THREE.PerspectiveCamera;
private running = false;
constructor(private readonly ngZone: NgZone) {}
get isInitialized(): boolean {
return !!this.renderer;
}
getScene(): THREE.Scene {
return this.requireInitialized(this.scene);
}
getCamera(): THREE.PerspectiveCamera {
return this.requireInitialized(this.camera);
}
getRenderer(): THREE.WebGPURenderer {
return this.requireInitialized(this.renderer);
}
/**
* Creates the renderer/scene/camera against the given canvas. Must be called once per canvas.
*/
async init(canvas: HTMLCanvasElement): Promise<void> {
this.canvas = canvas;
this.renderer = new THREE.WebGPURenderer({ canvas, antialias: true });
await this.renderer.init();
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
this.camera.position.set(0, 0, 5);
const { width, height } = this.canvasSize();
this.resize(width, height);
}
/**
* Registers a callback invoked once per rendered frame, before the scene is drawn.
* Returns an unsubscribe function.
*/
onTick(callback: EngineTickCallback): () => void {
this.tickCallbacks.add(callback);
return () => this.tickCallbacks.delete(callback);
}
/**
* Starts the render loop outside Angular's zone.
*/
start(): void {
if (this.running) {
return;
}
this.running = true;
this.clock.start();
this.ngZone.runOutsideAngular(() => {
this.requireInitialized(this.renderer).setAnimationLoop(() => this.tick());
});
}
/**
* Stops the render loop without disposing any resources.
*/
stop(): void {
if (!this.running) {
return;
}
this.running = false;
this.clock.stop();
this.renderer?.setAnimationLoop(null);
}
/**
* Updates the camera aspect ratio and renderer drawing buffer size.
*/
resize(width: number, height: number): void {
if (!this.renderer || !this.camera || width <= 0 || height <= 0) {
return;
}
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height, false);
}
/**
* Stops the loop and releases GPU resources. Call when the canvas host is destroyed.
*/
dispose(): void {
this.stop();
this.tickCallbacks.clear();
this.renderer?.dispose();
this.renderer = undefined;
this.scene = undefined;
this.camera = undefined;
this.canvas = undefined;
}
private tick(): void {
const deltaSeconds = this.clock.getDelta();
const elapsedSeconds = this.clock.getElapsedTime();
for (const callback of this.tickCallbacks) {
callback(deltaSeconds, elapsedSeconds);
}
this.requireInitialized(this.renderer).render(this.requireInitialized(this.scene), this.requireInitialized(this.camera));
}
private canvasSize(): { width: number; height: number } {
const canvas = this.requireInitialized(this.canvas);
return {
width: canvas.clientWidth || 1,
height: canvas.clientHeight || 1
};
}
private requireInitialized<T>(value: T | undefined): T {
if (value === undefined) {
throw new Error('EngineService used before init() completed.');
}
return value;
}
}
@@ -0,0 +1,304 @@
import { AfterViewInit, Component, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs';
import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { EngineService } from '../../core/engine/engine.service';
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 { 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 { 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']);
const GLOW_SCALE = 2.6;
/**
* Separate, focused route for inspecting a single planet/moon/exoplanet: its own scene/camera
* (via a dedicated `EngineService` instance, unrelated to the galaxy/system camera rig) plus
* an `InfoPanelComponent` showing its real NASA data. Reachable from system-view picking or
* search, and keeps `NavigationStore` in sync so returning to `/` resumes the correct system.
*
* Reacts to `ActivatedRoute.paramMap` (rather than reading the route snapshot once) because
* Angular's default route-reuse strategy keeps this component instance alive when navigating
* directly from one `/body/:id` to another (e.g. selecting a second search result while
* already on a body's detail page) — only the id param changes, not the route config.
*/
@Component({
selector: 'app-body-detail-scene',
providers: [EngineService],
imports: [InfoPanelComponent, RouterLink],
template: `
<div class="relative h-full w-full">
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
@if (viewModel()) {
<app-info-panel [body]="viewModel()!" />
} @else if (notFound()) {
<div class="absolute top-4 right-4 rounded-md border border-border bg-panel/80 p-5 font-body text-text backdrop-blur-md">
<p class="mb-2 text-sm">Couldn't find that body.</p>
<a routerLink="/" class="text-sm text-accent hover:underline">Back to the galaxy</a>
</div>
}
</div>
`
})
export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
private controls?: OrbitControls;
private scene?: THREE.Scene;
private planet?: THREE.Mesh;
private planetMaterial?: THREE.MeshStandardMaterial;
private ring?: THREE.Mesh;
private glow?: THREE.Sprite;
private resizeObserver?: ResizeObserver;
private unsubscribeTick?: () => void;
private paramSubscription?: Subscription;
private sceneReady = false;
private stars: readonly StarRecord[] = [];
private bodies: readonly BodyRecord[] = [];
private exoplanets: readonly ExoplanetRecord[] = [];
readonly viewModel = signal<BodyDetailViewModel | undefined>(undefined);
readonly notFound = signal(false);
constructor(
private readonly engine: EngineService,
private readonly dataLoader: DataLoaderService,
private readonly route: ActivatedRoute,
private readonly navigationStore: NavigationStore
) {}
ngAfterViewInit(): void {
void this.bootstrap();
}
ngOnDestroy(): void {
this.paramSubscription?.unsubscribe();
this.unsubscribeTick?.();
this.resizeObserver?.disconnect();
this.controls?.dispose();
this.planet?.geometry.dispose();
this.planetMaterial?.dispose();
this.disposeRing();
this.disposeGlow();
this.engine.dispose();
}
private async bootstrap(): Promise<void> {
const [stars, bodies, exoplanets] = await Promise.all([this.dataLoader.loadStars(), this.dataLoader.loadBodies(), this.dataLoader.loadExoplanets()]);
this.stars = stars.stars;
this.bodies = bodies;
this.exoplanets = exoplanets;
await this.initScene();
this.sceneReady = true;
this.paramSubscription = this.route.paramMap.subscribe((params) => {
this.showBody(params.get('id'));
});
}
private showBody(id: string | null): void {
if (!id) {
this.viewModel.set(undefined);
this.notFound.set(true);
return;
}
const body = this.bodies.find((candidate) => candidate.id === id);
const exoplanet = this.exoplanets.find((candidate) => candidate.id === id);
if (body) {
const hostStar = this.stars.find((star) => star.id === body.systemStarId);
this.viewModel.set({
id: body.id,
name: body.name,
kind: body.kind,
hostStarName: hostStar?.name ?? 'Unknown star',
radiusKm: body.radiusKm,
orbit: body.orbit
});
this.navigationStore.selectStar(body.systemStarId);
} else if (exoplanet) {
this.viewModel.set({
id: exoplanet.id,
name: exoplanet.name,
kind: 'exoplanet',
hostStarName: exoplanet.hostStarName,
radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * 6371 : undefined,
massEarth: exoplanet.massEarth,
discoveryYear: exoplanet.discoveryYear,
orbit: exoplanet.orbit
});
if (exoplanet.hostStarId !== null) {
this.navigationStore.selectStar(exoplanet.hostStarId);
}
} else {
this.viewModel.set(undefined);
this.notFound.set(true);
return;
}
this.notFound.set(false);
this.navigationStore.selectBody(id);
if (this.sceneReady) {
this.applyViewModelToScene();
}
}
private applyViewModelToScene(): void {
const viewModel = this.viewModel();
if (!viewModel || !this.planetMaterial) {
return;
}
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.needsUpdate = true;
this.disposeRing();
this.disposeGlow();
if (this.scene) {
if (viewModel.id === 'saturn') {
this.ring = this.buildSaturnRing();
this.scene.add(this.ring);
}
const atmosphereColor = atmosphereColorFor(viewModel.id);
if (atmosphereColor !== undefined) {
this.glow = createGlowSprite(atmosphereColor, 1, GLOW_SCALE);
this.scene.add(this.glow);
}
}
}
/**
* Saturn's rings, built from a real ring-transparency map. `RingGeometry`'s default UVs wrap
* around the angle rather than the radius, so the per-vertex U is remapped to distance from
* center — the standard fix for sampling a radially-varying ring texture correctly.
*/
private buildSaturnRing(): THREE.Mesh {
const geometry = new THREE.RingGeometry(1.4, 2.6, 128, 1);
const position = geometry.attributes['position'];
const uv = geometry.attributes['uv'];
const vertex = new THREE.Vector3();
for (let i = 0; i < position.count; i++) {
vertex.fromBufferAttribute(position, i);
const radialFraction = THREE.MathUtils.clamp((vertex.length() - 1.4) / (2.6 - 1.4), 0, 1);
uv.setXY(i, radialFraction, 1);
}
const ringTexture = loadCachedTexture(SATURN_RING_TEXTURE_PATH);
const material = new THREE.MeshBasicMaterial({
map: ringTexture,
alphaMap: ringTexture,
transparent: true,
opacity: 0.85,
side: THREE.DoubleSide,
depthWrite: false
});
const ring = new THREE.Mesh(geometry, material);
ring.rotation.x = Math.PI / 2 - THREE.MathUtils.degToRad(17);
return ring;
}
private disposeRing(): void {
if (!this.ring) {
return;
}
this.scene?.remove(this.ring);
this.ring.geometry.dispose();
(this.ring.material as THREE.Material).dispose();
this.ring = undefined;
}
private disposeGlow(): void {
if (!this.glow) {
return;
}
this.scene?.remove(this.glow);
(this.glow.material as THREE.SpriteMaterial).dispose();
this.glow = undefined;
}
private async initScene(): Promise<void> {
const canvas = this.canvasRef().nativeElement;
try {
await this.engine.init(canvas);
} catch (error) {
console.error('Failed to initialize the 3D engine.', error);
return;
}
const scene = this.engine.getScene();
this.scene = scene;
applyMilkyWaySkybox(scene, MILKY_WAY_SKYBOX_PATH);
const camera = this.engine.getCamera();
camera.position.set(0, 0.6, 3);
camera.near = 0.05;
camera.far = 100;
camera.updateProjectionMatrix();
this.controls = new OrbitControls(camera, canvas);
this.controls.enableDamping = true;
this.controls.minDistance = 1.5;
this.controls.maxDistance = 12;
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
const sunLight = new THREE.DirectionalLight(0xfff4e0, 1.6);
sunLight.position.set(4, 3, 5);
scene.add(sunLight);
const geometry = new THREE.SphereGeometry(1, 64, 48);
const viewModel = this.viewModel();
this.planetMaterial = new THREE.MeshStandardMaterial({
color: viewModel ? KIND_COLORS[viewModel.kind] : 0xffffff,
roughness: 0.85,
metalness: 0.05
});
this.planet = new THREE.Mesh(geometry, this.planetMaterial);
scene.add(this.planet);
this.observeResize(canvas);
this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(deltaSeconds));
this.engine.start();
}
private tick(deltaSeconds: number): void {
this.controls?.update();
if (this.planet) {
this.planet.rotation.y += deltaSeconds * 0.08;
}
}
private observeResize(canvas: HTMLCanvasElement): void {
this.resizeObserver = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
this.engine.resize(width, height);
});
this.resizeObserver.observe(canvas);
}
}
@@ -0,0 +1,19 @@
import { OrbitalElements } from '../../shared/models/body.model';
export type BodyDetailKind = 'planet' | 'moon' | 'dwarf' | 'exoplanet';
/**
* Flattened view model combining the fields `InfoPanelComponent` displays, regardless of
* whether the selected body came from `bodies.json` (solar-system `BodyRecord`) or
* `exoplanets.json` (`ExoplanetRecord`) — the two sources report different subsets of data.
*/
export interface BodyDetailViewModel {
id: string;
name: string;
kind: BodyDetailKind;
hostStarName: string;
radiusKm?: number;
massEarth?: number;
discoveryYear?: number;
orbit: Partial<OrbitalElements>;
}
@@ -0,0 +1,79 @@
import { DecimalPipe } from '@angular/common';
import { Component, input } from '@angular/core';
import { Router } from '@angular/router';
import { BodyDetailViewModel } from './body-detail.model';
const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
planet: 'Planet',
moon: 'Moon',
dwarf: 'Dwarf planet',
exoplanet: 'Exoplanet'
};
/**
* Displays the real NASA data for the currently selected body/exoplanet: kind, physical
* size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only —
* `BodyDetailSceneComponent` supplies the view model and owns navigation state.
*/
@Component({
selector: 'app-info-panel',
template: `
<div class="absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] rounded-md border border-border bg-panel/80 p-5 font-body text-text backdrop-blur-md">
<button
type="button"
(click)="goBack()"
class="mb-3 flex items-center gap-1.5 rounded-md border border-border bg-panel/60 px-3 py-1.5 text-xs tracking-wide text-muted uppercase transition-colors hover:border-accent hover:text-accent focus:outline-none focus:ring-1 focus:ring-accent/50"
>
<svg class="h-3.5 w-3.5" 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>
System
</button>
<h1 class="mb-0.5 font-display text-lg font-semibold tracking-wide text-text">{{ body().name }}</h1>
<p class="mb-4 text-xs tracking-wide text-accent uppercase">{{ kindLabel() }} · {{ body().hostStarName }}</p>
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
@if (body().radiusKm) {
<dt class="text-muted">Radius</dt>
<dd class="text-right text-text">{{ body().radiusKm | number: '1.0-1' }} km</dd>
}
@if (body().massEarth) {
<dt class="text-muted">Mass</dt>
<dd class="text-right text-text">{{ body().massEarth | number: '1.0-2' }} Earth masses</dd>
}
@if (body().orbit.semiMajorAxisAu) {
<dt class="text-muted">Semi-major axis</dt>
<dd class="text-right text-text">{{ body().orbit.semiMajorAxisAu | number: '1.0-4' }} AU</dd>
}
@if (body().orbit.eccentricity !== undefined) {
<dt class="text-muted">Eccentricity</dt>
<dd class="text-right text-text">{{ body().orbit.eccentricity | number: '1.0-4' }}</dd>
}
@if (body().orbit.inclinationDeg !== undefined) {
<dt class="text-muted">Inclination</dt>
<dd class="text-right text-text">{{ body().orbit.inclinationDeg | number: '1.0-2' }}°</dd>
}
@if (body().discoveryYear) {
<dt class="text-muted">Discovered</dt>
<dd class="text-right text-text">{{ body().discoveryYear }}</dd>
}
</dl>
</div>
`,
imports: [DecimalPipe]
})
export class InfoPanelComponent {
readonly body = input.required<BodyDetailViewModel>();
constructor(private readonly router: Router) {}
kindLabel(): string {
return KIND_LABELS[this.body().kind];
}
goBack(): void {
void this.router.navigate(['/']);
}
}
@@ -0,0 +1,174 @@
import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CameraPose, CameraRigController } from './camera-rig-controller';
function createRig(): { camera: THREE.PerspectiveCamera; controls: OrbitControls; rig: CameraRigController } {
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
camera.position.set(0, 0, 5);
const controls = new OrbitControls(camera, document.createElement('canvas'));
controls.target.set(0, 0, 0);
const rig = new CameraRigController(camera, controls);
return { camera, controls, rig };
}
function pose(position: [number, number, number], target: [number, number, number]): CameraPose {
return { position: new THREE.Vector3(...position), target: new THREE.Vector3(...target) };
}
describe('CameraRigController', () => {
let camera: THREE.PerspectiveCamera;
let controls: OrbitControls;
let rig: CameraRigController;
beforeEach(() => {
({ camera, controls, rig } = createRig());
});
it('is not animating and leaves controls enabled before any transition starts', () => {
expect(rig.isAnimating).toBe(false);
expect(controls.enabled).toBe(true);
});
it('flyTo starts an animation and disables the controls for its duration', () => {
rig.flyTo(pose([10, 0, 0], [1, 1, 1]), 2);
expect(rig.isAnimating).toBe(true);
expect(controls.enabled).toBe(false);
});
it('interpolates the camera position/target to the symmetric midpoint at the halfway point of the duration', () => {
rig.flyTo(pose([10, 0, 0], [2, 2, 2]), 2);
rig.update(1); // halfway through the 2s duration -> t = 0.5, eased(0.5) = 0.5 (symmetric midpoint)
expect(camera.position.x).toBeCloseTo(5, 6);
expect(camera.position.z).toBeCloseTo(2.5, 6);
expect(controls.target.x).toBeCloseTo(1, 6);
expect(rig.isAnimating).toBe(true);
});
it('eases the transition using the cubic in-out curve, not linearly', () => {
rig.flyTo(pose([8, 0, 0], [0, 0, 0]), 2);
rig.update(0.5); // t = 0.25 of the duration elapsed
// eased(0.25) = 4 * 0.25^3 = 0.0625 -> x = 0.5, well under the linear expectation of 25% (= 2).
expect(camera.position.x).toBeCloseTo(0.5, 6);
expect(camera.position.x).toBeLessThan(2);
});
it('calls lookAt toward the interpolated target on every update', () => {
const lookAtSpy = vi.spyOn(camera, 'lookAt');
rig.flyTo(pose([10, 0, 0], [2, 2, 2]), 2);
rig.update(1);
expect(lookAtSpy).toHaveBeenCalledWith(controls.target);
});
it('reaches the exact target pose, re-enables controls, and fires onComplete exactly once when the duration elapses', () => {
const onComplete = vi.fn();
rig.flyTo(pose([10, 0, 0], [2, 2, 2]), 2, onComplete);
rig.update(1); // halfway
expect(rig.isAnimating).toBe(true);
expect(onComplete).not.toHaveBeenCalled();
rig.update(1); // reaches the end exactly
expect(camera.position.x).toBeCloseTo(10, 9);
expect(camera.position.y).toBeCloseTo(0, 9);
expect(camera.position.z).toBeCloseTo(0, 9);
expect(controls.target.x).toBeCloseTo(2, 9);
expect(rig.isAnimating).toBe(false);
expect(controls.enabled).toBe(true);
expect(onComplete).toHaveBeenCalledTimes(1);
rig.update(1); // no active tween anymore -> no further calls, no throw
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('clamps overshooting deltas so the camera never travels past the destination pose', () => {
const onComplete = vi.fn();
rig.flyTo(pose([10, 0, 0], [2, 2, 2]), 2, onComplete);
rig.update(100); // way more than the whole duration in a single frame
expect(camera.position.x).toBeCloseTo(10, 9);
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('update() is a no-op while no transition is in flight', () => {
expect(() => rig.update(1)).not.toThrow();
expect(camera.position.x).toBeCloseTo(0, 9);
expect(camera.position.y).toBeCloseTo(0, 9);
expect(camera.position.z).toBeCloseTo(5, 9);
expect(controls.target.x).toBeCloseTo(0, 9);
expect(controls.target.y).toBeCloseTo(0, 9);
expect(controls.target.z).toBeCloseTo(0, 9);
expect(rig.isAnimating).toBe(false);
});
it('clones the pose passed to flyTo, so mutating the caller-owned vectors afterward does not affect the transition', () => {
const to = pose([10, 0, 0], [2, 2, 2]);
rig.flyTo(to, 2);
to.position.set(999, 999, 999);
to.target.set(999, 999, 999);
rig.update(2);
expect(camera.position.x).toBeCloseTo(10, 9);
expect(controls.target.x).toBeCloseTo(2, 9);
});
it('starting a new flyTo mid-transition replaces the old one, using the current interpolated pose as the new start', () => {
const firstOnComplete = vi.fn();
const secondOnComplete = vi.fn();
rig.flyTo(pose([10, 0, 0], [0, 0, 0]), 2, firstOnComplete);
rig.update(1); // halfway through the first tween: camera.position.x is now 5
const midPositionX = camera.position.x;
expect(midPositionX).toBeCloseTo(5, 6);
rig.flyTo(pose([0, 20, 0], [0, 0, 0]), 1, secondOnComplete);
// The new tween's "from" should be wherever the camera actually was, not the first tween's target.
rig.update(1); // completes the second (1s) tween
expect(camera.position.x).toBeCloseTo(0, 9);
expect(camera.position.y).toBeCloseTo(20, 9);
expect(firstOnComplete).not.toHaveBeenCalled();
expect(secondOnComplete).toHaveBeenCalledTimes(1);
expect(rig.isAnimating).toBe(false);
});
it('setImmediate jumps the camera/target with no easing and without requiring update()', () => {
rig.setImmediate(pose([3, 4, 5], [1, 1, 1]));
expect(camera.position.toArray()).toEqual([3, 4, 5]);
expect(controls.target.toArray()).toEqual([1, 1, 1]);
expect(rig.isAnimating).toBe(false);
});
it('setImmediate points the camera at the new target', () => {
const lookAtSpy = vi.spyOn(camera, 'lookAt');
rig.setImmediate(pose([3, 4, 5], [1, 1, 1]));
expect(lookAtSpy).toHaveBeenCalledWith(new THREE.Vector3(1, 1, 1));
});
it('treats a zero-second duration as effectively instantaneous on the next update, without dividing by zero', () => {
const onComplete = vi.fn();
rig.flyTo(pose([10, 0, 0], [2, 2, 2]), 0, onComplete);
rig.update(0.001);
expect(Number.isFinite(camera.position.x)).toBe(true);
expect(camera.position.x).toBeCloseTo(10, 9);
expect(onComplete).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,81 @@
import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
export interface CameraPose {
position: THREE.Vector3;
target: THREE.Vector3;
}
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
/**
* Drives smooth camera-position/look-at tweens between two poses. Deliberately unaware of
* parsecs vs. AU: the galaxy-to-system transition is built from two of these tweens (one per
* unit space) with a "floating-origin" recenter — an instantaneous {@link setImmediate} jump
* that swaps which group is visible and which unit scale the camera/controls operate in —
* spliced in between them by the caller (`GalaxySystemSceneComponent`).
*/
export class CameraRigController {
private active?: {
from: CameraPose;
to: CameraPose;
duration: number;
elapsed: number;
onComplete?: () => void;
};
constructor(
private readonly camera: THREE.PerspectiveCamera,
private readonly controls: OrbitControls
) {}
get isAnimating(): boolean {
return !!this.active;
}
/** Starts (replacing any in-flight tween) an eased camera-pose animation. */
flyTo(to: CameraPose, durationSeconds: number, onComplete?: () => void): void {
this.controls.enabled = false;
this.active = {
from: { position: this.camera.position.clone(), target: this.controls.target.clone() },
to: { position: to.position.clone(), target: to.target.clone() },
duration: Math.max(durationSeconds, 0.001),
elapsed: 0,
onComplete
};
}
/**
* Instantly places the camera/controls-target with no animation — the floating-origin
* recenter jump performed right after swapping which unit space/group is visible.
*/
setImmediate(pose: CameraPose): void {
this.camera.position.copy(pose.position);
this.controls.target.copy(pose.target);
this.camera.lookAt(pose.target);
}
/** Advances any in-flight tween. Call once per rendered frame. */
update(deltaSeconds: number): void {
if (!this.active) {
return;
}
this.active.elapsed += deltaSeconds;
const t = Math.min(this.active.elapsed / this.active.duration, 1);
const eased = easeInOutCubic(t);
this.camera.position.lerpVectors(this.active.from.position, this.active.to.position, eased);
this.controls.target.lerpVectors(this.active.from.target, this.active.to.target, eased);
this.camera.lookAt(this.controls.target);
if (t >= 1) {
const { onComplete } = this.active;
this.active = undefined;
this.controls.enabled = true;
onComplete?.();
}
}
}
@@ -0,0 +1,228 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import * as THREE from 'three/webgpu';
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 { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store';
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
// jsdom does not implement ResizeObserver; the component only uses it to react to real
// layout changes, which never happen in this headless test.
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver ??= class {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
};
const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 };
const ALPHA_CENTAURI: StarRecord = { id: 1, name: 'Alpha Centauri', x: 1.34, y: 0, z: 0, magnitude: 4.4, spectralType: 'G2V', colorIndex: 0.7 };
const PROXIMA: StarRecord = { id: 2, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 };
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
const EARTH: BodyRecord = {
id: 'earth',
systemStarId: SUN.id,
name: 'Earth',
kind: 'planet',
radiusKm: 6371,
orbit: {
semiMajorAxisAu: 1,
eccentricity: 0.0167,
inclinationDeg: 0,
longitudeOfAscendingNodeDeg: 0,
argumentOfPeriapsisDeg: 0,
meanAnomalyAtEpochDeg: 0,
epochJd: 2451545.0
}
};
/** Minimal stand-in for `EngineService` that skips real WebGPU/WebGL initialization entirely,
* while exposing the same tick-registration hook so tests can drive the render loop by hand. */
class FakeEngineService {
private readonly scene = new THREE.Scene();
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
private readonly tickCallbacks = new Set<EngineTickCallback>();
get isInitialized(): boolean {
return true;
}
async init(): Promise<void> {
// no-op: no real renderer/context is created in tests.
}
getScene(): THREE.Scene {
return this.scene;
}
getCamera(): THREE.PerspectiveCamera {
return this.camera;
}
onTick(callback: EngineTickCallback): () => void {
this.tickCallbacks.add(callback);
return () => this.tickCallbacks.delete(callback);
}
start(): void {}
stop(): void {}
dispose(): void {}
resize(): void {}
/** Test helper: simulates one rendered frame by invoking every registered tick callback. */
tick(deltaSeconds: number): void {
for (const callback of this.tickCallbacks) {
callback(deltaSeconds, 0);
}
}
}
class FakeDataLoaderService {
loadStars(): Promise<StarField> {
return Promise.resolve({ stars: STARS, positions: STAR_POSITIONS });
}
loadBodies(): Promise<BodyRecord[]> {
return Promise.resolve([EARTH]);
}
loadExoplanets(): Promise<ExoplanetRecord[]> {
return Promise.resolve([]);
}
}
/** Waits out several macrotask turns so chained promises (bootstrap's awaits) settle. */
async function flushAsync(turns = 8): Promise<void> {
for (let i = 0; i < turns; i++) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
/** Advances the fake render loop (and therefore any in-flight `CameraRigController` tween)
* by repeatedly ticking a small fixed step, flushing microtasks between frames so any
* `onComplete` callback's own side effects (e.g. starting the next leg of the flight) run. */
async function advanceFrames(engine: FakeEngineService, totalSeconds: number, stepSeconds = 0.05): Promise<void> {
let elapsed = 0;
while (elapsed < totalSeconds) {
engine.tick(stepSeconds);
elapsed += stepSeconds;
await flushAsync(1);
}
}
describe('GalaxySystemSceneComponent camera-flight transitions', () => {
let fixture: ComponentFixture<GalaxySystemSceneComponent>;
let engine: FakeEngineService;
let navigationStore: NavigationStore;
beforeEach(async () => {
engine = new FakeEngineService();
TestBed.configureTestingModule({
imports: [GalaxySystemSceneComponent],
providers: [
{ provide: DataLoaderService, useClass: FakeDataLoaderService },
{ provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } }
]
}).overrideComponent(GalaxySystemSceneComponent, {
set: { providers: [{ provide: EngineService, useValue: engine }] }
});
navigationStore = TestBed.inject(NavigationStore);
fixture = TestBed.createComponent(GalaxySystemSceneComponent);
fixture.detectChanges(); // triggers ngAfterViewInit -> bootstrap()
await flushAsync();
});
it('starts in the galaxy view with the system group hidden', () => {
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(navigationStore.viewLevel()).toBe('galaxy');
});
it('flies the camera into a selected star system: hides the galaxy group, shows the system group, and switches to AU-scale near/far planes', async () => {
navigationStore.selectStar(SUN.id);
await flushAsync();
// Approach leg (parsec space) + settle leg (AU space) with margin.
await advanceFrames(engine, 2.5);
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
expect(component.galaxyGroup.visible).toBe(false);
expect(component.systemGroup.visible).toBe(true);
expect(engine.getCamera().near).toBeCloseTo(0.002, 9);
expect(navigationStore.viewLevel()).toBe('system');
});
it('performs the floating-origin recenter: the camera lands close to the AU-space origin, not out at parsec-scale coordinates', async () => {
navigationStore.selectStar(ALPHA_CENTAURI.id);
await flushAsync();
await advanceFrames(engine, 2.5);
// Regardless of how far away (in parsecs) the star was, once we're in system space the
// camera must be within a few thousand AU of the origin -- never still out at the star's
// original parsec-scale distance from the Sun.
const distanceFromOrigin = engine.getCamera().position.length();
expect(distanceFromOrigin).toBeLessThan(1000);
expect(distanceFromOrigin).toBeGreaterThan(0);
});
it('flies back out to the galaxy overview and restores parsec-scale near/far planes when the selection is cleared', async () => {
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
expect(navigationStore.viewLevel()).toBe('system');
navigationStore.selectStar(null);
await flushAsync();
await advanceFrames(engine, 2.5);
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);
expect(navigationStore.viewLevel()).toBe('galaxy');
});
it('hopping directly from one system to another exits the first system before entering the second, without settling back in the galaxy view', async () => {
navigationStore.selectStar(SUN.id);
await flushAsync();
await advanceFrames(engine, 2.5);
expect(navigationStore.viewLevel()).toBe('system');
navigationStore.selectStar(ALPHA_CENTAURI.id);
await flushAsync();
await advanceFrames(engine, 3.5);
const component = fixture.componentInstance as unknown as { currentStarId: number | null };
expect(navigationStore.viewLevel()).toBe('system');
expect(component.currentStarId).toBe(ALPHA_CENTAURI.id);
});
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();
// Fire a second selection mid-flight, before the first transition has settled.
await advanceFrames(engine, 0.3);
navigationStore.selectStar(PROXIMA.id);
await flushAsync();
await advanceFrames(engine, 6);
const component = fixture.componentInstance as unknown as { currentStarId: number | null };
expect(component.currentStarId).toBe(PROXIMA.id);
expect(navigationStore.viewLevel()).toBe('system');
});
});
@@ -0,0 +1,447 @@
import { AfterViewInit, Component, effect, ElementRef, OnDestroy, 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 { DataLoaderService } from '../../core/data/data-loader.service';
import { EngineService } from '../../core/engine/engine.service';
import { BodyRecord } from '../../shared/models/body.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 { colorIndexToRgb, StarFieldRenderer } from './star-field-renderer';
import { 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 closer than this to the camera get a name label (always includes the selection). */
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 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;
const GALAXY_OVERVIEW_POSITION = new THREE.Vector3(0, 15, 30);
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;
/** How close (pc) the camera dives toward a selected star before the unit-space swap. */
const GALAXY_APPROACH_DISTANCE_PC = 0.05;
const SYSTEM_NEAR_AU = 0.002;
const SYSTEM_FAR_AU = 20000;
const SYSTEM_MIN_DISTANCE_AU = 0.05;
const SYSTEM_MAX_DISTANCE_AU = 5000;
/** Where the camera lands (AU) immediately after swapping into system space, pre-settle. */
const SYSTEM_ENTRY_DISTANCE_AU = 200;
/** How far out (AU) the camera flies before swapping back to galaxy/parsec space. */
const SYSTEM_EXIT_DISTANCE_AU = 400;
const 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 STAR_MARKER_RADIUS_AU = 0.2;
/**
* Hosts the shared galaxy + system scene: pan/zoom/rotate camera controls, click-to-select
* picking, proximity-based name labels, and — once a star is selected — a camera-flight
* transition into that star's system (real solar-system bodies for the Sun, cross-referenced
* exoplanets for other stars) with orbit ellipses and planet/moon markers. Owns its own
* `EngineService` instance.
*/
@Component({
selector: 'app-galaxy-system-scene',
providers: [EngineService],
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="absolute top-4 left-4 flex items-center gap-1.5 rounded-md border border-border bg-panel/70 px-3 py-1.5 font-body text-xs tracking-wide text-muted uppercase backdrop-blur-md transition-colors hover:border-accent hover:text-accent focus:outline-none focus:ring-1 focus:ring-accent/50"
>
<svg class="h-3.5 w-3.5" 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>
}
</div>
`
})
export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
private readonly labelHostRef = viewChild.required<ElementRef<HTMLDivElement>>('labelHost');
private readonly raycaster = new THREE.Raycaster();
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);
private controls?: OrbitControls;
private rig?: CameraRigController;
private starField?: StarFieldRenderer;
private labelOverlay?: StarLabelOverlay;
private stars: readonly StarRecord[] = [];
private starsById = new Map<number, StarRecord>();
private bodies: readonly BodyRecord[] = [];
private exoplanets: readonly ExoplanetRecord[] = [];
private resizeObserver?: ResizeObserver;
private unsubscribeTick?: () => void;
private labelUpdateAccumulator = 0;
private ready = false;
private busy = false;
/** Id of the star whose system is currently shown (or being flown to/from); null = galaxy view. */
private currentStarId: number | null = null;
private systemRenderer?: SystemOrbitsRenderer;
private starMarker?: THREE.Mesh;
private starGlow?: THREE.Sprite;
constructor(
private readonly engine: EngineService,
private readonly dataLoader: DataLoaderService,
private readonly router: Router,
readonly navigationStore: NavigationStore
) {
effect(() => {
const selectedStarId = this.navigationStore.selectedStarId();
if (this.ready) {
this.reconcileSelection(selectedStarId);
}
});
}
ngAfterViewInit(): void {
void this.bootstrap();
}
ngOnDestroy(): void {
this.unsubscribeTick?.();
this.resizeObserver?.disconnect();
this.canvasRef().nativeElement.removeEventListener('click', this.handleClick);
this.controls?.dispose();
this.starField?.dispose();
this.labelOverlay?.dispose();
this.systemRenderer?.dispose();
(this.starMarker?.material as THREE.Material | undefined)?.dispose();
(this.starGlow?.material as THREE.SpriteMaterial | undefined)?.dispose();
this.starMarkerGeometry.dispose();
this.starMarkerMaterial.dispose();
this.engine.dispose();
}
exitSystem(): void {
this.navigationStore.selectStar(null);
}
private async bootstrap(): Promise<void> {
const canvas = this.canvasRef().nativeElement;
try {
await this.engine.init(canvas);
} catch (error) {
console.error('Failed to initialize the 3D engine.', error);
return;
}
const scene = this.engine.getScene();
const camera = this.engine.getCamera();
camera.position.copy(GALAXY_OVERVIEW_POSITION);
camera.near = GALAXY_NEAR_PC;
camera.far = GALAXY_FAR_PC;
camera.updateProjectionMatrix();
this.controls = new OrbitControls(camera, canvas);
this.controls.enableDamping = true;
this.controls.minDistance = GALAXY_MIN_DISTANCE_PC;
this.controls.maxDistance = GALAXY_MAX_DISTANCE_PC;
this.controls.target.copy(GALAXY_OVERVIEW_TARGET);
this.rig = new CameraRigController(camera, this.controls);
scene.add(this.galaxyGroup, this.systemGroup);
this.systemGroup.visible = false;
applyMilkyWaySkybox(scene, MILKY_WAY_SKYBOX_PATH);
const [{ stars, positions }, bodies, exoplanets] = await Promise.all([
this.dataLoader.loadStars(),
this.dataLoader.loadBodies(),
this.dataLoader.loadExoplanets()
]);
this.stars = stars;
this.starsById = new Map(stars.map((star) => [star.id, star]));
this.bodies = bodies;
this.exoplanets = exoplanets;
this.starField = new StarFieldRenderer(stars, positions);
this.galaxyGroup.add(this.starField.object);
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('click', this.handleClick);
this.observeResize(canvas);
this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(camera, deltaSeconds));
this.engine.start();
this.ready = true;
this.reconcileSelection(this.navigationStore.selectedStarId());
}
private tick(camera: THREE.PerspectiveCamera, deltaSeconds: number): void {
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);
}
}
this.systemRenderer?.update(dateToJulianDate());
this.labelOverlay?.render(camera);
}
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;
const candidates: Array<{ star: StarRecord; distanceSq: number }> = [];
for (const star of this.stars) {
const dx = star.x - cx;
const dy = star.y - cy;
const dz = star.z - cz;
const distanceSq = dx * dx + dy * dy + dz * dz;
if (distanceSq <= maxDistanceSq || star.id === selectedId) {
candidates.push({ star, distanceSq });
}
}
candidates.sort((a, b) => a.distanceSq - b.distanceSq);
this.labelOverlay?.update(candidates.slice(0, LABEL_MAX_COUNT).map((candidate) => candidate.star));
}
private readonly handleClick = (event: MouseEvent): void => {
if (this.rig?.isAnimating) {
return;
}
const canvas = this.canvasRef().nativeElement;
const camera = this.engine.getCamera();
const rect = canvas.getBoundingClientRect();
const pointerNdc = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
this.raycaster.setFromCamera(pointerNdc, camera);
if (this.currentStarId === null) {
this.handleGalaxyClick();
} else {
this.handleSystemClick();
}
};
private handleGalaxyClick(): void {
if (!this.starField) {
return;
}
const [hit] = this.raycaster.intersectObject(this.starField.object);
const starId = hit?.index !== undefined ? this.starField.starIdAt(hit.index) : undefined;
if (starId !== undefined) {
this.navigationStore.selectStar(starId);
}
}
private handleSystemClick(): void {
if (!this.systemRenderer) {
return;
}
const [hit] = this.raycaster.intersectObjects(this.systemRenderer.pickableObjects);
const member = hit ? this.systemRenderer.memberForObject(hit.object) : undefined;
if (member) {
this.navigationStore.selectBody(member.id);
void this.router.navigate(['/body', member.id]);
}
}
/** Reacts to `NavigationStore.selectedStarId` changes coming from any source (click/search). */
private reconcileSelection(selectedStarId: number | null): void {
if (this.busy || selectedStarId === this.currentStarId) {
return;
}
this.busy = true;
if (selectedStarId === null) {
this.exitToGalaxy(() => this.finishTransition());
} else if (this.currentStarId === null) {
this.enterSystem(selectedStarId, () => this.finishTransition());
} else {
// Star-to-star: exit the current system (short outward hop) then fly into the new one.
this.exitToGalaxy(() => this.enterSystem(selectedStarId, () => this.finishTransition()), true);
}
}
/** Re-checks the store in case the selection changed again while a transition was in flight. */
private finishTransition(): void {
this.busy = false;
this.reconcileSelection(this.navigationStore.selectedStarId());
}
private enterSystem(starId: number, onComplete: () => void): void {
const star = this.starsById.get(starId);
if (!star || !this.rig) {
onComplete();
return;
}
const camera = this.engine.getCamera();
const starPc = new THREE.Vector3(star.x, star.y, star.z);
const direction = camera.position.clone().sub(this.controls!.target).normalize();
if (!Number.isFinite(direction.x) || direction.lengthSq() === 0) {
direction.set(0, 0.3, 1).normalize();
}
const approachPosition = starPc.clone().add(direction.clone().multiplyScalar(GALAXY_APPROACH_DISTANCE_PC));
this.rig.flyTo({ position: approachPosition, target: starPc }, APPROACH_DURATION_SECONDS, () => {
this.swapToSystemSpace(star, direction, onComplete);
});
}
private swapToSystemSpace(star: StarRecord, direction: THREE.Vector3, onComplete: () => void): void {
const camera = this.engine.getCamera();
this.systemRenderer?.dispose();
if (this.starMarker) {
this.systemGroup.remove(this.starMarker);
(this.starMarker.material as THREE.Material).dispose();
}
if (this.starGlow) {
this.systemGroup.remove(this.starGlow);
(this.starGlow.material as THREE.SpriteMaterial).dispose();
this.starGlow = undefined;
}
const systemBodies = this.bodies.filter((body) => body.systemStarId === star.id);
const systemExoplanets = this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id);
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets);
this.systemGroup.add(this.systemRenderer.object);
const starMarkerMaterial = this.starMarkerMaterial.clone();
const starColor = colorIndexToRgb(star.colorIndex);
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);
} else {
starMarkerMaterial.color.copy(starColor);
this.starGlow = createGlowSprite(starColor, STAR_MARKER_RADIUS_AU, SUN_GLOW_SCALE * 0.6);
}
this.starMarker = new THREE.Mesh(this.starMarkerGeometry, starMarkerMaterial);
this.systemGroup.add(this.starMarker, this.starGlow);
this.galaxyGroup.visible = false;
this.systemGroup.visible = true;
camera.near = SYSTEM_NEAR_AU;
camera.far = SYSTEM_FAR_AU;
camera.updateProjectionMatrix();
this.controls!.minDistance = SYSTEM_MIN_DISTANCE_AU;
this.controls!.maxDistance = SYSTEM_MAX_DISTANCE_AU;
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
);
this.rig!.flyTo({ position: direction.clone().multiplyScalar(framingDistance), target: new THREE.Vector3(0, 0, 0) }, SETTLE_DURATION_SECONDS, () => {
this.currentStarId = star.id;
this.navigationStore.setViewLevel('system');
onComplete();
});
}
private exitToGalaxy(onComplete: () => void, isSwitchingSystems = false): void {
if (this.currentStarId === null || !this.rig) {
onComplete();
return;
}
const camera = this.engine.getCamera();
const direction = camera.position.clone().sub(this.controls!.target).normalize();
if (!Number.isFinite(direction.x) || direction.lengthSq() === 0) {
direction.set(0, 0.3, 1).normalize();
}
const exitingStarId = this.currentStarId;
this.rig.flyTo({ position: direction.clone().multiplyScalar(SYSTEM_EXIT_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) }, EXIT_DURATION_SECONDS, () => {
this.swapToGalaxySpace(exitingStarId, direction, isSwitchingSystems, onComplete);
});
}
private swapToGalaxySpace(exitingStarId: number, direction: THREE.Vector3, isSwitchingSystems: boolean, onComplete: () => void): void {
const camera = this.engine.getCamera();
const star = this.starsById.get(exitingStarId);
const starPc = star ? new THREE.Vector3(star.x, star.y, star.z) : GALAXY_OVERVIEW_TARGET.clone();
this.systemGroup.visible = false;
this.galaxyGroup.visible = true;
camera.near = GALAXY_NEAR_PC;
camera.far = GALAXY_FAR_PC;
camera.updateProjectionMatrix();
this.controls!.minDistance = GALAXY_MIN_DISTANCE_PC;
this.controls!.maxDistance = GALAXY_MAX_DISTANCE_PC;
this.rig!.setImmediate({ position: starPc.clone().add(direction.clone().multiplyScalar(GALAXY_APPROACH_DISTANCE_PC)), target: starPc });
if (isSwitchingSystems) {
this.currentStarId = null;
onComplete();
return;
}
this.rig!.flyTo({ position: GALAXY_OVERVIEW_POSITION.clone(), target: GALAXY_OVERVIEW_TARGET.clone() }, RETURN_DURATION_SECONDS, () => {
this.currentStarId = null;
this.navigationStore.setViewLevel('galaxy');
onComplete();
});
}
private observeResize(canvas: HTMLCanvasElement): void {
this.resizeObserver = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
this.engine.resize(width, height);
this.labelOverlay?.setSize(width, height);
});
this.resizeObserver.observe(canvas);
}
}
@@ -0,0 +1,82 @@
import * as THREE from 'three/webgpu';
import { attribute } from 'three/tsl';
import { StarRecord } from '../../shared/models/star.model';
const MIN_POINT_SIZE = 1.5;
const MAX_POINT_SIZE = 6;
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);
/**
* 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.
*/
export function colorIndexToRgb(colorIndex: number): THREE.Color {
const t = THREE.MathUtils.clamp((colorIndex + 0.4) / 2.4, 0, 1);
const color = new THREE.Color();
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 {
const t = THREE.MathUtils.clamp(1 - (magnitude + 2) / 12, 0, 1);
return MIN_POINT_SIZE + t * (MAX_POINT_SIZE - MIN_POINT_SIZE);
}
/**
* 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.
*
* 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.
*/
export class StarFieldRenderer {
readonly object: THREE.Points;
private readonly geometry: THREE.BufferGeometry;
private readonly material: THREE.PointsNodeMaterial;
constructor(private readonly stars: readonly StarRecord[], positions: Float32Array) {
this.geometry = new THREE.BufferGeometry();
this.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const colors = new Float32Array(stars.length * 3);
const sizes = new Float32Array(stars.length);
stars.forEach((star, index) => {
const color = colorIndexToRgb(star.colorIndex);
colors[index * 3] = color.r;
colors[index * 3 + 1] = color.g;
colors[index * 3 + 2] = color.b;
sizes[index] = magnitudeToPointSize(star.magnitude);
});
this.geometry.setAttribute('starColor', new THREE.BufferAttribute(colors, 3));
this.geometry.setAttribute('starSize', new THREE.BufferAttribute(sizes, 1));
this.material = new THREE.PointsNodeMaterial({
colorNode: attribute('starColor', 'vec3'),
sizeNode: attribute('starSize', 'float'),
sizeAttenuation: true,
transparent: true,
depthWrite: false
});
this.object = new THREE.Points(this.geometry, this.material);
}
/** 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;
}
dispose(): void {
this.geometry.dispose();
this.material.dispose();
}
}
@@ -0,0 +1,78 @@
import * as THREE from 'three/webgpu';
import { CSS2DObject, CSS2DRenderer } from 'three/addons/renderers/CSS2DRenderer.js';
export interface LabeledPoint {
id: number;
name: string;
x: number;
y: number;
z: number;
}
/**
* Renders DOM-based (CSS2D) name labels anchored to 3D star positions. Labels are added as
* children of the main scene (so `CSS2DRenderer` can project them with the same camera) and
* diffed against the previous frame's set so the DOM is only touched when the visible set
* of stars actually changes, not every frame.
*/
export class StarLabelOverlay {
readonly domElement: HTMLElement;
private readonly cssRenderer = new CSS2DRenderer();
private readonly labelObjects = new Map<number, CSS2DObject>();
constructor(private readonly scene: THREE.Scene) {
this.cssRenderer.domElement.classList.add('star-label-layer');
this.domElement = this.cssRenderer.domElement;
}
setSize(width: number, height: number): void {
this.cssRenderer.setSize(width, height);
}
/** Shows exactly these labels, adding/removing DOM elements only for a changed set. */
update(points: readonly LabeledPoint[]): void {
const idsToShow = new Set(points.map((point) => point.id));
for (const [id, object] of this.labelObjects) {
if (!idsToShow.has(id)) {
this.removeLabel(id, object);
}
}
for (const point of points) {
if (!this.labelObjects.has(point.id)) {
this.addLabel(point);
}
}
}
render(camera: THREE.Camera): void {
this.cssRenderer.render(this.scene, camera);
}
dispose(): void {
for (const [id, object] of this.labelObjects) {
this.removeLabel(id, object);
}
}
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.
element.className = 'translate-x-1.5 -translate-y-1.5 whitespace-nowrap font-body text-[11px] text-accent [text-shadow:0_0_4px_rgba(0,0,0,0.9)]';
element.textContent = point.name;
const object = new CSS2DObject(element);
object.position.set(point.x, point.y, point.z);
this.scene.add(object);
this.labelObjects.set(point.id, object);
}
private removeLabel(id: number, object: CSS2DObject): void {
this.scene.remove(object);
object.element.remove();
this.labelObjects.delete(id);
}
}
@@ -0,0 +1,236 @@
import * as THREE from 'three/webgpu';
import { gmForParent } from '../../shared/astro/constants';
import { orbitEllipsePoints, propagateOrbit, resolveOrbitalElements } from '../../shared/astro/kepler';
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
export type SystemMemberKind = 'planet' | 'moon' | 'dwarf' | 'exoplanet';
/** A pickable marker for one rendered body/exoplanet, keyed by its own record id. */
export interface SystemMember {
id: string;
kind: SystemMemberKind;
marker: THREE.Object3D;
}
const PLANET_COLOR = new THREE.Color(0.55, 0.75, 1.0);
const DWARF_COLOR = new THREE.Color(0.8, 0.7, 0.55);
const MOON_COLOR = new THREE.Color(0.75, 0.75, 0.75);
const EXOPLANET_COLOR = new THREE.Color(0.85, 0.4, 0.85);
const ORBIT_LINE_OPACITY_BY_KIND: Record<SystemMemberKind, number> = {
planet: 0.5,
dwarf: 0.4,
moon: 0.35,
exoplanet: 0.35
};
const EARTH_RADIUS_KM = 6371;
const MIN_MARKER_RADIUS_AU = 0.012;
const MAX_MARKER_RADIUS_AU = 0.09;
/** 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;
}
return THREE.MathUtils.clamp(radiusKm / 18000, MIN_MARKER_RADIUS_AU, MAX_MARKER_RADIUS_AU);
}
function colorForKind(kind: SystemMemberKind): THREE.Color {
switch (kind) {
case 'planet':
return PLANET_COLOR;
case 'dwarf':
return DWARF_COLOR;
case 'moon':
return MOON_COLOR;
case 'exoplanet':
return EXOPLANET_COLOR;
}
}
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind): THREE.Line {
const points = orbitEllipsePoints(elements);
const positions = new Float32Array(points.length * 3);
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;
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const material = new THREE.LineBasicMaterial({
color: colorForKind(kind),
transparent: true,
opacity: ORBIT_LINE_OPACITY_BY_KIND[kind]
});
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) });
return new THREE.Mesh(geometry, material);
}
interface TrackedTopLevelBody {
id: string;
kind: SystemMemberKind;
elements: OrbitalElements;
gmAu3PerDay2: number;
marker: THREE.Mesh;
/** AU position last computed for this body; moons read their parent's here. */
position: THREE.Vector3;
}
interface TrackedMoon {
id: string;
elements: OrbitalElements;
gmAu3PerDay2: number;
marker: THREE.Mesh;
pivot: THREE.Group;
parentId: string;
}
/**
* Builds and animates the orbit ellipses + planet/moon/exoplanet markers for one star system,
* in AU, with the star itself at the origin. Moons are parented to a pivot group that tracks
* their planet's live position each tick, so their (small, planet-relative) orbit ellipse and
* marker never need to be rebuilt.
*/
export class SystemOrbitsRenderer {
readonly object = new THREE.Group();
readonly members: readonly SystemMember[];
/** Largest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
readonly maxTopLevelSemiMajorAxisAu: number;
private readonly topLevelBodies: TrackedTopLevelBody[] = [];
private readonly moons: TrackedMoon[] = [];
private readonly disposables: Array<{ geometry: THREE.BufferGeometry; material: THREE.Material }> = [];
constructor(bodies: readonly BodyRecord[], exoplanets: readonly ExoplanetRecord[]) {
const members: SystemMember[] = [];
const topLevelBodiesById = new Map<string, BodyRecord>();
for (const body of bodies) {
if (!body.parentBodyId) {
topLevelBodiesById.set(body.id, body);
}
}
for (const body of bodies) {
if (body.parentBodyId) {
continue;
}
// 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);
members.push({ id: body.id, kind, marker: tracked.marker });
}
for (const body of bodies) {
if (!body.parentBodyId) {
continue;
}
const parent = topLevelBodiesById.get(body.parentBodyId);
const parentTracked = parent && this.topLevelBodies.find((tracked) => tracked.id === parent.id);
if (!parentTracked) {
continue; // orphaned moon reference; skip rather than crash.
}
const moon = this.addMoon(body.id, body.orbit, gmForParent(body.parentBodyId), body.radiusKm, parentTracked);
members.push({ id: body.id, kind: 'moon', marker: moon.marker });
}
for (const exoplanet of exoplanets) {
if (!exoplanet.orbit.semiMajorAxisAu || exoplanet.orbit.eccentricity === undefined) {
continue; // not enough data to place on an orbit.
}
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 radiusKm = exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined;
const tracked = this.addTopLevelBody(exoplanet.id, 'exoplanet', elements, gmForParent(undefined), radiusKm);
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);
}
/** 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.
body.marker.position.copy(body.position);
}
for (const moon of this.moons) {
const parent = this.topLevelBodies.find((body) => body.id === moon.parentId);
if (!parent) {
continue;
}
moon.pivot.position.copy(parent.position);
const { x, y, z } = propagateOrbit(moon.elements, moon.gmAu3PerDay2, epochJd);
moon.marker.position.set(x, z, y);
}
}
/** Looks up which system member a marker object belongs to (e.g. from a raycast hit). */
memberForObject(object: THREE.Object3D): SystemMember | undefined {
return this.members.find((member) => member.marker === object);
}
/** All marker objects, for raycasting. */
get pickableObjects(): THREE.Object3D[] {
return this.members.map((member) => member.marker);
}
dispose(): void {
for (const { geometry, material } of this.disposables) {
geometry.dispose();
material.dispose();
}
}
private addTopLevelBody(id: string, kind: SystemMemberKind, elements: OrbitalElements, gmAu3PerDay2: number, radiusKm: number | undefined): TrackedTopLevelBody {
const orbitLine = buildOrbitLine(elements, kind);
const marker = buildMarker(kind, radiusKm);
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() };
this.topLevelBodies.push(tracked);
return tracked;
}
private addMoon(id: string, elements: OrbitalElements, gmAu3PerDay2: number, radiusKm: number | undefined, parent: TrackedTopLevelBody): TrackedMoon {
const pivot = new THREE.Group();
const orbitLine = buildOrbitLine(elements, 'moon');
const marker = buildMarker('moon', radiusKm);
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 };
this.moons.push(moon);
return moon;
}
private trackDisposable(geometry: THREE.BufferGeometry, material: THREE.Material): void {
this.disposables.push({ geometry, material });
}
}
View File
+141
View File
@@ -0,0 +1,141 @@
import { Component, computed, signal } from '@angular/core';
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;
}
const MAX_RESULTS = 8;
const MIN_QUERY_LENGTH = 2;
const KIND_LABELS: Record<SearchResultKind, string> = {
star: 'Star',
body: 'Body',
exoplanet: 'Exoplanet'
};
/**
* Name search across stars, solar-system bodies, and exoplanets. Selecting a star result
* jumps straight into that system's view; selecting a body/exoplanet result navigates to its
* detail route — both go through `NavigationStore`/the router so behavior matches an
* in-scene click, per the "consistent state across views" requirement.
*/
@Component({
selector: 'app-search',
template: `
<div class="fixed top-4 left-1/2 z-20 w-[22rem] max-w-[calc(100%-2rem)] -translate-x-1/2 font-body">
<div class="relative">
<svg class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
</svg>
<input
type="text"
placeholder="Search stars, planets, exoplanets…"
[value]="query()"
(input)="onInput($event)"
(keydown.escape)="clear()"
class="w-full rounded-md border border-border bg-panel/85 py-2 pl-9 pr-3 text-sm text-text backdrop-blur-md transition-colors placeholder:text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/50"
/>
</div>
@if (results().length) {
<ul data-testid="search-results" class="mt-1 divide-y divide-border/40 overflow-hidden rounded-md border border-border bg-panel/90 backdrop-blur-md">
@for (result of results(); track result.bodyId ?? result.starId) {
<li>
<button
type="button"
(click)="select(result)"
class="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors hover:bg-accent/10 focus:bg-accent/10 focus:outline-none"
>
<span class="text-sm text-text">{{ result.name }}</span>
<span class="text-xs tracking-wide text-muted uppercase">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span>
</button>
</li>
}
</ul>
}
</div>
`
})
export class SearchComponent {
readonly query = signal('');
private readonly index = signal<SearchEntry[]>([]);
readonly results = computed(() => {
const query = this.query().trim().toLowerCase();
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;
});
constructor(
private readonly dataLoader: DataLoaderService,
private readonly navigationStore: NavigationStore,
private readonly router: Router
) {
void this.buildIndex();
}
onInput(event: Event): void {
this.query.set((event.target as HTMLInputElement).value);
}
clear(): void {
this.query.set('');
}
kindLabel(kind: SearchResultKind): string {
return KIND_LABELS[kind];
}
select(result: SearchEntry): void {
this.clear();
if (result.kind === 'star' && result.starId !== undefined) {
this.navigationStore.selectStar(result.starId);
void this.router.navigate(['/']);
} else if (result.bodyId) {
void this.router.navigate(['/body', result.bodyId]);
}
}
private async buildIndex(): Promise<void> {
try {
const [{ stars }, bodies, exoplanets] = await Promise.all([
this.dataLoader.loadStars(),
this.dataLoader.loadBodies(),
this.dataLoader.loadExoplanets()
]);
const entries: SearchEntry[] = [
...stars.map((star): SearchEntry => ({ kind: 'star', name: star.name, subtitle: star.spectralType, starId: star.id })),
...bodies.map((body): SearchEntry => ({ kind: 'body', name: body.name, subtitle: body.kind, bodyId: body.id })),
...exoplanets.map((exoplanet): SearchEntry => ({ kind: 'exoplanet', name: exoplanet.name, subtitle: exoplanet.hostStarName, bodyId: exoplanet.id }))
];
this.index.set(entries);
} catch (error) {
console.error('Failed to build the search index.', error);
}
}
}
+52
View File
@@ -0,0 +1,52 @@
/** Astronomical unit conversion and gravitational constants shared by the astro math modules. */
/** Number of astronomical units in one parsec (IAU exact definition). */
export const AU_PER_PARSEC = 206264.80624709636;
/**
* Reference epoch (Julian date, J2000.0) used when orbital data lacks an explicit epoch —
* e.g. exoplanets from the NASA Exoplanet Archive only report a handful of elements
* (semi-major axis, eccentricity, sometimes argument of periapsis), not a mean-anomaly/epoch
* pair. Defaulting the missing epoch to J2000 still lets the body's real orbital period
* carry it around a plausible (if not phase-accurate) orbit over time.
*/
export const DEFAULT_EPOCH_JD = 2451545.0;
/**
* Heliocentric gravitational parameter (GM of the Sun), in AU^3/day^2 — the square of the
* Gaussian gravitational constant `k = 0.01720209895 rad/day`. Used to derive a body's mean
* motion from its semi-major axis via Kepler's third law.
*/
export const GM_SUN_AU3_PER_DAY2 = 0.01720209895 * 0.01720209895;
/**
* Approximate planet/Sun mass ratios for the major planets that host moons in `bodies.json`.
* Used to derive each planet's gravitational parameter (for propagating its moons) as
* `GM_SUN_AU3_PER_DAY2 * massRatio`. Precise enough for visualization; not JPL-grade.
*/
const PLANET_TO_SUN_MASS_RATIO: Record<string, number> = {
earth: 3.003e-6,
mars: 3.227e-7,
jupiter: 9.545e-4,
saturn: 2.857e-4,
uranus: 4.365e-5,
neptune: 5.151e-5
};
/**
* Gravitational parameter (AU^3/day^2) to use when propagating a body's orbit: the Sun's
* for planets/dwarfs/exoplanets, or the host planet's (derived from its Sun mass ratio) for
* moons. Falls back to the Sun's GM if `parentBodyId` isn't a known planet.
*/
export function gmForParent(parentBodyId: string | undefined): number {
if (!parentBodyId) {
return GM_SUN_AU3_PER_DAY2;
}
const massRatio = PLANET_TO_SUN_MASS_RATIO[parentBodyId];
return massRatio ? GM_SUN_AU3_PER_DAY2 * massRatio : GM_SUN_AU3_PER_DAY2;
}
/** Converts a JS `Date` into a Julian date (days), for driving the Kepler propagator "now". */
export function dateToJulianDate(date: Date = new Date()): number {
return date.getTime() / 86400000 + 2440587.5;
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { distanceBetween, parallaxMasToParsecs, raDecDistanceToXyz, 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).
describe('raDecDistanceToXyz', () => {
it('matches the HYG reference position for Sirius', () => {
const result = raDecDistanceToXyz(6.752481, -16.716116, 2.6371);
expect(result.x).toBeCloseTo(-0.494323, 3);
expect(result.y).toBeCloseTo(2.476731, 3);
expect(result.z).toBeCloseTo(-0.758485, 3);
});
it('matches the HYG reference position for Proxima Centauri', () => {
const result = raDecDistanceToXyz(14.495985, -62.679485, 1.2959);
expect(result.x).toBeCloseTo(-0.472264, 3);
expect(result.y).toBeCloseTo(-0.361451, 3);
expect(result.z).toBeCloseTo(-1.151219, 3);
});
it('places a star on RA 6h / Dec 0 entirely on the +Y axis', () => {
const result = raDecDistanceToXyz(6, 0, 10);
expect(result.x).toBeCloseTo(0, 9);
expect(result.y).toBeCloseTo(10, 9);
expect(result.z).toBeCloseTo(0, 9);
});
it('places the vernal equinox direction entirely on the +X axis', () => {
const result = raDecDistanceToXyz(0, 0, 10);
expect(result.x).toBeCloseTo(10, 9);
expect(result.y).toBeCloseTo(0, 9);
expect(result.z).toBeCloseTo(0, 9);
});
});
describe('raDegDecDistanceToXyz', () => {
it('is equivalent to raDecDistanceToXyz with RA converted from degrees to hours', () => {
const fromHours = raDecDistanceToXyz(6.752481, -16.716116, 2.6371);
const fromDegrees = raDegDecDistanceToXyz(6.752481 * 15, -16.716116, 2.6371);
expect(fromDegrees.x).toBeCloseTo(fromHours.x, 9);
expect(fromDegrees.y).toBeCloseTo(fromHours.y, 9);
expect(fromDegrees.z).toBeCloseTo(fromHours.z, 9);
});
});
describe('parallaxMasToParsecs', () => {
it('converts a positive parallax to the expected distance', () => {
expect(parallaxMasToParsecs(769.33)).toBeCloseTo(1.3, 2); // Proxima Centauri
});
it('returns Infinity for zero or negative parallax', () => {
expect(parallaxMasToParsecs(0)).toBe(Infinity);
expect(parallaxMasToParsecs(-5)).toBe(Infinity);
});
});
describe('distanceBetween', () => {
it('computes the Euclidean distance between two points', () => {
expect(distanceBetween({ x: 0, y: 0, z: 0 }, { x: 3, y: 4, z: 0 })).toBeCloseTo(5, 9);
});
});
+53
View File
@@ -0,0 +1,53 @@
const DEG_TO_RAD = Math.PI / 180;
const HOURS_TO_DEG = 15;
export interface CartesianCoordinates {
x: number;
y: number;
z: number;
}
/**
* Converts right ascension (hours), declination (degrees) and distance (parsecs) into
* equatorial Cartesian coordinates (parsecs). Matches the HYG database convention:
* +X toward the vernal equinox (epoch 2000), +Z toward the north celestial pole,
* +Y toward RA 6h / Dec 0.
*/
export function raDecDistanceToXyz(raHours: number, decDeg: number, distancePc: number): CartesianCoordinates {
const raRad = raHours * HOURS_TO_DEG * DEG_TO_RAD;
const decRad = decDeg * DEG_TO_RAD;
const cosDec = Math.cos(decRad);
return {
x: distancePc * cosDec * Math.cos(raRad),
y: distancePc * cosDec * Math.sin(raRad),
z: distancePc * Math.sin(decRad)
};
}
/**
* Same conversion as {@link raDecDistanceToXyz}, but for sources (e.g. exoplanet host
* stars, deep-sky catalogs) that report right ascension in degrees rather than hours.
*/
export function raDegDecDistanceToXyz(raDeg: number, decDeg: number, distancePc: number): CartesianCoordinates {
return raDecDistanceToXyz(raDeg / HOURS_TO_DEG, decDeg, distancePc);
}
/**
* Converts a parallax (milliarcseconds) into a distance in parsecs.
* Returns `Infinity` for non-positive parallax (unmeasured/negative parallax).
*/
export function parallaxMasToParsecs(parallaxMas: number): number {
return parallaxMas > 0 ? 1000 / parallaxMas : Infinity;
}
/**
* Euclidean distance (parsecs) between two Cartesian points, e.g. for nearest-neighbour
* star matching during exoplanet host-star cross-referencing.
*/
export function distanceBetween(a: CartesianCoordinates, b: CartesianCoordinates): number {
const dx = a.x - b.x;
const dy = a.y - b.y;
const dz = a.z - b.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { buildStarNameIndex, normalizeStarName, resolveHostStarId } from './host-star-matching';
import { StarRecord } from '../models/star.model';
// A small fixture standing in for a slice of the HYG star index, used to exercise the
// exoplanet host-star cross-referencing logic without hitting any real API.
const FIXTURE_STARS: StarRecord[] = [
{ id: 1, name: 'Proxima Centauri', x: -0.472264, y: -0.361451, z: -1.151219, magnitude: 11.01, spectralType: 'M5Ve', colorIndex: 1.807 },
{ id: 2, name: 'Sirius', x: -0.494323, y: 2.476731, z: -0.758485, magnitude: -1.44, spectralType: 'A0m...', colorIndex: 0.009 },
{ id: 3, name: 'GJ 3512', x: 3.0, y: 4.0, z: 5.0, magnitude: 11.0, spectralType: 'M5.5', colorIndex: 1.6 }
];
describe('normalizeStarName', () => {
it('lowercases and strips non-alphanumeric characters', () => {
expect(normalizeStarName('GJ 3512')).toBe('gj3512');
expect(normalizeStarName('Proxima Centauri')).toBe('proximacentauri');
});
});
describe('resolveHostStarId', () => {
it('matches by exact (normalized) host star name', () => {
const id = resolveHostStarId({ hostname: 'Proxima Centauri', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS, 0.5);
expect(id).toBe(1);
});
it('matches by name regardless of case/spacing differences', () => {
const id = resolveHostStarId({ hostname: 'gj3512', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS, 0.5);
expect(id).toBe(3);
});
it('falls back to nearest-neighbour position matching when the name is unknown', () => {
// Slightly off from Sirius's exact position, within tolerance.
const id = resolveHostStarId({ hostname: 'Sirius A', raDeg: 101.29, decDeg: -16.72, distancePc: 2.64 }, FIXTURE_STARS, 0.5);
expect(id).toBe(2);
});
it('returns null when no name match and no star is within tolerance', () => {
const id = resolveHostStarId({ hostname: 'Unknown Star XYZ', raDeg: 0, decDeg: 0, distancePc: 100 }, FIXTURE_STARS, 0.5);
expect(id).toBeNull();
});
it('returns null when there is no name match and no position is available', () => {
const id = resolveHostStarId({ hostname: 'Unknown Star XYZ', raDeg: NaN, decDeg: NaN, distancePc: NaN }, FIXTURE_STARS, 0.5);
expect(id).toBeNull();
});
it('picks the closest star when more than one falls within tolerance', () => {
const stars: StarRecord[] = [
{ id: 10, name: 'Near', x: 0, y: 0, z: 0, magnitude: 5, spectralType: 'G', colorIndex: 0.5 },
{ id: 11, name: 'Far', x: 0.4, y: 0, z: 0, magnitude: 5, spectralType: 'G', colorIndex: 0.5 }
];
const nameIndex = buildStarNameIndex(stars);
const id = resolveHostStarId({ hostname: 'Unmatched', raDeg: 0, decDeg: 0, distancePc: 0.2 }, stars, 0.5, nameIndex);
expect(id).toBe(10);
});
});
@@ -0,0 +1,59 @@
import { CartesianCoordinates, distanceBetween, raDegDecDistanceToXyz } from './coordinates';
import { StarRecord } from '../models/star.model';
/** Normalizes a star name for comparison: lowercase, alphanumeric characters only. */
export function normalizeStarName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]/g, '');
}
export interface HostStarQuery {
hostname: string;
raDeg: number;
decDeg: number;
distancePc: number;
}
/** Builds a lookup of normalized star name -> star, for fast repeated name matching. */
export function buildStarNameIndex(stars: readonly StarRecord[]): Map<string, StarRecord> {
return new Map(stars.map((star) => [normalizeStarName(star.name), star]));
}
/**
* Cross-references an exoplanet host star to the HYG star index: first by (normalized)
* name, then by nearest-neighbour position matching within `toleranceInPc`. Returns `null`
* when neither approach finds a confident match, rather than guessing.
*
* `nameIndex` should be built once (via {@link buildStarNameIndex}) and reused across calls
* when resolving many queries against the same star list.
*/
export function resolveHostStarId(
query: HostStarQuery,
stars: readonly StarRecord[],
toleranceInPc: number,
nameIndex: Map<string, StarRecord> = buildStarNameIndex(stars)
): number | null {
const byName = nameIndex.get(normalizeStarName(query.hostname));
if (byName) {
return byName.id;
}
if (![query.raDeg, query.decDeg, query.distancePc].every(Number.isFinite)) {
return null;
}
const hostPosition = raDegDecDistanceToXyz(query.raDeg, query.decDeg, query.distancePc);
return findNearestStarWithin(hostPosition, stars, toleranceInPc);
}
function findNearestStarWithin(position: CartesianCoordinates, stars: readonly StarRecord[], toleranceInPc: number): number | null {
let closest: { id: number; distance: number } | null = null;
for (const star of stars) {
const distance = distanceBetween(position, star);
if (distance <= toleranceInPc && (!closest || distance < closest.distance)) {
closest = { id: star.id, distance };
}
}
return closest ? closest.id : null;
}
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';
import { GM_SUN_AU3_PER_DAY2, DEFAULT_EPOCH_JD } from './constants';
import {
meanMotionRadPerDay,
orbitEllipsePoints,
orbitalPeriodDays,
positionAtTrueAnomaly,
propagateOrbit,
resolveOrbitalElements,
solveEccentricAnomaly,
trueAnomalyFromEccentricAnomaly
} from './kepler';
// Earth's actual orbital elements (osculating, ~J2000), used as a real-world reference case.
const EARTH_ELEMENTS = {
semiMajorAxisAu: 1.00000011,
eccentricity: 0.01671022,
inclinationDeg: 0.00005,
longitudeOfAscendingNodeDeg: -11.26064,
argumentOfPeriapsisDeg: 102.94719,
meanAnomalyAtEpochDeg: 100.46435,
epochJd: DEFAULT_EPOCH_JD
};
describe('solveEccentricAnomaly', () => {
it('satisfies Keplers equation for a range of eccentricities', () => {
for (const eccentricity of [0, 0.0167, 0.3, 0.6, 0.9]) {
for (const meanAnomalyRad of [0, 0.5, 1.5, 3.0, 5.5]) {
const e = solveEccentricAnomaly(meanAnomalyRad, eccentricity);
const residual = e - eccentricity * Math.sin(e) - meanAnomalyRad;
// residual is computed against the (possibly un-normalized) input, but the solver
// normalizes internally, so compare against the normalized mean anomaly instead.
const normalizedMeanAnomaly = ((meanAnomalyRad % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
expect(e - eccentricity * Math.sin(e)).toBeCloseTo(normalizedMeanAnomaly, 6);
expect(Number.isFinite(residual)).toBe(true);
}
}
});
});
describe('trueAnomalyFromEccentricAnomaly', () => {
it('returns 0 at periapsis and pi at apoapsis', () => {
expect(trueAnomalyFromEccentricAnomaly(0, 0.3)).toBeCloseTo(0, 9);
expect(trueAnomalyFromEccentricAnomaly(Math.PI, 0.3)).toBeCloseTo(Math.PI, 9);
});
it('matches the eccentric anomaly exactly for a circular orbit', () => {
expect(trueAnomalyFromEccentricAnomaly(1.234, 0)).toBeCloseTo(1.234, 9);
});
});
describe('positionAtTrueAnomaly', () => {
it('places a circular, unrotated orbit at radius = semiMajorAxisAu for every true anomaly', () => {
const circular = resolveOrbitalElements({ semiMajorAxisAu: 2.5, eccentricity: 0 });
for (const trueAnomalyRad of [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2]) {
const { x, y, z } = positionAtTrueAnomaly(circular, trueAnomalyRad);
expect(Math.sqrt(x * x + y * y + z * z)).toBeCloseTo(2.5, 9);
}
});
it('reaches periapsis distance a*(1-e) and apoapsis distance a*(1+e)', () => {
const elements = resolveOrbitalElements({ semiMajorAxisAu: 10, eccentricity: 0.2 });
const periapsis = positionAtTrueAnomaly(elements, 0);
const apoapsis = positionAtTrueAnomaly(elements, Math.PI);
expect(Math.hypot(periapsis.x, periapsis.y, periapsis.z)).toBeCloseTo(8, 9);
expect(Math.hypot(apoapsis.x, apoapsis.y, apoapsis.z)).toBeCloseTo(12, 9);
});
it('tilts a 90-degree-inclined orbit entirely onto the z axis at true anomaly 90 degrees', () => {
const elements = resolveOrbitalElements({ semiMajorAxisAu: 1, eccentricity: 0, inclinationDeg: 90 });
const { x, y, z } = positionAtTrueAnomaly(elements, Math.PI / 2);
expect(x).toBeCloseTo(0, 9);
expect(y).toBeCloseTo(0, 9);
expect(z).toBeCloseTo(1, 9);
});
});
describe('meanMotionRadPerDay / orbitalPeriodDays', () => {
it('reproduces Earths ~365.25-day year from its semi-major axis', () => {
const period = orbitalPeriodDays(EARTH_ELEMENTS.semiMajorAxisAu, GM_SUN_AU3_PER_DAY2);
expect(period).toBeCloseTo(365.25, 0);
});
it('is the inverse of orbitalPeriodDays', () => {
const n = meanMotionRadPerDay(1, GM_SUN_AU3_PER_DAY2);
const period = orbitalPeriodDays(1, GM_SUN_AU3_PER_DAY2);
expect(n * period).toBeCloseTo(2 * Math.PI, 9);
});
});
describe('propagateOrbit', () => {
it('reduces to the instantaneous position at the elements own epoch', () => {
const eccentricAnomalyRad = solveEccentricAnomaly((EARTH_ELEMENTS.meanAnomalyAtEpochDeg * Math.PI) / 180, EARTH_ELEMENTS.eccentricity);
const trueAnomalyRad = trueAnomalyFromEccentricAnomaly(eccentricAnomalyRad, EARTH_ELEMENTS.eccentricity);
const expected = positionAtTrueAnomaly(EARTH_ELEMENTS, trueAnomalyRad);
const actual = propagateOrbit(EARTH_ELEMENTS, GM_SUN_AU3_PER_DAY2, EARTH_ELEMENTS.epochJd);
expect(actual.x).toBeCloseTo(expected.x, 9);
expect(actual.y).toBeCloseTo(expected.y, 9);
expect(actual.z).toBeCloseTo(expected.z, 9);
});
it('stays within the periapsis/apoapsis distance bounds after propagating forward a year', () => {
const period = orbitalPeriodDays(EARTH_ELEMENTS.semiMajorAxisAu, GM_SUN_AU3_PER_DAY2);
const { x, y, z } = propagateOrbit(EARTH_ELEMENTS, GM_SUN_AU3_PER_DAY2, EARTH_ELEMENTS.epochJd + period * 0.37);
const distance = Math.hypot(x, y, z);
const { semiMajorAxisAu: a, eccentricity: e } = EARTH_ELEMENTS;
expect(distance).toBeGreaterThanOrEqual(a * (1 - e) - 1e-6);
expect(distance).toBeLessThanOrEqual(a * (1 + e) + 1e-6);
});
it('returns to (very nearly) the same position after exactly one full orbital period', () => {
const period = orbitalPeriodDays(EARTH_ELEMENTS.semiMajorAxisAu, GM_SUN_AU3_PER_DAY2);
const start = propagateOrbit(EARTH_ELEMENTS, GM_SUN_AU3_PER_DAY2, EARTH_ELEMENTS.epochJd + 12.3);
const afterOneOrbit = propagateOrbit(EARTH_ELEMENTS, GM_SUN_AU3_PER_DAY2, EARTH_ELEMENTS.epochJd + 12.3 + period);
expect(afterOneOrbit.x).toBeCloseTo(start.x, 6);
expect(afterOneOrbit.y).toBeCloseTo(start.y, 6);
expect(afterOneOrbit.z).toBeCloseTo(start.z, 6);
});
});
describe('orbitEllipsePoints', () => {
it('samples a closed loop whose distances stay within the periapsis/apoapsis bounds', () => {
const elements = resolveOrbitalElements({ semiMajorAxisAu: 5, eccentricity: 0.4 });
const points = orbitEllipsePoints(elements, 64);
expect(points).toHaveLength(65);
for (const { x, y, z } of points) {
const distance = Math.hypot(x, y, z);
expect(distance).toBeGreaterThanOrEqual(5 * (1 - 0.4) - 1e-9);
expect(distance).toBeLessThanOrEqual(5 * (1 + 0.4) + 1e-9);
}
// The first and last sampled points (true anomaly 0 and 2*pi) should coincide.
expect(points[0].x).toBeCloseTo(points[64].x, 9);
expect(points[0].y).toBeCloseTo(points[64].y, 9);
expect(points[0].z).toBeCloseTo(points[64].z, 9);
});
});
describe('resolveOrbitalElements', () => {
it('defaults missing angles to 0 and the missing epoch to J2000', () => {
const resolved = resolveOrbitalElements({ semiMajorAxisAu: 1.5, eccentricity: 0.1 });
expect(resolved.inclinationDeg).toBe(0);
expect(resolved.longitudeOfAscendingNodeDeg).toBe(0);
expect(resolved.argumentOfPeriapsisDeg).toBe(0);
expect(resolved.meanAnomalyAtEpochDeg).toBe(0);
expect(resolved.epochJd).toBe(DEFAULT_EPOCH_JD);
});
it('preserves explicitly provided fields', () => {
const resolved = resolveOrbitalElements({ semiMajorAxisAu: 1.5, eccentricity: 0.1, argumentOfPeriapsisDeg: 50 });
expect(resolved.argumentOfPeriapsisDeg).toBe(50);
});
});
+136
View File
@@ -0,0 +1,136 @@
import { CartesianCoordinates } from './coordinates';
import { DEFAULT_EPOCH_JD } from './constants';
import { OrbitalElements } from '../models/body.model';
const DEG_TO_RAD = Math.PI / 180;
const TWO_PI = Math.PI * 2;
/**
* Fills in the elements the Kepler propagator needs but that some sources (e.g. exoplanets,
* see `ExoplanetRecord.orbit: Partial<OrbitalElements>`) don't report: inclination, longitude
* of ascending node, mean anomaly at epoch, and the epoch itself. Missing angles default to
* zero (a face-on, unrotated ellipse) and the missing epoch defaults to J2000 — enough to draw
* a plausible, period-correct orbit even without full data.
*/
export function resolveOrbitalElements(partial: Partial<OrbitalElements> & Pick<OrbitalElements, 'semiMajorAxisAu' | 'eccentricity'>): OrbitalElements {
return {
semiMajorAxisAu: partial.semiMajorAxisAu,
eccentricity: partial.eccentricity,
inclinationDeg: partial.inclinationDeg ?? 0,
longitudeOfAscendingNodeDeg: partial.longitudeOfAscendingNodeDeg ?? 0,
argumentOfPeriapsisDeg: partial.argumentOfPeriapsisDeg ?? 0,
meanAnomalyAtEpochDeg: partial.meanAnomalyAtEpochDeg ?? 0,
epochJd: partial.epochJd ?? DEFAULT_EPOCH_JD
};
}
/** Mean motion (rad/day) of a body via Kepler's third law: n = sqrt(GM / a^3). */
export function meanMotionRadPerDay(semiMajorAxisAu: number, gmAu3PerDay2: number): number {
return Math.sqrt(gmAu3PerDay2 / (semiMajorAxisAu * semiMajorAxisAu * semiMajorAxisAu));
}
/** Orbital period (days) of a body via Kepler's third law: T = 2*pi / n. */
export function orbitalPeriodDays(semiMajorAxisAu: number, gmAu3PerDay2: number): number {
return TWO_PI / meanMotionRadPerDay(semiMajorAxisAu, gmAu3PerDay2);
}
/** Normalizes an angle (radians) into [0, 2*pi). */
function normalizeAngle(angleRad: number): number {
const wrapped = angleRad % TWO_PI;
return wrapped < 0 ? wrapped + TWO_PI : wrapped;
}
/**
* Solves Kepler's equation `M = E - e*sin(E)` for the eccentric anomaly `E` (radians) via
* Newton-Raphson iteration.
*/
export function solveEccentricAnomaly(meanAnomalyRad: number, eccentricity: number, tolerance = 1e-8, maxIterations = 30): number {
const m = normalizeAngle(meanAnomalyRad);
let e = eccentricity < 0.8 ? m : Math.PI;
for (let i = 0; i < maxIterations; i++) {
const delta = (e - eccentricity * Math.sin(e) - m) / (1 - eccentricity * Math.cos(e));
e -= delta;
if (Math.abs(delta) < tolerance) {
break;
}
}
return e;
}
/** Converts an eccentric anomaly (radians) into the true anomaly (radians). */
export function trueAnomalyFromEccentricAnomaly(eccentricAnomalyRad: number, eccentricity: number): number {
const cosE = Math.cos(eccentricAnomalyRad);
const sinE = Math.sin(eccentricAnomalyRad);
return Math.atan2(Math.sqrt(1 - eccentricity * eccentricity) * sinE, cosE - eccentricity);
}
/**
* Places a point at the given true anomaly (radians) along the orbit described by
* `elements`, in AU, relative to the central body (the Sun for planets/dwarfs, the host
* planet for moons — see `BodyRecord.parentBodyId`). Standard perifocal-to-reference-frame
* rotation: argument of periapsis, then inclination, then longitude of ascending node.
*/
export function positionAtTrueAnomaly(elements: OrbitalElements, trueAnomalyRad: number): CartesianCoordinates {
const { semiMajorAxisAu: a, eccentricity: e } = elements;
const semiLatusRectum = a * (1 - e * e);
const radius = semiLatusRectum / (1 + e * Math.cos(trueAnomalyRad));
// Position in the perifocal (orbital-plane) frame: +x toward periapsis.
const xPerifocal = radius * Math.cos(trueAnomalyRad);
const yPerifocal = radius * Math.sin(trueAnomalyRad);
const omega = elements.argumentOfPeriapsisDeg * DEG_TO_RAD; // argument of periapsis
const inclination = elements.inclinationDeg * DEG_TO_RAD;
const raan = elements.longitudeOfAscendingNodeDeg * DEG_TO_RAD; // right ascension of ascending node
const cosOmega = Math.cos(omega);
const sinOmega = Math.sin(omega);
const cosInclination = Math.cos(inclination);
const sinInclination = Math.sin(inclination);
const cosRaan = Math.cos(raan);
const sinRaan = Math.sin(raan);
// Rotate by argument of periapsis within the orbital plane first.
const xOrbitPlane = xPerifocal * cosOmega - yPerifocal * sinOmega;
const yOrbitPlane = xPerifocal * sinOmega + yPerifocal * cosOmega;
// Tilt by inclination, then rotate by the longitude of the ascending node.
const xTilted = xOrbitPlane;
const yTilted = yOrbitPlane * cosInclination;
const zTilted = yOrbitPlane * sinInclination;
return {
x: xTilted * cosRaan - yTilted * sinRaan,
y: xTilted * sinRaan + yTilted * cosRaan,
z: zTilted
};
}
/**
* Propagates `elements` to Julian date `epochJdEval`, returning the body's position (AU)
* relative to its central body. This is the app's "current epoch" evaluation used for live
* (and future time-scrubbable) positions, as opposed to {@link orbitEllipsePoints} which
* samples the fixed orbit shape independent of time.
*/
export function propagateOrbit(elements: OrbitalElements, gmAu3PerDay2: number, epochJdEval: number): CartesianCoordinates {
const meanMotion = meanMotionRadPerDay(elements.semiMajorAxisAu, gmAu3PerDay2);
const meanAnomalyRad = elements.meanAnomalyAtEpochDeg * DEG_TO_RAD + meanMotion * (epochJdEval - elements.epochJd);
const eccentricAnomalyRad = solveEccentricAnomaly(meanAnomalyRad, elements.eccentricity);
const trueAnomalyRad = trueAnomalyFromEccentricAnomaly(eccentricAnomalyRad, elements.eccentricity);
return positionAtTrueAnomaly(elements, trueAnomalyRad);
}
/**
* Samples `segments` points around the fixed shape of the orbit (AU, relative to the central
* body), for drawing the orbit ellipse. Independent of epoch/time — unlike {@link propagateOrbit}.
*/
export function orbitEllipsePoints(elements: OrbitalElements, segments = 128): CartesianCoordinates[] {
const points: CartesianCoordinates[] = [];
for (let i = 0; i <= segments; i++) {
const trueAnomalyRad = (i / segments) * TWO_PI;
points.push(positionAtTrueAnomaly(elements, trueAnomalyRad));
}
return points;
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Osculating Keplerian orbital elements at a reference epoch. Positions are derived
* client-side by propagating these elements forward/backward from `epochJd` (see
* `shared/astro/kepler.ts`), rather than fetching per-frame positions.
*/
export interface OrbitalElements {
semiMajorAxisAu: number;
eccentricity: number;
inclinationDeg: number;
longitudeOfAscendingNodeDeg: number;
argumentOfPeriapsisDeg: number;
meanAnomalyAtEpochDeg: number;
epochJd: number;
}
/**
* A solar-system planet, moon, or dwarf planet, sourced from JPL Horizons/SSD orbital
* elements. `systemStarId` links back to the HYG star index (the Sun, see `SUN_STAR_ID`).
*/
export interface BodyRecord {
id: string;
systemStarId: number;
name: string;
kind: 'planet' | 'moon' | 'dwarf';
radiusKm: number;
orbit: OrbitalElements;
/**
* For `kind: 'moon'`, the `id` of the planet it orbits — its `orbit` is expressed
* relative to that planet, not heliocentrically. Undefined for planets/dwarfs.
*/
parentBodyId?: string;
}
+16
View File
@@ -0,0 +1,16 @@
import { OrbitalElements } from './body.model';
/**
* A confirmed exoplanet from the NASA Exoplanet Archive (`Planetary Systems` TAP table),
* cross-referenced to its host star in the HYG index where possible.
*/
export interface ExoplanetRecord {
id: string;
hostStarId: number | null; // null if the host star could not be cross-referenced to HYG
hostStarName: string;
name: string;
radiusEarth?: number;
massEarth?: number;
discoveryYear?: number;
orbit: Partial<OrbitalElements>;
}
+19
View File
@@ -0,0 +1,19 @@
/**
* A single star from the HYG (Hipparcos/Yale/Gliese) catalog, positioned relative to the
* Sun in the galaxy-scale coordinate system (parsecs). The same positions are also packed
* into a compact binary buffer (`stars.bin`, in index order) for fast bulk rendering; this
* record format (`stars-index.json`) is used for search, labels, and lookups by id/name.
*/
export interface StarRecord {
id: number;
name: string;
x: number;
y: number;
z: number;
magnitude: number;
spectralType: string;
colorIndex: number;
}
/** HYG id used for the Sun itself, so solar-system bodies can reference their host star. */
export const SUN_STAR_ID = 0;
+78
View File
@@ -0,0 +1,78 @@
import * as THREE from 'three/webgpu';
/**
* Applies a real Milky Way panorama (see `texture-catalog.ts`/`README.md`) as the scene's
* background, mapped equirectangularly so it wraps the camera like a real sky rather than a
* flat image. Loading is non-blocking: the scene renders immediately with its previous
* background and swaps in the photo once it decodes.
*/
export function applyMilkyWaySkybox(scene: THREE.Scene, path: string): void {
new THREE.TextureLoader().load(
path,
(texture) => {
texture.mapping = THREE.EquirectangularReflectionMapping;
texture.colorSpace = THREE.SRGBColorSpace;
scene.background = texture;
},
undefined,
(error) => console.error(`Failed to load the skybox texture "${path}".`, error)
);
}
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.
*/
function glowSpriteTexture(color: THREE.ColorRepresentation): THREE.Texture | undefined {
const key = new THREE.Color(color).getHexString();
const cached = glowSpriteCache.get(key);
if (cached) {
return cached;
}
const size = 128;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (!context) {
return undefined;
}
const rgb = new THREE.Color(color);
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)`);
context.fillStyle = gradient;
context.fillRect(0, 0, size, size);
const texture = new THREE.CanvasTexture(canvas);
glowSpriteCache.set(key, texture);
return texture;
}
/**
* Builds a soft additive-blended glow halo (used for planetary atmospheres and the Sun's
* corona) sized relative to the given object radius. Cheap billboard-sprite approximation
* rather than a view-angle-correct Fresnel shader, chosen to stay within built-in material
* types the WebGPU backend renders natively (see plan risk on TSL/shader maturity). Falls back
* to a flat-colored (gradient-less) sprite if canvas rendering is unavailable.
*/
export function createGlowSprite(color: THREE.ColorRepresentation, radius: number, scale: number): THREE.Sprite {
const material = new THREE.SpriteMaterial({
map: glowSpriteTexture(color),
color: color,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending
});
const sprite = new THREE.Sprite(material);
sprite.scale.setScalar(radius * scale);
return sprite;
}
+140
View File
@@ -0,0 +1,140 @@
import * as THREE from 'three/webgpu';
/**
* Real NASA/ESA/USGS photography baked into `src/assets/textures/bodies/` at build time,
* keyed by the same ids used in `bodies.json`. Bodies without an entry here (most exoplanets,
* a few moons whose photo wasn't sourced this round, and any future body) fall back to
* `proceduralBodyTexture()` below rather than a flat color.
*
* Provenance (all public domain NASA/JPL or CC BY 4.0 Solar System Scope, via Wikimedia
* Commons — see each file's Commons page for the original credit line):
* mercury/venus/earth/mars/saturn/uranus/neptune/moon/sun/saturn-ring/skybox — Solar System
* Scope texture pack (CC BY 4.0); jupiter — Solar System Scope 8k pack (CC BY 4.0); pluto —
* NASA/JHUAPL/SwRI New Horizons true-color mosaic; deimos — NASA/JPL/University of Arizona
* MRO HiRISE; io — NASA/JPL Galileo highest-resolution true-color mosaic; titan — NASA/JPL
* Cassini true-color view.
*/
const BODY_TEXTURE_PATHS: Record<string, string> = {
mercury: 'assets/textures/bodies/mercury.jpg',
venus: 'assets/textures/bodies/venus.jpg',
earth: 'assets/textures/bodies/earth.jpg',
mars: 'assets/textures/bodies/mars.jpg',
jupiter: 'assets/textures/bodies/jupiter.jpg',
saturn: 'assets/textures/bodies/saturn.jpg',
uranus: 'assets/textures/bodies/uranus.jpg',
neptune: 'assets/textures/bodies/neptune.jpg',
pluto: 'assets/textures/bodies/pluto.jpg',
moon: 'assets/textures/bodies/moon.jpg',
deimos: 'assets/textures/bodies/deimos.jpg',
io: 'assets/textures/bodies/io.jpg',
titan: 'assets/textures/bodies/titan.jpg'
};
/** The Sun isn't a `BodyRecord` (it's the system's star marker), so it's looked up separately. */
export const SUN_TEXTURE_PATH = 'assets/textures/bodies/sun.jpg';
export const SATURN_RING_TEXTURE_PATH = 'assets/textures/bodies/saturn_ring.png';
export const MILKY_WAY_SKYBOX_PATH = 'assets/textures/skybox/milkyway.jpg';
/** True for the handful of bodies that have real photographic atmospheres worth glowing. */
const ATMOSPHERE_BY_ID: Record<string, THREE.ColorRepresentation> = {
venus: 0xf3dfa6,
earth: 0x7fb8ff,
mars: 0xd9a066,
jupiter: 0xe8d3ad,
saturn: 0xe0d2a8,
uranus: 0x9fe8e8,
neptune: 0x5b7fff,
titan: 0xf0b25c
};
export function bodyTexturePath(id: string): string | undefined {
return BODY_TEXTURE_PATHS[id];
}
export function atmosphereColorFor(id: string): THREE.ColorRepresentation | undefined {
return ATMOSPHERE_BY_ID[id];
}
const textureLoader = new THREE.TextureLoader();
const loadedTextures = new Map<string, THREE.Texture>();
/**
* Loads (and caches) a texture by asset path, applying `colorSpace` so JPEG/PNG source
* photography matches Three.js's expected sRGB working space. Non-blocking: the texture is
* returned immediately and updates in place once the image data arrives (or errors, which is
* logged rather than thrown so a slow/unavailable network never breaks the scene).
*/
export function loadCachedTexture(path: string): THREE.Texture {
const cached = loadedTextures.get(path);
if (cached) {
return cached;
}
const texture = textureLoader.load(
path,
undefined,
undefined,
(error) => console.error(`Failed to load texture "${path}".`, error)
);
texture.colorSpace = THREE.SRGBColorSpace;
loadedTextures.set(path, texture);
return texture;
}
const proceduralTextureCache = new Map<string, THREE.CanvasTexture>();
/**
* Generates a simple procedural surface for bodies with no real photograph available — mainly
* exoplanets, whose actual surfaces have never been directly imaged. This is an honest artistic
* stand-in (mottled bands tinted by the body's classification color), not a fabricated "real"
* texture, and is cached per color so repeated exoplanets of the same kind share one canvas.
* Returns `undefined` if 2D canvas rendering isn't available (e.g. under a test/jsdom
* environment with no canvas backend); callers should fall back to a flat material color.
*/
export function proceduralBodyTexture(baseColor: THREE.ColorRepresentation): THREE.CanvasTexture | undefined {
const key = new THREE.Color(baseColor).getHexString();
const cached = proceduralTextureCache.get(key);
if (cached) {
return cached;
}
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (!context) {
return undefined;
}
const base = new THREE.Color(baseColor);
const light = base.clone().offsetHSL(0, -0.15, 0.14);
const dark = base.clone().offsetHSL(0, 0.05, -0.16);
context.fillStyle = `#${base.getHexString()}`;
context.fillRect(0, 0, size, size);
// A handful of horizontal-ish noisy bands, reminiscent of banded gas giants / mottled rock,
// without claiming to depict any specific real surface feature.
let seed = key.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) || 1;
const random = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const bandCount = 10;
for (let i = 0; i < bandCount; i++) {
const y = (i / bandCount) * size + random() * (size / bandCount) * 0.4;
const height = size / bandCount * (0.5 + random() * 0.6);
context.fillStyle = `#${(random() > 0.5 ? light : dark).getHexString()}`;
context.globalAlpha = 0.35 + random() * 0.25;
context.fillRect(0, y, size, height);
}
context.globalAlpha = 1;
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = THREE.RepeatWrapping;
proceduralTextureCache.set(key, texture);
return texture;
}
View File
@@ -0,0 +1,37 @@
import { TestBed } from '@angular/core/testing';
import { describe, beforeEach, expect, it } from 'vitest';
import { NavigationStore } from './navigation.store';
describe('NavigationStore', () => {
let store: NavigationStore;
beforeEach(() => {
TestBed.configureTestingModule({});
store = TestBed.inject(NavigationStore);
});
it('starts at the galaxy view with nothing selected', () => {
expect(store.viewLevel()).toBe('galaxy');
expect(store.selectedStarId()).toBeNull();
expect(store.selectedBodyId()).toBeNull();
});
it('selectStar updates selectedStarId', () => {
store.selectStar(42);
expect(store.selectedStarId()).toBe(42);
});
it('selectBody updates selectedBodyId', () => {
store.selectBody('mars');
expect(store.selectedBodyId()).toBe('mars');
});
it('setViewLevel switches between galaxy and system', () => {
store.setViewLevel('system');
expect(store.viewLevel()).toBe('system');
});
});
+27
View File
@@ -0,0 +1,27 @@
import { Injectable, signal } from '@angular/core';
export type ViewLevel = 'galaxy' | 'system';
/**
* App-wide navigation state: which zoom level is active and what's currently selected.
* Scene components read these signals to drive rendering; UI (search, picking) writes to
* them to trigger navigation.
*/
@Injectable({ providedIn: 'root' })
export class NavigationStore {
readonly viewLevel = signal<ViewLevel>('galaxy');
readonly selectedStarId = signal<number | null>(null);
readonly selectedBodyId = signal<string | null>(null);
selectStar(starId: number | null): void {
this.selectedStarId.set(starId);
}
selectBody(bodyId: string | null): void {
this.selectedBodyId.set(bodyId);
}
setViewLevel(viewLevel: ViewLevel): void {
this.viewLevel.set(viewLevel);
}
}
+299
View File
@@ -0,0 +1,299 @@
[
{
"id": "mercury",
"systemStarId": 0,
"name": "Mercury",
"kind": "planet",
"radiusKm": 2439.4,
"orbit": {
"semiMajorAxisAu": 0.38709857292289906,
"eccentricity": 0.2056388859154261,
"inclinationDeg": 7.003502017708398,
"longitudeOfAscendingNodeDeg": 48.29980532881731,
"argumentOfPeriapsisDeg": 29.19561108948743,
"meanAnomalyAtEpochDeg": 103.9465145977117,
"epochJd": 2460676.5
}
},
{
"id": "venus",
"systemStarId": 0,
"name": "Venus",
"kind": "planet",
"radiusKm": 6051.84,
"orbit": {
"semiMajorAxisAu": 0.7233281694940689,
"eccentricity": 0.006746576710187382,
"inclinationDeg": 3.394393253252075,
"longitudeOfAscendingNodeDeg": 76.61185393039594,
"argumentOfPeriapsisDeg": 55.15075425343929,
"meanAnomalyAtEpochDeg": 280.0749102629981,
"epochJd": 2460676.5
}
},
{
"id": "earth",
"systemStarId": 0,
"name": "Earth",
"kind": "planet",
"radiusKm": 6371.01,
"orbit": {
"semiMajorAxisAu": 1.0009125494279245,
"eccentricity": 0.01756190256786036,
"inclinationDeg": 0.002977082685807642,
"longitudeOfAscendingNodeDeg": 190.1716375775361,
"argumentOfPeriapsisDeg": 272.9783142442708,
"meanAnomalyAtEpochDeg": 357.4122246804211,
"epochJd": 2460676.5
}
},
{
"id": "mars",
"systemStarId": 0,
"name": "Mars",
"kind": "planet",
"radiusKm": 3389.92,
"orbit": {
"semiMajorAxisAu": 1.5237367794976557,
"eccentricity": 0.09343026241683372,
"inclinationDeg": 1.847583389630714,
"longitudeOfAscendingNodeDeg": 49.48673257018439,
"argumentOfPeriapsisDeg": 286.7114828288203,
"meanAnomalyAtEpochDeg": 124.444888195349,
"epochJd": 2460676.5
}
},
{
"id": "jupiter",
"systemStarId": 0,
"name": "Jupiter",
"kind": "planet",
"radiusKm": 69911,
"orbit": {
"semiMajorAxisAu": 5.20282710471751,
"eccentricity": 0.04830624138918495,
"inclinationDeg": 1.303459692430419,
"longitudeOfAscendingNodeDeg": 100.5202095061913,
"argumentOfPeriapsisDeg": 273.6090683600047,
"meanAnomalyAtEpochDeg": 58.98282567286461,
"epochJd": 2460676.5
}
},
{
"id": "saturn",
"systemStarId": 0,
"name": "Saturn",
"kind": "planet",
"radiusKm": 58232,
"orbit": {
"semiMajorAxisAu": 9.555678383881443,
"eccentricity": 0.05522302318620472,
"inclinationDeg": 2.485819253218824,
"longitudeOfAscendingNodeDeg": 113.5595556634874,
"argumentOfPeriapsisDeg": 337.1663598259115,
"meanAnomalyAtEpochDeg": 264.9877650588944,
"epochJd": 2460676.5
}
},
{
"id": "uranus",
"systemStarId": 0,
"name": "Uranus",
"kind": "planet",
"radiusKm": 25362,
"orbit": {
"semiMajorAxisAu": 19.30135052232751,
"eccentricity": 0.04562415515293685,
"inclinationDeg": 0.7728969735590447,
"longitudeOfAscendingNodeDeg": 74.0126541901479,
"argumentOfPeriapsisDeg": 90.49593456147204,
"meanAnomalyAtEpochDeg": 255.8822481742851,
"epochJd": 2460676.5
}
},
{
"id": "neptune",
"systemStarId": 0,
"name": "Neptune",
"kind": "planet",
"radiusKm": 24624,
"orbit": {
"semiMajorAxisAu": 30.183632709237763,
"eccentricity": 0.01266098870528033,
"inclinationDeg": 1.774849832507214,
"longitudeOfAscendingNodeDeg": 131.9473970418126,
"argumentOfPeriapsisDeg": 268.1146665565159,
"meanAnomalyAtEpochDeg": 319.6858384317641,
"epochJd": 2460676.5
}
},
{
"id": "pluto",
"systemStarId": 0,
"name": "Pluto",
"kind": "dwarf",
"radiusKm": 1188.3,
"orbit": {
"semiMajorAxisAu": 39.28778257358678,
"eccentricity": 0.2438605399689669,
"inclinationDeg": 16.93906659887321,
"longitudeOfAscendingNodeDeg": 110.1714547539489,
"argumentOfPeriapsisDeg": 113.5754868232679,
"meanAnomalyAtEpochDeg": 51.93655343727463,
"epochJd": 2460676.5
}
},
{
"id": "moon",
"systemStarId": 0,
"name": "Moon",
"kind": "moon",
"radiusKm": 1737.53,
"orbit": {
"semiMajorAxisAu": 0.0025850155421324465,
"eccentricity": 0.04034704696474715,
"inclinationDeg": 5.004154175941119,
"longitudeOfAscendingNodeDeg": 0.531732989166472,
"argumentOfPeriapsisDeg": 6.557682814330136,
"meanAnomalyAtEpochDeg": 290.7825171697369,
"epochJd": 2460676.5
},
"parentBodyId": "earth"
},
{
"id": "phobos",
"systemStarId": 0,
"name": "Phobos",
"kind": "moon",
"radiusKm": 13.1,
"orbit": {
"semiMajorAxisAu": 0.00006269319601715431,
"eccentricity": 0.01559367482014246,
"inclinationDeg": 26.37766999967838,
"longitudeOfAscendingNodeDeg": 85.13091230996636,
"argumentOfPeriapsisDeg": 356.4060881660871,
"meanAnomalyAtEpochDeg": 342.6005509941174,
"epochJd": 2460676.5
},
"parentBodyId": "mars"
},
{
"id": "deimos",
"systemStarId": 0,
"name": "Deimos",
"kind": "moon",
"radiusKm": 7.8,
"orbit": {
"semiMajorAxisAu": 0.00015681797919999554,
"eccentricity": 0.0002637395029404219,
"inclinationDeg": 24.26157855280465,
"longitudeOfAscendingNodeDeg": 80.74605390828611,
"argumentOfPeriapsisDeg": 21.65246201037153,
"meanAnomalyAtEpochDeg": 273.8943716897566,
"epochJd": 2460676.5
},
"parentBodyId": "mars"
},
{
"id": "io",
"systemStarId": 0,
"name": "Io",
"kind": "moon",
"radiusKm": 1821.49,
"orbit": {
"semiMajorAxisAu": 0.0028210852715482176,
"eccentricity": 0.004228419931613188,
"inclinationDeg": 2.184262830697117,
"longitudeOfAscendingNodeDeg": 338.066968919613,
"argumentOfPeriapsisDeg": 164.6533514126175,
"meanAnomalyAtEpochDeg": 74.88524962049125,
"epochJd": 2460676.5
},
"parentBodyId": "jupiter"
},
{
"id": "europa",
"systemStarId": 0,
"name": "Europa",
"kind": "moon",
"radiusKm": 1560.8,
"orbit": {
"semiMajorAxisAu": 0.004486887602977481,
"eccentricity": 0.009740025569562362,
"inclinationDeg": 2.245018421035425,
"longitudeOfAscendingNodeDeg": 326.0277596344292,
"argumentOfPeriapsisDeg": 349.2045643684611,
"meanAnomalyAtEpochDeg": 40.72575250295771,
"epochJd": 2460676.5
},
"parentBodyId": "jupiter"
},
{
"id": "ganymede",
"systemStarId": 0,
"name": "Ganymede",
"kind": "moon",
"radiusKm": 2631.2,
"orbit": {
"semiMajorAxisAu": 0.007156574251479677,
"eccentricity": 0.001723569500229484,
"inclinationDeg": 2.332721144446764,
"longitudeOfAscendingNodeDeg": 339.4869144536639,
"argumentOfPeriapsisDeg": 0.02754303249978196,
"meanAnomalyAtEpochDeg": 355.7249344187845,
"epochJd": 2460676.5
},
"parentBodyId": "jupiter"
},
{
"id": "callisto",
"systemStarId": 0,
"name": "Callisto",
"kind": "moon",
"radiusKm": 2410.3,
"orbit": {
"semiMajorAxisAu": 0.012583604816889162,
"eccentricity": 0.007245216892383587,
"inclinationDeg": 1.94976938450231,
"longitudeOfAscendingNodeDeg": 336.7558983297979,
"argumentOfPeriapsisDeg": 32.65151937817475,
"meanAnomalyAtEpochDeg": 126.0198540648681,
"epochJd": 2460676.5
},
"parentBodyId": "jupiter"
},
{
"id": "titan",
"systemStarId": 0,
"name": "Titan",
"kind": "moon",
"radiusKm": 2575.5,
"orbit": {
"semiMajorAxisAu": 0.00816836581932601,
"eccentricity": 0.0288317905724529,
"inclinationDeg": 27.71117736818323,
"longitudeOfAscendingNodeDeg": 169.0716057233001,
"argumentOfPeriapsisDeg": 177.4798658320667,
"meanAnomalyAtEpochDeg": 32.18862839469676,
"epochJd": 2460676.5
},
"parentBodyId": "saturn"
},
{
"id": "triton",
"systemStarId": 0,
"name": "Triton",
"kind": "moon",
"radiusKm": 1352.6,
"orbit": {
"semiMajorAxisAu": 0.002371477351073512,
"eccentricity": 0.0001253161638938937,
"inclinationDeg": 129.1766133444893,
"longitudeOfAscendingNodeDeg": 222.3992799388247,
"argumentOfPeriapsisDeg": 97.422342803062,
"meanAnomalyAtEpochDeg": 273.1157438944629,
"epochJd": 2460676.5
},
"parentBodyId": "neptune"
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Star Map</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<app-root></app-root>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
+36
View File
@@ -0,0 +1,36 @@
/* You can add global styles to this file, and also import other style files */
@import 'tailwindcss';
/* Design tokens for the sci-fi "HUD" look shared across the app. `--color-*`/`--font-*` names
* automatically become Tailwind utilities (e.g. `--color-accent` -> `bg-accent`/`text-accent`). */
@theme {
--font-display: 'Orbitron', 'Segoe UI', sans-serif;
--font-body: 'JetBrains Mono', 'Segoe UI', monospace;
--color-void: #05070d;
--color-panel: #0b121f;
--color-accent: #4dd7ff;
--color-accent-dim: #1c4f66;
--color-text: #dbe6f3;
--color-muted: #7f93ab;
--color-border: #2a4a5c;
}
html,
body {
height: 100%;
margin: 0;
background: var(--color-void);
color: var(--color-text);
overflow: hidden;
font-family: var(--font-body);
}
/* Star name labels rendered by CSS2DRenderer (see StarLabelOverlay). These live outside
* Angular's view encapsulation as plain DOM nodes, so they're styled with Tailwind utility
* classes assigned directly in TypeScript rather than a scoped component stylesheet. */
.star-label-layer {
position: absolute;
top: 0;
left: 0;
}
+98
View File
@@ -0,0 +1,98 @@
import { statSync } from 'node:fs';
import { BodyRecord } from '../../src/app/shared/models/body.model';
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
import { StarRecord } from '../../src/app/shared/models/star.model';
import { fetchExoplanets } from './fetchExoplanets';
import { fetchSolarSystem } from './fetchSolarSystem';
import { fetchStars } from './fetchStars';
import { dataPath } from './lib/paths';
class ValidationError extends Error {}
function assertCondition(condition: boolean, message: string): void {
if (!condition) {
throw new ValidationError(message);
}
}
function validateStars(stars: StarRecord[]): void {
assertCondition(stars.length > 0, 'No stars were produced.');
const ids = new Set<number>();
for (const star of stars) {
assertCondition(Number.isFinite(star.id), `Star has a non-numeric id: ${JSON.stringify(star)}`);
assertCondition(!ids.has(star.id), `Duplicate star id: ${star.id}`);
ids.add(star.id);
assertCondition(!!star.name, `Star ${star.id} has no name.`);
assertCondition([star.x, star.y, star.z].every(Number.isFinite), `Star ${star.id} has a non-finite position.`);
}
const binSize = statSync(dataPath('stars.bin')).size;
assertCondition(binSize === stars.length * 3 * 4, `stars.bin size (${binSize}) does not match ${stars.length} stars.`);
}
function validateBodies(bodies: BodyRecord[]): void {
assertCondition(bodies.length > 0, 'No solar-system bodies were produced.');
const ids = new Set(bodies.map((body) => body.id));
assertCondition(ids.size === bodies.length, 'Duplicate body ids were found.');
for (const body of bodies) {
const orbitValues = Object.values(body.orbit);
assertCondition(orbitValues.every(Number.isFinite), `Body ${body.id} has non-finite orbital elements.`);
if (body.kind === 'moon') {
assertCondition(!!body.parentBodyId && ids.has(body.parentBodyId), `Moon ${body.id} has no valid parentBodyId.`);
}
}
const planetCount = bodies.filter((body) => body.kind === 'planet').length;
assertCondition(planetCount === 8, `Expected 8 planets, found ${planetCount}.`);
}
function validateExoplanets(exoplanets: ExoplanetRecord[], starIds: Set<number>): void {
assertCondition(exoplanets.length > 0, 'No exoplanets were produced.');
let crossReferenced = 0;
for (const exoplanet of exoplanets) {
assertCondition(!!exoplanet.name, `Exoplanet ${exoplanet.id} has no name.`);
if (exoplanet.hostStarId !== null) {
assertCondition(starIds.has(exoplanet.hostStarId), `Exoplanet ${exoplanet.id} references unknown star id ${exoplanet.hostStarId}.`);
crossReferenced++;
}
}
console.log(` ${crossReferenced}/${exoplanets.length} exoplanets cross-referenced to a HYG host star.`);
}
/**
* 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/`,
* then validates the combined output for completeness before declaring success.
*/
async function build(): Promise<void> {
console.log('=== NASA star map ETL ===\n');
const stars = await fetchStars();
console.log();
const bodies = await fetchSolarSystem();
console.log();
const exoplanets = await fetchExoplanets(stars);
console.log();
console.log('Validating output...');
validateStars(stars);
validateBodies(bodies);
validateExoplanets(exoplanets, new Set(stars.map((star) => star.id)));
console.log('\nETL completed successfully:');
console.log(` stars: ${stars.length}`);
console.log(` bodies: ${bodies.length}`);
console.log(` exoplanets: ${exoplanets.length}`);
}
build().catch((error) => {
console.error('\nETL failed:', error instanceof Error ? error.message : error);
process.exitCode = 1;
});
+98
View File
@@ -0,0 +1,98 @@
import { writeFileSync } from 'node:fs';
import { buildStarNameIndex, resolveHostStarId } from '../../src/app/shared/astro/host-star-matching';
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
import { StarRecord } from '../../src/app/shared/models/star.model';
import { fetchStars } from './fetchStars';
import { parseCsvObjects } from './lib/csv';
import { fetchTextCached } from './lib/http';
import { dataPath, ensureDataDir } from './lib/paths';
const TAP_BASE_URL = 'https://exoplanetarchive.ipac.caltech.edu/TAP/sync';
const TAP_COLUMNS = [
'pl_name',
'hostname',
'ra',
'dec',
'sy_dist',
'pl_orbsmax',
'pl_orbeccen',
'pl_orbincl',
'pl_orblper',
'pl_orbper',
'pl_rade',
'pl_bmasse',
'disc_year'
].join(',');
const TAP_QUERY = `select+${TAP_COLUMNS}+from+ps+where+default_flag=1&format=csv`;
const TAP_URL = `${TAP_BASE_URL}?query=${TAP_QUERY}`;
// A host star match must be within this many parsecs of the catalog position to be
// accepted as a cross-reference (guards against coincidental name/position collisions).
const MATCH_TOLERANCE_PC = 0.5;
/**
* Downloads confirmed exoplanets from the NASA Exoplanet Archive (`Planetary Systems` TAP
* table), cross-references each host star to the HYG index, and writes `exoplanets.json`.
*/
export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRecord[]> {
console.log('Fetching confirmed exoplanets from the NASA Exoplanet Archive...');
const knownStars = stars ?? (await fetchStars());
const nameIndex = buildStarNameIndex(knownStars);
const csv = await fetchTextCached(TAP_URL, 'exoplanet-archive-ps.csv');
const rows = parseCsvObjects(csv);
let matched = 0;
const exoplanets: ExoplanetRecord[] = rows.map((row, index) => {
const raDeg = Number(row['ra']);
const decDeg = Number(row['dec']);
const distancePc = Number(row['sy_dist']);
const hostStarId = resolveHostStarId(
{ hostname: row['hostname'], raDeg, decDeg, distancePc },
knownStars,
MATCH_TOLERANCE_PC,
nameIndex
);
if (hostStarId !== null) {
matched++;
}
return {
id: row['pl_name'] || `exoplanet-${index}`,
hostStarId,
hostStarName: row['hostname'],
name: row['pl_name'],
radiusEarth: parseOptionalNumber(row['pl_rade']),
massEarth: parseOptionalNumber(row['pl_bmasse']),
discoveryYear: parseOptionalNumber(row['disc_year']),
orbit: {
semiMajorAxisAu: parseOptionalNumber(row['pl_orbsmax']),
eccentricity: parseOptionalNumber(row['pl_orbeccen']),
inclinationDeg: parseOptionalNumber(row['pl_orbincl']),
argumentOfPeriapsisDeg: parseOptionalNumber(row['pl_orblper'])
}
};
});
ensureDataDir();
writeFileSync(dataPath('exoplanets.json'), JSON.stringify(exoplanets));
console.log(` wrote ${exoplanets.length} exoplanets (${matched} cross-referenced to a HYG host star).`);
return exoplanets;
}
function parseOptionalNumber(value: string | undefined): number | undefined {
if (!value) {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
if (require.main === module) {
fetchExoplanets().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
+81
View File
@@ -0,0 +1,81 @@
import { writeFileSync } from 'node:fs';
import { BodyRecord } from '../../src/app/shared/models/body.model';
import { SUN_STAR_ID } from '../../src/app/shared/models/star.model';
import { fetchHorizonsBody } from './lib/horizons';
import { dataPath, ensureDataDir } from './lib/paths';
interface BodySpec {
id: string;
name: string;
kind: BodyRecord['kind'];
horizonsCommand: string;
center: string;
parentBodyId?: string;
}
// Sun-centered planets/dwarf, then their major moons (planetocentric elements).
const BODY_SPECS: BodySpec[] = [
{ id: 'mercury', name: 'Mercury', kind: 'planet', horizonsCommand: '199', center: '500@10' },
{ id: 'venus', name: 'Venus', kind: 'planet', horizonsCommand: '299', center: '500@10' },
{ id: 'earth', name: 'Earth', kind: 'planet', horizonsCommand: '399', center: '500@10' },
{ id: 'mars', name: 'Mars', kind: 'planet', horizonsCommand: '499', center: '500@10' },
{ id: 'jupiter', name: 'Jupiter', kind: 'planet', horizonsCommand: '599', center: '500@10' },
{ id: 'saturn', name: 'Saturn', kind: 'planet', horizonsCommand: '699', center: '500@10' },
{ id: 'uranus', name: 'Uranus', kind: 'planet', horizonsCommand: '799', center: '500@10' },
{ id: 'neptune', name: 'Neptune', kind: 'planet', horizonsCommand: '899', center: '500@10' },
{ id: 'pluto', name: 'Pluto', kind: 'dwarf', horizonsCommand: '999', center: '500@10' },
{ id: 'moon', name: 'Moon', kind: 'moon', horizonsCommand: '301', center: '500@399', parentBodyId: 'earth' },
{ id: 'phobos', name: 'Phobos', kind: 'moon', horizonsCommand: '401', center: '500@499', parentBodyId: 'mars' },
{ id: 'deimos', name: 'Deimos', kind: 'moon', horizonsCommand: '402', center: '500@499', parentBodyId: 'mars' },
{ id: 'io', name: 'Io', kind: 'moon', horizonsCommand: '501', center: '500@599', parentBodyId: 'jupiter' },
{ id: 'europa', name: 'Europa', kind: 'moon', horizonsCommand: '502', center: '500@599', parentBodyId: 'jupiter' },
{ id: 'ganymede', name: 'Ganymede', kind: 'moon', horizonsCommand: '503', center: '500@599', parentBodyId: 'jupiter' },
{ id: 'callisto', name: 'Callisto', kind: 'moon', horizonsCommand: '504', center: '500@599', parentBodyId: 'jupiter' },
{ id: 'titan', name: 'Titan', kind: 'moon', horizonsCommand: '606', center: '500@699', parentBodyId: 'saturn' },
{ id: 'triton', name: 'Triton', kind: 'moon', horizonsCommand: '801', center: '500@899', parentBodyId: 'neptune' }
];
/**
* Queries JPL Horizons for the osculating orbital elements (and mean radius, where
* reported) of the major planets, Pluto, and a curated set of major moons, and writes
* `bodies.json`.
*/
export async function fetchSolarSystem(): Promise<BodyRecord[]> {
console.log(`Fetching ${BODY_SPECS.length} solar-system bodies from JPL Horizons...`);
const bodies: BodyRecord[] = [];
for (const spec of BODY_SPECS) {
const result = await fetchHorizonsBody({
command: spec.horizonsCommand,
center: spec.center,
cacheKey: `horizons-${spec.id}.txt`
});
if (result.radiusKm === undefined) {
console.warn(` no physical radius found for ${spec.name}; defaulting to 0.`);
}
bodies.push({
id: spec.id,
systemStarId: SUN_STAR_ID,
name: spec.name,
kind: spec.kind,
radiusKm: result.radiusKm ?? 0,
orbit: result.orbit,
...(spec.parentBodyId ? { parentBodyId: spec.parentBodyId } : {})
});
}
ensureDataDir();
writeFileSync(dataPath('bodies.json'), JSON.stringify(bodies, null, 2));
console.log(` wrote ${bodies.length} bodies.`);
return bodies;
}
if (require.main === module) {
fetchSolarSystem().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
+108
View File
@@ -0,0 +1,108 @@
import { writeFileSync } from 'node:fs';
import { raDecDistanceToXyz } from '../../src/app/shared/astro/coordinates';
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
import { parseCsvObjects } from './lib/csv';
import { fetchTextCached } from './lib/http';
import { dataPath, ensureDataDir } from './lib/paths';
const HYG_CSV_URL = 'https://raw.githubusercontent.com/astronexus/HYG-Database/main/hyg/CURRENT/hygdata_v41.csv';
const HYG_UNKNOWN_DISTANCE_PC = 100000; // HYG's placeholder for unmeasured/unreliable parallax
/** Stars within this distance (parsecs) of the Sun are kept for the galaxy view. */
const DISTANCE_CUTOFF_PC = Number(process.env['ETL_STAR_DISTANCE_PC'] ?? 50);
function resolveName(row: Record<string, string>): string {
if (row['proper']) {
return row['proper'];
}
if (row['bayer'] && row['con']) {
return `${row['bayer']} ${row['con']}`;
}
if (row['flam'] && row['con']) {
return `${row['flam']} ${row['con']}`;
}
if (row['hd']) {
return `HD ${row['hd']}`;
}
if (row['gl']) {
return `Gl ${row['gl']}`;
}
if (row['hip']) {
return `HIP ${row['hip']}`;
}
return `HYG ${row['id']}`;
}
/**
* Downloads the HYG (Hipparcos/Yale/Gliese) stellar database, converts each star's
* RA/Dec/distance into galaxy-scale Cartesian coordinates (parsecs), filters by distance,
* and writes `stars.bin` (packed positions) + `stars-index.json` (everything else).
*/
export async function fetchStars(): Promise<StarRecord[]> {
console.log(`Fetching HYG star catalog (distance cutoff: ${DISTANCE_CUTOFF_PC} pc)...`);
const csv = await fetchTextCached(HYG_CSV_URL, 'hygdata_v41.csv');
const rows = parseCsvObjects(csv);
const stars: StarRecord[] = [];
for (const row of rows) {
const id = Number(row['id']);
const distancePc = Number(row['dist']);
if (id === SUN_STAR_ID) {
stars.push({ id, name: 'Sol', x: 0, y: 0, z: 0, magnitude: Number(row['mag']), spectralType: row['spect'] || 'G2V', colorIndex: Number(row['ci']) || 0 });
continue;
}
if (!Number.isFinite(distancePc) || distancePc >= HYG_UNKNOWN_DISTANCE_PC || distancePc > DISTANCE_CUTOFF_PC) {
continue;
}
const raHours = Number(row['ra']);
const decDeg = Number(row['dec']);
if (!Number.isFinite(raHours) || !Number.isFinite(decDeg)) {
continue;
}
const { x, y, z } = raDecDistanceToXyz(raHours, decDeg, distancePc);
stars.push({
id,
name: resolveName(row),
x,
y,
z,
magnitude: Number(row['mag']) || 0,
spectralType: row['spect'] || 'Unknown',
colorIndex: Number(row['ci']) || 0
});
}
stars.sort((a, b) => a.id - b.id);
writeStarAssets(stars);
console.log(` kept ${stars.length} stars (of ${rows.length} in the catalog).`);
return stars;
}
function writeStarAssets(stars: StarRecord[]): void {
ensureDataDir();
const positions = new Float32Array(stars.length * 3);
stars.forEach((star, index) => {
positions[index * 3] = star.x;
positions[index * 3 + 1] = star.y;
positions[index * 3 + 2] = star.z;
});
writeFileSync(dataPath('stars.bin'), Buffer.from(positions.buffer));
writeFileSync(dataPath('stars-index.json'), JSON.stringify(stars));
}
if (require.main === module) {
fetchStars().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Minimal RFC4180-ish CSV parser: supports a custom delimiter, quoted fields (with escaped
* `""`), and embedded newlines inside quotes. Good enough for the HYG/Exoplanet Archive
* exports used by the ETL, without pulling in an extra dependency.
*/
export function parseCsv(text: string, delimiter = ','): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (inQuotes) {
if (char === '"') {
if (text[i + 1] === '"') {
field += '"';
i++;
} else {
inQuotes = false;
}
} else {
field += char;
}
continue;
}
if (char === '"') {
inQuotes = true;
} else if (char === delimiter) {
row.push(field);
field = '';
} else if (char === '\r') {
// skip; \n (handled below) terminates the row
} else if (char === '\n') {
row.push(field);
rows.push(row);
row = [];
field = '';
} else {
field += char;
}
}
if (field.length > 0 || row.length > 0) {
row.push(field);
rows.push(row);
}
return rows;
}
/** Parses `text` as CSV and maps each data row to an object keyed by the header row. */
export function parseCsvObjects(text: string, delimiter = ','): Array<Record<string, string>> {
const rows = parseCsv(text, delimiter).filter((row) => row.some((cell) => cell.length > 0));
if (rows.length === 0) {
return [];
}
const [header, ...dataRows] = rows;
return dataRows.map((row) => {
const record: Record<string, string> = {};
header.forEach((column, index) => {
record[column] = row[index] ?? '';
});
return record;
});
}
+96
View File
@@ -0,0 +1,96 @@
import { OrbitalElements } from '../../../src/app/shared/models/body.model';
import { fetchTextCached } from './http';
const HORIZONS_URL = 'https://ssd.jpl.nasa.gov/api/horizons.api';
const KM_PER_AU = 149597870.7;
// Fixed reference epoch: keeps the ETL output (and its cache) stable across reruns,
// consistent with the "bake once at build time" ETL philosophy.
const REFERENCE_START = '2025-01-01';
const REFERENCE_STOP = '2025-01-02';
export interface HorizonsQuery {
/** Horizons body id, e.g. `'499'` for Mars. */
command: string;
/** Horizons coordinate center, e.g. `'500@10'` (Sun) or `'500@399'` (Earth). */
center: string;
cacheKey: string;
}
export interface HorizonsResult {
radiusKm?: number;
orbit: OrbitalElements;
}
const RADIUS_PATTERNS = [
/Vol\.?\s*mean\s*radius[^=]*=\s*([\d.]+)/i,
/Mean\s*radius[^=]*=\s*([\d.]+)/i,
/Radius\s*\(IAU\)[^=]*=\s*([\d.]+)/i,
/Radius,?\s*\(km\)\s*=\s*([\d.]+)/i,
/Radius\s*\(gravity\),?\s*km\s*=\s*([\d.]+)/i
];
/**
* Queries JPL Horizons for a body's heliocentric (or planetocentric, for moons) osculating
* orbital elements plus, when available, its mean physical radius — both in a single
* request (`OBJ_DATA=YES` + `EPHEM_TYPE=ELEMENTS`).
*/
export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsResult> {
const url =
`${HORIZONS_URL}?format=text&COMMAND='${query.command}'&OBJ_DATA='YES'` +
`&MAKE_EPHEM='YES'&EPHEM_TYPE='ELEMENTS'&CENTER='${query.center}'` +
`&START_TIME='${REFERENCE_START}'&STOP_TIME='${REFERENCE_STOP}'&STEP_SIZE='1d'`;
const text = await fetchTextCached(url, query.cacheKey);
return {
radiusKm: extractRadiusKm(text),
orbit: extractOrbitalElements(text)
};
}
function extractRadiusKm(text: string): number | undefined {
for (const pattern of RADIUS_PATTERNS) {
const match = text.match(pattern);
if (match) {
return Number(match[1]);
}
}
return undefined;
}
function extractOrbitalElements(text: string): OrbitalElements {
const startIndex = text.indexOf('$$SOE');
const endIndex = text.indexOf('$$EOE');
if (startIndex === -1 || endIndex === -1) {
throw new Error('Horizons response did not contain an elements table ($$SOE/$$EOE).');
}
const block = text.slice(startIndex + '$$SOE'.length, endIndex).trim();
const firstRecord = block.split(/\n(?=\d)/)[0];
const epochJd = extractNumber(firstRecord, /^([\d.]+)\s*=/);
const eccentricity = extractNumber(firstRecord, /EC\s*=\s*([-\d.Ee+]+)/);
const inclinationDeg = extractNumber(firstRecord, /IN\s*=\s*([-\d.Ee+]+)/);
const longitudeOfAscendingNodeDeg = extractNumber(firstRecord, /OM\s*=\s*([-\d.Ee+]+)/);
const argumentOfPeriapsisDeg = extractNumber(firstRecord, /(?<!\w)W\s*=\s*([-\d.Ee+]+)/);
const meanAnomalyAtEpochDeg = extractNumber(firstRecord, /MA\s*=\s*([-\d.Ee+]+)/);
const semiMajorAxisKm = extractNumber(firstRecord, /(?<!\w)A\s*=\s*([-\d.Ee+]+)/);
return {
semiMajorAxisAu: semiMajorAxisKm / KM_PER_AU,
eccentricity,
inclinationDeg,
longitudeOfAscendingNodeDeg,
argumentOfPeriapsisDeg,
meanAnomalyAtEpochDeg,
epochJd
};
}
function extractNumber(text: string, pattern: RegExp): number {
const match = text.match(pattern);
if (!match) {
throw new Error(`Could not find pattern ${pattern} in Horizons elements record.`);
}
return Number(match[1]);
}
+34
View File
@@ -0,0 +1,34 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
const CACHE_DIR = join(process.cwd(), 'tools', 'etl', '.cache');
const FORCE_REFRESH = process.env['ETL_FORCE_REFRESH'] === '1';
/**
* Downloads `url` as text, caching the raw response under `tools/etl/.cache/<cacheKey>` so
* re-running the ETL doesn't hit live NASA/astronomy endpoints unless the cache is missing
* or `ETL_FORCE_REFRESH=1` is set. Keeps the pipeline idempotent and resilient to rate limits.
*/
export async function fetchTextCached(url: string, cacheKey: string): Promise<string> {
const cachePath = join(CACHE_DIR, cacheKey);
if (!FORCE_REFRESH && existsSync(cachePath)) {
return readFileSync(cachePath, 'utf-8');
}
console.log(` fetching ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
const text = await response.text();
mkdirSync(dirname(cachePath), { recursive: true });
writeFileSync(cachePath, text, 'utf-8');
return text;
}
/** Convenience wrapper around {@link fetchTextCached} that parses the cached response as JSON. */
export async function fetchJsonCached<T>(url: string, cacheKey: string): Promise<T> {
return JSON.parse(await fetchTextCached(url, cacheKey)) as T;
}
+12
View File
@@ -0,0 +1,12 @@
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
export const DATA_DIR = join(process.cwd(), 'src', 'assets', 'data');
export function ensureDataDir(): void {
mkdirSync(DATA_DIR, { recursive: true });
}
export function dataPath(fileName: string): string {
return join(DATA_DIR, fileName);
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "bundler",
"rootDir": "../..",
"outDir": "../../out-tsc/etl",
"types": ["node"]
},
"include": ["**/*.ts", "../../src/app/shared/**/*.ts"],
"exclude": ["**/*.spec.ts", "../../src/app/shared/**/*.spec.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}
+31
View File
@@ -0,0 +1,31 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+15
View File
@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}