diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json deleted file mode 100644 index 9fbf0c9..0000000 --- a/.claude-plugin/marketplace.json +++ /dev/null @@ -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" - } - ] -} diff --git a/.claude/skills/run-star-map/SKILL.md b/.claude/skills/run-star-map/SKILL.md index 7419616..dd00110 100644 --- a/.claude/skills/run-star-map/SKILL.md +++ b/.claude/skills/run-star-map/SKILL.md @@ -130,15 +130,38 @@ already-running server and leaves it alone on exit). ## Test ```bash -PATH=/opt/node/bin:$PATH npm test # 496 tests, 28 files, ~5s -PATH=/opt/node/bin:$PATH npm run build # ~9s +PATH=/opt/node/bin:$PATH npm test # 527 tests, 31 files, ~6s +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 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. +## 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 `` 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 - **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. - **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`. +- **A Pages build looks broken if you serve it at the root.** `` 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 ("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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a26435..4932d7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,16 @@ name: CI -# Runs on pull requests and on the branch they merge into, so a green tick means the code was -# checked in the state it will actually land in. +# 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. `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: push: - branches: [main] + branches: [main, develop] 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. concurrency: diff --git a/.github/workflows/data-refresh.yml b/.github/workflows/data-refresh.yml new file mode 100644 index 0000000..b1d3b87 --- /dev/null +++ b/.github/workflows/data-refresh.yml @@ -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 diff --git a/.github/workflows/junie-review.yml b/.github/workflows/junie-review.yml new file mode 100644 index 0000000..2694e50 --- /dev/null +++ b/.github/workflows/junie-review.yml @@ -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 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" diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..d26e0c6 --- /dev/null +++ b/.github/workflows/pages.yml @@ -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 `` 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 diff --git a/README.md b/README.md index b3f9167..ad06278 100644 --- a/README.md +++ b/README.md @@ -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 NASA figures behind it. -This repo also hosts a small Claude Code plugin marketplace — see [Plugins](#plugins) below. +**Live at **, 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. ![The solar neighbourhood: 68 388 catalogued stars within 250 parsecs, on a polar grid lying in the galactic plane](docs/screenshots/galaxy-view.jpg) @@ -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 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 same place an in-scene click would. @@ -281,36 +299,22 @@ src/app/ Milky Way structure shared/models/ record contracts shared by the app and the ETL shared/rendering/ skybox, glow sprites, texture catalog + shared/format/ one formatter per physical quantity, precision following magnitude shared/state/ navigation store (Angular signals) tools/etl/ build-time data pipeline 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`. ## Plugins -This repo doubles as a Claude Code plugin marketplace. Adding it and installing a plugin -defaults to **user scope**, meaning the plugin becomes available in *every* project on your -machine, not just the one you happen to be in: - -```bash -/plugin marketplace add avalon-vanguard/star-map -/plugin install caveman@star-map -``` - -Scope can be overridden at install time if you want it tied to a single repo instead: - -```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. +This repo carries the source of one Claude Code plugin, but no longer publishes itself as a +marketplace — `.claude-plugin/marketplace.json` was removed, which is the file `/plugin +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. - **caveman** — `/cs:caveman` ultra-compressed communication mode. - Command: [`commands/cs/caveman.md`](commands/cs/caveman.md) diff --git a/agents/cs-caveman-mode.md b/agents/cs-caveman-mode.md index 093a535..15b4aff 100644 --- a/agents/cs-caveman-mode.md +++ b/agents/cs-caveman-mode.md @@ -18,10 +18,9 @@ turns until explicit deactivation. ## Scope -Ships as part of this repo's plugin marketplace. Installing it via -`/plugin install caveman@star-map` defaults to **user scope**, so this -agent is available in every project, not scoped to a single repo. See the -[README](../README.md) for `--scope project` / `--scope local` overrides. +Part of the caveman plugin, whose source lives here. This repo no longer +publishes itself as a plugin marketplace, so the agent is not installable +from it as things stand. See the [README](../README.md). ## Activation Triggers diff --git a/commands/cs/caveman.md b/commands/cs/caveman.md index 08cef87..db2d711 100644 --- a/commands/cs/caveman.md +++ b/commands/cs/caveman.md @@ -11,12 +11,9 @@ Activate caveman mode. Stays active until explicit deactivation. ## Scope -Installed via this repo's plugin marketplace, this command defaults to -**user scope** — available in every project, not just the one it was -installed from. Override with `--scope project` (shared with collaborators -via `.claude/settings.json`) or `--scope local` (gitignored, this repo -only) if you want it scoped down. See the [README](../../README.md) for -install commands. +Part of the caveman plugin, whose source lives here. This repo no longer +publishes itself as a plugin marketplace, so the command is not installable +from it as things stand. See the [README](../../README.md). ## Activation diff --git a/public/social-preview.jpg b/public/social-preview.jpg new file mode 100644 index 0000000..9c47e6b Binary files /dev/null and b/public/social-preview.jpg differ diff --git a/skills/caveman/SKILL.md b/skills/caveman/SKILL.md index ba7ad87..a3c7e3e 100644 --- a/skills/caveman/SKILL.md +++ b/skills/caveman/SKILL.md @@ -15,12 +15,10 @@ exactly as-is. ## Scope -This skill ships via this repo's plugin marketplace -(`.claude-plugin/marketplace.json`). Installing with -`/plugin install caveman@star-map` defaults to **user scope** — global, -available in every project — instead of being tied to just one repo. Use -`--scope project` or `--scope local` to narrow it; see the -[README](../../README.md). +This repo no longer publishes itself as a plugin marketplace, so there is +currently no install path for this skill from here. Listing it from a +marketplace elsewhere is what would make `/plugin install` work again; see +the [README](../../README.md). ## Persistence diff --git a/src/app/features/body-detail/body-detail-scene.component.ts b/src/app/features/body-detail/body-detail-scene.component.ts index 68efcf2..3f03892 100644 --- a/src/app/features/body-detail/body-detail-scene.component.ts +++ b/src/app/features/body-detail/body-detail-scene.component.ts @@ -6,9 +6,6 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { DataLoaderService } from '../../core/data/data-loader.service'; import { EngineService } from '../../core/engine/engine.service'; -import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance'; -import { EARTH_RADIUS_KM } from '../../shared/astro/planet-appearance'; -import { luminositySolar } from '../../shared/astro/stellar'; import { planetTexture } from '../../shared/rendering/procedural-planet-texture'; import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox'; import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBOX_PATH, 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 { NavigationStore } from '../../shared/state/navigation.store'; import { BodyDetailViewModel } from './body-detail.model'; +import { buildBodyViewModel } from './body-view-model'; import { InfoPanelComponent } from './info-panel.component'; /** 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; } - const body = this.bodies.find((candidate) => candidate.id === id); - const exoplanet = this.exoplanets.find((candidate) => candidate.id === id); - - if (body) { - const hostStar = this.stars.find((star) => star.id === body.systemStarId); - this.viewModel.set({ - id: body.id, - name: body.name, - kind: body.kind, - hostStarName: hostStar?.name ?? 'Unknown star', - radiusKm: body.radiusKm, - orbit: body.orbit, - 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 { + // Shared with the system view's object card, so the same body cannot read differently there. + const viewModel = buildBodyViewModel(id, { bodies: this.bodies, exoplanets: this.exoplanets, stars: this.stars }); + if (!viewModel) { this.viewModel.set(undefined); this.notFound.set(true); return; } + this.viewModel.set(viewModel); + + if (viewModel.hostStarId !== undefined) { + this.navigationStore.selectStar(viewModel.hostStarId); + } this.notFound.set(false); 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 { const viewModel = this.viewModel(); if (!viewModel || !this.planetMaterial) { diff --git a/src/app/features/body-detail/body-detail.model.ts b/src/app/features/body-detail/body-detail.model.ts index 89bebd8..eed591b 100644 --- a/src/app/features/body-detail/body-detail.model.ts +++ b/src/app/features/body-detail/body-detail.model.ts @@ -13,6 +13,12 @@ export interface BodyDetailViewModel { name: string; kind: BodyDetailKind; 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; massEarth?: number; discoveryYear?: number; @@ -26,4 +32,16 @@ export interface BodyDetailViewModel { appearance: PlanetAppearance; /** True when a real photograph is being shown rather than the derived surface. */ 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'; } diff --git a/src/app/features/body-detail/body-readouts.ts b/src/app/features/body-detail/body-readouts.ts new file mode 100644 index 0000000..67611fc --- /dev/null +++ b/src/app/features/body-detail/body-readouts.ts @@ -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> = { + 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.'; +} diff --git a/src/app/features/body-detail/body-view-model.spec.ts b/src/app/features/body-detail/body-view-model.spec.ts new file mode 100644 index 0000000..a76f661 --- /dev/null +++ b/src/app/features/body-detail/body-view-model.spec.ts @@ -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 => ({ + 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); + }); +}); diff --git a/src/app/features/body-detail/body-view-model.ts b/src/app/features/body-detail/body-view-model.ts new file mode 100644 index 0000000..becca17 --- /dev/null +++ b/src/app/features/body-detail/body-view-model.ts @@ -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; +} diff --git a/src/app/features/body-detail/info-panel.component.ts b/src/app/features/body-detail/info-panel.component.ts index 04815bd..5e786ee 100644 --- a/src/app/features/body-detail/info-panel.component.ts +++ b/src/app/features/body-detail/info-panel.component.ts @@ -1,21 +1,16 @@ -import { DecimalPipe } from '@angular/common'; -import { Component, input } from '@angular/core'; +import { Component, computed, input } from '@angular/core'; 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'; -const KIND_LABELS: Record = { - planet: 'Planet', - moon: 'Moon', - dwarf: 'Dwarf planet', - exoplanet: 'Exoplanet' -}; - /** * Displays the real NASA data for the currently selected body/exoplanet: kind, physical * size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only — * `BodyDetailSceneComponent` supplies the view model and owns navigation state. + * + * 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({ selector: 'app-info-panel', @@ -36,103 +31,44 @@ const KIND_LABELS: Record = {

