Merge main again: readouts built once, said in the instrument's voice
Main moved while the previous merge was being verified. It brought the shared bodyReadouts builder — one source for a body's measured and derived rows, used by the detail page and the system view's new object card — plus the derived-value asterisk in the HUD readout panel. All of that data flow is kept. The templates it arrived in are restyled to this branch's idiom: the info panel and the object card share the same organism (header, full-bleed readout rows, provenance line, route rail), the object card's route rail sits at the bottom because there the route is the next step rather than the way back, and the derived asterisk and its footnote keep their meaning in sentence case. The card also needed the restyle to render at all — it arrived wearing hud-panel, a class the observatory system no longer defines. Verified: build clean, 527/527 unit, 6/6 e2e, design detector clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -130,15 +130,38 @@ already-running server and leaves it alone on exit).
|
|||||||
## Test
|
## Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PATH=/opt/node/bin:$PATH npm test # 496 tests, 28 files, ~5s
|
PATH=/opt/node/bin:$PATH npm test # 527 tests, 31 files, ~6s
|
||||||
PATH=/opt/node/bin:$PATH npm run build # ~9s
|
PATH=/opt/node/bin:$PATH npm run build # ~12s
|
||||||
PATH=/opt/node/bin:$PATH npm run etl:typecheck
|
PATH=/opt/node/bin:$PATH npm run etl:typecheck
|
||||||
PATH=/opt/node/bin:$PATH npm run e2e:typecheck
|
PATH=/opt/node/bin:$PATH npm run e2e:typecheck
|
||||||
PATH=/opt/node/bin:$PATH npm run e2e -- --workers=1 # 6 tests, ~2.3m
|
PATH=/opt/node/bin:$PATH npm run e2e -- --workers=1 # 6 tests, 3 files, ~2.3m
|
||||||
```
|
```
|
||||||
|
|
||||||
`--workers=1` on the e2e suite is **not optional here** — see Gotchas.
|
`--workers=1` on the e2e suite is **not optional here** — see Gotchas.
|
||||||
|
|
||||||
|
## Build for GitHub Pages
|
||||||
|
|
||||||
|
`.github/workflows/pages.yml` publishes the app on a push to `main`. To reproduce what it builds:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH=/opt/node/bin:$PATH
|
||||||
|
npm run build -- --base-href "/star-map/"
|
||||||
|
cp dist/star-map/browser/index.html dist/star-map/browser/404.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Two things differ from a plain `npm run build`, and both exist because a project site is served
|
||||||
|
from a subdirectory rather than a domain root:
|
||||||
|
|
||||||
|
- **`--base-href`.** `DataLoaderService` fetches its catalogues with relative URLs
|
||||||
|
(`assets/data/stars.bin`), which resolve against `<base>` rather than the current path. Get it
|
||||||
|
wrong and a deep link asks for `/body/assets/data/stars.bin`. The workflow derives it from the
|
||||||
|
repository name so a rename cannot strand it.
|
||||||
|
- **`404.html`.** Pages has no rewrite rules, so `/body/mars` has no file behind it. Serving the
|
||||||
|
app as the 404 body lets the router render the route. The response stays HTTP 404.
|
||||||
|
|
||||||
|
The uploaded artifact is `dist/star-map/browser`, **not** `dist/star-map` — the parent also holds
|
||||||
|
`3rdpartylicenses.txt` and `prerendered-routes.json`, which are not part of the site.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **The wrong Node is first on `PATH`.** `/opt/node22` is v22.22.2; `/opt/node` is v22.23.2.
|
- **The wrong Node is first on `PATH`.** `/opt/node22` is v22.22.2; `/opt/node` is v22.23.2.
|
||||||
@@ -172,6 +195,10 @@ PATH=/opt/node/bin:$PATH npm run e2e -- --workers=1 # 6 tests, ~2.3m
|
|||||||
view) to see Mercury/Venus/Earth/Mars labelled.
|
view) to see Mercury/Venus/Earth/Mars labelled.
|
||||||
- **Port 4300, not Angular's usual 4200** — the project's own convention, set in
|
- **Port 4300, not Angular's usual 4200** — the project's own convention, set in
|
||||||
`playwright.config.ts` so it never collides with an unrelated `ng serve`.
|
`playwright.config.ts` so it never collides with an unrelated `ng serve`.
|
||||||
|
- **A Pages build looks broken if you serve it at the root.** `<base href="/star-map/">` makes
|
||||||
|
every asset resolve under that prefix, so opening `dist/star-map/browser` at `/` gets you a
|
||||||
|
blank page and a wall of 404s. Serve it under the same prefix as Pages does, with unknown paths
|
||||||
|
falling back to `404.html`, or the check tells you nothing.
|
||||||
- **Don't write driver scripts in `.ts` outside the repo.** `tsx` compiles a bare `.ts` as CJS
|
- **Don't write driver scripts in `.ts` outside the repo.** `tsx` compiles a bare `.ts` as CJS
|
||||||
("Top-level await is currently not supported with the cjs output format"), and a script outside
|
("Top-level await is currently not supported with the cjs output format"), and a script outside
|
||||||
the repo can't resolve `@playwright/test` at all. The driver is `.mjs` inside the repo for both
|
the repo can't resolve `@playwright/test` at all. The driver is `.mjs` inside the repo for both
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# Runs on pull requests and on the branch they merge into, so a green tick means the code was
|
# Runs on pull requests and on the branches they merge into, so a green tick means the code was
|
||||||
# checked in the state it will actually land in.
|
# checked in the state it will actually land in. `develop` is where work integrates and `main` is
|
||||||
|
# what it is promoted to; a merge into either is a state nothing else would otherwise check,
|
||||||
|
# since a pull request is checked before the merge rather than after it.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, develop]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
# For the data-refresh workflow: its bot push to main fires no `push` events (GitHub's
|
||||||
|
# recursion guard), so it dispatches CI here explicitly to put checks on the new commit.
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
# A second push to the same branch makes the first run's answer irrelevant.
|
# A second push to the same branch makes the first run's answer irrelevant.
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
name: Refresh the catalogues
|
||||||
|
|
||||||
|
# Re-runs the ETL against the live archives on a schedule and republishes the site when the data
|
||||||
|
# actually changed, so the catalogues track NASA without anyone touching the code. The archives
|
||||||
|
# this reads — HYG, the Exoplanet Archive, JPL Horizons, OpenNGC, Gaia — are all anonymous
|
||||||
|
# public endpoints; no keys are involved.
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Mondays 05:23 UTC. An arbitrary minute rather than :00, which is the busiest minute on
|
||||||
|
# GitHub's cron fleet and the most likely to be delayed or dropped.
|
||||||
|
- cron: '23 5 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Never two refreshes at once, and never cancel one mid-push.
|
||||||
|
concurrency:
|
||||||
|
group: data-refresh
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # push the regenerated catalogues to main
|
||||||
|
actions: write # dispatch the deploy and CI afterwards — see the final step
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
refresh:
|
||||||
|
name: Fetch, gate, publish
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
# The data lives on main and the push below goes to main, whichever ref the workflow
|
||||||
|
# file itself ran from.
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
# The ETL's cache directory is gitignored and this is a fresh runner, so every source is
|
||||||
|
# fetched live (~50-100 MB). A failed fetch fails the run by design — no refresh is
|
||||||
|
# better than a partial one — except Gaia, which the ETL itself treats as best-effort.
|
||||||
|
- name: Rebuild the datasets
|
||||||
|
run: npm run etl
|
||||||
|
|
||||||
|
- name: Detect a real change
|
||||||
|
id: diff
|
||||||
|
run: |
|
||||||
|
if git diff --quiet -- src/assets/data; then
|
||||||
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "The archives published nothing new — catalogues are byte-identical." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
else
|
||||||
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
{ echo "Catalogue changes:"; echo '```'; git diff --stat -- src/assets/data; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The same gates CI runs, run here instead: the push below is made with GITHUB_TOKEN, and
|
||||||
|
# GitHub deliberately fires no workflows for such pushes, so the data must be proven
|
||||||
|
# before it lands rather than checked after.
|
||||||
|
- name: Unit tests against the new data
|
||||||
|
if: steps.diff.outputs.changed == 'true'
|
||||||
|
run: npm test -- --no-watch
|
||||||
|
|
||||||
|
- name: Production build against the new data
|
||||||
|
if: steps.diff.outputs.changed == 'true'
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Commit to main
|
||||||
|
if: steps.diff.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add src/assets/data
|
||||||
|
git commit -m "Refresh the astronomical catalogues" \
|
||||||
|
-m "Scheduled re-run of the ETL against the live archives. Gated on the unit suite and a production build in this same run, because a GITHUB_TOKEN push triggers no CI of its own."
|
||||||
|
git push origin HEAD:main
|
||||||
|
|
||||||
|
# The recursion guard that keeps the bot push from triggering `push` workflows also keeps
|
||||||
|
# it from deploying, so the deploy — and a visible CI record on the new commit — are
|
||||||
|
# dispatched explicitly. Dispatch does go through, unlike push events.
|
||||||
|
- name: Redeploy the site, and put checks on the commit
|
||||||
|
if: steps.diff.outputs.changed == 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh workflow run pages.yml --ref main
|
||||||
|
gh workflow run ci.yml --ref main
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
name: Junie review
|
||||||
|
|
||||||
|
# JetBrains' Junie agent reads each pull request and leaves inline review comments. It runs
|
||||||
|
# alongside CI rather than as part of it: CI answers whether the code works, this answers whether
|
||||||
|
# it reads well, and a review comment should never be able to turn the build red.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, ready_for_review]
|
||||||
|
|
||||||
|
# A review of the previous push is stale the moment a new one lands, so supersede it rather than
|
||||||
|
# letting two reviews comment on the same pull request. Keyed by pull request rather than by ref
|
||||||
|
# so a push to `main` never cancels a review.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
# Read the code, write the review. `issues: write` is what posts the summary comment — GitHub
|
||||||
|
# treats a pull request's conversation timeline as an issue.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
review:
|
||||||
|
name: Review the diff
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Drafts are work in progress and forks cannot see `JUNIE_API_KEY` — GitHub withholds secrets
|
||||||
|
# from `pull_request` runs on forked branches, so the job would fail on a missing key rather
|
||||||
|
# than say anything useful about the code.
|
||||||
|
if: >-
|
||||||
|
github.event.pull_request.draft == false &&
|
||||||
|
github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
env:
|
||||||
|
# Whether the key exists, resolved once here because `secrets` is not one of the contexts a
|
||||||
|
# step's `if` can read, while `env` is.
|
||||||
|
HAS_JUNIE_KEY: ${{ secrets.JUNIE_API_KEY != '' }}
|
||||||
|
steps:
|
||||||
|
# Without the key the action exits on "Missing required input", which would mark every pull
|
||||||
|
# request failed for a reason that has nothing to do with its code. Say so in the run
|
||||||
|
# summary and stop instead — visible to anyone who looks, blocking nobody who doesn't.
|
||||||
|
- name: Explain the absent key
|
||||||
|
if: env.HAS_JUNIE_KEY != 'true'
|
||||||
|
run: |
|
||||||
|
echo "No \`JUNIE_API_KEY\` secret is set, so this pull request was not reviewed." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "Generate a key at <https://junie.jetbrains.com/cli> and add it under" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "Settings → Secrets and variables → Actions." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
if: env.HAS_JUNIE_KEY == 'true'
|
||||||
|
with:
|
||||||
|
# Junie reads the diff through the GitHub API, so the full history it would otherwise
|
||||||
|
# clone is never used.
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- uses: JetBrains/junie-github-action@v1
|
||||||
|
if: env.HAS_JUNIE_KEY == 'true'
|
||||||
|
# An opinion, not a gate. If Junie is down or rate-limited that is worth seeing in the
|
||||||
|
# log, but it is not a reason to hold a pull request whose tests pass.
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
junie_api_key: ${{ secrets.JUNIE_API_KEY }}
|
||||||
|
# The action's built-in review prompt. Replace with a prompt block to review against
|
||||||
|
# criteria of our own.
|
||||||
|
prompt: code-review
|
||||||
|
# Rewrite one comment on each push instead of stacking a new one per revision, so the
|
||||||
|
# conversation shows the current state of the review rather than its history.
|
||||||
|
use_single_comment: "true"
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
name: Pages
|
||||||
|
|
||||||
|
# Publishes the built app to GitHub Pages. `main` only: `develop` is where work integrates and
|
||||||
|
# this is what the world sees, so a deploy should follow a promotion rather than every merge.
|
||||||
|
# `workflow_dispatch` covers the exception — dispatching from another ref publishes that ref.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# One deployment at a time, and never cancel one in flight: a half-replaced site is worse than a
|
||||||
|
# slightly stale one. Queued runs supersede each other, so the newest commit still wins.
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
# `deploy-pages` authenticates to the Pages service with an OIDC token rather than a secret.
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build the site
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
# Verifies Pages is enabled and reports the URL the site will live at. It cannot *enable*
|
||||||
|
# Pages itself: the action's `enablement` input requires an admin-scoped token, which the
|
||||||
|
# workflow's GITHUB_TOKEN is not. If this step fails with "Get Pages site failed", the
|
||||||
|
# one-time fix is Settings → Pages → Source: GitHub Actions, then re-run.
|
||||||
|
- uses: actions/configure-pages@v6
|
||||||
|
|
||||||
|
# A project site is served from a subdirectory, so the app cannot assume it sits at the
|
||||||
|
# root. The base href is what makes the router's URLs and the relative `fetch` calls in
|
||||||
|
# `DataLoaderService` resolve — those are written relative, so they follow `<base>` rather
|
||||||
|
# than the current path, and a deep link still finds `assets/data/*`.
|
||||||
|
#
|
||||||
|
# Derived from the repository name rather than written out, so a rename cannot leave a
|
||||||
|
# stale path here. A user/organisation site or a custom domain would want `/` instead.
|
||||||
|
- name: Build
|
||||||
|
run: npm run build -- --base-href "/${GITHUB_REPOSITORY#*/}/"
|
||||||
|
|
||||||
|
# Pages serves a static tree with no rewrite rules, so `/body/mars` has no file behind it
|
||||||
|
# and comes back 404. Answering that 404 with the app itself lets the router take over and
|
||||||
|
# render the route, which is the standard way to host a history-API app here. The status
|
||||||
|
# stays 404 — search engines notice, humans do not.
|
||||||
|
- name: Serve deep links through the app
|
||||||
|
run: cp dist/star-map/browser/index.html dist/star-map/browser/404.html
|
||||||
|
|
||||||
|
- uses: actions/upload-pages-artifact@v5
|
||||||
|
with:
|
||||||
|
path: dist/star-map/browser
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to Pages
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- id: deployment
|
||||||
|
uses: actions/deploy-pages@v5
|
||||||
@@ -5,7 +5,11 @@ real astronomical data instead of fictional systems. Browse the solar neighbourh
|
|||||||
star's system to see its planets on their real orbits, and drill into a single body for the
|
star's system to see its planets on their real orbits, and drill into a single body for the
|
||||||
NASA figures behind it.
|
NASA figures behind it.
|
||||||
|
|
||||||
This repo also hosts a small Claude Code plugin marketplace — see [Plugins](#plugins) below.
|
**Live at <https://avalon-vanguard.github.io/star-map/>**, published from `main` by
|
||||||
|
[`.github/workflows/pages.yml`](.github/workflows/pages.yml). There is no backend — the whole
|
||||||
|
thing is static files and a few megabytes of baked catalogue.
|
||||||
|
|
||||||
|
The source of a small Claude Code plugin also lives here — see [Plugins](#plugins) below.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -74,6 +78,20 @@ around it. Mercury's, three pixels wide at that range, does not survive either w
|
|||||||
with real photography where NASA/ESA/USGS imagery exists, and a surface derived from the body's
|
with real photography where NASA/ESA/USGS imagery exists, and a surface derived from the body's
|
||||||
own measurements where it does not. See "On surfaces that were never photographed" below.
|
own measurements where it does not. See "On surfaces that were never photographed" below.
|
||||||
|
|
||||||
|
**Object card** — hovering a body in the system view raises its figures over the live scene and
|
||||||
|
clicking pins them, so two planets can be compared without flying out and back; **Full view**
|
||||||
|
opens the detail route when the close-up is wanted. The card and the detail panel are built from
|
||||||
|
one shared view model and one shared set of readouts, which is what stops the same planet reading
|
||||||
|
255 K in one and 254 K in the other.
|
||||||
|
|
||||||
|
**Measured against derived** — every figure is filed under the heading it earns. A published
|
||||||
|
radius is measured; an equilibrium temperature computed from the host star's luminosity is
|
||||||
|
derived, and says so. Orbital period is shown where it can honestly be had: for a heliocentric
|
||||||
|
orbit it follows exactly from the semi-major axis, because in these units the Sun's mass *is* the
|
||||||
|
unit of mass — Mars comes back 687.0 d against a published 686.98. It is deliberately absent for
|
||||||
|
moons, whose elements are relative to a parent planet the catalogue carries no mass for, and for
|
||||||
|
exoplanets, where assuming a solar-mass host would mis-state every planet around an M dwarf.
|
||||||
|
|
||||||
**Search** — name search across stars, solar-system bodies and exoplanets, navigating to the
|
**Search** — name search across stars, solar-system bodies and exoplanets, navigating to the
|
||||||
same place an in-scene click would.
|
same place an in-scene click would.
|
||||||
|
|
||||||
@@ -281,36 +299,22 @@ src/app/
|
|||||||
Milky Way structure
|
Milky Way structure
|
||||||
shared/models/ record contracts shared by the app and the ETL
|
shared/models/ record contracts shared by the app and the ETL
|
||||||
shared/rendering/ skybox, glow sprites, texture catalog
|
shared/rendering/ skybox, glow sprites, texture catalog
|
||||||
|
shared/format/ one formatter per physical quantity, precision following magnitude
|
||||||
shared/state/ navigation store (Angular signals)
|
shared/state/ navigation store (Angular signals)
|
||||||
tools/etl/ build-time data pipeline
|
tools/etl/ build-time data pipeline
|
||||||
e2e/ Playwright end-to-end tests
|
e2e/ Playwright end-to-end tests
|
||||||
|
public/ files served at the site root (favicon, link-preview image)
|
||||||
|
.github/workflows/ CI, the Pages deploy, and an automated review pass
|
||||||
```
|
```
|
||||||
|
|
||||||
The design document behind all of this is `.junie/plans/nasa-star-map.md`.
|
The design document behind all of this is `.junie/plans/nasa-star-map.md`.
|
||||||
|
|
||||||
## Plugins
|
## Plugins
|
||||||
|
|
||||||
This repo doubles as a Claude Code plugin marketplace. Adding it and installing a plugin
|
This repo carries the source of one Claude Code plugin, but no longer publishes itself as a
|
||||||
defaults to **user scope**, meaning the plugin becomes available in *every* project on your
|
marketplace — `.claude-plugin/marketplace.json` was removed, which is the file `/plugin
|
||||||
machine, not just the one you happen to be in:
|
marketplace add` reads. So there is currently no install path from here; the manifest and the
|
||||||
|
plugin's own files are kept so it can be listed from a marketplace of its own later.
|
||||||
```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.
|
|
||||||
|
|
||||||
- **caveman** — `/cs:caveman` ultra-compressed communication mode.
|
- **caveman** — `/cs:caveman` ultra-compressed communication mode.
|
||||||
- Command: [`commands/cs/caveman.md`](commands/cs/caveman.md)
|
- Command: [`commands/cs/caveman.md`](commands/cs/caveman.md)
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ turns until explicit deactivation.
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Ships as part of this repo's plugin marketplace. Installing it via
|
Part of the caveman plugin, whose source lives here. This repo no longer
|
||||||
`/plugin install caveman@star-map` defaults to **user scope**, so this
|
publishes itself as a plugin marketplace, so the agent is not installable
|
||||||
agent is available in every project, not scoped to a single repo. See the
|
from it as things stand. See the [README](../README.md).
|
||||||
[README](../README.md) for `--scope project` / `--scope local` overrides.
|
|
||||||
|
|
||||||
## Activation Triggers
|
## Activation Triggers
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,9 @@ Activate caveman mode. Stays active until explicit deactivation.
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Installed via this repo's plugin marketplace, this command defaults to
|
Part of the caveman plugin, whose source lives here. This repo no longer
|
||||||
**user scope** — available in every project, not just the one it was
|
publishes itself as a plugin marketplace, so the command is not installable
|
||||||
installed from. Override with `--scope project` (shared with collaborators
|
from it as things stand. See the [README](../../README.md).
|
||||||
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
|
## Activation
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
@@ -15,12 +15,10 @@ exactly as-is.
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This skill ships via this repo's plugin marketplace
|
This repo no longer publishes itself as a plugin marketplace, so there is
|
||||||
(`.claude-plugin/marketplace.json`). Installing with
|
currently no install path for this skill from here. Listing it from a
|
||||||
`/plugin install caveman@star-map` defaults to **user scope** — global,
|
marketplace elsewhere is what would make `/plugin install` work again; see
|
||||||
available in every project — instead of being tied to just one repo. Use
|
the [README](../../README.md).
|
||||||
`--scope project` or `--scope local` to narrow it; see the
|
|
||||||
[README](../../README.md).
|
|
||||||
|
|
||||||
## Persistence
|
## Persistence
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|||||||
|
|
||||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||||
import { EngineService } from '../../core/engine/engine.service';
|
import { EngineService } from '../../core/engine/engine.service';
|
||||||
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
|
||||||
import { EARTH_RADIUS_KM } from '../../shared/astro/planet-appearance';
|
|
||||||
import { luminositySolar } from '../../shared/astro/stellar';
|
|
||||||
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||||
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
|
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
|
||||||
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SATURN_RING_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
||||||
@@ -17,6 +14,7 @@ import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
|||||||
import { StarRecord } from '../../shared/models/star.model';
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||||
import { BodyDetailViewModel } from './body-detail.model';
|
import { BodyDetailViewModel } from './body-detail.model';
|
||||||
|
import { buildBodyViewModel } from './body-view-model';
|
||||||
import { InfoPanelComponent } from './info-panel.component';
|
import { InfoPanelComponent } from './info-panel.component';
|
||||||
|
|
||||||
/** Gas giants read as smoother/less rocky than terrestrial bodies under the same lighting rig. */
|
/** Gas giants read as smoother/less rocky than terrestrial bodies under the same lighting rig. */
|
||||||
@@ -127,44 +125,18 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = this.bodies.find((candidate) => candidate.id === id);
|
// Shared with the system view's object card, so the same body cannot read differently there.
|
||||||
const exoplanet = this.exoplanets.find((candidate) => candidate.id === id);
|
const viewModel = buildBodyViewModel(id, { bodies: this.bodies, exoplanets: this.exoplanets, stars: this.stars });
|
||||||
|
if (!viewModel) {
|
||||||
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,
|
|
||||||
appearance: appearanceForBody(body, this.bodies, this.luminosityOf(hostStar)),
|
|
||||||
hasPhotography: bodyTexturePath(body.id) !== undefined
|
|
||||||
});
|
|
||||||
this.navigationStore.selectStar(body.systemStarId);
|
|
||||||
} else if (exoplanet) {
|
|
||||||
const hostStar = this.stars.find((star) => star.id === exoplanet.hostStarId);
|
|
||||||
this.viewModel.set({
|
|
||||||
id: exoplanet.id,
|
|
||||||
name: exoplanet.name,
|
|
||||||
kind: 'exoplanet',
|
|
||||||
hostStarName: exoplanet.hostStarName,
|
|
||||||
radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined,
|
|
||||||
massEarth: exoplanet.massEarth,
|
|
||||||
discoveryYear: exoplanet.discoveryYear,
|
|
||||||
orbit: exoplanet.orbit,
|
|
||||||
appearance: appearanceForExoplanet(exoplanet, this.luminosityOf(hostStar)),
|
|
||||||
hasPhotography: bodyTexturePath(exoplanet.id) !== undefined
|
|
||||||
});
|
|
||||||
if (exoplanet.hostStarId !== null) {
|
|
||||||
this.navigationStore.selectStar(exoplanet.hostStarId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.viewModel.set(undefined);
|
this.viewModel.set(undefined);
|
||||||
this.notFound.set(true);
|
this.notFound.set(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.viewModel.set(viewModel);
|
||||||
|
|
||||||
|
if (viewModel.hostStarId !== undefined) {
|
||||||
|
this.navigationStore.selectStar(viewModel.hostStarId);
|
||||||
|
}
|
||||||
|
|
||||||
this.notFound.set(false);
|
this.notFound.set(false);
|
||||||
this.navigationStore.selectBody(id);
|
this.navigationStore.selectBody(id);
|
||||||
@@ -173,18 +145,6 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The host star's luminosity in solar units, from its own catalogued magnitude and distance.
|
|
||||||
* `null` for an exoplanet whose host never cross-referenced to the star catalogue, which
|
|
||||||
* leaves its planets with no derived temperature rather than a guessed one.
|
|
||||||
*/
|
|
||||||
private luminosityOf(star: StarRecord | undefined): number | null {
|
|
||||||
if (!star) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return luminositySolar({ magnitude: star.magnitude, distancePc: Math.hypot(star.x, star.y, star.z), spectralType: star.spectralType });
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyViewModelToScene(): void {
|
private applyViewModelToScene(): void {
|
||||||
const viewModel = this.viewModel();
|
const viewModel = this.viewModel();
|
||||||
if (!viewModel || !this.planetMaterial) {
|
if (!viewModel || !this.planetMaterial) {
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export interface BodyDetailViewModel {
|
|||||||
name: string;
|
name: string;
|
||||||
kind: BodyDetailKind;
|
kind: BodyDetailKind;
|
||||||
hostStarName: string;
|
hostStarName: string;
|
||||||
|
/**
|
||||||
|
* The host star's catalogue id, so a caller that has the view model does not have to rescan the
|
||||||
|
* catalogues to find what building it already resolved. Undefined for an exoplanet whose host
|
||||||
|
* never cross-referenced to the star catalogue.
|
||||||
|
*/
|
||||||
|
hostStarId?: number;
|
||||||
radiusKm?: number;
|
radiusKm?: number;
|
||||||
massEarth?: number;
|
massEarth?: number;
|
||||||
discoveryYear?: number;
|
discoveryYear?: number;
|
||||||
@@ -26,4 +32,16 @@ export interface BodyDetailViewModel {
|
|||||||
appearance: PlanetAppearance;
|
appearance: PlanetAppearance;
|
||||||
/** True when a real photograph is being shown rather than the derived surface. */
|
/** True when a real photograph is being shown rather than the derived surface. */
|
||||||
hasPhotography: boolean;
|
hasPhotography: boolean;
|
||||||
|
/**
|
||||||
|
* Sidereal orbital period. Measured where the archive published one; otherwise derived from the
|
||||||
|
* semi-major axis for heliocentric orbits, where the central mass is known exactly. Undefined
|
||||||
|
* when neither applies — see `heliocentricPeriodDays`.
|
||||||
|
*/
|
||||||
|
orbitalPeriodDays?: number;
|
||||||
|
/**
|
||||||
|
* Which of those two the period is, so the surfaces can file it under the right heading. A
|
||||||
|
* derived period presented as an observation is the same category of error as presenting a
|
||||||
|
* derived surface as a photograph.
|
||||||
|
*/
|
||||||
|
orbitalPeriodSource?: 'measured' | 'derived';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { PLANET_CLASS_LABELS } from '../../shared/astro/planet-appearance';
|
||||||
|
import { formatAu, formatDensity, formatMassEarth, formatPeriod, formatRadiusKm, formatTemperature } from '../../shared/format/quantity';
|
||||||
|
import { Readout } from '../../shared/models/readout';
|
||||||
|
import { BodyDetailViewModel } from './body-detail.model';
|
||||||
|
|
||||||
|
export const KIND_LABELS: Readonly<Record<BodyDetailViewModel['kind'], string>> = {
|
||||||
|
planet: 'Planet',
|
||||||
|
moon: 'Moon',
|
||||||
|
dwarf: 'Dwarf planet',
|
||||||
|
exoplanet: 'Exoplanet'
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BodyReadouts {
|
||||||
|
readonly kindLabel: string;
|
||||||
|
/** Only what a catalogue actually published for this body. */
|
||||||
|
readonly measured: readonly Readout[];
|
||||||
|
/** Everything computed from those measurements rather than observed directly. */
|
||||||
|
readonly derived: readonly Readout[];
|
||||||
|
/** Says plainly which of the two the surface being drawn is. */
|
||||||
|
readonly provenance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a body into the rows its panels display.
|
||||||
|
*
|
||||||
|
* Shared by the in-map card and the detail page for the same reason `buildBodyViewModel` is:
|
||||||
|
* two independent assemblies of "what do we know about this world" drift, and here the drift
|
||||||
|
* would be in which side of the measured/derived line a quantity falls on — which is the one
|
||||||
|
* distinction these panels exist to make.
|
||||||
|
*/
|
||||||
|
export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts {
|
||||||
|
const measured: Readout[] = [];
|
||||||
|
if (body.radiusKm !== undefined) {
|
||||||
|
measured.push({ label: 'Radius', value: formatRadiusKm(body.radiusKm) });
|
||||||
|
}
|
||||||
|
if (body.massEarth !== undefined) {
|
||||||
|
measured.push({ label: 'Mass', value: formatMassEarth(body.massEarth) });
|
||||||
|
}
|
||||||
|
if (body.orbit.semiMajorAxisAu !== undefined) {
|
||||||
|
measured.push({ label: 'Semi-major axis', value: formatAu(body.orbit.semiMajorAxisAu) });
|
||||||
|
}
|
||||||
|
if (body.orbit.eccentricity !== undefined) {
|
||||||
|
measured.push({ label: 'Eccentricity', value: body.orbit.eccentricity.toFixed(3) });
|
||||||
|
}
|
||||||
|
if (body.orbit.inclinationDeg !== undefined) {
|
||||||
|
measured.push({ label: 'Inclination', value: `${body.orbit.inclinationDeg.toFixed(2)}°` });
|
||||||
|
}
|
||||||
|
// The period sits under whichever heading its provenance calls for. Same number, same field —
|
||||||
|
// a published period is an observation and a computed one is not.
|
||||||
|
if (body.orbitalPeriodDays !== undefined && body.orbitalPeriodSource === 'measured') {
|
||||||
|
measured.push({ label: 'Period', value: formatPeriod(body.orbitalPeriodDays) });
|
||||||
|
}
|
||||||
|
if (body.discoveryYear !== undefined) {
|
||||||
|
measured.push({ label: 'Discovered', value: `${body.discoveryYear}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const derived: Readout[] = [{ label: 'Class', value: PLANET_CLASS_LABELS[body.appearance.planetClass] }];
|
||||||
|
if (body.orbitalPeriodDays !== undefined && body.orbitalPeriodSource === 'derived') {
|
||||||
|
derived.push({ label: 'Period', value: formatPeriod(body.orbitalPeriodDays) });
|
||||||
|
}
|
||||||
|
if (body.appearance.equilibriumTemperatureK !== null) {
|
||||||
|
derived.push({ label: 'Equilibrium temp.', value: formatTemperature(body.appearance.equilibriumTemperatureK) });
|
||||||
|
}
|
||||||
|
if (body.appearance.bulkDensityGramsPerCm3 !== null) {
|
||||||
|
derived.push({ label: 'Bulk density', value: formatDensity(body.appearance.bulkDensityGramsPerCm3) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kindLabel: KIND_LABELS[body.kind], measured, derived, provenance: provenanceFor(body) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The derived surface is a reasoned illustration, and a panel of real measurements sitting next
|
||||||
|
* to it is exactly the context in which it could be mistaken for another one.
|
||||||
|
*/
|
||||||
|
function provenanceFor(body: BodyDetailViewModel): string {
|
||||||
|
if (body.hasPhotography) {
|
||||||
|
return 'Surface: NASA/ESA/USGS photography.';
|
||||||
|
}
|
||||||
|
return body.appearance.equilibriumTemperatureK === null
|
||||||
|
? 'Surface illustrated from this body’s measured size and mass. Its host star is not in the catalogue, so no temperature could be derived. Not an observation — no image of this world exists.'
|
||||||
|
: 'Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — no image of this world exists.';
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
||||||
|
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||||
|
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model';
|
||||||
|
import { buildBodyViewModel, heliocentricPeriodDays } from './body-view-model';
|
||||||
|
|
||||||
|
const orbit = (overrides: Partial<OrbitalElements> = {}): OrbitalElements => ({
|
||||||
|
semiMajorAxisAu: 1,
|
||||||
|
eccentricity: 0.0167,
|
||||||
|
inclinationDeg: 0,
|
||||||
|
longitudeOfAscendingNodeDeg: 0,
|
||||||
|
argumentOfPeriapsisDeg: 0,
|
||||||
|
meanAnomalyAtEpochDeg: 0,
|
||||||
|
epochJd: 2451545,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sun: StarRecord = {
|
||||||
|
id: SUN_STAR_ID,
|
||||||
|
name: 'Sol',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
z: 0,
|
||||||
|
magnitude: -26.7,
|
||||||
|
spectralType: 'G2V',
|
||||||
|
colorIndex: 0.65,
|
||||||
|
};
|
||||||
|
|
||||||
|
const earth: BodyRecord = {
|
||||||
|
id: 'earth',
|
||||||
|
systemStarId: SUN_STAR_ID,
|
||||||
|
name: 'Earth',
|
||||||
|
kind: 'planet',
|
||||||
|
radiusKm: 6371,
|
||||||
|
orbit: orbit(),
|
||||||
|
};
|
||||||
|
const luna: BodyRecord = {
|
||||||
|
id: 'luna',
|
||||||
|
systemStarId: SUN_STAR_ID,
|
||||||
|
name: 'Moon',
|
||||||
|
kind: 'moon',
|
||||||
|
radiusKm: 1737,
|
||||||
|
parentBodyId: 'earth',
|
||||||
|
orbit: orbit({ semiMajorAxisAu: 0.00257 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('heliocentricPeriodDays', () => {
|
||||||
|
it('recovers a known period from the semi-major axis alone', () => {
|
||||||
|
// P² = a³ in these units, so Earth must come back a year.
|
||||||
|
expect(heliocentricPeriodDays(earth)).toBeCloseTo(365.25, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales as the three-halves power', () => {
|
||||||
|
const jupiter: BodyRecord = {
|
||||||
|
...earth,
|
||||||
|
id: 'jupiter',
|
||||||
|
name: 'Jupiter',
|
||||||
|
orbit: orbit({ semiMajorAxisAu: 5.2044 }),
|
||||||
|
};
|
||||||
|
// Jupiter's real sidereal period is 4332.6 days.
|
||||||
|
expect(heliocentricPeriodDays(jupiter)).toBeCloseTo(4335, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to compute a period for a moon', () => {
|
||||||
|
// A moon's elements are relative to its planet, whose mass is not in the catalogue — the
|
||||||
|
// same arithmetic would be wrong by the ratio of that planet's mass to the Sun's.
|
||||||
|
expect(heliocentricPeriodDays(luna)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildBodyViewModel', () => {
|
||||||
|
const catalogues = { bodies: [earth, luna], exoplanets: [] as ExoplanetRecord[], stars: [sun] };
|
||||||
|
|
||||||
|
it('marks a period computed from the semi-major axis as derived', () => {
|
||||||
|
const model = buildBodyViewModel('earth', catalogues);
|
||||||
|
expect(model?.orbitalPeriodSource).toBe('derived');
|
||||||
|
expect(model?.orbitalPeriodDays).toBeCloseTo(365.25, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a moon without a period rather than inventing one', () => {
|
||||||
|
const model = buildBodyViewModel('luna', catalogues);
|
||||||
|
expect(model?.orbitalPeriodDays).toBeUndefined();
|
||||||
|
expect(model?.orbitalPeriodSource).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a published exoplanet period as measured, not derived', () => {
|
||||||
|
const exoplanet: ExoplanetRecord = {
|
||||||
|
id: 'kepler-22-b',
|
||||||
|
hostStarId: null,
|
||||||
|
hostStarName: 'Kepler-22',
|
||||||
|
name: 'Kepler-22 b',
|
||||||
|
periodDays: 289.9,
|
||||||
|
orbit: { semiMajorAxisAu: 0.849 },
|
||||||
|
};
|
||||||
|
const model = buildBodyViewModel('kepler-22-b', {
|
||||||
|
bodies: [],
|
||||||
|
exoplanets: [exoplanet],
|
||||||
|
stars: [],
|
||||||
|
});
|
||||||
|
expect(model?.orbitalPeriodSource).toBe('measured');
|
||||||
|
expect(model?.orbitalPeriodDays).toBe(289.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an exoplanet with no published period undefined rather than assuming a solar-mass host', () => {
|
||||||
|
const exoplanet: ExoplanetRecord = {
|
||||||
|
id: 'x',
|
||||||
|
hostStarId: null,
|
||||||
|
hostStarName: 'X',
|
||||||
|
name: 'X b',
|
||||||
|
orbit: { semiMajorAxisAu: 0.05 },
|
||||||
|
};
|
||||||
|
const model = buildBodyViewModel('x', { bodies: [], exoplanets: [exoplanet], stars: [] });
|
||||||
|
expect(model?.orbitalPeriodDays).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for an id in neither catalogue', () => {
|
||||||
|
expect(buildBodyViewModel('nowhere', catalogues)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the host star id, so callers need not rescan the catalogues for it', () => {
|
||||||
|
expect(buildBodyViewModel('earth', catalogues)?.hostStarId).toBe(SUN_STAR_ID);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
||||||
|
import { EARTH_RADIUS_KM } from '../../shared/astro/planet-appearance';
|
||||||
|
import { luminositySolar } from '../../shared/astro/stellar';
|
||||||
|
import { bodyTexturePath } from '../../shared/rendering/texture-catalog';
|
||||||
|
import { BodyRecord } from '../../shared/models/body.model';
|
||||||
|
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||||
|
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model';
|
||||||
|
import { BodyDetailViewModel } from './body-detail.model';
|
||||||
|
|
||||||
|
/** Everything the view model is assembled from — the three catalogues, already loaded. */
|
||||||
|
export interface BodyCatalogues {
|
||||||
|
readonly bodies: readonly BodyRecord[];
|
||||||
|
readonly exoplanets: readonly ExoplanetRecord[];
|
||||||
|
readonly stars: readonly StarRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bolometric luminosity of a star in solar units, from what the catalogue measured: apparent
|
||||||
|
* magnitude, parallax distance, and a bolometric correction read off the spectral type.
|
||||||
|
*/
|
||||||
|
export function luminosityOf(star: StarRecord | undefined): number | null {
|
||||||
|
if (!star) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return luminositySolar({
|
||||||
|
magnitude: star.magnitude,
|
||||||
|
distancePc: Math.hypot(star.x, star.y, star.z),
|
||||||
|
spectralType: star.spectralType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the flattened view model for one body or exoplanet.
|
||||||
|
*
|
||||||
|
* Shared rather than duplicated per surface: the in-map card and the full detail page show
|
||||||
|
* overlapping subsets of the same quantities, and two independent assemblies of "what do we
|
||||||
|
* know about this world" is exactly the shape of bug where a planet reads 255 K in one panel
|
||||||
|
* and 254 K in the other.
|
||||||
|
*/
|
||||||
|
export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): BodyDetailViewModel | undefined {
|
||||||
|
const body = catalogues.bodies.find((candidate) => candidate.id === id);
|
||||||
|
if (body) {
|
||||||
|
const hostStar = catalogues.stars.find((star) => star.id === body.systemStarId);
|
||||||
|
const periodDays = heliocentricPeriodDays(body);
|
||||||
|
return {
|
||||||
|
id: body.id,
|
||||||
|
name: body.name,
|
||||||
|
kind: body.kind,
|
||||||
|
hostStarName: hostStar?.name ?? 'Unknown star',
|
||||||
|
hostStarId: body.systemStarId,
|
||||||
|
radiusKm: body.radiusKm,
|
||||||
|
orbit: body.orbit,
|
||||||
|
appearance: appearanceForBody(body, catalogues.bodies, luminosityOf(hostStar)),
|
||||||
|
hasPhotography: bodyTexturePath(body.id) !== undefined,
|
||||||
|
orbitalPeriodDays: periodDays,
|
||||||
|
orbitalPeriodSource: periodDays === undefined ? undefined : 'derived',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const exoplanet = catalogues.exoplanets.find((candidate) => candidate.id === id);
|
||||||
|
if (!exoplanet) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const hostStar = catalogues.stars.find((star) => star.id === exoplanet.hostStarId);
|
||||||
|
return {
|
||||||
|
id: exoplanet.id,
|
||||||
|
name: exoplanet.name,
|
||||||
|
kind: 'exoplanet',
|
||||||
|
hostStarName: exoplanet.hostStarName,
|
||||||
|
hostStarId: exoplanet.hostStarId ?? undefined,
|
||||||
|
radiusKm: exoplanet.radiusEarth ? exoplanet.radiusEarth * EARTH_RADIUS_KM : undefined,
|
||||||
|
massEarth: exoplanet.massEarth,
|
||||||
|
discoveryYear: exoplanet.discoveryYear,
|
||||||
|
orbit: exoplanet.orbit,
|
||||||
|
appearance: appearanceForExoplanet(exoplanet, luminosityOf(hostStar)),
|
||||||
|
hasPhotography: bodyTexturePath(exoplanet.id) !== undefined,
|
||||||
|
// `periodDays` is populated for none of the shipped records, and deriving one would need the
|
||||||
|
// host star's mass, which is equally absent. Left undefined rather than assuming a solar-mass
|
||||||
|
// host, which would silently mis-state the period of every planet around an M dwarf.
|
||||||
|
orbitalPeriodDays: exoplanet.periodDays,
|
||||||
|
orbitalPeriodSource: exoplanet.periodDays === undefined ? undefined : 'measured',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kepler's third law for a body orbiting the Sun: P² = a³ with P in years and a in AU, which
|
||||||
|
* holds exactly in these units because the Sun's mass is the unit of mass.
|
||||||
|
*
|
||||||
|
* Only for heliocentric orbits. A moon's elements are relative to its parent planet, whose mass
|
||||||
|
* the catalogue does not carry, so the same arithmetic there would be wrong by the ratio of the
|
||||||
|
* planet's mass to the Sun's — a factor of a thousand for Jupiter.
|
||||||
|
*/
|
||||||
|
export function heliocentricPeriodDays(body: BodyRecord): number | undefined {
|
||||||
|
if (body.parentBodyId !== undefined || body.systemStarId !== SUN_STAR_ID) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const a = body.orbit.semiMajorAxisAu;
|
||||||
|
return a > 0 ? Math.pow(a, 1.5) * 365.25 : undefined;
|
||||||
|
}
|
||||||
@@ -1,21 +1,16 @@
|
|||||||
import { DecimalPipe } from '@angular/common';
|
import { Component, computed, input } from '@angular/core';
|
||||||
import { Component, input } from '@angular/core';
|
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
import { PLANET_CLASS_LABELS } from '../../shared/astro/planet-appearance';
|
import { bodyReadouts } from './body-readouts';
|
||||||
import { BodyDetailViewModel } from './body-detail.model';
|
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
|
* Displays the real NASA data for the currently selected body/exoplanet: kind, physical
|
||||||
* size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only —
|
* size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only —
|
||||||
* `BodyDetailSceneComponent` supplies the view model and owns navigation state.
|
* `BodyDetailSceneComponent` supplies the view model and owns navigation state.
|
||||||
|
*
|
||||||
|
* The rows come from `bodyReadouts`, shared with the system view's object card so the same body
|
||||||
|
* cannot read differently in the two places it can be inspected.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-info-panel',
|
selector: 'app-info-panel',
|
||||||
@@ -36,103 +31,44 @@ const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
|
|||||||
|
|
||||||
<header class="px-4 pt-4 pb-3">
|
<header class="px-4 pt-4 pb-3">
|
||||||
<h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1>
|
<h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1>
|
||||||
<p class="mt-1 truncate text-[10px] tracking-[0.18em] text-accent uppercase">{{ kindLabel() }} · {{ body().hostStarName }}</p>
|
<p class="mt-1 truncate text-[10px] tracking-[0.18em] text-accent uppercase">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Readout rows: label left, figure right, unit tinted back so the number is what the eye
|
<!-- Readout rows: label left, figure right. Tabular figures keep the decimal points
|
||||||
lands on. Tabular figures keep the decimal points aligned down the column. -->
|
aligned down the column. -->
|
||||||
<dl class="divide-y divide-border/25 border-t border-border/40">
|
@if (readouts().measured.length) {
|
||||||
@if (body().radiusKm) {
|
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">Measured</p>
|
||||||
|
<dl class="divide-y divide-border/25">
|
||||||
|
@for (row of readouts().measured; track row.label) {
|
||||||
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
||||||
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Radius</dt>
|
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">{{ row.label }}</dt>
|
||||||
<dd class="text-sm tabular-nums">{{ body().radiusKm | number: '1.0-1' }} <span class="text-muted">km</span></dd>
|
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (body().massEarth) {
|
|
||||||
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
|
||||||
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Mass</dt>
|
|
||||||
<dd class="text-sm tabular-nums">{{ body().massEarth | number: '1.0-2' }} <span class="text-muted">Earth masses</span></dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (body().orbit.semiMajorAxisAu) {
|
|
||||||
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
|
||||||
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Semi-major axis</dt>
|
|
||||||
<dd class="text-sm tabular-nums">{{ body().orbit.semiMajorAxisAu | number: '1.0-4' }} <span class="text-muted">AU</span></dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (body().orbit.eccentricity !== undefined) {
|
|
||||||
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
|
||||||
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Eccentricity</dt>
|
|
||||||
<dd class="text-sm tabular-nums">{{ body().orbit.eccentricity | number: '1.0-4' }}</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (body().orbit.inclinationDeg !== undefined) {
|
|
||||||
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
|
||||||
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Inclination</dt>
|
|
||||||
<dd class="text-sm tabular-nums">{{ body().orbit.inclinationDeg | number: '1.0-2' }}<span class="text-muted">°</span></dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (body().discoveryYear) {
|
|
||||||
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
|
||||||
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Discovered</dt>
|
|
||||||
<dd class="text-sm tabular-nums">{{ body().discoveryYear }}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
|
}
|
||||||
|
|
||||||
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">Derived</p>
|
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">Derived</p>
|
||||||
<dl class="divide-y divide-border/25">
|
<dl class="divide-y divide-border/25">
|
||||||
|
@for (row of readouts().derived; track row.label) {
|
||||||
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
||||||
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Class</dt>
|
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">{{ row.label }}</dt>
|
||||||
<dd class="text-sm">{{ classLabel() }}</dd>
|
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
|
||||||
</div>
|
|
||||||
@if (body().appearance.equilibriumTemperatureK !== null) {
|
|
||||||
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
|
||||||
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Equilibrium temp.</dt>
|
|
||||||
<dd class="text-sm tabular-nums">{{ body().appearance.equilibriumTemperatureK | number: '1.0-0' }} <span class="text-muted">K</span></dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (body().appearance.bulkDensityGramsPerCm3 !== null) {
|
|
||||||
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
|
||||||
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Bulk density</dt>
|
|
||||||
<dd class="text-sm tabular-nums">{{ body().appearance.bulkDensityGramsPerCm3 | number: '1.0-2' }} <span class="text-muted">g/cm³</span></dd>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<p class="border-t border-border/40 px-4 py-3 text-[10px] leading-relaxed text-muted">{{ surfaceProvenance() }}</p>
|
<p class="border-t border-border/40 px-4 py-3 text-[10px] leading-relaxed text-muted">{{ readouts().provenance }}</p>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`
|
||||||
imports: [DecimalPipe]
|
|
||||||
})
|
})
|
||||||
export class InfoPanelComponent {
|
export class InfoPanelComponent {
|
||||||
readonly body = input.required<BodyDetailViewModel>();
|
readonly body = input.required<BodyDetailViewModel>();
|
||||||
|
|
||||||
|
readonly readouts = computed(() => bodyReadouts(this.body()));
|
||||||
|
|
||||||
constructor(private readonly router: Router) {}
|
constructor(private readonly router: Router) {}
|
||||||
|
|
||||||
kindLabel(): string {
|
|
||||||
return KIND_LABELS[this.body().kind];
|
|
||||||
}
|
|
||||||
|
|
||||||
classLabel(): string {
|
|
||||||
return PLANET_CLASS_LABELS[this.body().appearance.planetClass];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Says plainly which of the two the viewer is looking at. The derived surface is a reasoned
|
|
||||||
* illustration, and a panel of real measurements sitting next to it is exactly the context in
|
|
||||||
* which it could be mistaken for another one.
|
|
||||||
*/
|
|
||||||
surfaceProvenance(): string {
|
|
||||||
if (this.body().hasPhotography) {
|
|
||||||
return 'Surface: NASA/ESA/USGS photography.';
|
|
||||||
}
|
|
||||||
const temperature = this.body().appearance.equilibriumTemperatureK;
|
|
||||||
return temperature === null
|
|
||||||
? 'Surface illustrated from this body’s measured size and mass. Its host star is not in the catalogue, so no temperature could be derived. Not an observation — no image of this world exists.'
|
|
||||||
: 'Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — no image of this world exists.';
|
|
||||||
}
|
|
||||||
|
|
||||||
goBack(): void {
|
goBack(): void {
|
||||||
void this.router.navigate(['/']);
|
void this.router.navigate(['/']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|||||||
|
|
||||||
import { dateToJulianDate } from '../../shared/astro/constants';
|
import { dateToJulianDate } from '../../shared/astro/constants';
|
||||||
import { galacticCentrePositionPc, galacticToEquatorial, MILKY_WAY_ARMS, SUN_GALACTOCENTRIC_RADIUS_PC } from '../../shared/astro/galaxy';
|
import { galacticCentrePositionPc, galacticToEquatorial, MILKY_WAY_ARMS, SUN_GALACTOCENTRIC_RADIUS_PC } from '../../shared/astro/galaxy';
|
||||||
import { luminositySolar } from '../../shared/astro/stellar';
|
|
||||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||||
import { EngineService } from '../../core/engine/engine.service';
|
import { EngineService } from '../../core/engine/engine.service';
|
||||||
import { BodyRecord } from '../../shared/models/body.model';
|
import { BodyRecord } from '../../shared/models/body.model';
|
||||||
@@ -20,7 +19,11 @@ import { DeepSkyRenderer } from './deep-sky-renderer';
|
|||||||
import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
|
import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
|
||||||
import { MilkyWayRenderer } from './milky-way-renderer';
|
import { MilkyWayRenderer } from './milky-way-renderer';
|
||||||
import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing';
|
import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing';
|
||||||
|
import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity';
|
||||||
|
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
|
||||||
|
import { buildBodyViewModel, luminosityOf } from '../body-detail/body-view-model';
|
||||||
import { HudReadout, StarmapHudComponent } from './starmap-hud.component';
|
import { HudReadout, StarmapHudComponent } from './starmap-hud.component';
|
||||||
|
import { SystemObjectCardComponent } from './system-object-card.component';
|
||||||
import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
|
import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
|
||||||
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
||||||
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
||||||
@@ -125,15 +128,6 @@ const EXIT_DURATION_SECONDS = 0.9;
|
|||||||
const RETURN_DURATION_SECONDS = 1.1;
|
const RETURN_DURATION_SECONDS = 1.1;
|
||||||
const GALACTIC_FLIGHT_SECONDS = 2.4;
|
const GALACTIC_FLIGHT_SECONDS = 2.4;
|
||||||
|
|
||||||
/** Camera range for the readout panel, in the unit that suits the distance. */
|
|
||||||
function formatParsecs(distancePc: number): string {
|
|
||||||
return distancePc >= 1000 ? `${(distancePc / 1000).toFixed(1)} kpc` : `${distancePc.toFixed(distancePc < 10 ? 2 : 0)} pc`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAu(distanceAu: number): string {
|
|
||||||
return distanceAu >= 100 ? `${distanceAu.toFixed(0)} AU` : `${distanceAu.toFixed(2)} AU`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the camera sits to hold the whole Galaxy: above the disc and back past the Sun, looking
|
* Where the camera sits to hold the whole Galaxy: above the disc and back past the Sun, looking
|
||||||
* at the centre — near enough to the angle the Galaxy is usually drawn from, and it keeps the
|
* at the centre — near enough to the angle the Galaxy is usually drawn from, and it keeps the
|
||||||
@@ -157,7 +151,7 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-galaxy-system-scene',
|
selector: 'app-galaxy-system-scene',
|
||||||
providers: [EngineService],
|
providers: [EngineService],
|
||||||
imports: [StarmapHudComponent],
|
imports: [StarmapHudComponent, SystemObjectCardComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="relative h-full w-full">
|
<div class="relative h-full w-full">
|
||||||
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
|
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
|
||||||
@@ -172,6 +166,9 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
|
|||||||
[range]="hudRange()"
|
[range]="hudRange()"
|
||||||
(levelSelected)="goToLevel($event)"
|
(levelSelected)="goToLevel($event)"
|
||||||
/>
|
/>
|
||||||
|
@if (objectCard(); as card) {
|
||||||
|
<app-system-object-card [body]="card" (dismissed)="dismissObjectCard()" (openRequested)="openObjectDetail(card.id)" />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
@@ -194,6 +191,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
readonly hudNote = signal('');
|
readonly hudNote = signal('');
|
||||||
readonly hudRange = signal('');
|
readonly hudRange = signal('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The body whose card is showing: whichever is pinned by a click, else whatever the pointer is
|
||||||
|
* over. Undefined outside the system view, and cleared when the view leaves one.
|
||||||
|
*/
|
||||||
|
readonly objectCard = signal<BodyDetailViewModel | undefined>(undefined);
|
||||||
|
private enterableSystems = 0;
|
||||||
|
private pinnedBodyId: string | null = null;
|
||||||
|
private hoveredBodyId: string | null = null;
|
||||||
|
|
||||||
private controls?: OrbitControls;
|
private controls?: OrbitControls;
|
||||||
private rig?: CameraRigController;
|
private rig?: CameraRigController;
|
||||||
private starField?: StarFieldRenderer;
|
private starField?: StarFieldRenderer;
|
||||||
@@ -251,6 +257,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.resizeObserver?.disconnect();
|
this.resizeObserver?.disconnect();
|
||||||
this.canvasRef().nativeElement.removeEventListener('pointerdown', this.handlePointerDown);
|
this.canvasRef().nativeElement.removeEventListener('pointerdown', this.handlePointerDown);
|
||||||
this.canvasRef().nativeElement.removeEventListener('click', this.handleClick);
|
this.canvasRef().nativeElement.removeEventListener('click', this.handleClick);
|
||||||
|
this.canvasRef().nativeElement.removeEventListener('pointermove', this.handlePointerMove);
|
||||||
this.controls?.dispose();
|
this.controls?.dispose();
|
||||||
this.starField?.dispose();
|
this.starField?.dispose();
|
||||||
this.deepSky?.dispose();
|
this.deepSky?.dispose();
|
||||||
@@ -341,6 +348,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
||||||
this.bodies = bodies;
|
this.bodies = bodies;
|
||||||
this.exoplanets = exoplanets;
|
this.exoplanets = exoplanets;
|
||||||
|
// Which stars can be flown into: those with catalogued bodies of their own, plus the Sun.
|
||||||
|
this.enterableSystems = new Set<number>([...bodies.map((body) => body.systemStarId), ...exoplanets.map((exoplanet) => exoplanet.hostStarId)].filter((id) => id !== null)).size;
|
||||||
// Built once rather than per label refresh: it is a scan of every body and exoplanet, and the
|
// Built once rather than per label refresh: it is a scan of every body and exoplanet, and the
|
||||||
// labels are recomputed whenever the camera moves.
|
// labels are recomputed whenever the camera moves.
|
||||||
this.starIdsWithBodies = new Set([...bodies.map((body) => body.systemStarId), ...exoplanets.map((exoplanet) => exoplanet.hostStarId)].filter(
|
this.starIdsWithBodies = new Set([...bodies.map((body) => body.systemStarId), ...exoplanets.map((exoplanet) => exoplanet.hostStarId)].filter(
|
||||||
@@ -389,6 +398,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
canvas.addEventListener('pointerdown', this.handlePointerDown);
|
canvas.addEventListener('pointerdown', this.handlePointerDown);
|
||||||
canvas.addEventListener('click', this.handleClick);
|
canvas.addEventListener('click', this.handleClick);
|
||||||
|
canvas.addEventListener('pointermove', this.handlePointerMove);
|
||||||
this.observeResize(canvas);
|
this.observeResize(canvas);
|
||||||
|
|
||||||
this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(camera, deltaSeconds));
|
this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(camera, deltaSeconds));
|
||||||
@@ -628,13 +638,19 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
if (this.systemGroup.visible && star) {
|
if (this.systemGroup.visible && star) {
|
||||||
const planetCount = this.bodies.filter((body) => body.systemStarId === star.id && !body.parentBodyId).length + this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id).length;
|
const planetCount = this.bodies.filter((body) => body.systemStarId === star.id && !body.parentBodyId).length + this.exoplanets.filter((exoplanet) => exoplanet.hostStarId === star.id).length;
|
||||||
|
const moonCount = this.bodies.filter((body) => body.systemStarId === star.id && body.parentBodyId).length;
|
||||||
|
const distancePc = Math.hypot(star.x, star.y, star.z);
|
||||||
|
const luminosity = luminosityOf(star);
|
||||||
this.hudEyebrow.set('System');
|
this.hudEyebrow.set('System');
|
||||||
this.hudTitle.set(star.name);
|
this.hudTitle.set(star.name);
|
||||||
this.hudSubtitle.set(star.spectralType ? `Spectral type ${star.spectralType}` : '');
|
this.hudSubtitle.set(star.spectralType ? `Spectral type ${star.spectralType}` : '');
|
||||||
this.hudReadouts.set([
|
this.hudReadouts.set([
|
||||||
{ label: 'Bodies', value: `${planetCount}` },
|
{ label: 'Bodies', value: moonCount > 0 ? `${planetCount} + ${moonCount} moons` : `${planetCount}` },
|
||||||
{ label: 'Distance', value: `${Math.hypot(star.x, star.y, star.z).toFixed(2)} pc` },
|
// Suppressed for the Sun rather than printed as `0.00 pc`, which is arithmetically right
|
||||||
{ label: 'Magnitude', value: star.magnitude.toFixed(2) }
|
// and reads as a bug: the distance from here to here is not a measurement.
|
||||||
|
...(distancePc > 0 ? [{ label: 'Distance', value: formatParsecs(distancePc) }] : []),
|
||||||
|
{ label: 'Magnitude', value: star.magnitude.toFixed(2) },
|
||||||
|
...(luminosity !== null ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : [])
|
||||||
]);
|
]);
|
||||||
this.hudNote.set('Orbits propagated from published elements to the current date.');
|
this.hudNote.set('Orbits propagated from published elements to the current date.');
|
||||||
this.hudRange.set(formatAu(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)));
|
this.hudRange.set(formatAu(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)));
|
||||||
@@ -665,7 +681,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
// what it draws. See `STAR_RENDER_BUDGET`.
|
// what it draws. See `STAR_RENDER_BUDGET`.
|
||||||
{ label: 'Stars', value: this.starField && this.starField.drawnCount < this.stars.length ? `${this.starField.drawnCount} / ${this.stars.length}` : `${this.stars.length}` },
|
{ label: 'Stars', value: this.starField && this.starField.drawnCount < this.stars.length ? `${this.starField.drawnCount} / ${this.stars.length}` : `${this.stars.length}` },
|
||||||
{ label: 'Radius', value: `${LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]} pc` },
|
{ label: 'Radius', value: `${LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]} pc` },
|
||||||
{ label: 'Exoplanets', value: `${this.exoplanets.length}` }
|
{ label: 'Exoplanets', value: `${this.exoplanets.length}` },
|
||||||
|
// The one thing the field itself cannot show: which of those points can be flown into.
|
||||||
|
{ label: 'Systems', value: `${this.enterableSystems}` }
|
||||||
]);
|
]);
|
||||||
this.hudNote.set('Positions from measured parallaxes. Grid marks the galactic plane through the Sun.');
|
this.hudNote.set('Positions from measured parallaxes. Grid marks the galactic plane through the Sun.');
|
||||||
}
|
}
|
||||||
@@ -714,16 +732,76 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a body in the system view. Clicking one pins its card; clicking empty space unpins,
|
||||||
|
* which is also how the card is dismissed without aiming for its close control.
|
||||||
|
*
|
||||||
|
* This used to navigate straight to `/body/:id`. That tore down the system scene and the camera
|
||||||
|
* with it, so comparing two planets meant flying back into the system between each — the card
|
||||||
|
* shows the same numbers over the live view instead, and `Full view` still opens the route.
|
||||||
|
*/
|
||||||
private handleSystemClick(): void {
|
private handleSystemClick(): void {
|
||||||
if (!this.systemRenderer) {
|
if (!this.systemRenderer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [hit] = this.raycaster.intersectObjects(this.systemRenderer.pickableObjects);
|
const [hit] = this.raycaster.intersectObjects(this.systemRenderer.pickableObjects);
|
||||||
const member = hit ? this.systemRenderer.memberForObject(hit.object) : undefined;
|
const member = hit ? this.systemRenderer.memberForObject(hit.object) : undefined;
|
||||||
|
|
||||||
|
this.pinnedBodyId = member ? member.id : null;
|
||||||
if (member) {
|
if (member) {
|
||||||
this.navigationStore.selectBody(member.id);
|
this.navigationStore.selectBody(member.id);
|
||||||
void this.router.navigate(['/body', member.id]);
|
|
||||||
}
|
}
|
||||||
|
this.refreshObjectCard();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hover preview, so a body's figures can be read without committing a click.
|
||||||
|
*
|
||||||
|
* The raycast is against the system's own handful of pickable meshes rather than the star field,
|
||||||
|
* so it stays cheap even on a software rasterizer — it is the rendering that is slow in that
|
||||||
|
* environment, not the picking. Skipped outside the system view and during a camera flight.
|
||||||
|
*/
|
||||||
|
private readonly handlePointerMove = (event: PointerEvent): void => {
|
||||||
|
if (!this.systemRenderer || !this.systemGroup.visible || this.rig?.isAnimating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvas = this.canvasRef().nativeElement;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const pointerNdc = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
|
||||||
|
this.raycaster.setFromCamera(pointerNdc, this.engine.getCamera());
|
||||||
|
|
||||||
|
const [hit] = this.raycaster.intersectObjects(this.systemRenderer.pickableObjects);
|
||||||
|
const hoveredId = (hit ? this.systemRenderer.memberForObject(hit.object) : undefined)?.id ?? null;
|
||||||
|
if (hoveredId === this.hoveredBodyId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.hoveredBodyId = hoveredId;
|
||||||
|
canvas.style.cursor = hoveredId ? 'pointer' : '';
|
||||||
|
this.refreshObjectCard();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A pinned body wins over a hovered one, so the card does not change under the pointer. */
|
||||||
|
private refreshObjectCard(): void {
|
||||||
|
const id = this.pinnedBodyId ?? this.hoveredBodyId;
|
||||||
|
this.objectCard.set(id === null ? undefined : buildBodyViewModel(id, { bodies: this.bodies, exoplanets: this.exoplanets, stars: this.stars }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the card and everything that would bring it straight back. */
|
||||||
|
private clearObjectCard(): void {
|
||||||
|
this.pinnedBodyId = null;
|
||||||
|
this.hoveredBodyId = null;
|
||||||
|
this.objectCard.set(undefined);
|
||||||
|
this.canvasRef().nativeElement.style.cursor = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissObjectCard(): void {
|
||||||
|
this.clearObjectCard();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The deliberate step out to the dedicated route, from the card's own control. */
|
||||||
|
openObjectDetail(id: string): void {
|
||||||
|
this.navigationStore.selectBody(id);
|
||||||
|
void this.router.navigate(['/body', id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reacts to `NavigationStore.selectedStarId` changes coming from any source (click/search). */
|
/** Reacts to `NavigationStore.selectedStarId` changes coming from any source (click/search). */
|
||||||
@@ -797,7 +875,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
// exoplanets, so it has no meaningful direction and the renderer falls back.
|
// exoplanets, so it has no meaningful direction and the renderer falls back.
|
||||||
// The star's luminosity, derived from its own catalogued magnitude and distance, is what
|
// The star's luminosity, derived from its own catalogued magnitude and distance, is what
|
||||||
// decides how hot each body in the system is — and so what each of them looks like.
|
// decides how hot each body in the system is — and so what each of them looks like.
|
||||||
const hostLuminosity = luminositySolar({ magnitude: star.magnitude, distancePc: Math.hypot(star.x, star.y, star.z), spectralType: star.spectralType });
|
const hostLuminosity = luminosityOf(star);
|
||||||
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity);
|
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity);
|
||||||
this.systemGroup.add(this.systemRenderer.object);
|
this.systemGroup.add(this.systemRenderer.object);
|
||||||
|
|
||||||
@@ -881,6 +959,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
this.systemGroup.visible = false;
|
this.systemGroup.visible = false;
|
||||||
this.galaxyGroup.visible = true;
|
this.galaxyGroup.visible = true;
|
||||||
|
// The bodies it described are no longer on screen, and a stale pin would otherwise survive
|
||||||
|
// into the next system entered.
|
||||||
|
this.clearObjectCard();
|
||||||
|
|
||||||
camera.near = GALAXY_NEAR_PC;
|
camera.near = GALAXY_NEAR_PC;
|
||||||
camera.far = GALAXY_FAR_PC;
|
camera.far = GALAXY_FAR_PC;
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { ViewLevel } from '../../shared/state/navigation.store';
|
|||||||
export interface HudReadout {
|
export interface HudReadout {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly value: string;
|
readonly value: string;
|
||||||
|
/**
|
||||||
|
* True when the figure was computed from other measurements rather than catalogued directly.
|
||||||
|
* Marked in the panel and explained in its footnote, so a reasoned number is never mistaken for
|
||||||
|
* an observed one.
|
||||||
|
*/
|
||||||
|
readonly derived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LadderStep {
|
interface LadderStep {
|
||||||
@@ -101,14 +107,14 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
|||||||
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
||||||
@for (readout of readouts(); track readout.label) {
|
@for (readout of readouts(); track readout.label) {
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">{{ readout.label }}</dt>
|
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt>
|
||||||
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
}
|
}
|
||||||
@if (note()) {
|
@if (note() || hasDerived()) {
|
||||||
<p class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted">{{ note() }}</p>
|
<p class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted">@if (hasDerived()) {<span class="text-accent/80">*</span> Derived, not catalogued. }{{ note() }}</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -128,6 +134,9 @@ export class StarmapHudComponent {
|
|||||||
readonly readouts = input<readonly HudReadout[]>([]);
|
readonly readouts = input<readonly HudReadout[]>([]);
|
||||||
/** Standing caveat for the current view, e.g. that galactic structure is a model. */
|
/** Standing caveat for the current view, e.g. that galactic structure is a model. */
|
||||||
readonly note = input('');
|
readonly note = input('');
|
||||||
|
/** Whether any readout needs the derived-value footnote. */
|
||||||
|
readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived));
|
||||||
|
|
||||||
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
|
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
|
||||||
readonly range = input('');
|
readonly range = input('');
|
||||||
readonly showReticle = input(true);
|
readonly showReticle = input(true);
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
|
||||||
|
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
||||||
|
import { SystemObjectCardComponent } from './system-object-card.component';
|
||||||
|
|
||||||
|
const appearance = (overrides: Partial<PlanetAppearance> = {}): PlanetAppearance =>
|
||||||
|
({
|
||||||
|
planetClass: 'temperate',
|
||||||
|
palette: { structure: 'mottled' },
|
||||||
|
equilibriumTemperatureK: 255,
|
||||||
|
bulkDensityGramsPerCm3: 5.51,
|
||||||
|
polarCapExtentDeg: 25,
|
||||||
|
seed: 1,
|
||||||
|
...overrides,
|
||||||
|
}) as PlanetAppearance;
|
||||||
|
|
||||||
|
const earth: BodyDetailViewModel = {
|
||||||
|
id: 'earth',
|
||||||
|
name: 'Earth',
|
||||||
|
kind: 'planet',
|
||||||
|
hostStarName: 'Sol',
|
||||||
|
radiusKm: 6371,
|
||||||
|
orbit: { semiMajorAxisAu: 1, eccentricity: 0.0167 },
|
||||||
|
appearance: appearance(),
|
||||||
|
hasPhotography: true,
|
||||||
|
orbitalPeriodDays: 365.25,
|
||||||
|
orbitalPeriodSource: 'derived',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SystemObjectCardComponent', () => {
|
||||||
|
let fixture: ComponentFixture<SystemObjectCardComponent>;
|
||||||
|
|
||||||
|
function render(body: BodyDetailViewModel): HTMLElement {
|
||||||
|
fixture.componentRef.setInput('body', body);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture.nativeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The label/value pairs under one of the two headings, in order. */
|
||||||
|
function block(host: HTMLElement, heading: 'Measured' | 'Derived'): string[] {
|
||||||
|
const headings = [...host.querySelectorAll('p')].filter((p) => p.textContent?.trim() === heading);
|
||||||
|
const list = headings[0]?.nextElementSibling;
|
||||||
|
return list ? [...list.querySelectorAll('dt')].map((dt) => dt.textContent?.trim() ?? '') : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [SystemObjectCardComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
fixture = TestBed.createComponent(SystemObjectCardComponent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('files a period computed from the semi-major axis under Derived', () => {
|
||||||
|
const host = render(earth);
|
||||||
|
expect(block(host, 'Derived')).toContain('Period');
|
||||||
|
expect(block(host, 'Measured')).not.toContain('Period');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('files a published period under Measured instead', () => {
|
||||||
|
// Same field, opposite heading — the provenance flag is what decides, not the field's name.
|
||||||
|
const host = render({ ...earth, orbitalPeriodSource: 'measured' });
|
||||||
|
expect(block(host, 'Measured')).toContain('Period');
|
||||||
|
expect(block(host, 'Derived')).not.toContain('Period');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the period entirely when there is none to show', () => {
|
||||||
|
const host = render({ ...earth, orbitalPeriodDays: undefined, orbitalPeriodSource: undefined });
|
||||||
|
expect(block(host, 'Measured')).not.toContain('Period');
|
||||||
|
expect(block(host, 'Derived')).not.toContain('Period');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never shows an empty Measured block', () => {
|
||||||
|
const bare: BodyDetailViewModel = {
|
||||||
|
...earth,
|
||||||
|
radiusKm: undefined,
|
||||||
|
orbit: {},
|
||||||
|
orbitalPeriodDays: undefined,
|
||||||
|
orbitalPeriodSource: undefined,
|
||||||
|
discoveryYear: undefined,
|
||||||
|
};
|
||||||
|
expect(render(bare).textContent).not.toContain('Measured');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says a photographed surface is a photograph', () => {
|
||||||
|
expect(render(earth).textContent).toContain('photography');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says an illustrated surface is not an observation', () => {
|
||||||
|
expect(render({ ...earth, hasPhotography: false }).textContent).toContain('Not an observation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains a missing temperature rather than leaving the row blank', () => {
|
||||||
|
const host = render({
|
||||||
|
...earth,
|
||||||
|
hasPhotography: false,
|
||||||
|
appearance: appearance({ equilibriumTemperatureK: null }),
|
||||||
|
});
|
||||||
|
expect(block(host, 'Derived')).not.toContain('Equilibrium temp.');
|
||||||
|
expect(host.textContent).toContain('host star is not in the catalogue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits rather than navigating, so the scene decides what selection means', () => {
|
||||||
|
const host = render(earth);
|
||||||
|
let opened = 0;
|
||||||
|
let dismissed = 0;
|
||||||
|
fixture.componentInstance.openRequested.subscribe(() => (opened += 1));
|
||||||
|
fixture.componentInstance.dismissed.subscribe(() => (dismissed += 1));
|
||||||
|
|
||||||
|
host.querySelector<HTMLButtonElement>('button[aria-label="Close"]')!.click();
|
||||||
|
[...host.querySelectorAll('button')].find((button) => button.textContent?.includes('Full view'))!.click();
|
||||||
|
|
||||||
|
expect({ opened, dismissed }).toEqual({ opened: 1, dismissed: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||||
|
|
||||||
|
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
|
||||||
|
import { bodyReadouts } from '../body-detail/body-readouts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card shown for a body picked in the system view, without leaving it.
|
||||||
|
*
|
||||||
|
* Selecting a planet used to navigate straight to `/body/:id`, which tore down the system scene
|
||||||
|
* and the camera position with it — so comparing two planets meant flying back in twice. This
|
||||||
|
* shows the same numbers over the live view, and keeps the route as the deliberate step for the
|
||||||
|
* full 3D inspection.
|
||||||
|
*
|
||||||
|
* The rows themselves come from `bodyReadouts`, shared with the detail page. Presentational only.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-system-object-card',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
// Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against
|
||||||
|
// the scene rather than against whatever box the inline default would have left it in — which
|
||||||
|
// put the card off the bottom-left corner of the viewport entirely.
|
||||||
|
host: { class: 'pointer-events-none absolute inset-0 block' },
|
||||||
|
template: `
|
||||||
|
<!-- Positioning and panel styling stay on separate elements, so the panel's own layout
|
||||||
|
never fights the absolute placement. Same organism as the detail page's info panel:
|
||||||
|
header, full-bleed readout rows, provenance, and a route rail — there at the top,
|
||||||
|
here at the bottom, because here the route is the next step rather than the way back. -->
|
||||||
|
<div class="pointer-events-auto absolute top-16 right-6 w-80 max-w-[calc(100%-3rem)]">
|
||||||
|
<div data-testid="object-card" class="hud-brackets hud-acquire border border-border/60 bg-panel/92 font-body text-text backdrop-blur-md">
|
||||||
|
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3">
|
||||||
|
<header class="min-w-0">
|
||||||
|
<p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p>
|
||||||
|
<p class="mt-1 truncate text-[10px] tracking-[0.18em] text-accent uppercase">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||||
|
</header>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="dismissed.emit()"
|
||||||
|
aria-label="Close"
|
||||||
|
class="-mt-2 -mr-2 shrink-0 p-1 text-muted transition-colors hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
<svg class="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||||
|
<path d="M3 3l8 8M11 3l-8 8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (readouts().measured.length) {
|
||||||
|
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">Measured</p>
|
||||||
|
<dl class="divide-y divide-border/25">
|
||||||
|
@for (row of readouts().measured; track row.label) {
|
||||||
|
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
||||||
|
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">{{ row.label }}</dt>
|
||||||
|
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">Derived</p>
|
||||||
|
<dl class="divide-y divide-border/25">
|
||||||
|
@for (row of readouts().derived; track row.label) {
|
||||||
|
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
||||||
|
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">{{ row.label }}</dt>
|
||||||
|
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p class="border-t border-border/40 px-4 py-3 text-[10px] leading-relaxed text-muted">{{ readouts().provenance }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="openRequested.emit()"
|
||||||
|
class="flex w-full items-center justify-center gap-1.5 border-t border-border/40 px-3 py-2 text-[10px] tracking-[0.16em] text-muted uppercase transition-colors hover:bg-accent/8 hover:text-accent focus-visible:bg-accent/12 focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
Full view
|
||||||
|
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M9 6l6 6-6 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class SystemObjectCardComponent {
|
||||||
|
readonly body = input.required<BodyDetailViewModel>();
|
||||||
|
/** The close control, and anything else that should dismiss the card. */
|
||||||
|
readonly dismissed = output<void>();
|
||||||
|
/** Request for the full `/body/:id` route. */
|
||||||
|
readonly openRequested = output<void>();
|
||||||
|
|
||||||
|
readonly readouts = computed(() => bodyReadouts(this.body()));
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { formatAu, formatDensity, formatLuminosity, formatMassEarth, formatParsecs, formatPeriod, formatRadiusKm, formatTemperature } from './quantity';
|
||||||
|
|
||||||
|
describe('formatParsecs', () => {
|
||||||
|
it('switches to kiloparsecs past a thousand parsecs', () => {
|
||||||
|
expect(formatParsecs(8180)).toBe('8.2 kpc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps two decimals only while they carry information', () => {
|
||||||
|
expect(formatParsecs(1.3)).toBe('1.30 pc');
|
||||||
|
expect(formatParsecs(250)).toBe('250 pc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatAu', () => {
|
||||||
|
it('holds four decimals for close-in orbits', () => {
|
||||||
|
// 0.0026 AU is a real published semi-major axis; two decimals would render it — and every
|
||||||
|
// other hot Jupiter — as a flat 0.00.
|
||||||
|
expect(formatAu(0.0026)).toBe('0.0026 AU');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops to whole units past 100 AU', () => {
|
||||||
|
expect(formatAu(120)).toBe('120 AU');
|
||||||
|
expect(formatAu(5.53)).toBe('5.53 AU');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatPeriod', () => {
|
||||||
|
it('uses hours below a day', () => {
|
||||||
|
expect(formatPeriod(0.5)).toBe('12.0 h');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses days through the short end and years beyond', () => {
|
||||||
|
expect(formatPeriod(88)).toBe('88.0 d');
|
||||||
|
expect(formatPeriod(365.25)).toBe('365.3 d');
|
||||||
|
expect(formatPeriod(4332.6)).toBe('11.9 yr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the decimal for periods of centuries', () => {
|
||||||
|
expect(formatPeriod(90_560)).toBe('248 yr');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatRadiusKm', () => {
|
||||||
|
it('groups thousands on both sides of the decimal threshold', () => {
|
||||||
|
// Not just above it: 69,911 km beside a bare 6371 km reads as two different conventions.
|
||||||
|
expect(formatRadiusKm(69_911)).toBe('69,911 km');
|
||||||
|
expect(formatRadiusKm(6371)).toBe('6,371 km');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a decimal only for small bodies', () => {
|
||||||
|
expect(formatRadiusKm(11.3)).toBe('11.3 km');
|
||||||
|
expect(formatRadiusKm(1737)).toBe('1,737 km');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatMassEarth', () => {
|
||||||
|
it('scales precision to magnitude', () => {
|
||||||
|
expect(formatMassEarth(0.815)).toBe('0.815 M⊕');
|
||||||
|
expect(formatMassEarth(17.15)).toBe('17.15 M⊕');
|
||||||
|
expect(formatMassEarth(317.8)).toBe('318 M⊕');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatTemperature and formatDensity', () => {
|
||||||
|
it('rounds temperature to a whole kelvin', () => {
|
||||||
|
expect(formatTemperature(254.6)).toBe('255 K');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds two decimals of density', () => {
|
||||||
|
expect(formatDensity(5.514)).toBe('5.51 g/cm³');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatLuminosity', () => {
|
||||||
|
it('stays decimal across the ordinary range', () => {
|
||||||
|
expect(formatLuminosity(1)).toBe('1.00 L☉');
|
||||||
|
expect(formatLuminosity(0.0017)).toBe('0.002 L☉');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes to powers of ten at the extremes', () => {
|
||||||
|
expect(formatLuminosity(126_000)).toBe('1.3×10⁵ L☉');
|
||||||
|
expect(formatLuminosity(0.00004)).toBe('4.0×10⁻⁵ L☉');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Formatting for every physical quantity the HUD and the body cards display.
|
||||||
|
*
|
||||||
|
* Shared rather than per-component so the same measurement reads identically wherever it
|
||||||
|
* appears. The rule throughout is that precision follows magnitude — a figure is shown to the
|
||||||
|
* digits that carry information at its own scale, not to a fixed decimal count that reads as
|
||||||
|
* false precision at one end (`0.00 pc`) and loses real information at the other (`26000 pc`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Distance in parsecs, switching to kiloparsecs where the number would otherwise run long. */
|
||||||
|
export function formatParsecs(distancePc: number): string {
|
||||||
|
return distancePc >= 1000 ? `${(distancePc / 1000).toFixed(1)} kpc` : `${distancePc.toFixed(distancePc < 10 ? 2 : 0)} pc`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distance in astronomical units, for anything inside a system. */
|
||||||
|
export function formatAu(distanceAu: number): string {
|
||||||
|
if (distanceAu < 0.01) {
|
||||||
|
// Close-in exoplanets: 0.0026 AU is a real, published semi-major axis, and two decimals
|
||||||
|
// would round every hot Jupiter in the catalogue to the same `0.00`.
|
||||||
|
return `${distanceAu.toFixed(4)} AU`;
|
||||||
|
}
|
||||||
|
return distanceAu >= 100 ? `${distanceAu.toFixed(0)} AU` : `${distanceAu.toFixed(2)} AU`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orbital period in whichever unit reads naturally at its length — hours for the very short
|
||||||
|
* periods common among hot Jupiters, days up to a couple of years, then years.
|
||||||
|
*/
|
||||||
|
export function formatPeriod(days: number): string {
|
||||||
|
if (days < 1) {
|
||||||
|
return `${(days * 24).toFixed(1)} h`;
|
||||||
|
}
|
||||||
|
if (days < 700) {
|
||||||
|
return `${days.toFixed(days < 10 ? 2 : 1)} d`;
|
||||||
|
}
|
||||||
|
return `${(days / 365.25).toFixed(days / 365.25 < 100 ? 1 : 0)} yr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Radius in kilometres. */
|
||||||
|
export function formatRadiusKm(radiusKm: number): string {
|
||||||
|
// Grouped on both sides of the decimal threshold: 69,911 km beside a bare 6371 km reads as two
|
||||||
|
// different conventions rather than one.
|
||||||
|
const digits = radiusKm < 100 ? 1 : 0;
|
||||||
|
return `${radiusKm.toLocaleString('en-GB', { minimumFractionDigits: digits, maximumFractionDigits: digits })} km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mass in Earth masses. */
|
||||||
|
export function formatMassEarth(massEarth: number): string {
|
||||||
|
if (massEarth >= 100) {
|
||||||
|
return `${massEarth.toFixed(0)} M⊕`;
|
||||||
|
}
|
||||||
|
return `${massEarth.toFixed(massEarth < 1 ? 3 : 2)} M⊕`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Equilibrium temperature. Always a whole kelvin — the model is not good to a fraction of one. */
|
||||||
|
export function formatTemperature(kelvin: number): string {
|
||||||
|
return `${Math.round(kelvin)} K`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulk density. */
|
||||||
|
export function formatDensity(gramsPerCm3: number): string {
|
||||||
|
return `${gramsPerCm3.toFixed(2)} g/cm³`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bolometric luminosity in solar units, which spans many orders of magnitude. */
|
||||||
|
export function formatLuminosity(solar: number): string {
|
||||||
|
if (solar >= 1000 || (solar > 0 && solar < 0.001)) {
|
||||||
|
const exponent = Math.floor(Math.log10(solar));
|
||||||
|
return `${(solar / Math.pow(10, exponent)).toFixed(1)}×10${superscript(exponent)} L☉`;
|
||||||
|
}
|
||||||
|
return `${solar.toFixed(solar < 1 ? 3 : 2)} L☉`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function superscript(value: number): string {
|
||||||
|
return `${value}`.replace('-', '⁻').replace(/\d/g, (digit) => '⁰¹²³⁴⁵⁶⁷⁸⁹'[Number(digit)]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** A labelled quantity ready for display — the shape every panel in the app renders. */
|
||||||
|
export interface Readout {
|
||||||
|
readonly label: string;
|
||||||
|
readonly value: string;
|
||||||
|
}
|
||||||
+17
-1
@@ -2,10 +2,26 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Star Map</title>
|
<title>Star Map — the real solar neighbourhood in 3D</title>
|
||||||
|
<!-- Overwritten at build time with the deploy's subpath (`ng build --base-href`). The app's
|
||||||
|
data fetches are relative, so this is what they resolve against. -->
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="An interactive 3D star map built from real astronomical data: 68,388 catalogued stars out to 250 parsecs, planets on their measured orbits, and the NASA figures behind every body.">
|
||||||
|
<!-- Matches --color-void, so the browser chrome does not flash white around a black sky. -->
|
||||||
|
<meta name="theme-color" content="#05070d">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
|
||||||
|
<!-- Link previews. These URLs must be absolute — a scraper resolves them against nothing — so
|
||||||
|
unlike everything else here they do not follow <base> and name the deployment outright. -->
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://avalon-vanguard.github.io/star-map/">
|
||||||
|
<meta property="og:title" content="Star Map — the real solar neighbourhood in 3D">
|
||||||
|
<meta property="og:description" content="68,388 catalogued stars out to 250 parsecs, planets on their measured orbits, and the NASA figures behind every body.">
|
||||||
|
<meta property="og:image" content="https://avalon-vanguard.github.io/star-map/social-preview.jpg">
|
||||||
|
<meta property="og:image:alt" content="The Milky Way's bar, bulge and five spiral arms, with the Sun marked on the Orion Spur">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
|||||||
@@ -126,7 +126,10 @@ export async function fetchDeepSky(): Promise<DeepSkyRecord[]> {
|
|||||||
|
|
||||||
// Brightest first, so a consumer taking a prefix gets the most prominent objects. Objects
|
// Brightest first, so a consumer taking a prefix gets the most prominent objects. Objects
|
||||||
// with no measured magnitude sort last rather than being treated as infinitely bright.
|
// with no measured magnitude sort last rather than being treated as infinitely bright.
|
||||||
records.sort((a, b) => (a.magnitude ?? Infinity) - (b.magnitude ?? Infinity) || a.id.localeCompare(b.id));
|
// The tie-break compares code points rather than localeCompare: locale collation depends on
|
||||||
|
// the ICU build of whichever Node runs the ETL, and a scheduled re-run must not reorder the
|
||||||
|
// file just because the runner's ICU disagrees with the machine that wrote it last.
|
||||||
|
records.sort((a, b) => (a.magnitude ?? Infinity) - (b.magnitude ?? Infinity) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
||||||
|
|
||||||
ensureDataDir();
|
ensureDataDir();
|
||||||
writeFileSync(dataPath('deepsky.json'), JSON.stringify(records));
|
writeFileSync(dataPath('deepsky.json'), JSON.stringify(records));
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ const TAP_COLUMNS = [
|
|||||||
'st_mass',
|
'st_mass',
|
||||||
'disc_year'
|
'disc_year'
|
||||||
].join(',');
|
].join(',');
|
||||||
const TAP_QUERY = `select+${TAP_COLUMNS}+from+ps+where+default_flag=1&format=csv`;
|
// Ordered explicitly: without it the archive is free to return rows in any order, and a
|
||||||
|
// scheduled re-run of the ETL would then rewrite exoplanets.json — and commit a diff — when
|
||||||
|
// nothing was actually published. pl_name is unique among default_flag=1 rows, so the order
|
||||||
|
// is total and the output is a pure function of the archive's content.
|
||||||
|
const TAP_QUERY = `select+${TAP_COLUMNS}+from+ps+where+default_flag=1+order+by+pl_name&format=csv`;
|
||||||
const TAP_URL = `${TAP_BASE_URL}?query=${TAP_QUERY}`;
|
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
|
// A host star match must be within this many parsecs of the catalog position to be
|
||||||
|
|||||||
Reference in New Issue
Block a user