@
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> @
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -26,3 +26,15 @@
|
||||
npm-debug.log*
|
||||
yarn-debug.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
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugins": {
|
||||
"@tailwindcss/postcss": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +1,35 @@
|
||||
# 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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()`.
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"rootDir": "..",
|
||||
"outDir": "../out-tsc/e2e",
|
||||
"types": ["node", "@playwright/test"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
<app-search></app-search>
|
||||
<router-outlet></router-outlet>
|
||||
@@ -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)
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
@@ -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>);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 771 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 803 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 246 KiB |
@@ -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>
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||