{{ body().name }}

-

{{ kindLabel() }} · {{ body().hostStarName }}

+

{{ readouts().kindLabel }} · {{ body().hostStarName }}

- -
- @if (body().radiusKm) { -
-
Radius
-
{{ body().radiusKm | number: '1.0-1' }} km
-
- } - @if (body().massEarth) { -
-
Mass
-
{{ body().massEarth | number: '1.0-2' }} Earth masses
-
- } - @if (body().orbit.semiMajorAxisAu) { -
-
Semi-major axis
-
{{ body().orbit.semiMajorAxisAu | number: '1.0-4' }} AU
-
- } - @if (body().orbit.eccentricity !== undefined) { -
-
Eccentricity
-
{{ body().orbit.eccentricity | number: '1.0-4' }}
-
- } - @if (body().orbit.inclinationDeg !== undefined) { -
-
Inclination
-
{{ body().orbit.inclinationDeg | number: '1.0-2' }}°
-
- } - @if (body().discoveryYear) { -
-
Discovered
-
{{ body().discoveryYear }}
-
- } -
+ + @if (readouts().measured.length) { +

Measured

+
+ @for (row of readouts().measured; track row.label) { +
+
{{ row.label }}
+
{{ row.value }}
+
+ } +
+ }

Derived

-
-
Class
-
{{ classLabel() }}
-
- @if (body().appearance.equilibriumTemperatureK !== null) { + @for (row of readouts().derived; track row.label) {
-
Equilibrium temp.
-
{{ body().appearance.equilibriumTemperatureK | number: '1.0-0' }} K
-
- } - @if (body().appearance.bulkDensityGramsPerCm3 !== null) { -
-
Bulk density
-
{{ body().appearance.bulkDensityGramsPerCm3 | number: '1.0-2' }} g/cm³
+
{{ row.label }}
+
{{ row.value }}
}
-

{{ surfaceProvenance() }}

+

{{ readouts().provenance }}

- `, - imports: [DecimalPipe] + ` }) export class InfoPanelComponent { readonly body = input.required(); + readonly readouts = computed(() => bodyReadouts(this.body())); + 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 { void this.router.navigate(['/']); } diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 12b0027..992f76d 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -5,7 +5,6 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { dateToJulianDate } from '../../shared/astro/constants'; import { galacticCentrePositionPc, galacticToEquatorial, MILKY_WAY_ARMS, SUN_GALACTOCENTRIC_RADIUS_PC } from '../../shared/astro/galaxy'; -import { luminositySolar } from '../../shared/astro/stellar'; import { DataLoaderService } from '../../core/data/data-loader.service'; import { EngineService } from '../../core/engine/engine.service'; import { BodyRecord } from '../../shared/models/body.model'; @@ -20,7 +19,11 @@ import { DeepSkyRenderer } from './deep-sky-renderer'; import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane'; import { MilkyWayRenderer } from './milky-way-renderer'; import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramingDistanceAu, systemViewDirection } from './system-framing'; +import { 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 { SystemObjectCardComponent } from './system-object-card.component'; import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer'; import { LabeledPoint, StarLabelOverlay } from './star-label-overlay'; import { SystemOrbitsRenderer } from './system-orbits-renderer'; @@ -125,15 +128,6 @@ const EXIT_DURATION_SECONDS = 0.9; const RETURN_DURATION_SECONDS = 1.1; 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 * 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({ selector: 'app-galaxy-system-scene', providers: [EngineService], - imports: [StarmapHudComponent], + imports: [StarmapHudComponent, SystemObjectCardComponent], template: `
@@ -172,6 +166,9 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector [range]="hudRange()" (levelSelected)="goToLevel($event)" /> + @if (objectCard(); as card) { + + }
` }) @@ -194,6 +191,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { readonly hudNote = 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(undefined); + private enterableSystems = 0; + private pinnedBodyId: string | null = null; + private hoveredBodyId: string | null = null; + private controls?: OrbitControls; private rig?: CameraRigController; private starField?: StarFieldRenderer; @@ -251,6 +257,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.resizeObserver?.disconnect(); this.canvasRef().nativeElement.removeEventListener('pointerdown', this.handlePointerDown); this.canvasRef().nativeElement.removeEventListener('click', this.handleClick); + this.canvasRef().nativeElement.removeEventListener('pointermove', this.handlePointerMove); this.controls?.dispose(); this.starField?.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.bodies = bodies; this.exoplanets = exoplanets; + // Which stars can be flown into: those with catalogued bodies of their own, plus the Sun. + this.enterableSystems = new Set([...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 // labels are recomputed whenever the camera moves. 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('click', this.handleClick); + canvas.addEventListener('pointermove', this.handlePointerMove); this.observeResize(canvas); 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) { 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.hudTitle.set(star.name); this.hudSubtitle.set(star.spectralType ? `Spectral type ${star.spectralType}` : ''); this.hudReadouts.set([ - { label: 'Bodies', value: `${planetCount}` }, - { label: 'Distance', value: `${Math.hypot(star.x, star.y, star.z).toFixed(2)} pc` }, - { label: 'Magnitude', value: star.magnitude.toFixed(2) } + { label: 'Bodies', value: moonCount > 0 ? `${planetCount} + ${moonCount} moons` : `${planetCount}` }, + // Suppressed for the Sun rather than printed as `0.00 pc`, which is arithmetically right + // 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.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`. { label: 'Stars', value: this.starField && this.starField.drawnCount < this.stars.length ? `${this.starField.drawnCount} / ${this.stars.length}` : `${this.stars.length}` }, { label: 'Radius', value: `${LOCAL_GRID_RINGS_PC[LOCAL_GRID_RINGS_PC.length - 1]} pc` }, - { label: 'Exoplanets', value: `${this.exoplanets.length}` } + { 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.'); } @@ -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 { if (!this.systemRenderer) { return; } const [hit] = this.raycaster.intersectObjects(this.systemRenderer.pickableObjects); const member = hit ? this.systemRenderer.memberForObject(hit.object) : undefined; + + this.pinnedBodyId = member ? member.id : null; if (member) { 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). */ @@ -797,7 +875,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { // exoplanets, so it has no meaningful direction and the renderer falls back. // The star's luminosity, derived from its own catalogued magnitude and distance, is what // decides how hot each body in the system is — and so what each of them looks like. - const hostLuminosity = luminositySolar({ magnitude: star.magnitude, distancePc: Math.hypot(star.x, star.y, star.z), spectralType: star.spectralType }); + const hostLuminosity = luminosityOf(star); this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity); this.systemGroup.add(this.systemRenderer.object); @@ -881,6 +959,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.systemGroup.visible = false; 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.far = GALAXY_FAR_PC; diff --git a/src/app/features/galaxy-system/starmap-hud.component.ts b/src/app/features/galaxy-system/starmap-hud.component.ts index 472204f..9019aaa 100644 --- a/src/app/features/galaxy-system/starmap-hud.component.ts +++ b/src/app/features/galaxy-system/starmap-hud.component.ts @@ -5,6 +5,12 @@ import { ViewLevel } from '../../shared/state/navigation.store'; export interface HudReadout { readonly label: 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 { @@ -101,14 +107,14 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
@for (readout of readouts(); track readout.label) {
-
{{ readout.label }}
+
{{ readout.label }}@if (readout.derived) {}
{{ readout.value }}
}
} - @if (note()) { -

{{ note() }}

+ @if (note() || hasDerived()) { +

@if (hasDerived()) {* Derived, not catalogued. }{{ note() }}

} @@ -128,6 +134,9 @@ export class StarmapHudComponent { readonly readouts = input([]); /** Standing caveat for the current view, e.g. that galactic structure is a model. */ 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. */ readonly range = input(''); readonly showReticle = input(true); diff --git a/src/app/features/galaxy-system/system-object-card.component.spec.ts b/src/app/features/galaxy-system/system-object-card.component.spec.ts new file mode 100644 index 0000000..fcd56d9 --- /dev/null +++ b/src/app/features/galaxy-system/system-object-card.component.spec.ts @@ -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 => + ({ + 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; + + 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('button[aria-label="Close"]')!.click(); + [...host.querySelectorAll('button')].find((button) => button.textContent?.includes('Full view'))!.click(); + + expect({ opened, dismissed }).toEqual({ opened: 1, dismissed: 1 }); + }); +}); diff --git a/src/app/features/galaxy-system/system-object-card.component.ts b/src/app/features/galaxy-system/system-object-card.component.ts new file mode 100644 index 0000000..a1736a1 --- /dev/null +++ b/src/app/features/galaxy-system/system-object-card.component.ts @@ -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: ` + +
+
+
+
+

{{ body().name }}

+

{{ readouts().kindLabel }} · {{ body().hostStarName }}

+
+ +
+ + @if (readouts().measured.length) { +

Measured

+
+ @for (row of readouts().measured; track row.label) { +
+
{{ row.label }}
+
{{ row.value }}
+
+ } +
+ } + +

Derived

+
+ @for (row of readouts().derived; track row.label) { +
+
{{ row.label }}
+
{{ row.value }}
+
+ } +
+ +

{{ readouts().provenance }}

+ + +
+
+ ` +}) +export class SystemObjectCardComponent { + readonly body = input.required(); + /** The close control, and anything else that should dismiss the card. */ + readonly dismissed = output(); + /** Request for the full `/body/:id` route. */ + readonly openRequested = output(); + + readonly readouts = computed(() => bodyReadouts(this.body())); +} diff --git a/src/app/shared/format/quantity.spec.ts b/src/app/shared/format/quantity.spec.ts new file mode 100644 index 0000000..3195cdb --- /dev/null +++ b/src/app/shared/format/quantity.spec.ts @@ -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☉'); + }); +}); diff --git a/src/app/shared/format/quantity.ts b/src/app/shared/format/quantity.ts new file mode 100644 index 0000000..3ba8403 --- /dev/null +++ b/src/app/shared/format/quantity.ts @@ -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)]); +} diff --git a/src/app/shared/models/readout.ts b/src/app/shared/models/readout.ts new file mode 100644 index 0000000..db01b19 --- /dev/null +++ b/src/app/shared/models/readout.ts @@ -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; +} diff --git a/src/index.html b/src/index.html index fec8bf3..9c4d2e3 100644 --- a/src/index.html +++ b/src/index.html @@ -2,10 +2,26 @@ - Star Map + Star Map — the real solar neighbourhood in 3D + + + + + + + + + + + + + + diff --git a/tools/etl/fetchDeepSky.ts b/tools/etl/fetchDeepSky.ts index f2d4caa..4ba285b 100644 --- a/tools/etl/fetchDeepSky.ts +++ b/tools/etl/fetchDeepSky.ts @@ -126,7 +126,10 @@ export async function fetchDeepSky(): Promise { // Brightest first, so a consumer taking a prefix gets the most prominent objects. Objects // with no measured magnitude sort last rather than being treated as infinitely bright. - records.sort((a, b) => (a.magnitude ?? Infinity) - (b.magnitude ?? Infinity) || a.id.localeCompare(b.id)); + // 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(); writeFileSync(dataPath('deepsky.json'), JSON.stringify(records)); diff --git a/tools/etl/fetchExoplanets.ts b/tools/etl/fetchExoplanets.ts index ade5d34..dac2b21 100644 --- a/tools/etl/fetchExoplanets.ts +++ b/tools/etl/fetchExoplanets.ts @@ -25,7 +25,11 @@ const TAP_COLUMNS = [ 'st_mass', 'disc_year' ].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}`; // A host star match must be within this many parsecs of the catalog position to be