diff --git a/.github/scripts/architecture/nebula-packages.test.mts b/.github/scripts/architecture/nebula-packages.test.mts index 32068a31157..ede179ddf7c 100644 --- a/.github/scripts/architecture/nebula-packages.test.mts +++ b/.github/scripts/architecture/nebula-packages.test.mts @@ -39,7 +39,7 @@ test('actual illegal imports, missing exports and line overflows fail the packag assert.ok(checkNebulaBoundaries(root).some(error => error.includes('relative import leaves package'))); write(source, '\n'.repeat(601)); assert.ok(checkNebulaBoundaries(root).some(error => error.includes('exceeds 600'))); - write(source, "const source = 'src/objects/m42/source/config.json';\n"); + write(source, "const source = 'src/objects/m42-volume/source/config.json';\n"); assert.ok(checkNebulaBoundaries(root).some(error => error.includes('object/source path'))); write(source, 'const load = (name: string) => import(name);\n'); assert.ok(checkNebulaBoundaries(root).some(error => error.includes('unchecked boundary'))); @@ -64,7 +64,7 @@ test('the main volume entry keeps volume-core\'s platform ban and never reaches assert.ok(check(node, `import '${bakeVolumeEntries.main}';`).some(error => error.includes('bake topic imports a bake entry'))); assert.ok(check(node, "import '@cssearth/nebula-lab/cli';").some(error => error.includes('forbidden import lab'))); assert.ok(check(node, "import 'undeclared-library';").some(error => error.includes('undeclared external dependency undeclared-library'))); - assert.ok(check(node, "const source = 'src/objects/m42/source/config.json';").some(error => error.includes('object/source path'))); + assert.ok(check(node, "const source = 'src/objects/m42-volume/source/config.json';").some(error => error.includes('object/source path'))); assert.ok(check(node, 'const load = (name: string) => import(name);').some(error => error.includes('unchecked boundary'))); assert.ok(check(main, '\n'.repeat(601)).some(error => error.includes('exceeds 600'))); write('packages/bake/package.json', JSON.stringify({ name: bakePackage, private: true, exports: { './volume': './dist/volume.js' } })); diff --git a/.github/scripts/ci/prepare-ci-inputs.test.mts b/.github/scripts/ci/prepare-ci-inputs.test.mts index a8df1cd1e8a..2d35035aaac 100644 --- a/.github/scripts/ci/prepare-ci-inputs.test.mts +++ b/.github/scripts/ci/prepare-ci-inputs.test.mts @@ -39,7 +39,7 @@ async function fixture(t: { after: (cleanup: () => Promise) => void }) await inventory('milky-way', 'prepared', ['volume.json', 'slices/z/one.webp']); await inventory('helix', 'prepared', ['datasets.json', 'slice.webp', 'presentation.json']); await inventory('helix', 'runtime', ['unrelated-preview.webp']); - await json('src/objects/helix/source/presentation.json', { schema: 'cssearth-volume-presentation-source@2', objectId: 'helix' }); + await json('src/objects/helix-volume/source/presentation.json', { schema: 'cssearth-volume-presentation-source@2', objectId: 'helix' }); await inventory('local-group', 'runtime', ['catalogue.json'], 'prepared'); await json('src/objects/local-group/source/presentation.json', { provenance: { products: [] } }); const fetcher: typeof fetch = async url => { diff --git a/.gitignore b/.gitignore index 0de63e64628..f693ff592ee 100644 --- a/.gitignore +++ b/.gitignore @@ -26,8 +26,8 @@ output/playwright/ # Reacquirable binary source inputs. Source manifests, hashes, provenance, # licenses, configuration, and authored source modules remain tracked. src/objects/*/source/**/*.csv -# LGGS M31 star table (12.5 MB); restored from its CDS origin (src/objects/m31/source/lggs-stars/points.json). -src/objects/m31/source/lggs-stars/table4.dat.gz +# LGGS M31 star table (12.5 MB); restored from its CDS origin (src/objects/m31-layers/source/lggs-stars/points.json). +src/objects/m31-layers/source/lggs-stars/table4.dat.gz # Earth's dated NASA MUR tile archives (45 MB a day); restored from the source mirror (src/objects/earth/source/manifest.json). src/objects/earth/source/science/mur/*/tiles.tar.gz src/objects/*/source/**/*.fits @@ -69,10 +69,10 @@ src/objects/venus/source/akatsuki/*.nc !src/objects/mercury/source/features/tectonic/global-tectonic-map.zip # Offline-authored galaxy overview crops with no acquisition plan to restore them. -!src/objects/lmc/source/source.jpg -!src/objects/m31/source/source.jpg -!src/objects/m33/source/source.jpg -!src/objects/smc/source/source.jpg +!src/objects/lmc-volume/source/source.jpg +!src/objects/m31-layers/source/source.jpg +!src/objects/m33-layers/source/source.jpg +!src/objects/smc-volume/source/source.jpg !src/objects/milky-way/source/sky/nasa-preview.jpg # Retained upstream catalogue bytes with no acquisition plan to restore them. @@ -207,12 +207,12 @@ src/objects/*/source/**/*.bpc !src/objects/*/source/science/geology-*/Screenshot *.png # SMC compact replay inputs: small PNGs prepare:nebulae needs to rebuild the dataset bank and the neutral emission volume. -!src/objects/smc/source/compact/lenses/*/*.png -!src/objects/smc/source/compact/neutral/slices/*/*.png +!src/objects/smc-volume/source/compact/lenses/*/*.png +!src/objects/smc-volume/source/compact/neutral/slices/*/*.png # LMC compact replay inputs, the same shape plus the model projection its dataset tone curves read. -!src/objects/lmc/source/compact/lenses/*/*.png -!src/objects/lmc/source/compact/neutral/slices/*/*.png -!src/objects/lmc/source/compact/fit-projection.png +!src/objects/lmc-volume/source/compact/lenses/*/*.png +!src/objects/lmc-volume/source/compact/neutral/slices/*/*.png +!src/objects/lmc-volume/source/compact/fit-projection.png # USGS Gazetteer nomenclature archives re-pinned this phase: named explicitly (not a glob) so tracking stays limited # to the bodies actually verified here; each manifest documents these as a committed public-domain snapshot. @@ -314,15 +314,15 @@ src/objects/*/source/**/*.bpc /src/objects/eps-eridani-disc/.prepared-*/ /src/objects/hd-181327-disc/.prepared-*/ /src/objects/pds-70-disc/.prepared-*/ -/src/objects/m42/.prepared-*/ -/src/objects/helix/.prepared-*/ -/src/objects/m2-9/.prepared-*/ -/src/objects/m1/.prepared-*/ -/src/objects/smc/.prepared-*/ -/src/objects/lmc/.prepared-*/ -/src/objects/m45/.prepared-*/ -/src/objects/m8/.prepared-*/ -/src/objects/omega-centauri/.prepared-*/ +/src/objects/m42-volume/.prepared-*/ +/src/objects/helix-volume/.prepared-*/ +/src/objects/m2-9-volume/.prepared-*/ +/src/objects/m1-volume/.prepared-*/ +/src/objects/smc-volume/.prepared-*/ +/src/objects/lmc-volume/.prepared-*/ +/src/objects/m45-volume/.prepared-*/ +/src/objects/m8-volume/.prepared-*/ +/src/objects/omega-centauri-volume/.prepared-*/ # SPICE kernels are restored from their origins, never committed: bank kernels by packages/bake/cli/kernel-bank.mts, # body kernels by their acquisition steps (packages/bake/cli/restore-source-inputs.mts), evidence kernels by the script # that reads them. diff --git a/data/chandra/ledger.json b/data/chandra/ledger.json index d56efa8ddd1..90258945c92 100644 --- a/data/chandra/ledger.json +++ b/data/chandra/ledger.json @@ -482,7 +482,7 @@ "raDeg": 337.4107083333334, "decDeg": -20.83717222222222 }, - "source": "src/objects/helix/source/nebula.json", + "source": "src/objects/helix-volume/source/nebula.json", "observations": 2, "totalExposureKs": 47.7, "longest": [ @@ -565,7 +565,7 @@ "raDeg": 83.6334511837, "decDeg": 22.0151236394 }, - "source": "src/objects/m1/source/nebula.json", + "source": "src/objects/m1-volume/source/nebula.json", "observations": 102, "totalExposureKs": 688.8, "longest": [ @@ -601,7 +601,7 @@ "raDeg": 256.407962101, "decDeg": -10.1425459988 }, - "source": "src/objects/m2-9/source/nebula.json", + "source": "src/objects/m2-9-volume/source/nebula.json", "observations": 1, "totalExposureKs": 19.6, "longest": [ @@ -693,7 +693,7 @@ "raDeg": 83.78715182853901, "decDeg": -5.400382551151912 }, - "source": "src/objects/m42/source/nebula.json", + "source": "src/objects/m42-volume/source/nebula.json", "observations": 111, "totalExposureKs": 3736.5, "longest": [ @@ -729,7 +729,7 @@ "raDeg": 56.75, "decDeg": 24.1167 }, - "source": "src/objects/m45/source/nebula.json", + "source": "src/objects/m45-volume/source/nebula.json", "observations": 6, "totalExposureKs": 254.9, "longest": [ @@ -765,7 +765,7 @@ "raDeg": 270.76531645904527, "decDeg": -24.30622861770443 }, - "source": "src/objects/m8/source/nebula.json", + "source": "src/objects/m8-volume/source/nebula.json", "observations": 5, "totalExposureKs": 318.2, "longest": [ diff --git a/docs/flight-lifecycle.md b/docs/flight-lifecycle.md index ef18dd56175..4d0fc008799 100644 --- a/docs/flight-lifecycle.md +++ b/docs/flight-lifecycle.md @@ -21,7 +21,7 @@ runtime forwards them without remeasuring the camera DOM. Application navigation owns history restoration; each detail only writes its current saved view. `createCameraFlight` owns scheduling, acceleration, cancellation and completion -for world navigation, prepared-focus flights and native surface fly-to. Each +for world navigation and native surface fly-to. Each path supplies its existing camera sampling math. World navigation retains one document wheel listener for the navigation owner's lifetime and switches only its active callback. Adding or removing a nonpassive document wheel listener at @@ -235,10 +235,7 @@ A newly mounted world receives the latest settings. Playback permission remains governed by the shared runtime policy. `site/scene/scene-selection.mts` owns the committed subject and projects its URL. -Prepared-focus navigation owns the acquired target and executes camera/dataset -commands. It publishes one result to the selection owner, including whether a -saved camera must be discarded. It neither mirrors the selected ID nor formats -another selection URL. Native camera focus remains the geometric pivot. +Every subject is an object's scene; the geometric pivot is that object's centre. ## Scene activation and prepared ownership diff --git a/docs/galaxies/README.md b/docs/galaxies/README.md index 7678ef3cc89..7d8ffd6d03b 100644 --- a/docs/galaxies/README.md +++ b/docs/galaxies/README.md @@ -25,7 +25,7 @@ The [source recipe](../../labs/nebula/models/image-candidates.json) retains the | Point stars | [Bonanos et al. (2009)](https://arxiv.org/abs/0905.1328), [CDS J/AJ/138/1003](https://cdsarc.cds.unistra.fr/viz-bin/cat/J/AJ/138/1003) | 943 stars with measured sky positions and photometry. Depths are assigned from the density. All three datasets use identical positions. | | Surface color | The three registered observations above | Native star removal, then color sampling onto the fixed density. Uncovered density keeps neutral color. | -The [accepted bake recipe](../../labs/nebula/models/lmc/bake.json) pins image placement and color settings; the [app settings](../../src/objects/lmc/README.md#evidence) retain cloud and star display. Switching datasets changes the material while retaining the cloud, stars and camera. +The [accepted bake recipe](../../labs/nebula/models/lmc/bake.json) pins image placement and color settings; the [app settings](../../src/objects/lmc-volume/README.md#evidence) retain cloud and star display. Switching datasets changes the material while retaining the cloud, stars and camera. ## Extragalactic datasets @@ -44,9 +44,9 @@ The [accepted bake recipe](../../labs/nebula/models/lmc/bake.json) pins image pl | --- | --- | --- | | Milky Way exterior | [OpenSpace/AMNH/NAOJ volume](https://docs.openspaceproject.com/latest/content/milky-way/galaxy/milky-way-volume/index.html) | 1,024 × 1,024 × 128 RGBA model, baked into its bulge and inner disc only. Simulation-based visualization. [Provenance](../../src/objects/milky-way/source/provenance.json). | | Milky Way interior | [NASA SVS, Deep Star Maps 2020](https://svs.gsfc.nasa.gov/4851/) | 8,192 × 4,096 Milky Way-only map derived from star catalogues; the panorama shell adds authored visual depth. Credits: NASA/Goddard SVS, Ernie Wright (USRA), ESA/Gaia/DPAC. [Provenance](../../src/objects/milky-way/source/sky/provenance.json). | -| M31 | [ESA/Hubble & Digitized Sky Survey 2, heic1112f](https://esahubble.org/images/heic1112f/) | 4,783 × 5,000 crop/resample; 1 kpc parametric depth. Acknowledgment: Davide De Martin (ESA/Hubble). [Provenance](../../src/objects/m31/source/provenance.json). | -| M33 | [ESO VST/OmegaCAM, eso1424a](https://www.eso.org/public/images/eso1424a/) | 4,000 × 3,355, g/r/Hα; 1.2 kpc parametric envelope. Excludes the larger H I outskirts. [Provenance](../../src/objects/m33/source/provenance.json). | -| SMC | [SMASH/NOIRLab, noirlab2030b](https://noirlab.edu/public/images/noirlab2030b/) | 3,000 × 2,501, g/r/i/z; authored 25 kpc envelope. Excludes the full Bridge, Wing and tidal debris. [Full credit and provenance](../../src/objects/smc/source/provenance.json). | +| M31 | [ESA/Hubble & Digitized Sky Survey 2, heic1112f](https://esahubble.org/images/heic1112f/) | 4,783 × 5,000 crop/resample; 1 kpc parametric depth. Acknowledgment: Davide De Martin (ESA/Hubble). [Provenance](../../src/objects/m31-layers/source/provenance.json). | +| M33 | [ESO VST/OmegaCAM, eso1424a](https://www.eso.org/public/images/eso1424a/) | 4,000 × 3,355, g/r/Hα; 1.2 kpc parametric envelope. Excludes the larger H I outskirts. [Provenance](../../src/objects/m33-layers/source/provenance.json). | +| SMC | [SMASH/NOIRLab, noirlab2030b](https://noirlab.edu/public/images/noirlab2030b/) | 3,000 × 2,501, g/r/i/z; authored 25 kpc envelope. Excludes the full Bridge, Wing and tidal debris. [Full credit and provenance](../../src/objects/smc-volume/source/provenance.json). | The Milky Way overview enables the galaxy's prepared depth layers; other destinations keep its distant views. M31, M33 and the SMC use image-derived parametric layers, not the LMC's star-removal and density-coloring pipeline. diff --git a/docs/navigation-identity.md b/docs/navigation-identity.md index 358206b62c9..91004a5148b 100644 --- a/docs/navigation-identity.md +++ b/docs/navigation-identity.md @@ -6,34 +6,37 @@ nebula catalogues. Search, aliases and classification tabs use this inventory. | Concept | Meaning and owner | | --- | --- | -| Scene destination | A body package with a route, prepared world frame and scene loader. | -| Prepared-focus destination | A catalogue subject the map can open: a galaxy cluster, or a subject an object package details. Its page, `//`, is its host scene’s page with the subject selected; `?dataset=` selects its dataset, as on every page. Other catalogue rows are labels with no page. | -| Overview | The Milky Way, the Local Group, the Nearby and the Observable Universe: registry entries of kind `overview`, authored under `properties.overview` in their packages: name, description, order, the zoom thresholds and framing of their level, the classifications they hold and the packages they draw. The zoom ladder, the cards, the breadcrumbs and the sidebar tree's sections are built from these entries. Its page, `//`, is the world host's scene page with the overview selected. | +| Object | A package with a catalogue entry: a route, a prepared world frame and a scene loader. A planet, a star, a galaxy, a nebula and a cluster of galaxies are all this. `?dataset=` selects one of its datasets. | +| Level | The Milky Way, the Local Group, the Nearby and the Observable Universe: views of a scene on the zoom ladder, not objects, authored under `properties.overview` in their packages (name, description, order, zoom thresholds, what they hold). A level's page, `//`, is the world host's scene at that zoom. | | Rendering resource | A volume, image bank, point field or other prepared content. A resource descriptor alone does not publish a destination. | | Dataset view | A selectable `(objectId, datasetId)` presentation, which may combine several products and published sources. | | Published source | A scientific work, release or product identified in the source catalogue; a local file hash identifies retained bytes separately. | -Every page is `//`, one URL system for all three kinds of page: +Every page is `//`, one URL system for every object: [`navigation-scope.mts`](../site/navigation/navigation-scope.mts) reads and -writes them. A scene's page mounts that scene. A catalogue subject or an overview -is drawn by a mounted scene: opened cold, its page mounts the world host, the Sun, -which every catalogue subject and overview is placed from. In place, a catalogue -subject keeps the mounted scene; an overview is always the host's, as zooming out -of any system reaches it. Their pages carry none of the scene's own selections -(`dataset`, `feature`), so a cold open never reads them as the host's. - -`SCENE_OBJECTS` filters `OBJECTS` by scene capability. Routes, body preparation -and scene conformance use this filter because a prepared focus does not own -another scene. `requireObject` resolves either destination kind; -`requireSceneObject` rejects a focus when a caller needs a scene loader. - -One object has one id. A catalogue row that an object package details takes the -package's id when the catalogue is prepared: Andromeda is `m31` in the catalogue, -the registry, its page and its links, and the LVDB key `m_031` stays in its source -references. Non-navigable context resources remain outside `OBJECTS`. +writes them. Every object has a scene of its own, and its page mounts it. A level +of the zoom ladder (the Milky Way, the Local Group, the nearby and the observable +universe) is not an object: it is a view of a scene, reached by zooming out of any +system. Its page, opened cold, mounts the world host, the Sun, at that zoom. A +level's page carries none of the scene's own selections (`dataset`, `feature`), so +a cold open never reads them as the host's. + +`OBJECTS` has one entry shape and no kinds. Each entry is built from its package's +own descriptor: a catalogue block, a world frame and a scene. A galaxy, a nebula +and a cluster of galaxies are objects like any body; their recipe declares no +surface, and their imagery is a context bank a dataset names. `SCENE_OBJECTS` is +the same list. The levels are a separate short list (`OVERVIEWS`), each authored +under its package's `properties.overview`. The Local Group is a level with a +place of its own, so the world context also draws and names it. + +One object has one id. The spatial catalogues (Local Group galaxies, galaxy +clusters, the nearby field) are data the world draws as dots: a row makes no page +and no registry entry, and nothing in the application reads a row for an object's +identity, position or facts. Non-navigable context resources remain outside +`OBJECTS`. Adding a classification does not add a renderer, shell or camera owner. -`sceneHostId` identifies the scene displaying a prepared focus. A galaxy's +A catalogue galaxy's `hostId` identifies its physical host. Referenced hosts excluded from positional selection remain in the catalogue's `unpositionedHosts`, with a source reference and exclusion reason. They acquire no position or destination. The reader rejects @@ -157,6 +160,6 @@ owners. Its test preload rejects accidental use of application boot; it does not prove the Vite-generated context inventory or browser startup. Scene conformance remains derived from the scene capability filter. Run -`pnpm test:site` (the browser suites were retired; scene retention is checked in `site/test/scene-session.test.mts`) to check prepared-focus search, selection, saved links and retained +`pnpm test:site` (the browser suites were retired; scene retention is checked in `site/test/scene-session.test.mts`) to check search, selection, saved links and retained camera ownership. These checks do not establish the scientific accuracy of a catalogue measurement or a reconstructed volume. diff --git a/docs/nebulae/README.md b/docs/nebulae/README.md index bae61f83beb..030944dff65 100644 --- a/docs/nebulae/README.md +++ b/docs/nebulae/README.md @@ -4,13 +4,13 @@ M42, Helix, M2–9, Pleiades, Crab and Lagoon use the retained `volume-dataset-b | Object | Method | Datasets | Adopted distance | | --- | --- | --- | --- | -| [M42](../../src/objects/m42/README.md) | Image emission with an evidence-guided coherent depth surface | ESO optical and VISTA infrared | 414 ± 7 pc | -| [Helix](../../src/objects/helix/README.md) | Multi-image emission with a molecular velocity scaffold | ESO WFI optical, VISTA infrared, wide optical | 216 −12/+14 pc | -| [M2–9](../../src/objects/m2-9/README.md) | Axially symmetric image-conditioned emission | Hubble optical | 650 pc, uncertain | -| [Pleiades · M45](../../src/objects/m45/README.md) | Authored finite dust-display surface and observed stellar catalogue | NOIRLab + Niittee optical composite, NOIRLab optical, two Spitzer composites, WISE | 136.2 ± 1.2 pc | -| [Crab · M1](../../src/objects/m1/README.md) | Released SITELLE ejecta samples with conditional expansion depth and separate pulsar-wind components | Hubble, two Webb views, Spitzer, VLA, Chandra | 2,000 pc adopted model scale | -| [Lagoon · M8](../../src/objects/m8/README.md) | Authored coherent front with local published structure constraints | ESO optical, VISTA infrared, Spitzer infrared | 1,326 −69/+77 pc | -| [LMC](../../src/objects/lmc/README.md) | Registered image colors on a simulated stellar-density prior | VISTA infrared, Horálek visible light, WISE infrared | The Local Group catalogue owns its distance | +| [M42](../../src/objects/m42-volume/README.md) | Image emission with an evidence-guided coherent depth surface | ESO optical and VISTA infrared | 414 ± 7 pc | +| [Helix](../../src/objects/helix-volume/README.md) | Multi-image emission with a molecular velocity scaffold | ESO WFI optical, VISTA infrared, wide optical | 216 −12/+14 pc | +| [M2–9](../../src/objects/m2-9-volume/README.md) | Axially symmetric image-conditioned emission | Hubble optical | 650 pc, uncertain | +| [Pleiades · M45](../../src/objects/m45-volume/README.md) | Authored finite dust-display surface and observed stellar catalogue | NOIRLab + Niittee optical composite, NOIRLab optical, two Spitzer composites, WISE | 136.2 ± 1.2 pc | +| [Crab · M1](../../src/objects/m1-volume/README.md) | Released SITELLE ejecta samples with conditional expansion depth and separate pulsar-wind components | Hubble, two Webb views, Spitzer, VLA, Chandra | 2,000 pc adopted model scale | +| [Lagoon · M8](../../src/objects/m8-volume/README.md) | Authored coherent front with local published structure constraints | ESO optical, VISTA infrared, Spitzer infrared | 1,326 −69/+77 pc | +| [LMC](../../src/objects/lmc-volume/README.md) | Registered image colors on a simulated stellar-density prior | VISTA infrared, Horálek visible light, WISE infrared | The Local Group catalogue owns its distance | These are relative display emission models, not measured 3D gas density. The six Galactic nebulae use [surrounding Gaia/Bailer-Jones stellar fields](stellar-fields.md), independent of source-image boundaries. Each body README distinguishes its model limits from app integration. diff --git a/docs/page-navigation-transport.md b/docs/page-navigation-transport.md index 62b9e0cae14..0d20695baed 100644 --- a/docs/page-navigation-transport.md +++ b/docs/page-navigation-transport.md @@ -77,7 +77,7 @@ GET form; sky contrast follows its checkbox directly in CSS. Dataset attribution cards are already present, and CSS owns their placement and visibility. Startup preserves open settings, early selections and these same elements. -The response can restore a saved camera URL and select a prepared focus volume. +The response can restore a saved camera URL and select a dataset that shows a companion bank. JavaScript adds camera input, animation, live filtering, dataset changes in place and continuing world navigation. The separate [native camera experiment](native-resize-input.md) explores scroll zoom and resize rotation; it is not enabled in the normal site. @@ -109,18 +109,18 @@ renderer intercepts those buttons and updates the retained scene in place. Startup adopts the dataset rendered by the server as its first selection, without publishing the default dataset first. -A catalogue focus has its own page, such as Orion's `/m42/`: its host scene's page -with the focus selected. Its dataset buttons submit `dataset`, as on every page; -the response selects the authenticated bank inside the existing scene and fills -the shared focus card. The deploy build renders each focus page through the same -function once (`site/build/prerender-focus-pages.mts`), so the static page opens -on its card with or without JavaScript. Catalogue transport is separate pinned JSON, shared by the response and +A galaxy, cluster or nebula with a package is an ordinary object: `/m42/` is its own +scene's page, with its card and dataset buttons as on every page. A level of the zoom +ladder has no scene of its own; its page is the world host's page framed on it, and the +deploy build renders each level page through the search function once +(`site/build/prerender-level-pages.mts`), so the static page opens on its card with or +without JavaScript. Catalogue transport is separate pinned JSON, shared by the response and browser. Construction-order identities let the live volume publisher adopt the response's elements and selected dataset. Responsive CSS lengths retain the -prepared camera's projection until the browser resolves its viewport. A flight -to a prepared focus arrives on the line of sight from the Sun with celestial -north up, as its datasets were observed, whichever way the previous view faced; -a saved camera URL that still shows the focus keeps its own pose. Before this +prepared camera's projection until the browser resolves its viewport. A galaxy, +nebula or cluster opens on the line of sight from the Sun, as its datasets were +observed: its default camera is derived like a placed star's (default-camera.ts). +A saved camera URL keeps its own pose. Before this rule, [the Crab reached from Pluto](images/crab-focus-arrival.png) was seen from behind, with the Sun and other foreground markers across it. diff --git a/docs/seo.md b/docs/seo.md index d6c00326171..b6554abd797 100644 --- a/docs/seo.md +++ b/docs/seo.md @@ -23,7 +23,7 @@ application controls hidden. Every other scene page uses its arrival billboard: `site/build/share-images.mts` runs in `pnpm build:deploy` after `astro build`. It centres each billboard on black at 1200×630 and writes `dist/social/.jpg` (about 3,570 cards, 24 MB). A billboard it cannot read fails the deploy. Pages -with neither, such as catalogue focus pages, fall back to the Earth capture. +with neither, such as the pages of galaxies and nebulae, fall back to the Earth capture. None of these images adds requests to ordinary page loads. ![Share images drawn from the arrival billboards of Betelgeuse, WASP-43 b, Phobos and Comet 67P](images/share-cards.webp) diff --git a/docs/sidebar-thumbnails.md b/docs/sidebar-thumbnails.md index 3e1754f0527..7ed224c6bac 100644 --- a/docs/sidebar-thumbnails.md +++ b/docs/sidebar-thumbnails.md @@ -1,7 +1,7 @@ # Sidebar thumbnails Galaxy and nebula object rows use the default dataset's own image. Dataset rows -use their selected dataset's image. The prepared focus bank contains 80 px WebPs; +use their selected dataset's image. The prepared bank of thumbnails contains 80 px WebPs; search and overview members display them in the shared 40 px result row. Smaller navigation markers display them at 14 or 16 CSS pixels. The sidebar does not download a full preview just for an icon. diff --git a/labs/investigations/capture-galaxies.mts b/labs/investigations/capture-galaxies.mts index 84072147b81..1c062c047b6 100644 --- a/labs/investigations/capture-galaxies.mts +++ b/labs/investigations/capture-galaxies.mts @@ -33,7 +33,7 @@ const receipt = { schema: 'cssearth-galaxy-documentation-captures@1', viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1, capture: 'Actual Chromium app viewport; WebP quality 90, no crop, resize, color or exposure changes.', - deliveryManifest: 'src/objects/lmc/source/lens-manifest.json', + deliveryManifest: 'src/objects/lmc-volume/source/lens-manifest.json', passed: false, images, errors, }; await mkdir(resolve(root, 'docs/images/galaxies'), { recursive: true }); diff --git a/labs/nebula/application-isolation.gate.ts b/labs/nebula/application-isolation.gate.ts index fdd5c1d33fd..5b004c1639b 100644 --- a/labs/nebula/application-isolation.gate.ts +++ b/labs/nebula/application-isolation.gate.ts @@ -33,9 +33,9 @@ for (const path of inputs) { } // Runtime host fingerprints include validation owners even when esbuild removes type-only imports. for (const path of ['packages/renderer/src/volume/types.ts', 'packages/renderer/src/navigation/world-camera-math.ts', - 'packages/renderer/src/stars/prepared-catalogue-points.ts', 'src/objects/m42/object.json', - 'src/objects/m42/source', 'src/objects/m2-9/object.json', 'src/objects/m2-9/source', - 'src/objects/lmc/object.json', 'src/objects/lmc/prepared/datasets.json', 'src/objects/lmc/source', 'src/objects/stellar-neighbourhood/source/stars.json']) { + 'packages/renderer/src/stars/prepared-catalogue-points.ts', 'src/objects/m42-volume/object.json', + 'src/objects/m42-volume/source', 'src/objects/m2-9-volume/object.json', 'src/objects/m2-9-volume/source', + 'src/objects/lmc-volume/object.json', 'src/objects/lmc-volume/prepared/datasets.json', 'src/objects/lmc-volume/source', 'src/objects/stellar-neighbourhood/source/stars.json']) { const destination = resolve(sandbox, path); await mkdir(dirname(destination), { recursive: true }); await cp(resolve(root, path), destination, { recursive: true }); } diff --git a/labs/nebula/docs/baking.md b/labs/nebula/docs/baking.md index d79e09d81f1..2d57807576a 100644 --- a/labs/nebula/docs/baking.md +++ b/labs/nebula/docs/baking.md @@ -38,7 +38,7 @@ Success is **`BAKE_COMPLETE lmc` followed by `NEBULA_VERIFIED`**. The verifier c The bake downloads missing native originals and the pinned NOX model, creates a local Python environment with pinned dependencies, and checks every input is present before processing. Large downloads and intermediate files stay in `.local/`. Allow at least 8 GiB beyond the clone for dependencies, originals and generated products. The measured clean run took **15 minutes** on macOS arm64, including downloads and Python setup; a cached full replay took **4.2 seconds**. Hardware and network speed affect these times. The saved pre-NOX baseline examines about 1.7 million VISTA candidates and dominates the first run. Later runs verify and reuse completed native removal/reconstruction results. Python package installation requires an available wheel for the machine's platform. -The command does not need the browser's localStorage, a running server or previously completed image jobs. A full bake assembles its three-dataset repaint bank under `.local/nebula-lab/bakes/` only: the application LMC now ships the finite-emission model in `models/lmc/envelope/`, restored by `pnpm prepare:nebulae` from `src/objects/lmc/source/compact/`, so this recipe no longer writes into `src/objects/lmc`. It does not restart the lab, change browser settings or publish anything. +The command does not need the browser's localStorage, a running server or previously completed image jobs. A full bake assembles its three-dataset repaint bank under `.local/nebula-lab/bakes/` only: the application LMC now ships the finite-emission model in `models/lmc/envelope/`, restored by `pnpm prepare:nebulae` from `src/objects/lmc-volume/source/compact/`, so this recipe no longer writes into `src/objects/lmc`. It does not restart the lab, change browser settings or publish anything. ## Stages and configuration diff --git a/labs/nebula/docs/clean-install-verification.json b/labs/nebula/docs/clean-install-verification.json index a0d5e7fa169..9a8f066ece1 100644 --- a/labs/nebula/docs/clean-install-verification.json +++ b/labs/nebula/docs/clean-install-verification.json @@ -49,7 +49,7 @@ }, { "case": "corrupt-delivery", - "path": "src/objects/lmc/prepared/vista-infrared/slices/z/24.webp", + "path": "src/objects/lmc-volume/prepared/vista-infrared/slices/z/24.webp", "rejected": true, "notRepaired": true } diff --git a/labs/nebula/models/helix/README.md b/labs/nebula/models/helix/README.md index f046ca56209..ad4ccec7ee1 100644 --- a/labs/nebula/models/helix/README.md +++ b/labs/nebula/models/helix/README.md @@ -1,6 +1,6 @@ # Helix: conditional emission compiler -This lab model studies how to turn Helix Nebula photographs into a 3D emission cloud. The [shipped object record](../../../../src/objects/helix/README.md) owns the active sources, delivery evidence and known problems. +This lab model studies how to turn Helix Nebula photographs into a 3D emission cloud. The [shipped object record](../../../../src/objects/helix-volume/README.md) owns the active sources, delivery evidence and known problems. Open `/reconstruction?subject=helix-model-prior&inspection=compiler`. **Nebula → Compile nebula** runs the configured sources through separation, evidence, an optional velocity scaffold, positive multiscale emission fitting and a shared three-dataset bake. The [compiler method and complete setup](../../docs/emission-compiler.md) describe jobs, replay and assumptions. Visual acceptance is still open, and no compiled result is a production nebula asset. diff --git a/labs/nebula/models/lmc/envelope/EMISSION-METHOD.md b/labs/nebula/models/lmc/envelope/EMISSION-METHOD.md index bdc9f60c3b5..b64cbcd4bbd 100644 --- a/labs/nebula/models/lmc/envelope/EMISSION-METHOD.md +++ b/labs/nebula/models/lmc/envelope/EMISSION-METHOD.md @@ -2,7 +2,7 @@ This is a new inferred-emission model for the LMC. It does not repaint the unchanged stellar-density volume. Model `lmc-clouds-emission-envelope` and its three datasets are promoted to the application by -[`app-datasets.json`](app-datasets.json); [the object's README](../../../../../src/objects/lmc/README.md) +[`app-datasets.json`](app-datasets.json); [the object's README](../../../../../src/objects/lmc-volume/README.md) is the delivered account, and the `alignment-density-material-v1` datasets it replaced remain a historical reference. diff --git a/labs/nebula/models/lmc/envelope/app-datasets.json b/labs/nebula/models/lmc/envelope/app-datasets.json index cf92dfd57bd..f64d1de7882 100644 --- a/labs/nebula/models/lmc/envelope/app-datasets.json +++ b/labs/nebula/models/lmc/envelope/app-datasets.json @@ -4,8 +4,8 @@ "defaultDataset": "horalek-widefield", "framingRadiusUnits": 5, "evidence": { - "emissionRecipe": "src/objects/lmc/source/evidence/lmc/envelope/emission-envelope.json", - "datasetRecipe": "src/objects/lmc/source/evidence/lmc/envelope/finite-lenses.json" + "emissionRecipe": "src/objects/lmc-volume/source/evidence/lmc/envelope/emission-envelope.json", + "datasetRecipe": "src/objects/lmc-volume/source/evidence/lmc/envelope/finite-lenses.json" }, "datasets": [ { diff --git a/labs/nebula/models/m1/README.md b/labs/nebula/models/m1/README.md index 1ba7f1e8c0e..274d0bab2f6 100644 --- a/labs/nebula/models/m1/README.md +++ b/labs/nebula/models/m1/README.md @@ -1,7 +1,7 @@ # Crab Nebula: measured ejecta and a separate pulsar wind This lab model holds processing studies for the Crab Nebula. The -[current shipped object record](../../../../src/objects/m1/README.md) owns +[current shipped object record](../../../../src/objects/m1-volume/README.md) owns the active sources, delivery evidence and known problems. Experiments here do not qualify later deliveries. diff --git a/labs/nebula/models/m2-9/README.md b/labs/nebula/models/m2-9/README.md index dd4ce137d3b..7dd3fd77eed 100644 --- a/labs/nebula/models/m2-9/README.md +++ b/labs/nebula/models/m2-9/README.md @@ -1,7 +1,7 @@ # M2–9: image-to-volume experiment This file preserves processing studies and their dated results. The -[current shipped object record](../../../../src/objects/m2-9/README.md) owns +[current shipped object record](../../../../src/objects/m2-9-volume/README.md) owns the active sources, delivery evidence and known problems. Historical experiments below do not qualify later deliveries. diff --git a/labs/nebula/models/m42/README.md b/labs/nebula/models/m42/README.md index abd1b769b18..09c5ee10638 100644 --- a/labs/nebula/models/m42/README.md +++ b/labs/nebula/models/m42/README.md @@ -1,6 +1,6 @@ # Orion · M42 -This lab model studies a 3D emission reconstruction of M42, an asymmetric H II region and star-forming nebula. The [shipped object record](../../../../src/objects/m42/README.md) owns the active sources, delivery evidence and known problems. +This lab model studies a 3D emission reconstruction of M42, an asymmetric H II region and star-forming nebula. The [shipped object record](../../../../src/objects/m42-volume/README.md) owns the active sources, delivery evidence and known problems. ## Sources diff --git a/labs/nebula/models/m45/README.md b/labs/nebula/models/m45/README.md index ae214a4f9c4..7ac4a28f96f 100644 --- a/labs/nebula/models/m45/README.md +++ b/labs/nebula/models/m45/README.md @@ -1,6 +1,6 @@ # Pleiades (M45): source evidence and 3D experiment -This file holds processing studies for the Pleiades nebula lab. The [shipped object record](../../../../src/objects/m45/README.md) owns the active sources, delivery evidence and known problems. The experiments below do not qualify later deliveries. +This file holds processing studies for the Pleiades nebula lab. The [shipped object record](../../../../src/objects/m45-volume/README.md) owns the active sources, delivery evidence and known problems. The experiments below do not qualify later deliveries. The current result is an experimental display volume, not a recovered dust cloud or an accepted production-quality scene. Reconstruction has an **Optical composite · NOIRLab + Niittee** research dataset: Niittee's registered optical footprint covers 99.84% of the model's projected emission, compared with 60.14% for NOIRLab alone. See the [method, result, credits, rejected trials and replay limits](optical-composite-notes.md). diff --git a/labs/nebula/models/m8/README.md b/labs/nebula/models/m8/README.md index 7c420871a36..9b7d953e76d 100644 --- a/labs/nebula/models/m8/README.md +++ b/labs/nebula/models/m8/README.md @@ -1,6 +1,6 @@ # Lagoon · M8 -This lab holds the processing studies for the Lagoon Nebula, an asymmetric H II region and star-forming nebula. The [current shipped object record](../../../../src/objects/m8/README.md) owns the active sources, delivery evidence and known problems. Results below do not qualify later deliveries. +This lab holds the processing studies for the Lagoon Nebula, an asymmetric H II region and star-forming nebula. The [current shipped object record](../../../../src/objects/m8-volume/README.md) owns the active sources, delivery evidence and known problems. Results below do not qualify later deliveries. The selected optical, near-infrared and mid-infrared images have verified native star separation and a shared 3D cloud. The geometry uses a paper-guided local PDR interpretation with explicitly authored depth across the wider field. It is relative display emission, not recovered gas or dust density. diff --git a/labs/nebula/models/m8/alignment-candidates.json b/labs/nebula/models/m8/alignment-candidates.json index 54069edfb60..7ae93db13b1 100644 --- a/labs/nebula/models/m8/alignment-candidates.json +++ b/labs/nebula/models/m8/alignment-candidates.json @@ -254,7 +254,7 @@ "credit": "NASA/JPL-Caltech; HiPS: CDS (CNRS/Unistra)", "termsUrl": "https://www.spitzer.caltech.edu/page/image-use-policy", "skyBands": { - "path": "src/objects/m8/source/sky-bands/spitzer-irac.json" + "path": "src/objects/m8-volume/source/sky-bands/spitzer-irac.json" }, "metadataNote": "Composed from pinned CDS hips2fits IRAC maps in MJy/sr, calibrated with IRAC Handbook surface-brightness factors, one measured background and range per band, then one Lupton asinh display. WCS is the recipe grid. MIPS 24 µm is omitted: its HiPS has no data over the Hourglass core." }, diff --git a/labs/nebula/models/m8/edge-taper-evidence.json b/labs/nebula/models/m8/edge-taper-evidence.json index b21b84a3444..2e5e79b67d7 100644 --- a/labs/nebula/models/m8/edge-taper-evidence.json +++ b/labs/nebula/models/m8/edge-taper-evidence.json @@ -69,6 +69,6 @@ "oblique" ], "result": "All six main-app captures inspected: faint outer footprints fade without the previous clear local rectangular seams. Browser and HTTP errors: zero. Coarse model and incomplete spectral coverage remain.", - "evidence": "src/objects/m8/README.md" + "evidence": "src/objects/m8-volume/README.md" } } diff --git a/labs/nebula/models/m8/processing-observations.json b/labs/nebula/models/m8/processing-observations.json index a04cd483a9a..364ffeb1447 100644 --- a/labs/nebula/models/m8/processing-observations.json +++ b/labs/nebula/models/m8/processing-observations.json @@ -156,7 +156,7 @@ "credit": "NASA/JPL-Caltech; HiPS: CDS (CNRS/Unistra)", "termsUrl": "https://www.spitzer.caltech.edu/page/image-use-policy", "skyBands": { - "path": "src/objects/m8/source/sky-bands/spitzer-irac.json" + "path": "src/objects/m8-volume/source/sky-bands/spitzer-irac.json" }, "metadataNote": "Composed from pinned CDS hips2fits IRAC maps in MJy/sr, calibrated with IRAC Handbook surface-brightness factors, one measured background and range per band, then one Lupton asinh display. WCS is the recipe grid. MIPS 24 µm is omitted: its HiPS has no data over the Hourglass core." } diff --git a/labs/nebula/models/magellanic-particles.json b/labs/nebula/models/magellanic-particles.json index 2b905ff4ef9..cfa04fddffe 100644 --- a/labs/nebula/models/magellanic-particles.json +++ b/labs/nebula/models/magellanic-particles.json @@ -17,7 +17,7 @@ { "id": "lmc-particles", "directory": "labs/nebula/models/lmc-particles", - "referenceObject": "src/objects/lmc/object.json", + "referenceObject": "src/objects/lmc-volume/object.json", "photo": "labs/nebula/sources/lmc-smash-full.webp", "photoUrl": "https://noirlab.edu/public/images/noirlab2030a/", "photoCredit": "CTIO/NOIRLab/NSF/AURA/SMASH/D. Nidever (Montana State University) Acknowledgment: Image processing: Travis Rector (University of Alaska Anchorage), Mahdi Zamani & Davide de Martin", @@ -91,8 +91,8 @@ { "id": "smc-particles", "directory": "labs/nebula/models/smc-particles", - "referenceObject": "src/objects/smc/object.json", - "photo": "src/objects/smc/source/source.jpg", + "referenceObject": "src/objects/smc-volume/object.json", + "photo": "src/objects/smc-volume/source/source.jpg", "photoUrl": "https://noirlab.edu/public/images/noirlab2030b/", "photoCredit": "CTIO/NOIRLab/NSF/AURA/SMASH/D. Nidever (Montana State University) Acknowledgment: Image processing: Travis Rector (University of Alaska Anchorage), Mahdi Zamani & Davide de Martin", "starRange": { diff --git a/labs/nebula/models/smc/README.md b/labs/nebula/models/smc/README.md index 755c0dcb5a0..97347b6ae3f 100644 --- a/labs/nebula/models/smc/README.md +++ b/labs/nebula/models/smc/README.md @@ -48,6 +48,6 @@ The simulation is displaced and broader than the observed SMC. Measured image-to The inherited fixed-density painter is a **projection-only research baseline**. It does not satisfy the finite-3D-material acceptance gate. Its starless colours cannot establish gas/dust depth; front-image agreement would not validate side structure. The result records `materialGatePassed: false`. It is preserved as a rejected counterexample, not as a delivery. -The finite emission models that superseded it also record `materialGatePassed: false`: a fitted projection and an inspected render are not a measured gas reconstruction. The broad envelope is the VMC-constrained ellipsoid's shape hypothesis and the detail components are conditioned on the Garver simulation, which the same VMC comparison disfavours. [Model `smc-constrained-emission-envelope-ellipsoid`](constrained/EMISSION-METHOD.md#ellipsoid-envelope-2026-09-17) and its five publisher-image datasets are now promoted to the application object; [its README](../../../../src/objects/smc/README.md) owns that delivery's evidence, inspection record and limitations. The four FITS survey-band composites stay in the lab with their recorded defects. +The finite emission models that superseded it also record `materialGatePassed: false`: a fitted projection and an inspected render are not a measured gas reconstruction. The broad envelope is the VMC-constrained ellipsoid's shape hypothesis and the detail components are conditioned on the Garver simulation, which the same VMC comparison disfavours. [Model `smc-constrained-emission-envelope-ellipsoid`](constrained/EMISSION-METHOD.md#ellipsoid-envelope-2026-09-17) and its five publisher-image datasets are now promoted to the application object; [its README](../../../../src/objects/smc-volume/README.md) owns that delivery's evidence, inspection record and limitations. The four FITS survey-band composites stay in the lab with their recorded defects. Next: test spatially coherent depth-mode selection and finer finite supports against the image residual; correct the frozen VMC intake's RA-wrap omission as a versioned source change; give the Herschel SPIRE and DSS2 plate composites the coverage and gradient corrections their notes record. Preserve the failing projected-painter counterexample, the independent neutral shape comparison and the measured catalogue identities. diff --git a/labs/nebula/models/smc/constrained/EMISSION-METHOD.md b/labs/nebula/models/smc/constrained/EMISSION-METHOD.md index 37aaa4557a5..4feaeef46ef 100644 --- a/labs/nebula/models/smc/constrained/EMISSION-METHOD.md +++ b/labs/nebula/models/smc/constrained/EMISSION-METHOD.md @@ -93,6 +93,6 @@ The re-derivation is byte-identical wherever recorded provenance does not enter **Stars.** `prepare-smc-stars labs/nebula/models/smc/constrained/finite-datasets-ellipsoid.json` writes `../stars/prepared/stars-ellipsoid.json` (1,803 Bonanos et al. 2010 massive stars) and the model-owned index `.local/nebula-lab/finite-stars-smc-constrained-emission-envelope-ellipsoid.json`. Depths sample the joint emission and **the ellipsoid** density, so the same measured rays land a median 0.48 kpc from their positions in the simulation-envelope model. Tangent positions are identical by construction. -**Application promotion.** This model's five publisher-image datasets are the shipped SMC object; see [the object's source-and-evidence README](../../../../../src/objects/smc/README.md) for the promotion recipe, the delivered bank and the app inspection record. The four FITS survey-band composites stay lab-only for the defects recorded in [candidate intake](../README-candidates.md#survey-band-composites). +**Application promotion.** This model's five publisher-image datasets are the shipped SMC object; see [the object's source-and-evidence README](../../../../../src/objects/smc-volume/README.md) for the promotion recipe, the delivered bank and the app inspection record. The four FITS survey-band composites stay lab-only for the defects recorded in [candidate intake](../README-candidates.md#survey-band-composites). **Known problems.** Unchanged from the two-scale section: faint concentric ripples in the outer halo where alpha is still quantized, and detail bounded by the 384-pixel fit. The envelope is now the *ellipsoid's* shape hypothesis, still not measured gas depth, and an ellipsoid that scores better against red-clump tracers is not a measurement of the gas body. The detail components remain conditioned on the simulation the same comparison disfavours. diff --git a/labs/nebula/models/smc/particles/source/provenance.json b/labs/nebula/models/smc/particles/source/provenance.json index 707526482a4..c1823a1e03c 100644 --- a/labs/nebula/models/smc/particles/source/provenance.json +++ b/labs/nebula/models/smc/particles/source/provenance.json @@ -23,7 +23,7 @@ }, "density": "Mass-weighted CIC deposition of simulation stellar particles with authored offline Gaussian smoothing.", "photo": { - "path": "src/objects/smc/source/source.jpg", + "path": "src/objects/smc-volume/source/source.jpg", "url": "https://noirlab.edu/public/images/noirlab2030b/", "credit": "CTIO/NOIRLab/NSF/AURA/SMASH/D. Nidever (Montana State University) Acknowledgment: Image processing: Travis Rector (University of Alaska Anchorage), Mahdi Zamani & Davide de Martin", "license": "CC-BY-4.0" diff --git a/labs/nebula/packages/lab/src/adapters/application/delivery/catalogue-field.test.ts b/labs/nebula/packages/lab/src/adapters/application/delivery/catalogue-field.test.ts index 150edc0f6d9..1d45b6588e3 100644 --- a/labs/nebula/packages/lab/src/adapters/application/delivery/catalogue-field.test.ts +++ b/labs/nebula/packages/lab/src/adapters/application/delivery/catalogue-field.test.ts @@ -155,7 +155,7 @@ test('runtime validation rejects lossy identifiers, malformed uncertainty, dupli }); test('the acquired Crab catalogue prepares a nonempty physical volume with pinned uncertainty and no image stars', async () => { - const path = 'src/objects/m1/source/stellar-field.json', bytes = await readFile(path); + const path = 'src/objects/m1-volume/source/stellar-field.json', bytes = await readFile(path); const target = embedNebulaFrame(frame, { centerIcrsDegrees: [83.6334511837, 22.0151236394], distancePc: 2000, imageRotationDegrees: 0, arcsecPerUnit: 1 }); const result = await prepareNebulaCatalogueField(process.cwd(), { path }, target, [{ ...retainedPoint, id: 'crab-pulsar' }]); assert.ok(result.receipt.catalogueCount > 0); assert.ok(result.receipt.maximumRelativeDistanceHalfWidth > 0); @@ -166,7 +166,7 @@ test('the acquired Crab catalogue prepares a nonempty physical volume with pinne }); test('the checked-in Helix selection retains wide-image cores and its central star across the budget and datasets', async () => { - const path = 'src/objects/helix/source/stellar-field.json', bytes = await readFile(path); + const path = 'src/objects/helix-volume/source/stellar-field.json', bytes = await readFile(path); const source: unknown = JSON.parse(bytes.toString()); assert.ok(source && typeof source === 'object' && 'selection' in source && 'provenance' in source); const selection = source.selection, provenance = source.provenance; @@ -208,12 +208,12 @@ test('the checked-in Helix selection retains wide-image cores and its central st }); test('catalogue refresh preserves the existing Helix core identities, matching tolerance and evidence', async t => { - const sourceBytes = await readFile('src/objects/helix/source/stellar-field.json'); + const sourceBytes = await readFile('src/objects/helix-volume/source/stellar-field.json'); const source = JSON.parse(sourceBytes.toString()); - const { root } = await fixture(t, {}), owner = join(root, 'src/objects/helix/source'); + const { root } = await fixture(t, {}), owner = join(root, 'src/objects/helix-volume/source'); await mkdir(owner, { recursive: true }); await writeFile(join(owner, 'stellar-field.json'), sourceBytes); - await writeFile(join(owner, 'delivery.json'), await readFile('src/objects/helix/source/delivery.json')); + await writeFile(join(owner, 'delivery.json'), await readFile('src/objects/helix-volume/source/delivery.json')); // A one-row catalogue cache exercises the actual refresh CLI without requiring native processing or archive access. const query = source.provenance.query.replace(/^WITH candidates AS \(SELECT (?!ALL )/, 'WITH candidates AS (SELECT ALL '); const star = source.stars[0], columns = ['source_id', 'ra', 'dec', 'pmra', 'pmdec', 'parallax', 'parallax_error', @@ -221,10 +221,10 @@ test('catalogue refresh preserves the existing Helix core identities, matching t const cells = [star.sourceId, star.raDeg, star.decDeg, star.pmRaMasYr, star.pmDecMasYr, star.parallaxMas, star.parallaxErrorMas, star.photGMeanMag, '', '', star.ruwe, star.distancePc, star.distanceLowerPc, star.distanceUpperPc]; const cache = join(root, '.local/nebula-lab/stellar-fields'); await mkdir(cache, { recursive: true }); - await writeFile(join(cache, 'helix.csv'), `${columns.join(',')}\n${cells.join(',')}\n`); - await writeFile(join(cache, 'helix.csv.query.json'), JSON.stringify({ query })); + await writeFile(join(cache, 'helix-volume.csv'), `${columns.join(',')}\n${cells.join(',')}\n`); + await writeFile(join(cache, 'helix-volume.csv.query.json'), JSON.stringify({ query })); const refreshed = spawnSync(process.execPath, ['--experimental-strip-types', - join(process.cwd(), 'packages/bake/cli/prepare-nebula-field-catalogues.mts'), 'helix'], { cwd: root, encoding: 'utf8', timeout: 3000 }); + join(process.cwd(), 'packages/bake/cli/prepare-nebula-field-catalogues.mts'), 'helix-volume'], { cwd: root, encoding: 'utf8', timeout: 3000 }); assert.equal(refreshed.status, 0, `${refreshed.error?.message ?? ''}\n${refreshed.stdout}\n${refreshed.stderr}`); assert.match(refreshed.stdout, /NEBULA_FIELD_CATALOGUES_COMPLETE/); const next = JSON.parse(await readFile(join(owner, 'stellar-field.json'), 'utf8')); diff --git a/labs/nebula/packages/lab/src/adapters/application/delivery/cold-replay-parity.test.ts b/labs/nebula/packages/lab/src/adapters/application/delivery/cold-replay-parity.test.ts index bb447fde2a1..3017060673b 100644 --- a/labs/nebula/packages/lab/src/adapters/application/delivery/cold-replay-parity.test.ts +++ b/labs/nebula/packages/lab/src/adapters/application/delivery/cold-replay-parity.test.ts @@ -6,7 +6,7 @@ import { readCompactCompiler } from './compact-compiler.ts'; import { assertReplayScene } from './cold-replay-parity.ts'; test('cold parity rejects independent field, frame, star, dataset and alpha mutations', async () => { - const input = readCompactCompiler(JSON.parse(gunzipSync(await readFile('src/objects/m42/source/bake-inputs.json.gz')).toString())); + const input = readCompactCompiler(JSON.parse(gunzipSync(await readFile('src/objects/m42-volume/source/bake-inputs.json.gz')).toString())); const expected = input.scene; assertReplayScene(structuredClone(expected), expected); for (const mutate of [ diff --git a/labs/nebula/packages/lab/src/adapters/application/delivery/compact-compiler.test.ts b/labs/nebula/packages/lab/src/adapters/application/delivery/compact-compiler.test.ts index ce55b3089ff..f79d7077ae6 100644 --- a/labs/nebula/packages/lab/src/adapters/application/delivery/compact-compiler.test.ts +++ b/labs/nebula/packages/lab/src/adapters/application/delivery/compact-compiler.test.ts @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { gunzipSync } from 'node:zlib'; import { readCompactCompiler } from './compact-compiler.ts'; -const input = async () => JSON.parse(gunzipSync(await readFile('src/objects/m42/source/bake-inputs.json.gz')).toString()); +const input = async () => JSON.parse(gunzipSync(await readFile('src/objects/m42-volume/source/bake-inputs.json.gz')).toString()); test('compact compiler validates the accepted field, material correspondence and sampling', async () => { const value = await input(), parsed = readCompactCompiler(value); assert.equal(parsed.objectId, 'm42'); diff --git a/labs/nebula/packages/lab/src/adapters/application/delivery/compact-sampled.test.ts b/labs/nebula/packages/lab/src/adapters/application/delivery/compact-sampled.test.ts index b9e44d39a69..fba2b7d3c1d 100644 --- a/labs/nebula/packages/lab/src/adapters/application/delivery/compact-sampled.test.ts +++ b/labs/nebula/packages/lab/src/adapters/application/delivery/compact-sampled.test.ts @@ -10,7 +10,7 @@ import { maximumPlanningEmission, prepareCompactSampledInputs } from '@cssearth/ test("compact sampled replay rejects changed measured particles before any reconstruction", async () => { const root = await mkdtemp(join(tmpdir(), "nebula-sampled-")); try { - const path = "src/objects/m1/source/compact/model.json.gz"; + const path = "src/objects/m1-volume/source/compact/model.json.gz"; const bytes = await readFile(path); const model: unknown = JSON.parse(gunzipSync(bytes).toString()); assert.ok( @@ -56,7 +56,7 @@ test('sampled planning preserves features unique to any dataset without changing test('retained sampled loader rejects a renamed scene before reading particles or writing outputs', async () => { const root = await mkdtemp(join(tmpdir(), 'nebula-retained-sampled-')); try { - const value: unknown = JSON.parse(gunzipSync(await readFile('src/objects/m1/source/compact/model.json.gz')).toString()); + const value: unknown = JSON.parse(gunzipSync(await readFile('src/objects/m1-volume/source/compact/model.json.gz')).toString()); assert.ok(jointRecord(value)); value.sourceResult = 'another-result'; const bytes = gzipSync(Buffer.from(JSON.stringify(value))), path = 'input.json.gz'; await writeFile(join(root, path), bytes); diff --git a/labs/nebula/packages/lab/src/adapters/application/delivery/compact-symmetry.test.ts b/labs/nebula/packages/lab/src/adapters/application/delivery/compact-symmetry.test.ts index 737573af800..72bffe9db5f 100644 --- a/labs/nebula/packages/lab/src/adapters/application/delivery/compact-symmetry.test.ts +++ b/labs/nebula/packages/lab/src/adapters/application/delivery/compact-symmetry.test.ts @@ -4,13 +4,13 @@ import { mkdtemp, cp, readFile, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { replayCompactSymmetry } from "./compact-symmetry.ts"; -const path = "src/objects/m2-9/source/compact/model.json"; +const path = "src/objects/m2-9-volume/source/compact/model.json"; test("retained symmetry fields replay accepted slices without source photographs", async () => { const root = await mkdtemp(join(tmpdir(), "nebula-symmetry-")); try { await cp( - "src/objects/m2-9/source/compact", - join(root, "src/objects/m2-9/source/compact"), + "src/objects/m2-9-volume/source/compact", + join(root, "src/objects/m2-9-volume/source/compact"), { recursive: true }, ); const bytes = await readFile(join(root, path)); @@ -26,11 +26,11 @@ test("retained symmetry fields replay accepted slices without source photographs assert.equal(pixels.length, resource.bytes); } const corrupt = await readFile( - join(root, "src/objects/m2-9/source/compact/emission-0.f32.gz"), + join(root, "src/objects/m2-9-volume/source/compact/emission-0.f32.gz"), ); corrupt[0] = corrupt[0]! ^ 1; await writeFile( - join(root, "src/objects/m2-9/source/compact/emission-0.f32.gz"), + join(root, "src/objects/m2-9-volume/source/compact/emission-0.f32.gz"), corrupt, ); await assert.rejects( diff --git a/labs/nebula/packages/lab/src/adapters/application/delivery/nebula-objects.test.ts b/labs/nebula/packages/lab/src/adapters/application/delivery/nebula-objects.test.ts index cdbb099c35a..cd8d5c1cfcd 100644 --- a/labs/nebula/packages/lab/src/adapters/application/delivery/nebula-objects.test.ts +++ b/labs/nebula/packages/lab/src/adapters/application/delivery/nebula-objects.test.ts @@ -9,13 +9,13 @@ import { validatePreparedVolumeDatasets } from '@cssearth/renderer/volume/prepar import { prepareNebulaObject, readNebulaDelivery } from './nebula-objects.ts'; test('a pinned optical composite is a compiler delivery stage, never a symmetry fallback', async () => { - const recipe: unknown = JSON.parse(await readFile('src/objects/m45/source/delivery.json', 'utf8')); + const recipe: unknown = JSON.parse(await readFile('src/objects/m45-volume/source/delivery.json', 'utf8')); const parsed = readNebulaDelivery(recipe); assert.equal(parsed.defaultDataset, 'optical-composite'); assert.equal(parsed.compositeRecipe?.path, 'labs/nebula/models/m45/optical-composite.json'); assert.ok(parsed.compositeRecipe); assert.throws(() => readNebulaDelivery({ ...parsed, schema: 'cssearth-nebula-delivery@2', method: 'axial-symmetry' }), /requires compiler/); - const ordinary = readNebulaDelivery(JSON.parse(await readFile('src/objects/m8/source/delivery.json', 'utf8'))); + const ordinary = readNebulaDelivery(JSON.parse(await readFile('src/objects/m8-volume/source/delivery.json', 'utf8'))); assert.equal(ordinary.compositeRecipe, undefined); }); async function put(root: string, path: string, bytes: Uint8Array | string) { @@ -32,7 +32,7 @@ async function fixture(root: string) { } } const request = '{}', directory = join(root,'object'); - const recipe = JSON.parse(await readFile('src/objects/m2-9/source/delivery.json','utf8')); + const recipe = JSON.parse(await readFile('src/objects/m2-9-volume/source/delivery.json','utf8')); for (const path of [recipe.fieldStars.path, 'packages/renderer/src/navigation/world-camera-math.ts', 'packages/renderer/src/stars/prepared-catalogue-points.ts']) { await put(root,path,await readFile(path)); @@ -40,7 +40,7 @@ async function fixture(root: string) { await put(root,'input/request.json',request); await put(directory,'source/delivery.json',JSON.stringify({...recipe, request:{path:'input/request.json'},inputPins:[],compactInputs:undefined,compactMethod:undefined,symmetryDirectory:'input'})); - await copyFile('src/objects/m2-9/source/nebula.json',join(directory,'source/nebula.json')); + await copyFile('src/objects/m2-9-volume/source/nebula.json',join(directory,'source/nebula.json')); const pixels = await sharp({create:{width:2,height:2,channels:4,background:{r:220,g:80,b:30,alpha:0.6}}}).png().toBuffer(); const matrices: Record = { x:'50,0,0,0,0,0,50,0,0,1,0,0,-50,0,-50,1', diff --git a/labs/nebula/packages/lab/src/features/observations/recipe.test.ts b/labs/nebula/packages/lab/src/features/observations/recipe.test.ts index c607951ec48..12aad65f411 100644 --- a/labs/nebula/packages/lab/src/features/observations/recipe.test.ts +++ b/labs/nebula/packages/lab/src/features/observations/recipe.test.ts @@ -25,6 +25,6 @@ test('registration detector settings reject unbounded, noninteger and incompatib test('composed sky band sources cache under their own name; publisher sources keep their downloaded name', () => { assert.equal(observationSourceFile({ id: 'spitzer-mid-infrared' }), 'spitzer-mid-infrared.tif'); - assert.equal(observationSourceFile({ id: 'spitzer-mid-infrared', skyBands: { path: 'src/objects/m8/source/sky-bands/spitzer-irac.json' } }), + assert.equal(observationSourceFile({ id: 'spitzer-mid-infrared', skyBands: { path: 'src/objects/m8-volume/source/sky-bands/spitzer-irac.json' } }), 'spitzer-mid-infrared.skybands.png'); }); diff --git a/labs/nebula/packages/lab/src/resources/model-catalogue.test.ts b/labs/nebula/packages/lab/src/resources/model-catalogue.test.ts index 8998b62dbb4..fa75913b8e4 100644 --- a/labs/nebula/packages/lab/src/resources/model-catalogue.test.ts +++ b/labs/nebula/packages/lab/src/resources/model-catalogue.test.ts @@ -9,7 +9,7 @@ const read = async (path: string) => parseLabModelJson(await readFile(path, 'utf test('the curated lab retains the selected LMC images, SMC comparisons and verified density banks', async () => { const ids = ['vista-infrared', 'horalek-widefield', 'wise-wide-infrared']; const subjects = await read('labs/nebula/packages/lab/src/state/subjects.json'); - assert.deepEqual(subjects.map((subject: {id: string}) => subject.id), ['lmc-clouds', 'smc-particles', 'smc-vmc', 'smc-ellipsoid', 'smc-constrained', 'm2-9-inferred', 'helix-single-axis', 'helix-model-prior', 'm42', 'm8', 'carina', 'ngc6357', 'm78', 'horsehead', 'm45', 'omega-centauri', 'm1']); + assert.deepEqual(subjects.map((subject: {id: string}) => subject.id), ['lmc-clouds', 'smc-particles', 'smc-vmc', 'smc-ellipsoid', 'smc-constrained', 'm2-9-inferred', 'helix-single-axis', 'helix-model-prior', 'm42', 'm8', 'carina', 'ngc6357', 'm78', 'horsehead', 'm45', 'omega-centauri', 'm1', 'm87-inferred']); const experiment = subjects.find((subject: {id: string}) => subject.id === 'm2-9-inferred'); assert.equal(experiment.density, undefined, 'An inferred-emission experiment must not masquerade as an independent density prior.'); assert.equal(experiment.directory, experiment.emissionExperiment.directory); diff --git a/package.json b/package.json index a7d4acb73dd..10e8d217081 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "scripts": { "build": "astro build && node packages/bake/cli/run-implemented-objects.mts assemble", - "build:deploy": "pnpm build:packages && pnpm setup:asset-data && pnpm prepare:tool-inputs && pnpm prepare:world-presentation && pnpm prepare:shell-assets && pnpm prepare:dataset-sprites && node packages/bake/cli/prepare-factsheets.mts --quiet && node site/build/prepare/prepare-feature-index.mts && pnpm prepare:dataset-billboards && pnpm prepare:environment-images && node packages/bake/cli/prepare-nebulae.mts --if-missing && node site/build/prepare/prepare-facilities.mts --catalog-only && astro build && node site/build/share-images.mts && node packages/bake/cli/run-implemented-objects.mts assemble && node site/build/bundle-netlify-functions.mts && node site/build/prerender-focus-pages.mts", + "build:deploy": "pnpm build:packages && pnpm setup:asset-data && pnpm prepare:tool-inputs && pnpm prepare:world-presentation && pnpm prepare:shell-assets && pnpm prepare:dataset-sprites && node packages/bake/cli/prepare-factsheets.mts --quiet && node site/build/prepare/prepare-feature-index.mts && pnpm prepare:dataset-billboards && pnpm prepare:environment-images && node packages/bake/cli/prepare-nebulae.mts --if-missing && node site/build/prepare/prepare-facilities.mts --catalog-only && astro build && node site/build/share-images.mts && node packages/bake/cli/run-implemented-objects.mts assemble && node site/build/bundle-netlify-functions.mts && node site/build/prerender-level-pages.mts", "build:astronomy": "pnpm --filter @cssearth/astronomy build", "build:catalog": "pnpm --filter @cssearth/catalog build", "build:core": "pnpm --filter @cssearth/core build", diff --git a/packages/astronomy/cli/body-records.mts b/packages/astronomy/cli/body-records.mts index cedf071c872..53a5be47a2a 100644 --- a/packages/astronomy/cli/body-records.mts +++ b/packages/astronomy/cli/body-records.mts @@ -43,12 +43,13 @@ function bodyRecord(value: unknown): BodyRecord { } // Zero radius means no source measures one; only a star or black hole known from its hosted orbit alone may lack it (most S-stars, // the black hole of Cygnus X-1). - if (physical.meanRadiusKm === 0 && (!['star', 'black-hole'].includes(record.classification) || record.hostedOrbit === undefined)) { - throw new TypeError(`Only a hosted star or black hole may have an unmeasured radius: ${record.id} has meanRadiusKm 0.`); + // An extended placed body (a galaxy, a cluster, a nebula) has no single measured radius: its package frames it instead. + if (physical.meanRadiusKm === 0 && !EXTENDED.includes(record.classification) && (!['star', 'black-hole'].includes(record.classification) || record.hostedOrbit === undefined)) { + throw new TypeError(`Only a hosted star or black hole, or an extended placed body, may have an unmeasured radius: ${record.id} has meanRadiusKm 0.`); } // A star or black hole beyond the Solar System is placed by its astrometry and orbits nothing this package models. - if (record.star !== undefined && (!['star', 'black-hole'].includes(record.classification) || physical.parent !== null)) { - throw new TypeError(`Placement astrometry belongs to a parentless star or black hole: ${record.id}.`); + if (record.star !== undefined && (!PLACED.includes(record.classification) || physical.parent !== null)) { + throw new TypeError(`Placement astrometry belongs to a parentless placed body (${PLACED.join(', ')}): ${record.id}.`); } // A hosted orbit is placed around its parent: every exoplanet has one, and a star may have one instead of its own placement // (the S-stars around Sgr A*), and so may a black hole (the one of Cygnus X-1). The parent must be placed; readBodyRecords checks that. @@ -67,7 +68,10 @@ const packageRoot = resolve(import.meta.dirname, '..'); const order = (a: {id: string; order?: number}, b: {id: string; order?: number}) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER) || a.id.localeCompare(b.id, 'en'); const kinds = { planet: 'PLANET_IDS', 'dwarf-planet': 'DWARF_PLANET_IDS', asteroid: 'ASTEROID_IDS', 'trans-neptunian': 'TRANS_NEPTUNIAN_IDS', interstellar: 'INTERSTELLAR_IDS', comet: 'COMET_IDS', exoplanet: 'EXOPLANET_IDS', - 'black-hole': 'BLACK_HOLE_IDS' }; + 'black-hole': 'BLACK_HOLE_IDS', galaxy: 'GALAXY_IDS', 'galaxy-cluster': 'GALAXY_CLUSTER_IDS', nebula: 'NEBULA_IDS', 'globular-cluster': 'GLOBULAR_CLUSTER_IDS' }; +/** Classifications placed by astrometry, as @cssearth/objects PLACED_CLASSIFICATIONS lists them (this package does not import it). */ +const EXTENDED = ['galaxy', 'galaxy-cluster', 'nebula', 'globular-cluster']; +const PLACED = ['star', 'black-hole', ...EXTENDED]; const models = { asteroid: ['ASTEROID_ELEMENTS', "import type { KeplerianElements } from '../../kepler.js'", '{ query: string; elements: KeplerianElements }'], comet: ['COMET_ELEMENTS', "import type { KeplerianElements } from '../../kepler.js'", '{ query: string; elements: KeplerianElements }'], diff --git a/packages/astronomy/data/bodies/fornax-cluster.json b/packages/astronomy/data/bodies/fornax-cluster.json new file mode 100644 index 00000000000..83dfecbf140 --- /dev/null +++ b/packages/astronomy/data/bodies/fornax-cluster.json @@ -0,0 +1,28 @@ +{ + "id": "fornax-cluster", + "classification": "galaxy-cluster", + "order": 5136, + "physical": { + "name": "Fornax Cluster", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of Fornax Cluster is recorded: it is an extended cluster of galaxies, and its package frames it at 619600 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 54.6163, + "declinationDegrees": -35.4482, + "positionEpochJulianYear": 2000, + "distanceParsecs": 19660746.338790987, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": 1528.9, + "sources": { + "position": "Sadibekova et al. (2024), MCXC-II, A&A 688 A187; CDS J/A+A/688/A187, byte columns defined by https://cdsarc.cds.unistra.fr/ftp/J/A+A/688/A187/ReadMe (https://cdsarc.cds.unistra.fr/ftp/J/A+A/688/A187/mcxcii.dat.gz); catalogue reference mcxc-ii:MCXC J0338.4-3526", + "distance": "Tully et al. (2023), Cosmicflows-4, ApJ 944, 94; CDS J/ApJ/944/94, tables 2 (group membership, 1PGC) and 3 (group distance modulus, DMzp), as joined into the Nearby Universe's tracked table (https://cdsarc.cds.unistra.fr/viz-bin/cat/J/ApJ/944/94); catalogue reference cosmicflows-4:13418: 19660746.338790987 pc; Cosmicflows-4 group distance: the weighted average of its members' distance moduli on the calibrated scale (table 3, DMzp).", + "properMotion": "Zero: the cluster of galaxies is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "c z for the catalogued redshift z = 0.0051: Redshift reference 2004A&A...425..367B, transcribed from MCXC-II. (https://ui.adsabs.harvard.edu/abs/2004A&A...425..367B); catalogue reference 2004A&A...425..367B" + } + } +} diff --git a/packages/astronomy/data/bodies/helix.json b/packages/astronomy/data/bodies/helix.json new file mode 100644 index 00000000000..114d6ab817d --- /dev/null +++ b/packages/astronomy/data/bodies/helix.json @@ -0,0 +1,28 @@ +{ + "id": "helix", + "classification": "nebula", + "order": 5137, + "physical": { + "name": "Helix Nebula", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of Helix Nebula is recorded: it is an extended nebula, and its package frames it at 1.571 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 337.4107083333334, + "declinationDegrees": -20.83717222222222, + "positionEpochJulianYear": 2000, + "distanceParsecs": 216, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": -15, + "sources": { + "position": "ESO ESO WFI · optical detail image and publisher astrometry; registration retained in the observation recipe (https://cdn.eso.org/images/original/eso0907a.tif); catalogue reference helix-registered-images", + "distance": "Benedict et al. (2009), HST parallaxes of planetary nebula nuclei (https://arxiv.org/abs/0909.4281); catalogue reference benedict-2009: 216 pc (-12/+14); Adopted published trigonometric parallax; not a fitted image scale.", + "properMotion": "Zero: the nebula is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "SIMBAD NGC 7293: -15 km/s +/- 10, from 1953GCRV..C......0W (https://ui.adsabs.harvard.edu/abs/1953GCRV..C......0W/abstract)" + } + } +} diff --git a/packages/astronomy/data/bodies/lmc.json b/packages/astronomy/data/bodies/lmc.json new file mode 100644 index 00000000000..62ed279eaba --- /dev/null +++ b/packages/astronomy/data/bodies/lmc.json @@ -0,0 +1,28 @@ +{ + "id": "lmc", + "classification": "galaxy", + "order": 5138, + "physical": { + "name": "LMC", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of LMC is recorded: it is an extended galaxy, and its package frames it at 5000 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 78.76, + "declinationDegrees": -69.19, + "positionEpochJulianYear": 2000, + "distanceParsecs": 49590.6727505, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": 262.2, + "sources": { + "position": "vanderMarel2014ApJ...781..121V: Third-epoch Magellanic Cloud Proper Motions. II. The Large Magellanic Cloud Rotation Field in Three Dimensions (https://ui.adsabs.harvard.edu/abs/2014ApJ...781..121V); catalogue reference vanderMarel2014ApJ...781..121V", + "distance": "Pietrzynski2019Natur.567..200P: A distance to the Large Magellanic Cloud that is precise to one per cent (https://ui.adsabs.harvard.edu/abs/2019Natur.567..200P); catalogue reference Pietrzynski2019Natur.567..200P: 49590.6727505 pc (-545.078605979/+551.136452599); unspecified", + "properMotion": "Zero: the galaxy is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "SIMBAD NAME LMC: 262.2 km/s +/- 3.4, from 2012AJ....144....4M (https://ui.adsabs.harvard.edu/abs/2012AJ....144....4M/abstract)" + } + } +} diff --git a/packages/astronomy/data/bodies/local-group.json b/packages/astronomy/data/bodies/local-group.json new file mode 100644 index 00000000000..d34948d4af2 --- /dev/null +++ b/packages/astronomy/data/bodies/local-group.json @@ -0,0 +1,28 @@ +{ + "id": "local-group", + "classification": "galaxy-cluster", + "order": 5154, + "physical": { + "name": "Local Group", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of the Local Group is recorded here: it is a group of galaxies, and its package frames it at 1060000 pc (object.json worldFrame.bodyRadiusM), the zero-velocity surface radius R_LG = 1060 +/- 70 kpc of McConnachie (2012), AJ 144, 4 (https://arxiv.org/abs/1204.1562), section 3.", + "star": { + "rightAscensionDegrees": 9.99294, + "declinationDegrees": 41.12706, + "positionEpochJulianYear": 2000, + "distanceParsecs": 386146, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": 0, + "sources": { + "position": "The mid-point of the vector connecting the Milky Way and M31, where McConnachie (2012), AJ 144, 4 (https://arxiv.org/abs/1204.1562), Table 2 notes to columns 8 to 10, places the barycentre of the Local Group. Computed 2026-10-01 from this repository's records of the Galactic centre (sgr-a-star.json) and M31 (m31.json).", + "distance": "The Sun's distance from that mid-point, from the same two records: 386146 pc. The paper adopts (m-M)0 = 24.47 for M31; the mid-point here uses the M31 distance this repository draws M31 at, so the marker sits between the two as drawn.", + "properMotion": "Zero: the group is held at this position; no motion across the sky is applied.", + "radialVelocity": "Zero: a mid-point has no measured radial velocity; the group is held at its distance." + } + } +} diff --git a/packages/astronomy/data/bodies/m1.json b/packages/astronomy/data/bodies/m1.json new file mode 100644 index 00000000000..c48b771162b --- /dev/null +++ b/packages/astronomy/data/bodies/m1.json @@ -0,0 +1,28 @@ +{ + "id": "m1", + "classification": "nebula", + "order": 5139, + "physical": { + "name": "Crab Nebula", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of Crab Nebula is recorded: it is an extended nebula, and its package frames it at 1.317 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 83.6334511837, + "declinationDegrees": 22.0151236394, + "positionEpochJulianYear": 2000, + "distanceParsecs": 2000, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": 0, + "sources": { + "position": "Pinned common ICRS image-frame origin derived from registered observations; adopted display centre, not a measured nebular centroid. (https://github.com/layoutit/css.earth/blob/main/labs/nebula/models/m1/observations.json); catalogue reference m1-registered-frame", + "distance": "Martin, Milisavljevic & Drissen (2021), 3D mapping of the Crab Nebula with SITELLE. I (https://arxiv.org/pdf/2101.02709v1); catalogue reference martin-2021: 2000 pc; Adopted 2 kpc reconstruction scale from Martin et al. (2021), section 3.4, following Trimble (1973). The cited 1.7–2.4 kpc range is not a statistical error. Lin et al. (2023) independently find 1.90 −0.18/+0.22 kpc; no distance refit is applied.", + "properMotion": "Zero: the nebula is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "Zero: SIMBAD lists no radial velocity for M 1 (queried 2026-10-01); the nebula is held at its catalogued distance." + } + } +} diff --git a/packages/astronomy/data/bodies/m2-9.json b/packages/astronomy/data/bodies/m2-9.json new file mode 100644 index 00000000000..9a26ef67d41 --- /dev/null +++ b/packages/astronomy/data/bodies/m2-9.json @@ -0,0 +1,28 @@ +{ + "id": "m2-9", + "classification": "nebula", + "order": 5140, + "physical": { + "name": "Twin Jet Nebula", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of Twin Jet Nebula is recorded: it is an extended nebula, and its package frames it at 0.09144 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 256.407962101, + "declinationDegrees": -10.1425459988, + "positionEpochJulianYear": 2000, + "distanceParsecs": 650, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": 0, + "sources": { + "position": "NASA/ESA Hubble 1997 image opo9738a; AVM sky astrometry (https://esahubble.org/media/archives/images/original/opo9738a.tif); catalogue reference hst-opo9738a", + "distance": "Sánchez Contreras et al. A&A 692, A151 (2024) (https://arxiv.org/html/2411.03825v1); catalogue reference sanchez-contreras-2024: 650 pc; Adopted model distance, not a parallax. Published expansion alternative:1300±200pc.", + "properMotion": "Zero: the nebula is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "Zero: SIMBAD lists no radial velocity for PN M 2-9 (queried 2026-10-01); the nebula is held at its catalogued distance." + } + } +} diff --git a/packages/astronomy/data/bodies/m31.json b/packages/astronomy/data/bodies/m31.json new file mode 100644 index 00000000000..4a5ab377323 --- /dev/null +++ b/packages/astronomy/data/bodies/m31.json @@ -0,0 +1,28 @@ +{ + "id": "m31", + "classification": "galaxy", + "order": 5141, + "physical": { + "name": "M 31", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of M 31 is recorded: it is an extended galaxy, and its package frames it at 25000 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 10.683916666666665, + "declinationDegrees": 41.26566666666666, + "positionEpochJulianYear": 2000, + "distanceParsecs": 776247.116629, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": -300, + "sources": { + "position": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1. (https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv); catalogue reference lvdb-v1.1.1:m_031:location", + "distance": "Savino2022ApJ...938..101S: The Hubble Space Telescope Survey of M31 Satellite Galaxies. I. RR Lyrae-based Distances and Refined 3D Geometric Structure (https://ui.adsabs.harvard.edu/abs/2022ApJ...938..101S); catalogue reference Savino2022ApJ...938..101S: 776247.116629 pc (-21154.8889633/+21747.5706393); unspecified", + "properMotion": "Zero: the galaxy is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "SIMBAD M 31: -300 km/s +/- 4, from 2012AJ....144....4M (https://ui.adsabs.harvard.edu/abs/2012AJ....144....4M/abstract)" + } + } +} diff --git a/packages/astronomy/data/bodies/m33.json b/packages/astronomy/data/bodies/m33.json new file mode 100644 index 00000000000..05f689c564f --- /dev/null +++ b/packages/astronomy/data/bodies/m33.json @@ -0,0 +1,28 @@ +{ + "id": "m33", + "classification": "galaxy", + "order": 5142, + "physical": { + "name": "M 33", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of M 33 is recorded: it is an extended galaxy, and its package frames it at 9000 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 23.462, + "declinationDegrees": 30.6603, + "positionEpochJulianYear": 2000, + "distanceParsecs": 859013.521505, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": -179.2, + "sources": { + "position": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1. (https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv); catalogue reference lvdb-v1.1.1:m_033:location", + "distance": "Savino2022ApJ...938..101S: The Hubble Space Telescope Survey of M31 Satellite Galaxies. I. RR Lyrae-based Distances and Refined 3D Geometric Structure (https://ui.adsabs.harvard.edu/abs/2022ApJ...938..101S); catalogue reference Savino2022ApJ...938..101S: 859013.521505 pc (-23410.5032741/+24066.3789132); unspecified", + "properMotion": "Zero: the galaxy is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "SIMBAD M 33: -179.2 km/s +/- 1.7, from 2012AJ....144....4M (https://ui.adsabs.harvard.edu/abs/2012AJ....144....4M/abstract)" + } + } +} diff --git a/packages/astronomy/data/bodies/m42.json b/packages/astronomy/data/bodies/m42.json new file mode 100644 index 00000000000..726b922e81e --- /dev/null +++ b/packages/astronomy/data/bodies/m42.json @@ -0,0 +1,28 @@ +{ + "id": "m42", + "classification": "nebula", + "order": 5143, + "physical": { + "name": "Orion Nebula", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of Orion Nebula is recorded: it is an extended nebula, and its package frames it at 4.817 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 83.78715182853901, + "declinationDegrees": -5.400382551151912, + "positionEpochJulianYear": 2000, + "distanceParsecs": 414, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": 27.8, + "sources": { + "position": "ESO ESO · optical image and publisher astrometry; registration retained in the observation recipe (https://cdn.eso.org/images/publicationtiff/eso1723a.tif); catalogue reference m42-registered-images", + "distance": "Menten et al. (2007), The distance to the Orion Nebula (https://arxiv.org/abs/0709.0485); catalogue reference menten-2007: 414 pc (-7/+7); Adopted published trigonometric parallax; not a fitted image scale.", + "properMotion": "Zero: the nebula is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "SIMBAD M 42: 27.8 km/s +/- 5, from 2017A&A...600A.106C (https://ui.adsabs.harvard.edu/abs/2017A%26A...600A.106C/abstract)" + } + } +} diff --git a/packages/astronomy/data/bodies/m45.json b/packages/astronomy/data/bodies/m45.json new file mode 100644 index 00000000000..9a71cef222c --- /dev/null +++ b/packages/astronomy/data/bodies/m45.json @@ -0,0 +1,28 @@ +{ + "id": "m45", + "classification": "nebula", + "order": 5144, + "physical": { + "name": "Pleiades", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of Pleiades is recorded: it is an extended nebula, and its package frames it at 3.539 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 56.75, + "declinationDegrees": 24.1167, + "positionEpochJulianYear": 2000, + "distanceParsecs": 136.2, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": 5.88, + "sources": { + "position": "Pinned common ICRS image-frame origin derived from registered observations; adopted display centre, not a measured nebular centroid. (https://github.com/layoutit/css.earth/blob/main/labs/nebula/models/m45/processing-observations.json); catalogue reference m45-registered-frame", + "distance": "Melis et al. (2014), A VLBI Resolution of the Pleiades Distance Controversy (https://arxiv.org/pdf/1408.6544); catalogue reference melis-2014: 136.2 pc (-1.2/+1.2); Adopted VLBI stellar-cluster distance from Melis et al. (2014), main text and Table 1; not a measured distance to each dust filament.", + "properMotion": "Zero: the nebula is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "SIMBAD M 45: 5.88 km/s +/- 0.03, from 2021A&A...647A..19T (https://ui.adsabs.harvard.edu/abs/2021A%26A...647A..19T/abstract)" + } + } +} diff --git a/packages/astronomy/data/bodies/m8.json b/packages/astronomy/data/bodies/m8.json new file mode 100644 index 00000000000..5f78552144c --- /dev/null +++ b/packages/astronomy/data/bodies/m8.json @@ -0,0 +1,28 @@ +{ + "id": "m8", + "classification": "nebula", + "order": 5145, + "physical": { + "name": "Lagoon Nebula", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of Lagoon Nebula is recorded: it is an extended nebula, and its package frames it at 21.95 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 270.76531645904527, + "declinationDegrees": -24.30622861770443, + "positionEpochJulianYear": 2000, + "distanceParsecs": 1326, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": 0, + "sources": { + "position": "Pinned common ICRS image-frame origin derived from registered observations; adopted display centre, not a measured nebular centroid. (https://github.com/layoutit/css.earth/blob/main/labs/nebula/models/m8/processing-observations.json); catalogue reference m8-registered-frame", + "distance": "Wright et al. (2019), The Gaia-ESO Survey: Asymmetric expansion of the Lagoon Nebula cluster NGC 6530 from GES and Gaia DR2 (https://arxiv.org/pdf/1903.12176v1); catalogue reference wright-2019-kinematics: 1326 pc (-69/+77); Adopted Gaia DR2 cluster distance from Wright et al. (2019), section 3.4: 1326 −69/+77 pc, including the stated parallax zero-point correction and systematic uncertainty. It is not an individual gas-cloud depth measurement.", + "properMotion": "Zero: the nebula is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "Zero: SIMBAD lists no radial velocity for M 8 (queried 2026-10-01); the nebula is held at its catalogued distance." + } + } +} diff --git a/packages/astronomy/data/bodies/m81.json b/packages/astronomy/data/bodies/m81.json new file mode 100644 index 00000000000..4c996505d45 --- /dev/null +++ b/packages/astronomy/data/bodies/m81.json @@ -0,0 +1,28 @@ +{ + "id": "m81", + "classification": "galaxy", + "order": 5146, + "physical": { + "name": "M81", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of M81 is recorded: it is an extended galaxy, and its package frames it at 16500 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 148.8883, + "declinationDegrees": 69.065278, + "positionEpochJulianYear": 2000, + "distanceParsecs": 3614098.6264, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": -47.003684215379074, + "sources": { + "position": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1. (https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv); catalogue reference lvdb-v1.1.1:m_081:location", + "distance": "Newman2024ApJ...966..175N: An Empirical Calibration of the Tip of the Red Giant Branch Distance Method in the Near Infrared. I. Hubble Space Telescope WFC3/IR F110W and F160W Filters (https://ui.adsabs.harvard.edu/abs/2024ApJ...966..175N); catalogue reference Newman2024ApJ...966..175N: 3614098.6264 pc (-65964.7340604/+33440.8428599); trgb", + "properMotion": "Zero: the galaxy is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "SIMBAD M 81: -47.003684215379074 km/s +/- 0.00007004846, from 2022ApJS..261....6K (https://ui.adsabs.harvard.edu/abs/2022ApJS..261....6K/abstract)" + } + } +} diff --git a/packages/astronomy/data/bodies/m83.json b/packages/astronomy/data/bodies/m83.json new file mode 100644 index 00000000000..a95c40ee3fb --- /dev/null +++ b/packages/astronomy/data/bodies/m83.json @@ -0,0 +1,28 @@ +{ + "id": "m83", + "classification": "galaxy", + "order": 5147, + "physical": { + "name": "M83", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of M83 is recorded: it is an extended galaxy, and its package frames it at 9300 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 204.2504167, + "declinationDegrees": -29.8678, + "positionEpochJulianYear": 2000, + "distanceParsecs": 4897788.19368, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": 519.1, + "sources": { + "position": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1. (https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv); catalogue reference lvdb-v1.1.1:m_083:location", + "distance": "Tully2009AJ....138..323T: The Extragalactic Distance Database (https://ui.adsabs.harvard.edu/abs/2009AJ....138..323T); catalogue reference Tully2009AJ....138..323T: 4897788.19368 pc (-67200.1732867/+68135.0208189); unspecified", + "properMotion": "Zero: the galaxy is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "SIMBAD M 83: 519.1 km/s, from 2004MNRAS.350.1195M (https://ui.adsabs.harvard.edu/abs/2004MNRAS.350.1195M/abstract)" + } + } +} diff --git a/packages/astronomy/data/bodies/m87.json b/packages/astronomy/data/bodies/m87.json new file mode 100644 index 00000000000..c95a82d7576 --- /dev/null +++ b/packages/astronomy/data/bodies/m87.json @@ -0,0 +1,28 @@ +{ + "id": "m87", + "classification": "galaxy", + "order": 5148, + "physical": { + "name": "M87", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of M87 is recorded: it is an extended galaxy, and its package frames it at 199000 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 187.70593076725, + "declinationDegrees": 12.391123246083334, + "positionEpochJulianYear": 2000, + "distanceParsecs": 16800000, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": 1256.4841773442704, + "sources": { + "position": "Charlot et al. (2020), A&A 644, A159: ICRF3, the position SIMBAD adopts for M 87. (https://doi.org/10.1051/0004-6361/202038368); catalogue reference Charlot2020A&A...644A.159C", + "distance": "Event Horizon Telescope Collaboration (2019), ApJL 875, L1 (M87 Paper I), Table 1: D = 16.8 +/- 0.8 Mpc, rederived from likelihood distributions in Paper VI. (https://arxiv.org/abs/1906.11238); catalogue reference EHT2019ApJ...875L...1E: 16800000 pc (-800000/+800000); adopted", + "properMotion": "Zero: the galaxy is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "SIMBAD M 87: 1256.4841773442704 km/s +/- 0.00012, from 2009ApJS..182..543A (https://ui.adsabs.harvard.edu/abs/2009ApJS..182..543A/abstract)" + } + } +} diff --git a/packages/astronomy/data/bodies/ngc-253.json b/packages/astronomy/data/bodies/ngc-253.json new file mode 100644 index 00000000000..1ee2f15b74a --- /dev/null +++ b/packages/astronomy/data/bodies/ngc-253.json @@ -0,0 +1,28 @@ +{ + "id": "ngc-253", + "classification": "galaxy", + "order": 5149, + "physical": { + "name": "NGC 253", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of NGC 253 is recorded: it is an extended galaxy, and its package frames it at 19900 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 11.888, + "declinationDegrees": -25.28822, + "positionEpochJulianYear": 2000, + "distanceParsecs": 3467368.50453, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": 258.8, + "sources": { + "position": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1. (https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv); catalogue reference lvdb-v1.1.1:ngc_0253:location", + "distance": "RadburnSmith2011ApJS..195...18R: The GHOSTS Survey. I. Hubble Space Telescope Advanced Camera for Surveys Data (https://ui.adsabs.harvard.edu/abs/2011ApJS..195...18R); catalogue reference RadburnSmith2011ApJS..195...18R: 3467368.50453 pc (-109992.362096/+113595.866501); trgb", + "properMotion": "Zero: the galaxy is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "SIMBAD NGC 253: 258.8 km/s, from 2004MNRAS.350.1195M (https://ui.adsabs.harvard.edu/abs/2004MNRAS.350.1195M/abstract)" + } + } +} diff --git a/packages/astronomy/data/bodies/ngc-300.json b/packages/astronomy/data/bodies/ngc-300.json new file mode 100644 index 00000000000..6deff55641f --- /dev/null +++ b/packages/astronomy/data/bodies/ngc-300.json @@ -0,0 +1,28 @@ +{ + "id": "ngc-300", + "classification": "galaxy", + "order": 5150, + "physical": { + "name": "NGC 300", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of NGC 300 is recorded: it is an extended galaxy, and its package frames it at 9400 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 13.7229167, + "declinationDegrees": -37.68444444, + "positionEpochJulianYear": 2000, + "distanceParsecs": 2089296.13085, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": 146, + "sources": { + "position": "Pace (2025), The Local Volume Database, DOI 10.33232/001c.144859; release v1.1.1. (https://github.com/apace7/local_volume_database/releases/download/v1.1.1/comb_all.csv); catalogue reference lvdb-v1.1.1:ngc_0300:location", + "distance": "Tully2009AJ....138..323T: The Extragalactic Distance Database (https://ui.adsabs.harvard.edu/abs/2009AJ....138..323T); catalogue reference Tully2009AJ....138..323T: 2089296.13085 pc (-56939.1199178/+58534.3432765); trgb", + "properMotion": "Zero: the galaxy is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "SIMBAD NGC 300: 146 km/s +/- 2, from 2012AJ....144....4M (https://ui.adsabs.harvard.edu/abs/2012AJ....144....4M/abstract)" + } + } +} diff --git a/packages/astronomy/data/bodies/omega-centauri.json b/packages/astronomy/data/bodies/omega-centauri.json new file mode 100644 index 00000000000..bdba7e7d75b --- /dev/null +++ b/packages/astronomy/data/bodies/omega-centauri.json @@ -0,0 +1,28 @@ +{ + "id": "omega-centauri", + "classification": "globular-cluster", + "order": 5151, + "physical": { + "name": "Omega Centauri", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of Omega Centauri is recorded: it is an extended globular cluster, and its package frames it at 43.4 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 201.696958, + "declinationDegrees": -47.479539, + "positionEpochJulianYear": 2000, + "distanceParsecs": 5426, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": 232.7, + "sources": { + "position": "ESO/INAF-VST/OmegaCAM eso1119b native TIFF and embedded AVM. The common observation-frame center is an adopted registration origin, not a new dynamical centroid measurement. (https://cdn.eso.org/images/original/eso1119b.tif); catalogue reference eso1119b-frame", + "distance": "Baumgardt & Vasiliev (2021), Accurate distances to Galactic globular clusters through a combination of Gaia EDR3, HST and literature data, Table 2, NGC 5139: combined distance 5.426 +/- 0.047 kpc. Full downloaded paper bytes. (https://arxiv.org/pdf/2105.09526); catalogue reference baumgardt-vasiliev-2021: 5426 pc (-47/+47); Combined Gaia EDR3, HST and literature distance, Table 2, NGC 5139. One cluster scale; individual stellar distances are not recovered.", + "properMotion": "Zero: the globular cluster is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "SIMBAD NGC 5139: 232.7 km/s +/- 0.2, from 2018MNRAS.478.1520B (https://ui.adsabs.harvard.edu/abs/2018MNRAS.478.1520B/abstract)" + } + } +} diff --git a/packages/astronomy/data/bodies/smc.json b/packages/astronomy/data/bodies/smc.json new file mode 100644 index 00000000000..aa04a3a63be --- /dev/null +++ b/packages/astronomy/data/bodies/smc.json @@ -0,0 +1,28 @@ +{ + "id": "smc", + "classification": "galaxy", + "order": 5152, + "physical": { + "name": "SMC", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of SMC is recorded: it is an extended galaxy, and its package frames it at 4000 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 16.25, + "declinationDegrees": -72.42, + "positionEpochJulianYear": 2000, + "distanceParsecs": 62440, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": 145.6, + "sources": { + "position": "Stanimirovic2004ApJ...604..176S: A New Look at the Kinematics of Neutral Hydrogen in the Small Magellanic Cloud (https://ui.adsabs.harvard.edu/abs/2004ApJ...604..176S); catalogue reference Stanimirovic2004ApJ...604..176S", + "distance": "Graczyk et al. (2020), ApJ904,13; DOI 10.3847/1538-4357/abbb2b (https://arxiv.org/abs/2010.08754); catalogue reference Graczyk2020ApJ...904...13G: 62440 pc; eclipsing-binaries", + "properMotion": "Zero: the galaxy is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "SIMBAD NAME SMC: 145.6 km/s +/- 0.6, from 2012AJ....144....4M (https://ui.adsabs.harvard.edu/abs/2012AJ....144....4M/abstract)" + } + } +} diff --git a/packages/astronomy/data/bodies/virgo-cluster.json b/packages/astronomy/data/bodies/virgo-cluster.json new file mode 100644 index 00000000000..e494896e9de --- /dev/null +++ b/packages/astronomy/data/bodies/virgo-cluster.json @@ -0,0 +1,28 @@ +{ + "id": "virgo-cluster", + "classification": "galaxy-cluster", + "order": 5153, + "physical": { + "name": "Virgo Cluster", + "horizonsCode": null, + "meanRadiusKm": 0, + "gravitationalParameterKm3PerS2": 0, + "parent": null + }, + "physicalNotes": "No radius or mass of Virgo Cluster is recorded: it is an extended cluster of galaxies, and its package frames it at 1625000 pc (object.json worldFrame.bodyRadiusM), a presentation value.", + "star": { + "rightAscensionDegrees": 187.697, + "declinationDegrees": 12.337, + "positionEpochJulianYear": 2000, + "distanceParsecs": 16203170.427400004, + "properMotionRaMasPerYear": 0, + "properMotionDecMasPerYear": 0, + "radialVelocityKmPerS": 1079.3, + "sources": { + "position": "Sadibekova et al. (2024), MCXC-II, A&A 688 A187; CDS J/A+A/688/A187, byte columns defined by https://cdsarc.cds.unistra.fr/ftp/J/A+A/688/A187/ReadMe (https://cdsarc.cds.unistra.fr/ftp/J/A+A/688/A187/mcxcii.dat.gz); catalogue reference mcxc-ii:MCXC J1230.7+1220", + "distance": "Tully et al. (2023), Cosmicflows-4, ApJ 944, 94; CDS J/ApJ/944/94, tables 2 (group membership, 1PGC) and 3 (group distance modulus, DMzp), as joined into the Nearby Universe's tracked table (https://cdsarc.cds.unistra.fr/viz-bin/cat/J/ApJ/944/94); catalogue reference cosmicflows-4:41220: 16203170.427400004 pc; Cosmicflows-4 group distance: the weighted average of its members' distance moduli on the calibrated scale (table 3, DMzp).", + "properMotion": "Zero: the cluster of galaxies is held at its catalogued position; no motion across the sky is applied.", + "radialVelocity": "c z for the catalogued redshift z = 0.0036: Redshift reference 1995ApJ...446....1S, transcribed from MCXC-II. (https://ui.adsabs.harvard.edu/abs/1995ApJ...446....1S); catalogue reference 1995ApJ...446....1S" + } + } +} diff --git a/packages/astronomy/src/bodies.test.ts b/packages/astronomy/src/bodies.test.ts index ca458480328..3d426ab2a09 100644 --- a/packages/astronomy/src/bodies.test.ts +++ b/packages/astronomy/src/bodies.test.ts @@ -10,6 +10,10 @@ import { COMET_IDS, EXOPLANET_IDS, BLACK_HOLE_IDS, + GALAXY_IDS, + GALAXY_CLUSTER_IDS, + NEBULA_IDS, + GLOBULAR_CLUSTER_IDS, PLANET_IDS, bodyData, moonsOf, @@ -26,6 +30,8 @@ const HOSTED_STAR_IDS = HOSTED_PLANET_IDS.filter(id => !(EXOPLANET_IDS as readon const GRAVITATIONAL_CONSTANT_KM3_PER_KG_S2 = 6.6743e-20 +const EXTENDED_IDS: readonly string[] = [...GALAXY_IDS, ...GALAXY_CLUSTER_IDS, ...NEBULA_IDS, ...GLOBULAR_CLUSTER_IDS] + describe('the body table', () => { it('has an entry for the Sun, eight planets, the Moon, every satellite, the five dwarf planets, every placed star, black hole and hosted star, and every exoplanet', () => { assert.equal(BODY_IDS.length, 1 + 8 + 1 + SATELLITE_IDS.length + SCENE_SATELLITE_IDS.length + DWARF_PLANET_IDS.length + SMALL_BODY_IDS.length + COMET_IDS.length + STAR_IDS.length + EXOPLANET_IDS.length + HOSTED_STAR_IDS.length) @@ -65,6 +71,12 @@ describe('the body table', () => { it('has physically plausible radii and masses', () => { for (const id of BODY_IDS) { const data = bodyData(id) + // An extended body (a galaxy, a cluster of galaxies, a nebula, a globular cluster) has no radius or mass of a sphere. + if (EXTENDED_IDS.includes(id)) { + assert.equal(data.meanRadiusKm, 0, id) + assert.equal(data.gravitationalParameterKm3PerS2, 0, id) + continue + } // Zero radius is an unmeasured one, allowed only for a hosted star or black hole. Such a star has no published mass either; // a hosted black hole's mass is what its orbit measures. if (data.meanRadiusKm === 0) { diff --git a/packages/astronomy/src/bodies.ts b/packages/astronomy/src/bodies.ts index 5304b4cc3a5..d51991f7b4c 100644 --- a/packages/astronomy/src/bodies.ts +++ b/packages/astronomy/src/bodies.ts @@ -1,6 +1,6 @@ import { PLANET_IDS } from './body-types.js' import type { BodyId, BodyData, PlanetId } from './body-types.js' -export { PLANET_IDS, DWARF_PLANET_IDS, ASTEROID_IDS, TRANS_NEPTUNIAN_IDS, SMALL_BODY_IDS, COMET_IDS, INTERSTELLAR_IDS, EXOPLANET_IDS, BLACK_HOLE_IDS } from './body-types.js' +export { PLANET_IDS, DWARF_PLANET_IDS, ASTEROID_IDS, TRANS_NEPTUNIAN_IDS, SMALL_BODY_IDS, COMET_IDS, INTERSTELLAR_IDS, EXOPLANET_IDS, BLACK_HOLE_IDS, GALAXY_IDS, GALAXY_CLUSTER_IDS, NEBULA_IDS, GLOBULAR_CLUSTER_IDS } from './body-types.js' export type { BodyData, PlanetId, DwarfPlanetId, AsteroidId, TransNeptunianId, SmallBodyId, BodyId, CometId, InterstellarId, ExoplanetId } from './body-types.js' import { BODIES } from './body-data.js' export { BODIES } from './body-data.js' diff --git a/packages/astronomy/src/body-types.ts b/packages/astronomy/src/body-types.ts index dca4275c036..a5990154016 100644 --- a/packages/astronomy/src/body-types.ts +++ b/packages/astronomy/src/body-types.ts @@ -1,5 +1,5 @@ -import { BODY_IDS, PLANET_IDS, DWARF_PLANET_IDS, ASTEROID_IDS, TRANS_NEPTUNIAN_IDS, INTERSTELLAR_IDS, COMET_IDS, EXOPLANET_IDS, BLACK_HOLE_IDS } from './data/generated/bodies.js' -export { PLANET_IDS, DWARF_PLANET_IDS, ASTEROID_IDS, TRANS_NEPTUNIAN_IDS, INTERSTELLAR_IDS, COMET_IDS, EXOPLANET_IDS, BLACK_HOLE_IDS } +import { BODY_IDS, PLANET_IDS, DWARF_PLANET_IDS, ASTEROID_IDS, TRANS_NEPTUNIAN_IDS, INTERSTELLAR_IDS, COMET_IDS, EXOPLANET_IDS, BLACK_HOLE_IDS, GALAXY_IDS, GALAXY_CLUSTER_IDS, NEBULA_IDS, GLOBULAR_CLUSTER_IDS } from './data/generated/bodies.js' +export { PLANET_IDS, DWARF_PLANET_IDS, ASTEROID_IDS, TRANS_NEPTUNIAN_IDS, INTERSTELLAR_IDS, COMET_IDS, EXOPLANET_IDS, BLACK_HOLE_IDS, GALAXY_IDS, GALAXY_CLUSTER_IDS, NEBULA_IDS, GLOBULAR_CLUSTER_IDS } export interface BodyData { readonly id: BodyId readonly name: string diff --git a/packages/bake/AGENTS.md b/packages/bake/AGENTS.md index 1f22cee15ba..4b3038d3eaa 100644 --- a/packages/bake/AGENTS.md +++ b/packages/bake/AGENTS.md @@ -106,8 +106,8 @@ its validators accept); the renderer never imports the bake. package (`loadAstronomyPackage`), which finds the build through this package's own name so the path holds from `dist/`. It imports no topic. `packages/bake/cli/prepare-solar-geometry.mts` generates `src/platform/solar-geometry.mts` from it; its test is `src/platform/solar-geometry.test.mts`. -- `src/navigation/` is published as `@cssearth/bake/navigation` (Node only): the prepared focus objects and scene distances - the catalogue and search destinations are built from, and the marker recipes whose source bytes are checked and drawn +- `src/navigation/` is published as `@cssearth/bake/navigation` (Node only): the navigation distances + the catalogue and search destinations are built with, and the marker recipes whose source bytes are checked and drawn into navigation marker sprites, and the navigation preparation (`prepare-navigation.ts`, with the Sun, black-hole, supernova and action marker sources in `marker-descriptors.ts`) that writes the marker atlases, action markers and `site/prepared-navigation-markers.mjs`, and the flat neutral disc (`neutral-disc-marker.ts`) that marks an unresolved, diff --git a/packages/bake/authoring/fornax-cluster/members.mts b/packages/bake/authoring/fornax-cluster/members.mts index 79c4c1e5474..2c50663effe 100644 --- a/packages/bake/authoring/fornax-cluster/members.mts +++ b/packages/bake/authoring/fornax-cluster/members.mts @@ -15,7 +15,7 @@ * Inputs: FCC p2tbl2, by default `output/clusters/fcc-p2tbl2.dat.gz`, from * https://cdsarc.cds.unistra.fr/ftp/VII/180/p2tbl2.dat.gz (byte columns from its ReadMe), and the tracked * `src/objects/nearby-universe/source/galaxies/cf4-hyperleda.csv.gz`. - * Output: `src/objects/fornax-cluster/source/dots/fcc-members.csv.gz`. It prints what it kept. + * Output: `src/objects/fornax-cluster-members/source/dots/fcc-members.csv.gz`. It prints what it kept. */ import { spawnSync } from 'node:child_process'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; @@ -25,7 +25,7 @@ import { gunzipSync, gzipSync } from 'node:zlib'; const repository = resolve(import.meta.dirname, '../../../..'); const fccPath = resolve(process.argv[2] ?? resolve(repository, 'output/clusters/fcc-p2tbl2.dat.gz')); const fieldPath = resolve(repository, 'src/objects/nearby-universe/source/galaxies/cf4-hyperleda.csv.gz'); -const outputPath = resolve(repository, 'src/objects/fornax-cluster/source/dots/fcc-members.csv.gz'); +const outputPath = resolve(repository, 'src/objects/fornax-cluster-members/source/dots/fcc-members.csv.gz'); const FORNAX_GROUP = '13418', MATCH_ARCSEC = 10; const stage = (type: string): string => { diff --git a/packages/bake/authoring/m31/compose-optical.mts b/packages/bake/authoring/m31/compose-optical.mts index ec9123d835e..411ad693a56 100644 --- a/packages/bake/authoring/m31/compose-optical.mts +++ b/packages/bake/authoring/m31/compose-optical.mts @@ -13,7 +13,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { parseImageLayerRecipe, imageLayerView } from '@cssearth/bake/image-layers'; -const sourceDirectory = resolve(import.meta.dirname, '../../../../src/objects/m31/source'); +const sourceDirectory = resolve(import.meta.dirname, '../../../../src/objects/m31-layers/source'); const recipe = JSON.parse(await readFile(resolve(sourceDirectory, 'recipe.json'), 'utf8')); const composition = recipe.composition as { frame: { centerRaDeg: number; centerDecDeg: number; fieldOfViewDeg: [number, number]; northClockwiseDeg: number; arcsecPerPixel: number }; @@ -21,7 +21,7 @@ const composition = recipe.composition as { fill: { path: string; dimensions: [number, number]; originalDimensions: [number, number]; parentPixelWindow: [number, number, number, number]; observation: { centerRaDeg: number; centerDecDeg: number; fieldOfViewDeg: [number, number]; northClockwiseDeg: number } }; featherArcsec: number; noDataLevel: number }; -if (!composition) throw new TypeError('src/objects/m31/source/recipe.json has no "composition" block.'); +if (!composition) throw new TypeError('src/objects/m31-layers/source/recipe.json has no "composition" block.'); sharp.cache(false); const { frame, survey, fill } = composition; const width = Math.round(frame.fieldOfViewDeg[0] * 3600 / frame.arcsecPerPixel), height = Math.round(frame.fieldOfViewDeg[1] * 3600 / frame.arcsecPerPixel); diff --git a/packages/bake/authoring/virgo-cluster/members.mts b/packages/bake/authoring/virgo-cluster/members.mts index 5fb314f73e4..a20f40b9b53 100644 --- a/packages/bake/authoring/virgo-cluster/members.mts +++ b/packages/bake/authoring/virgo-cluster/members.mts @@ -14,7 +14,7 @@ * Inputs: EVCC table 2, by default `output/clusters/evcc-table2.dat`, from * https://cdsarc.cds.unistra.fr/ftp/J/ApJS/215/22/table2.dat (byte columns from its ReadMe), and the tracked * `src/objects/nearby-universe/source/galaxies/cf4-hyperleda.csv.gz`. - * Output: `src/objects/virgo-cluster/source/dots/evcc-members.csv.gz`. It prints what it kept. + * Output: `src/objects/virgo-cluster-members/source/dots/evcc-members.csv.gz`. It prints what it kept. */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; @@ -23,7 +23,7 @@ import { gunzipSync, gzipSync } from 'node:zlib'; const repository = resolve(import.meta.dirname, '../../../..'); const evccPath = resolve(process.argv[2] ?? resolve(repository, 'output/clusters/evcc-table2.dat')); const fieldPath = resolve(repository, 'src/objects/nearby-universe/source/galaxies/cf4-hyperleda.csv.gz'); -const outputPath = resolve(repository, 'src/objects/virgo-cluster/source/dots/evcc-members.csv.gz'); +const outputPath = resolve(repository, 'src/objects/virgo-cluster-members/source/dots/evcc-members.csv.gz'); const VIRGO_GROUP = '41220', MATCH_ARCSEC = 10; // EVCC table 2 byte columns (ReadMe): 1-4 EVCC, 17-24 RAdeg, 26-32 DEdeg, 74 MmI, 90-92 TT1, 126-130 gmag, 138-142 rmag. diff --git a/packages/bake/cli/prepare-solar-geometry.mts b/packages/bake/cli/prepare-solar-geometry.mts index d1f3e4b1f08..2c2d7bc1ab1 100644 --- a/packages/bake/cli/prepare-solar-geometry.mts +++ b/packages/bake/cli/prepare-solar-geometry.mts @@ -32,6 +32,7 @@ import { resolve } from "node:path"; import { loadAstronomyPackage } from "@cssearth/bake/astronomy"; import { loadSceneEpochEphemeris } from "../../astronomy/cli/scene-ephemeris.mts"; import { readPreparedObjects } from "@cssearth/objects/node"; +import { isPlacedClassification } from "@cssearth/objects"; /** Write src/platform/solar-geometry.mts for the registered bodies at the pinned scene epoch and print each body's geometry. */ export async function prepareSolarGeometry() { @@ -60,9 +61,9 @@ export async function prepareSolarGeometry() { const isPlacedStar = (id: string) => isIncluded(STAR_IDS, id); // A planet of another star orbits a placed star on its transit-fitted orbit; its host is its light source. const isHostedPlanet = (id: string) => isIncluded(HOSTED_PLANET_IDS, id); - const PACKAGED = readPreparedObjects(resolve(import.meta.dirname, "../../..")).sceneObjects.filter(body => + const PACKAGED = readPreparedObjects(resolve(import.meta.dirname, "../../..")).worldObjects.filter(body => ["planet", "dwarf-planet", "satellite", "asteroid", "trans-neptunian", "comet", "interstellar", "exoplanet"].includes(body.classification) || - ((body.classification === "star" || body.classification === "black-hole") && (isPlacedStar(body.id) || isHostedPlanet(body.id)))).map(body => { + (isPlacedClassification(body.classification) && (isPlacedStar(body.id) || isHostedPlanet(body.id)))).map(body => { if (!Object.hasOwn(ASTRONOMY_BODY_DATA, body.id)) throw new TypeError(`Unknown astronomy body: ${body.id}.`); return body.id as BodyId; }); @@ -122,7 +123,9 @@ export async function prepareSolarGeometry() { const authoredRotations = new Map(await Promise.all(BODIES.map(async (id): Promise => { if (RECORD_ONLY_HOSTED.includes(id)) return [id, recordOnlyRotation(id)]; const descriptor = requireRecord(await readJsonSource(resolve("src/objects", id, "object.json"))); - const recipe = requireRecord(requireRecord(descriptor.properties).recipe); + // No surface (a galaxy, a nebula, a cluster, the Local Group level) means nothing rotates: the body-fixed frame is the ICRF. + const authored = requireRecord(descriptor.properties).recipe, recipe = authored === undefined ? null : requireRecord(authored); + if (!recipe || !requireArray(recipe.surfaces).length) return [id, { poleRightAscensionRad: 0, poleDeclinationRad: Math.PI / 2, primeMeridianRad: 0, spinRateRadPerDay: 0 }]; const ref = requireArray(recipe.sources).map(source => requireRecord(source)).find(source => source.id === "rotation"); if (!ref) return [id, null]; const { readAuthoredRotation } = await import('@cssearth/bake/objects/scene'); @@ -226,7 +229,7 @@ export async function prepareSolarGeometry() { const angularMomentum = cross(orbitPositionAu, velocityAuPerDay); const radialOnly = star && Math.hypot(...angularMomentum) <= 1e-12 * Math.hypot(...orbitPositionAu) * Math.hypot(...velocityAuPerDay); const orbitNormalIcrf = normalize(radialOnly ? cross(orbitPositionAu, ECLIPTIC_NORTH_ICRF) : angularMomentum); - const velocityIcrf = normalize(velocityAuPerDay); + const velocityIcrf = normalize(Math.hypot(...velocityAuPerDay) > 0 ? velocityAuPerDay : orbitPositionAu); // A placed body held still has no velocity direction: its line of sight stands in, and nothing draws its path. // Columns of bodyFixedToIcrf are the body axes in ICRF, so its transpose // takes an ICRF direction into the body-fixed frame. const matrix = bodyFixedToIcrf(rotationAtEpoch(body)); diff --git a/packages/bake/src/contract/object-runtime.test.mts b/packages/bake/src/contract/object-runtime.test.mts index 7457b87b3a9..91a856e9888 100644 --- a/packages/bake/src/contract/object-runtime.test.mts +++ b/packages/bake/src/contract/object-runtime.test.mts @@ -101,7 +101,7 @@ function harness(options: HarnessOptions = {}, overrides: Partial => ({ ...publication, distance: 12345000, distanceKilometers: 12345, distanceRadii: 12345000, focal: 1000, projectionScale: 1, principalOffset: [0,0], visibleRect: null, offAxisDegrees: null, silhouetteRadius: null, levelOfDetail: { stage: 'geometry', silhouetteDiameter: 400, billboardOpacity: 0, markerOpacity: 0, proxyOpacity: 0 }, pitch: publication.controlPitch, pose: { schema: "cssearth-camera-pose@2" as const, scene: matrix } }); - return { publicationState: () => ({ requestedRevision: 0, presentedRevision: 0, presentedWorld: null }), mobilePageFlow: () => false, initialResponsiveZoom: () => definition.camera.defaultZoom, currentResponsiveZoom: () => definition.camera.defaultZoom, setZoomOutCentering() {}, preparedFocus: () => null, setPreparedFocus() { throw new Error("Focus changes are outside this mount fixture."); }, async flyToPreparedFocus() { throw new Error("Focus flights are outside this mount fixture."); }, captureWorldCamera() { throw new Error("unused"); }, applyWorldCamera() {}, rebaseScene() {}, flyToState: async (...args: Parameters) => { flights.push(args); return { completed: true }; }, invalidate: () => orbitConfiguration.onPublish?.(publication), refresh: () => orbitConfiguration.onPublish?.(publication), setState: (value: Parameters[0]) => { events.push("camera:reset"); Object.assign(publication, value); return state(); }, state, sharedState: () => ({ distanceKilometers: 12345, pose: { schema: "cssearth-camera-pose@2", scene: matrix } }), skyState: () => ({ sunViewDirection: null, sunVisible: false, sunClassification: "absent" }), stats: (): never => { throw new Error("Orbit stats are outside this mount harness."); }, destroy() { events.push("remove:orbit"); } } satisfies Orbit; + return { publicationState: () => ({ requestedRevision: 0, presentedRevision: 0, presentedWorld: null }), mobilePageFlow: () => false, initialResponsiveZoom: () => definition.camera.defaultZoom, currentResponsiveZoom: () => definition.camera.defaultZoom, setZoomOutCentering() {}, captureWorldCamera() { throw new Error("unused"); }, applyWorldCamera() {}, rebaseScene() {}, flyToState: async (...args: Parameters) => { flights.push(args); return { completed: true }; }, invalidate: () => orbitConfiguration.onPublish?.(publication), refresh: () => orbitConfiguration.onPublish?.(publication), setState: (value: Parameters[0]) => { events.push("camera:reset"); Object.assign(publication, value); return state(); }, state, sharedState: () => ({ distanceKilometers: 12345, pose: { schema: "cssearth-camera-pose@2", scene: matrix } }), skyState: () => ({ sunViewDirection: null, sunVisible: false, sunClassification: "absent" }), stats: (): never => { throw new Error("Orbit stats are outside this mount harness."); }, destroy() { events.push("remove:orbit"); } } satisfies Orbit; }, waitDocument: () => Promise.resolve(), waitPaint: () => Promise.resolve(), ...overrides, }); diff --git a/packages/bake/src/navigation/navigation-destinations.ts b/packages/bake/src/navigation/navigation-destinations.ts index 2240d8d6178..84db0629a29 100644 --- a/packages/bake/src/navigation/navigation-destinations.ts +++ b/packages/bake/src/navigation/navigation-destinations.ts @@ -1,11 +1,5 @@ -import { readFile, readdir } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { catalogueClassification, parsePreparedGalaxyCatalog } from '@cssearth/catalog'; -import { parsePreparedClusterCatalog, isPreparedCluster, isNavigableCatalogObject } from '@cssearth/catalog'; -import { parsePreparedNebulaCatalog, isPreparedNebula } from '@cssearth/catalog'; -import type { PreparedCatalogObject } from '@cssearth/catalog'; -import { defineObjects, definePreparedFocus, normalizeDestinationQuery, parseNavigationDistance } from '@cssearth/objects'; -import { isRecord, hasErrorCode } from '@cssearth/core'; +import { isExtendedClassification, parseNavigationDistance } from '@cssearth/objects'; +import { isRecord } from '@cssearth/core'; const AU_M = 149597870700, PC_M = 3.085677581491367e16; /** One tenth of a parsec, about 20,000 AU: the far edge of the Oort cloud. */ @@ -17,50 +11,15 @@ export function prepareSceneDistance(descriptor: unknown) { throw new TypeError('Navigation distance requires a prepared Sun-centred world frame.'); } const meters = Math.hypot(...frame.originM); + // A galaxy, a cluster or a nebula, or a level that is a body too, sits where its catalogued distance puts it: that distance + // has no epoch, and may be another subject's (a reflection nebula placed at its star cluster's). + const catalog = isRecord(descriptor) && isRecord(descriptor.properties) ? descriptor.properties.catalog : null; + const extended = isRecord(catalog) && typeof catalog.classification === 'string' && isExtendedClassification(catalog.classification); + if (extended || (isRecord(descriptor) && isRecord(descriptor.properties) && descriptor.properties.overview !== undefined)) { + const subject = isRecord(catalog) ? catalog.distanceSubject : undefined; + return parseNavigationDistance({ ...(subject === undefined ? {} : { subject }), meters, value: meters / PC_M, unit: 'pc', quantity: 'catalogue', referencePoint: 'observer', epochJdTt: null }); + } // A body beyond the Solar System is read in parsecs; astronomical units stop meaning anything past the Oort cloud. const parsecs = meters >= PARSEC_THRESHOLD_M; return parseNavigationDistance({ meters, value: meters / (parsecs ? PC_M : AU_M), unit: parsecs ? 'pc' : 'AU', quantity: 'geometric', referencePoint: 'heliocentre', epochJdTt: frame.epochJdTt }); } - -export function prepareFocusObject(object: PreparedCatalogObject, sceneHostId: string) { - const classification = catalogueClassification(object); - return definePreparedFocus({ kind: 'prepared-focus', id: object.id, focusId: object.id, name: object.name, - searchNames: [...new Set([object.id, object.name, ...object.aliases].flatMap(name => { - const normalized = normalizeDestinationQuery(name); - return [normalized, normalized.replaceAll(' ', '')]; - }))], classification, - systemName: isPreparedNebula(object) ? 'Milky Way' : isPreparedCluster(object) ? 'Galaxy clusters' - : object.membership.group === 'local-group' ? 'Local Group' : 'Galaxy catalogue', - sceneHostId, route: `/${object.id}/`, - distance: { ...(object.distance.subject ? { subject: object.distance.subject } : {}), meters: object.distance.valuePc * PC_M, value: object.distance.valuePc, unit: 'pc', - quantity: 'catalogue', referencePoint: 'observer', epochJdTt: null } }); -} - -/** Every row of every spatial catalogue (galaxies, clusters, nebulae), in folder order. */ -export async function readPreparedCatalogObjects(objectsDirectory: string) { - const read = async (path: string): Promise => JSON.parse(await readFile(path, 'utf8')); - const objects: PreparedCatalogObject[] = []; - // Every folder's two catalogue files, read in parallel and parsed below in folder order. - const names = (await readdir(objectsDirectory, { withFileTypes: true })).filter(entry => entry.isDirectory()).map(entry => entry.name); - const files = await Promise.all(names.flatMap(name => ['prepared/catalogue.json', 'source/nebula.json'].map(async path => { - try { return { path, value: await read(resolve(objectsDirectory, name, path)) }; } - catch (error) { if (hasErrorCode(error, 'ENOENT')) return null; throw error; } - }))); - for (const file of files) { - if (file === null) continue; - const { path, value } = file; - if (!isRecord(value)) throw new TypeError('Invalid spatial catalogue.'); - if (value.schema === 'cssearth-galaxy-catalog@1') objects.push(...parsePreparedGalaxyCatalog(value).objects); - else if (value.schema === 'cssearth-cluster-catalog@1') objects.push(...parsePreparedClusterCatalog(value).objects); - else if (path === 'source/nebula.json') objects.push(...parsePreparedNebulaCatalog(value).objects); - } - return objects; -} - -/** Source catalogues own identity. Rendering-resource descriptors do not add destinations. */ -export async function readPreparedFocusObjects(objectsDirectory: string, sceneHostId: string, objects?: readonly PreparedCatalogObject[]) { - objects ??= await readPreparedCatalogObjects(objectsDirectory); - // Only a subject the application can open has a page (isNavigableCatalogObject); the rest stay labels. - const destinations = objects.filter(isNavigableCatalogObject).map(object => prepareFocusObject(object, sceneHostId)).sort((a, b) => a.id.localeCompare(b.id, 'en')); - return destinations.length ? defineObjects(destinations) : Object.freeze(destinations); -} diff --git a/packages/bake/src/nebula/objects.test.ts b/packages/bake/src/nebula/objects.test.ts index 5a8ad760f7d..793df8d64a0 100644 --- a/packages/bake/src/nebula/objects.test.ts +++ b/packages/bake/src/nebula/objects.test.ts @@ -16,25 +16,25 @@ import { validatePreparedVolumeDatasets } from '@cssearth/renderer/volume/prepar const root = projectRoot(import.meta.url); test('sanitizeVolumeProvenance replaces the process-pid staging directory with a stable placeholder', () => { - const volume = { provenance: { layout: 'x', sourceVolume: { path: 'src/objects/m1/.prepared-59471/compact/hubble-optical/datasets/hubble-optical/volume.json' } } }; + const volume = { provenance: { layout: 'x', sourceVolume: { path: 'src/objects/m1-volume/.prepared-59471/compact/hubble-optical/datasets/hubble-optical/volume.json' } } }; const sanitized = sanitizeVolumeProvenance(volume); assert.equal(sanitized.provenance.sourceVolume.path, - 'src/objects/m1/.prepared-compact/compact/hubble-optical/datasets/hubble-optical/volume.json'); + 'src/objects/m1-volume/.prepared-compact/compact/hubble-optical/datasets/hubble-optical/volume.json'); assert.doesNotMatch(sanitized.provenance.sourceVolume.path, /\.prepared-\d+/); // A different process's bake must record the identical, pid-independent path. - const otherPid = { provenance: { layout: 'x', sourceVolume: { path: 'src/objects/m1/.prepared-1/compact/hubble-optical/datasets/hubble-optical/volume.json' } } }; + const otherPid = { provenance: { layout: 'x', sourceVolume: { path: 'src/objects/m1-volume/.prepared-1/compact/hubble-optical/datasets/hubble-optical/volume.json' } } }; assert.deepEqual(sanitizeVolumeProvenance(otherPid), sanitized); }); test('sanitizeVolumeProvenance leaves provenance without a staging path untouched', () => { const noProvenance = { provenance: null }; assert.equal(sanitizeVolumeProvenance(noProvenance), noProvenance); - const stablePath = { provenance: { sourceVolume: { path: 'src/objects/m1/prepared/hubble-optical/volume.json' } } }; + const stablePath = { provenance: { sourceVolume: { path: 'src/objects/m1-volume/prepared/hubble-optical/volume.json' } } }; assert.equal(sanitizeVolumeProvenance(stablePath), stablePath); const noSourceVolume = { provenance: { layout: 'x' } }; assert.equal(sanitizeVolumeProvenance(noSourceVolume), noSourceVolume); // Only a whole `.prepared-` path segment is the staging directory. - const lookalike = { provenance: { sourceVolume: { path: 'src/objects/m1/data.prepared-7/volume.json' } } }; + const lookalike = { provenance: { sourceVolume: { path: 'src/objects/m1-volume/data.prepared-7/volume.json' } } }; assert.equal(sanitizeVolumeProvenance(lookalike), lookalike); }); @@ -59,7 +59,7 @@ test('every volume the nebula delivery validates is sanitized, and an explicit b }); test('a prepared m1 dataset bank, if baked locally, records no process-pid staging directory', async () => { - const path = resolve(root, 'src/objects/m1/prepared/datasets.json'); + const path = resolve(root, 'src/objects/m1-volume/prepared/datasets.json'); let bytes: string; try { bytes = await readFile(path, 'utf8'); } catch (error) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return; throw error; } diff --git a/packages/bake/src/nebula/objects.ts b/packages/bake/src/nebula/objects.ts index 721a906405a..3e3d8517596 100644 --- a/packages/bake/src/nebula/objects.ts +++ b/packages/bake/src/nebula/objects.ts @@ -102,7 +102,8 @@ export async function prepareNebulaObject(root: string, directory: string, ifMis if (catalogueBytes === null && recipe.attachedTo === undefined) throw new TypeError('A nebula delivery without a catalogue entry names the body it attaches to.'); if (catalogueBytes !== null) { const catalogue = parsePreparedNebulaCatalog(JSON.parse(catalogueBytes.toString()) as unknown); - if (catalogue.objects.length !== 1 || catalogue.objects[0]!.id !== recipe.id || + // The catalogue row names the nebula; the package it details to is this delivery's bank. + if (catalogue.objects.length !== 1 || catalogue.objects[0]!.detailedObjectId !== recipe.id || catalogue.objects[0]!.distance.valuePc !== recipe.sky.distancePc || catalogue.objects[0]!.skyPosition.raDeg !== recipe.sky.centerIcrsDegrees[0] || catalogue.objects[0]!.skyPosition.decDeg !== recipe.sky.centerIcrsDegrees[1]) throw new TypeError('Nebula catalogue and delivery identity/sky frame differ.'); diff --git a/packages/bake/src/objects/lineage/lineage-recipes.ts b/packages/bake/src/objects/lineage/lineage-recipes.ts index 7a1c23d0dcb..5392f5a809c 100644 --- a/packages/bake/src/objects/lineage/lineage-recipes.ts +++ b/packages/bake/src/objects/lineage/lineage-recipes.ts @@ -177,7 +177,8 @@ export function lineageProducts({ id, recipes, inputs, paths: declared, controls // turns on a volume another package prepares. Its parent is that dataset; the cloud's own sources are that package's. for (const dataset of controls) { const volume = maybeRecord(dataset.volume); - if (volume && !products.some(product => product.id === dataset.id)) add(dataset.id, [], { parents: [text(volume.surface)], interpretation: kind('companion-volume') }); + // On an object with no surface the dataset is the companion alone: it borrows no surface, so it has no parent. + if (volume && !products.some(product => product.id === dataset.id)) add(dataset.id, [], { parents: volume.surface === dataset.id ? [] : [text(volume.surface)], interpretation: kind('companion-volume') }); } const content = recipes.get('content'); if (content) add('content', [content.path.replace(/^source\//u, '')], { label: 'Object information', datasetIds: [] }); diff --git a/packages/bake/src/objects/sources/authored-sources.ts b/packages/bake/src/objects/sources/authored-sources.ts index 6ae87c99756..ea814cca9ed 100644 --- a/packages/bake/src/objects/sources/authored-sources.ts +++ b/packages/bake/src/objects/sources/authored-sources.ts @@ -41,7 +41,9 @@ export async function verifiedSource(objectDirectory: string, manifest: Authored /** The authored descriptor, its manifest, and every recipe source verified against the manifest. */ export async function readAuthoredSources(objectDirectory: string, descriptorValue?: unknown): Promise { const descriptor = parseAuthoredObjectDescriptor(descriptorValue ?? JSON.parse(await readFile(resolve(objectDirectory, 'object.json'), 'utf8')) as unknown); - const manifest = await createSourceManifest({ objectId: descriptor.id, objectName: descriptor.id, sourceRoot: resolve(objectDirectory, 'source') }); + // A surface is prepared from input files; an object with no surface may record none (its datasets' inputs are their banks'). + const manifest = await createSourceManifest({ objectId: descriptor.id, objectName: descriptor.id, sourceRoot: resolve(objectDirectory, 'source'), + inputs: descriptor.recipe.surfaces.length ? 'required' : 'optional' }); const entries = await Promise.all(descriptor.recipe.sources.map(reference => verifiedSource(objectDirectory, manifest, reference))); return Object.freeze({ descriptor, manifest, entries, sources: new Map(entries.map(entry => [entry.reference.id, entry])) }); } diff --git a/packages/bake/src/site-assets/prepare-search-thumbnails.ts b/packages/bake/src/site-assets/prepare-search-thumbnails.ts index 3646c94ff6e..284f46748cc 100644 --- a/packages/bake/src/site-assets/prepare-search-thumbnails.ts +++ b/packages/bake/src/site-assets/prepare-search-thumbnails.ts @@ -82,7 +82,6 @@ async function featuredStarPreviews(projectRoot: string): Promise(); for (const entry of module.CATALOGUE_ENTRIES) { if (!isRecord(entry)) throw new TypeError('Invalid prepared catalogue entry.'); - if (entry.kind !== 'scene') continue; if (!isRecord(entry.descriptor) || !isRecord(entry.descriptor.properties) || !isRecord(entry.descriptor.properties.catalog)) throw new TypeError('Invalid scene catalogue descriptor.'); if (entry.descriptor.properties.catalog.classification !== 'star') continue; diff --git a/packages/bake/src/sources/context-source-records.ts b/packages/bake/src/sources/context-source-records.ts index 7bebe4a6d71..d5ba486c92f 100644 --- a/packages/bake/src/sources/context-source-records.ts +++ b/packages/bake/src/sources/context-source-records.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path'; import { hasErrorCode } from '@cssearth/core'; import { checkLineage, lineageSource } from '@cssearth/objects/provenance'; import type { LineageSource, ObjectLineage } from '@cssearth/objects/provenance'; -import { sourceArray, sourceObject, sourceText } from '@cssearth/objects/sources'; +import { sourceArray, sourceId, sourceObject, sourceText } from '@cssearth/objects/sources'; /** Every record a context or image-layer manifest lists: its inputs, documents and generated intermediates. */ export function manifestSources(manifest: Record): LineageSource[] { @@ -36,7 +36,10 @@ export async function contextLineages({ route, root = process.cwd(), input = (pa interpretation: { ...(typeof interpretation.kind === 'string' ? { kind: interpretation.kind } : {}), ...(typeof interpretation.sourceKind === 'string' ? { sourceKind: interpretation.sourceKind } : {}) } }; }); - results.push({ id, name: sourceText(presentation.name), route, base, controls: [], + // A bank that names its host object (`properties.host` in its descriptor) is seen on that object's page. + const descriptor = sourceObject(JSON.parse((await input(`${base}/object.json`)).toString())); + const properties = sourceObject(descriptor.properties), host = properties.host === undefined ? undefined : sourceId(properties.host); + results.push({ id, name: sourceText(presentation.name), route: host === undefined ? route : `/${host}/`, base, controls: [], lineage: checkLineage({ objectId: id, manifestPath: 'source/manifest.json', sources: manifestSources(manifest), products }) }); } return results; diff --git a/packages/bake/src/volume/node/compiler/compiler-sampling.test.ts b/packages/bake/src/volume/node/compiler/compiler-sampling.test.ts index 4c2e67a80a0..159c76173e2 100644 --- a/packages/bake/src/volume/node/compiler/compiler-sampling.test.ts +++ b/packages/bake/src/volume/node/compiler/compiler-sampling.test.ts @@ -32,7 +32,7 @@ test('the actual compiler default creates a bounded plan without an opt-in sampl }); test('actual accepted M42 sampling bypasses optimization with its historical omission unchanged', async () => { - const compact = readCompactCompiler(JSON.parse(gunzipSync(await readFile(fileURLToPath(new URL('../../../../../../src/objects/m42/source/bake-inputs.json.gz', import.meta.url)))).toString())); + const compact = readCompactCompiler(JSON.parse(gunzipSync(await readFile(fileURLToPath(new URL('../../../../../../src/objects/m42-volume/source/bake-inputs.json.gz', import.meta.url)))).toString())); const saved = compact.scene.sampling, before = JSON.stringify(saved); assert.equal(saved.layerPlan, undefined); const forbidden = () => { throw new Error('Historical replay must not probe or replan the source field.'); }; diff --git a/packages/bake/src/world-context/spatial-context.ts b/packages/bake/src/world-context/spatial-context.ts index 5bda9b4245f..acd267809e7 100644 --- a/packages/bake/src/world-context/spatial-context.ts +++ b/packages/bake/src/world-context/spatial-context.ts @@ -519,10 +519,10 @@ function validateState(state: OrbitalState, id: string, placed: boolean): void { identifier(state.centerBodyId, `${id} orbit parent`); [state.positionM, state.centerPositionM, state.normal, state.perihelionDirection].forEach((value, index) => { if (!Array.isArray(value) || value.length !== 3 || !value.every(Number.isFinite)) throw new TypeError(`${id} vector ${index} is invalid.`); }); const axis = finite(state.semiMajorAxisM, `${id} semi-major axis`), eccentricity = state.eccentricity; - // Eccentricity 1 is a placed source moving straight along its line of sight (prepare-solar-geometry.mts): placed by its - // position, never drawn as a path. + // Eccentricity 1 is a placed source moving straight along its line of sight, or held still (prepare-solar-geometry.mts): + // placed by its position, never drawn as a path, and bound or unbound. if (!(Number.isFinite(eccentricity) && eccentricity >= 0 && (eccentricity !== 1 || placed) && - (eccentricity < 1 ? axis > 0 : axis < 0)) || !Number.isFinite(state.trueAnomalyRadians) || + (eccentricity < 1 ? axis > 0 : eccentricity === 1 ? axis !== 0 : axis < 0)) || !Number.isFinite(state.trueAnomalyRadians) || 1 + eccentricity * Math.cos(state.trueAnomalyRadians) <= 0 || Math.abs(dot(state.normal, state.perihelionDirection)) > 1e-8) { throw new TypeError(`${id} orbit is invalid.`); } diff --git a/packages/objects/AGENTS.md b/packages/objects/AGENTS.md index 888dc9c7ca2..8a5b3ed8988 100644 --- a/packages/objects/AGENTS.md +++ b/packages/objects/AGENTS.md @@ -19,7 +19,7 @@ An object type describes supported behavior and data, not an individual planet. Do not ship per-object configuration, generated payload modules, shell content, or renderer code here. Keep one shared object contract; application discovery remains in the existing registry. `src/registry/` holds that registry's shared contracts, exported from the main entry: the entry schema and catalogue -decoding (`defineObjects`, `catalogEntry`, `catalogueObject`), the discovery, distance, arrival, prepared-focus and overview parsers, the classification +decoding (`defineObjects`, `catalogEntry`, `catalogueObject`), the discovery, distance, arrival, overview-level and package-fact parsers, the classification categories, the fact order, the destination-name normalisation preparation and search share, the context colour and world-rotation validation. `site/objects.mts` stays the one `OBJECTS` registry: it binds these contracts to the shell's scene loader. The registry here never loads a scene, reads a file or lists an object. Preparation reads that same diff --git a/packages/objects/src/authored.ts b/packages/objects/src/authored.ts index fa2278afafb..7316495e1ea 100644 --- a/packages/objects/src/authored.ts +++ b/packages/objects/src/authored.ts @@ -1,5 +1,7 @@ import type { ObjectDescriptor } from './descriptor.js'; import { parseObjectDescriptor } from './parse.js'; +import { isRecord } from '@cssearth/core'; +import { isExtendedClassification } from './registry/object-schema.js'; export type ShapeKind = 'sphere' | 'ellipsoid' | 'radial-terrain'; /** A recipe input by id and package path. The source manifest declares the file once, never here. */ @@ -115,7 +117,8 @@ function parseMaterials(value: unknown, sourceIds: ReadonlySet, frameIds unique(output.map(item => item.id), 'recipe.materials'); return freeze(output); } function parseSurfaces(value: unknown, sourceIds: ReadonlySet, materialIds: ReadonlySet, frameIds: ReadonlySet): readonly SurfaceRecipe[] { - if (!Array.isArray(value) || !value.length) throw new TypeError('recipe.surfaces must be a nonempty array.'); + // An object with no surface (a galaxy, a nebula, a cluster of galaxies) declares none: its datasets show companion banks. + if (!Array.isArray(value)) throw new TypeError('recipe.surfaces must be an array.'); const output = value.map((item, index) => { const at = `recipe.surfaces[${index}]`, input = record(item, at); keys(input, ['id', 'source', 'projection', 'datasets'], at); if (input.projection !== 'equirectangular' && input.projection !== 'cubemap') throw new TypeError(`${at}.projection is not supported.`); @@ -177,5 +180,10 @@ export function parseAuthoredObjectDescriptor(value: unknown): AuthoredObjectDes const descriptor = parseObjectDescriptor(value); const raw = descriptor.properties.recipe; if (raw === undefined) throw new TypeError('object.properties.recipe is required for authored objects.'); - return freeze({ ...descriptor, recipe: parseAuthoredRecipe(raw) }); + const recipe = parseAuthoredRecipe(raw), catalog = descriptor.properties.catalog; + // Only an object with no solid surface (a galaxy, a nebula, a cluster) may declare none: a body without surfaces is a mistake. + if (!recipe.surfaces.length && !(isRecord(catalog) && typeof catalog.classification === 'string' && isExtendedClassification(catalog.classification))) { + throw new TypeError(`${descriptor.id}: recipe.surfaces is empty, which only a galaxy, a galaxy cluster, a nebula or a globular cluster may declare.`); + } + return freeze({ ...descriptor, recipe }); } diff --git a/packages/objects/src/image-layer-bank.ts b/packages/objects/src/image-layer-bank.ts index 08ce959ab18..29ac459b5f6 100644 --- a/packages/objects/src/image-layer-bank.ts +++ b/packages/objects/src/image-layer-bank.ts @@ -16,7 +16,8 @@ export function parseImageLayerBankDescriptor(input: unknown): ImageLayerBankDes const descriptor = parseObjectDescriptor(input); if (descriptor.type !== 'image-layer-bank') throw new TypeError('Object is not an image-layer bank.'); const properties = descriptor.properties; - if (Object.keys(properties).some(key => key !== 'frame' && key !== 'preparation' && key !== 'cataloguePoints')) throw new TypeError('Unknown image-layer property.'); + const unknown = Object.keys(properties).filter(key => !['frame', 'preparation', 'cataloguePoints', 'host'].includes(key)); + if (unknown.length) throw new TypeError(`Unknown image-layer property: ${unknown.join(', ')}.`); const cataloguePoints = properties.cataloguePoints ?? []; if (!Array.isArray(cataloguePoints) || !cataloguePoints.every(bank => typeof bank === 'string' && /^[a-z][a-z0-9-]*$/u.test(bank)) || new Set(cataloguePoints).size !== cataloguePoints.length) { throw new TypeError(`${descriptor.id}: image-layer cataloguePoints must be distinct bank ids, not ${JSON.stringify(cataloguePoints)}.`); diff --git a/packages/objects/src/node/catalog-directory.test.ts b/packages/objects/src/node/catalog-directory.test.ts index a67d73aa096..6f0bd2494a9 100644 --- a/packages/objects/src/node/catalog-directory.test.ts +++ b/packages/objects/src/node/catalog-directory.test.ts @@ -22,14 +22,14 @@ async function objects(packages: Record) { const level = (order: number, classifications: string[], packages: string[] = []) => ({ name: `Level ${order}`, description: 'A level.', order, zoom, holds: [{ classifications }], packages }); -test('reads the overviews the packages author, from the nearest level out, hosted by the world host', async () => { +test('reads the overviews the packages author, from the nearest level out', async () => { const root = await objects({ outer: level(2, ['galaxy-cluster']), inner: level(1, ['nebula'], ['stars']), stars: null }); - const overviews = await readOverviews(root, 'sun'); - assert.deepEqual(overviews.map(overview => [overview.id, overview.sceneHostId]), [['inner', 'sun'], ['outer', 'sun']]); + const overviews = await readOverviews(root); + assert.deepEqual(overviews.map(overview => [overview.id, overview.level.order]), [['inner', 1], ['outer', 2]]); }); test('refuses levels that share an order, a classification or draw a missing package, naming them', async () => { - await assert.rejects(readOverviews(await objects({ a: level(1, ['nebula']), b: level(1, ['galaxy']) }), 'sun'), /a and b share order 1/); - await assert.rejects(readOverviews(await objects({ a: level(1, ['galaxy']), b: level(2, ['galaxy']) }), 'sun'), /a and b both hold galaxy/); - await assert.rejects(readOverviews(await objects({ a: level(1, ['nebula'], ['stars']) }), 'sun'), /a draws package stars/); + await assert.rejects(readOverviews(await objects({ a: level(1, ['nebula']), b: level(1, ['galaxy']) })), /a and b share order 1/); + await assert.rejects(readOverviews(await objects({ a: level(1, ['galaxy']), b: level(2, ['galaxy']) })), /a and b both hold galaxy/); + await assert.rejects(readOverviews(await objects({ a: level(1, ['nebula'], ['stars']) })), /a draws package stars/); }); diff --git a/packages/objects/src/node/catalog-directory.ts b/packages/objects/src/node/catalog-directory.ts index 769ee13d339..99b8904bb84 100644 --- a/packages/objects/src/node/catalog-directory.ts +++ b/packages/objects/src/node/catalog-directory.ts @@ -4,7 +4,7 @@ import { readFile, readdir } from 'node:fs/promises'; import { resolve } from 'node:path'; import { hasErrorCode, isRecord } from '@cssearth/core'; -import { catalogEntry, defineObjects, overviewEntry } from '../registry/index.js'; +import { catalogEntry, defineObjects, overviewLevel } from '../registry/index.js'; import type { CatalogEntry, NavigationDistance, OverviewObject } from '../registry/index.js'; const byOrder = (a: CatalogEntry, b: CatalogEntry) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER) || a.id.localeCompare(b.id, 'en'); @@ -36,34 +36,35 @@ export async function readCatalog(objectsDirectory: string, distance: (descripto return entries; } -/** Registered descriptors without a catalog entry are application context: the world loads their prepared resources directly. */ +/** A descriptor that mounts no scene of its own is application context: the world loads its prepared resources directly. */ export async function readContextObjects(objectsDirectory: string, descriptors?: ObjectDescriptors) { const contexts: { id: string; type: string }[] = []; for (const [name, descriptor] of descriptors ?? await readObjectDescriptors(objectsDirectory)) { - if (!isRecord(descriptor) || !isRecord(descriptor.properties) || descriptor.properties.catalog !== undefined) continue; + if (!isRecord(descriptor) || !isRecord(descriptor.properties) || descriptor.properties.catalog !== undefined && descriptor.properties.overview === undefined) continue; if (descriptor.id !== name || typeof descriptor.type !== 'string') throw new TypeError(`Context object identity differs: ${name}.`); contexts.push({ id: name, type: descriptor.type }); } return contexts.sort((a, b) => a.id.localeCompare(b.id, 'en')); } -/** The overviews the object folders author (`properties.overview`): levels above the star systems, each an entry of the one - * registry hosted by `sceneHostId`, in their order on the zoom ladder. */ -export async function readOverviews(objectsDirectory: string, sceneHostId: string, descriptors?: ObjectDescriptors) { - const overviews: OverviewObject[] = [], read = descriptors ?? await readObjectDescriptors(objectsDirectory); +/** The levels above the star systems the object folders author (`properties.overview`), in their order on the zoom ladder, + * each with its descriptor: an entry of the one registry like any other package's. */ +export async function readOverviews(objectsDirectory: string, descriptors?: ObjectDescriptors) { + const overviews: { readonly id: string; readonly descriptor: unknown; readonly level: NonNullable> }[] = []; + const read = descriptors ?? await readObjectDescriptors(objectsDirectory); for (const [name, descriptor] of read) { - const overview = overviewEntry(descriptor, sceneHostId); - if (!overview) continue; - if (overview.id !== name) throw new TypeError(`Overview identity differs: ${name}.`); - overviews.push(overview); + const level = overviewLevel(descriptor); + if (!level) continue; + if (!isRecord(descriptor) || descriptor.id !== name) throw new TypeError(`Overview identity differs: ${name}.`); + overviews.push({ id: name, descriptor, level }); } - overviews.sort((a, b) => a.order - b.order); + overviews.sort((a, b) => a.level.order - b.level.order); for (const [index, overview] of overviews.entries()) { - if (index > 0 && overview.order === overviews[index - 1]!.order) throw new TypeError(`Overviews ${overviews[index - 1]!.id} and ${overview.id} share order ${overview.order} on the zoom ladder.`); - for (const id of overview.packages) if (!read.has(id)) throw new TypeError(`Overview ${overview.id} draws package ${id}, which is not in ${objectsDirectory}.`); + if (index > 0 && overview.level.order === overviews[index - 1]!.level.order) throw new TypeError(`Overviews ${overviews[index - 1]!.id} and ${overview.id} share order ${overview.level.order} on the zoom ladder.`); + for (const id of overview.level.packages) if (!read.has(id)) throw new TypeError(`Overview ${overview.id} draws package ${id}, which is not in ${objectsDirectory}.`); // One level holds each classification: a subject's breadcrumbs lead to exactly one. - for (const other of overviews.slice(0, index)) for (const classification of overview.holds.flatMap(group => group.classifications)) { - if (other.holds.some(group => group.classifications.includes(classification))) throw new TypeError(`Overviews ${other.id} and ${overview.id} both hold ${classification}.`); + for (const other of overviews.slice(0, index)) for (const classification of overview.level.holds.flatMap(group => group.classifications)) { + if (other.level.holds.some(group => group.classifications.includes(classification))) throw new TypeError(`Overviews ${other.id} and ${overview.id} both hold ${classification}.`); } } return overviews; diff --git a/packages/objects/src/node/prepared-registry.test.ts b/packages/objects/src/node/prepared-registry.test.ts index 5711f2f2005..8d72248f3af 100644 --- a/packages/objects/src/node/prepared-registry.test.ts +++ b/packages/objects/src/node/prepared-registry.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { after, test } from 'node:test'; import { parseNavigationDistance } from '../registry/index.js'; import { PREPARED_CATALOGUE, preparedCatalogueModule, readPreparedObjects } from './prepared-registry.js'; +import type { PreparedCatalogueRow } from './prepared-registry.js'; // Temporary checkouts the tests make, removed once the file's tests finish. const temporary: string[] = []; @@ -17,19 +18,21 @@ const descriptor = (id: string, order: number, classification: string) => ({ sch worldFrame: frame } }); const au = (value: number) => ({ meters: value * 149597870700, value, unit: 'AU', quantity: 'geometric', referencePoint: 'heliocentre', epochJdTt: 2461286.5 }); const discovery = (featured: boolean) => ({ featured, imagery: featured, illustration: false }); -const focus = { kind: 'prepared-focus', id: 'helix', focusId: 'helix', name: 'Helix Nebula', searchNames: ['helix'], classification: 'nebula', - systemName: 'Milky Way', route: '/helix/', sceneHostId: 'sun', - distance: { meters: 3.085677581491367e16, value: 1, unit: 'pc', quantity: 'catalogue', referencePoint: 'observer', epochJdTt: null } }; +const parsec = { meters: 3.085677581491367e16, value: 1, unit: 'pc', quantity: 'catalogue', referencePoint: 'observer', epochJdTt: null }; +// A nebula: an object like any body, placed by a catalogue distance. +const hosted = (id: string) => ({ descriptor: descriptor(id, 9, 'nebula'), distance: parseNavigationDistance(parsec), discovery: discovery(false) }); +const focus = hosted('helix'); -const overview = { kind: 'overview', id: 'milky-way', name: 'Milky Way', description: 'Our galaxy.', order: 1, +const level = (id: string, name: string) => ({ schema: 'cssearth-object@2', id, type: 'density-volume', properties: { overview: { name, description: 'Our galaxy.', order: 1, zoom: { enter: { fade: 'system', at: 'end' }, returnBelow: { fade: 'system', at: 'middle' }, frame: { distance: { distancePc: 8000 } } }, - holds: [{ classifications: ['nebula'] }], packages: [], route: '/milky-way/', sceneHostId: 'sun' }; + holds: [{ classifications: ['nebula'] }], packages: [] } } }); +const overview = { descriptor: level('milky-way', 'Milky Way') }; const scene = (id: string, order: number, classification: string, distanceAu: number, featured: boolean) => ({ descriptor: descriptor(id, order, classification), distance: parseNavigationDistance(au(distanceAu)), discovery: discovery(featured) }); const scenes = [scene('sun', 0, 'star', 0, false), scene('mars', 4, 'planet', 1.5, true)]; -async function checkout(records: { scenes?: Parameters[0]; focuses?: readonly unknown[]; module?: string; overviews?: unknown } = {}) { +async function checkout(records: { scenes?: PreparedCatalogueRow[]; focuses?: unknown[]; module?: string; overviews?: unknown } = {}) { const root = await mkdtemp(join(tmpdir(), 'cssearth-prepared-registry-')); temporary.push(root); for (const [id, order, classification] of [['sun', 0, 'star'], ['mars', 4, 'planet']] as const) { @@ -39,15 +42,16 @@ async function checkout(records: { scenes?: Parameters[1])); + preparedCatalogueModule([...records.scenes ?? scenes, ...records.focuses ?? [focus]] as PreparedCatalogueRow[])); await writeFile(join(root, PREPARED_CATALOGUE.overviews), JSON.stringify(records.overviews ?? [overview])); return root; } -test('reads the registry in catalogue order, with distances, discoveries, prepared focuses and overviews', async () => { +test('reads the registry in catalogue order, with distances, discoveries and overviews', async () => { const registry = readPreparedObjects(await checkout()); - assert.deepEqual(registry.objects.map(object => object.id), ['sun', 'mars', 'helix', 'milky-way']); - assert.deepEqual(registry.sceneObjects.map(object => object.id), ['sun', 'mars']); + assert.deepEqual(registry.objects.map(object => object.id), ['sun', 'mars', 'helix']); + assert.equal(registry.sceneObjects, registry.objects); + assert.deepEqual(registry.levels.map(level => level.id), ['milky-way']); const mars = registry.requireSceneObject('mars'); assert.equal(mars.distance.value, 1.5); assert.equal(mars.discovery.featured, true); @@ -55,8 +59,20 @@ test('reads the registry in catalogue order, with distances, discoveries, prepar // The catalogue's order and context stay out of the registry, as the application registry leaves them out. assert.ok(!('order' in mars) && !('context' in mars)); await assert.rejects(mars.loadScene(), /cannot mount a scene/); - assert.throws(() => registry.requireSceneObject('helix'), /prepared focus/); - assert.throws(() => registry.requireSceneObject('milky-way'), /is an overview, not a scene owner/); + // A nebula is a scene and a body of the world like any other. + assert.equal(registry.requireSceneObject('helix').id, 'helix'); + assert.deepEqual(registry.worldObjects.map(object => object.id), ['sun', 'mars', 'helix']); + const helix = registry.objects.find(object => object.id === 'helix')!; + // A level with a catalogue entry is a body of the world too. It is still a level, not an object. + const placedLevel = { descriptor: { ...hosted('local-group').descriptor, type: 'galaxy-catalog', properties: { ...hosted('local-group').descriptor.properties, + overview: { order: 2, zoom: level('x', 'x').properties.overview.zoom, holds: [{ classifications: ['galaxy'] }] } } }, + distance: parseNavigationDistance(parsec), discovery: discovery(true) }; + const placed = readPreparedObjects(await checkout({ overviews: [overview, placedLevel] })); + assert.deepEqual(placed.worldObjects.map(object => object.id), ['sun', 'mars', 'helix', 'local-group']); + assert.deepEqual(placed.levels.map(level => level.id), ['milky-way', 'local-group']); + assert.deepEqual(placed.objects.map(object => object.id), ['sun', 'mars', 'helix']); + assert.partialDeepStrictEqual(helix, { classification: 'nebula', route: '/helix/', systemName: 'Solar System' }); + assert.throws(() => registry.requireSceneObject('milky-way'), /no scene of its own/); assert.throws(() => registry.requireSceneObject('pluto'), /Unknown/); }); @@ -65,14 +81,12 @@ test('reads each checkout once per process', async () => { assert.equal(readPreparedObjects(root), readPreparedObjects(join(root, 'site', '..'))); }); -test('refuses a catalogue it cannot decode or a focus without its host', async () => { +test('refuses a catalogue it cannot decode or a level without its host', async () => { const cases: [Parameters[0], RegExp][] = [ - [{ scenes: [...scenes, { ...scenes[1]!, descriptor: { id: 'pluto' } }] }, /Invalid catalogue descriptor/], + [{ scenes: [...scenes, { ...scenes[1]!, descriptor: { id: 'pluto' } }] }, /Invalid catalogue entry/], [{ scenes: [{ ...scenes[0]!, discovery: { featured: 'yes' } as never }] }, /Invalid prepared object discovery/], - [{ focuses: [{ ...focus, sceneHostId: 'mars', route: '/helix/' }, { ...focus, id: 'm1', focusId: 'm1', route: '/m1/', sceneHostId: 'jupiter' }] }, /not a registered scene: m1/], - [{ focuses: [{ kind: 'planet', id: 'x' }] }, /Invalid catalogue entry/], + [{ focuses: [{ ...focus, descriptor: null }] }, /Invalid catalogue entry/], [{ module: 'export const OTHER = [];\n' }, /exports no CATALOGUE_ENTRIES array/], - [{ overviews: [{ ...overview, sceneHostId: 'jupiter' }] }, /Overview host is not a registered scene: milky-way/], [{ overviews: {} }, /overviews/], ]; for (const [records, error] of cases) { diff --git a/packages/objects/src/node/prepared-registry.ts b/packages/objects/src/node/prepared-registry.ts index ca02ff08425..32712a956ca 100644 --- a/packages/objects/src/node/prepared-registry.ts +++ b/packages/objects/src/node/prepared-registry.ts @@ -4,23 +4,25 @@ import { createRequire } from 'node:module'; import { resolve } from 'node:path'; import { isRecord } from '@cssearth/core'; -import { catalogueObject, defineObjects, isSceneObject } from '../registry/index.js'; -import type { NavigableObject, NavigationDistance, ObjectDiscovery, ObjectEntry, PreparedFocusObject } from '../registry/index.js'; +import { catalogueLevel, catalogueObject, defineObjects } from '../registry/index.js'; +import type { NavigableObject, NavigationDistance, ObjectDiscovery, ObjectEntry, OverviewObject, WorldBody } from '../registry/index.js'; /** The catalogue files, relative to the checkout. `entries` is a module: each scene object's descriptor, as its folder's - * object.json holds it, with its distance and discovery, then each prepared focus, in registry order. The overviews are apart because every + * object.json holds it, with its distance and discovery, in registry order. The overviews are apart because every * page's object directory reads them (`site/object-directory.mts`) and a page never loads the whole registry. */ export const PREPARED_CATALOGUE = Object.freeze({ entries: 'site/prepared-catalogue.mjs', overviews: 'site/prepared-overview-objects.json', }); +/** One row of the prepared catalogue: a package's descriptor with its distance and discovery. Every object's row has this + * shape (`catalogueObject` reads it); a level's row has it too, without a distance unless the level has a place. */ +export interface PreparedCatalogueRow { descriptor: unknown; distance?: NavigationDistance; discovery?: ObjectDiscovery } + /** The catalogue module `prepare:catalog` writes at `PREPARED_CATALOGUE.entries`. */ -export function preparedCatalogueModule(scenes: readonly { descriptor: unknown; distance: NavigationDistance; discovery: ObjectDiscovery }[], - focuses: readonly PreparedFocusObject[]) { +export function preparedCatalogueModule(rows: readonly PreparedCatalogueRow[]) { // Each descriptor is read here and written into the module as one JSON string: a module that imported every package's // object.json was 3,756 module requests, past the dev server's 60 s module-fetch limit (2026-09-30). - const rows = [...scenes.map(({ descriptor, distance, discovery }) => ({ kind: 'scene', descriptor, distance, discovery })), ...focuses]; return '// Generated by pnpm prepare:catalog. Edit each object.json.\n' + `export const CATALOGUE_ENTRIES = Object.freeze(JSON.parse(${JSON.stringify(JSON.stringify(rows))}));\n`; } @@ -29,12 +31,16 @@ export function preparedCatalogueModule(scenes: readonly { descriptor: unknown; export type PreparedSceneObject = ObjectEntry; export type PreparedNavigableObject = NavigableObject; export interface PreparedObjectRegistry { - /** The catalogue entries as written, then the overviews, in registry order. */ + /** The catalogue entries as written, in registry order. */ readonly entries: readonly Readonly>[]; - /** Every navigable object, as `OBJECTS` lists them. */ + /** Every object, as `OBJECTS` lists them. Each has a scene of its own. */ readonly objects: readonly PreparedNavigableObject[]; - /** The scene objects, as `SCENE_OBJECTS` lists them. */ + /** The same list, under the name the body lanes read. */ readonly sceneObjects: readonly PreparedSceneObject[]; + /** The levels of the zoom ladder, from the nearest out. They are views, not objects. */ + readonly levels: readonly OverviewObject[]; + /** What the world context draws and names: every object, and a level with a place of its own (the Local Group). */ + readonly worldObjects: readonly WorldBody[]; requireSceneObject(id: string): PreparedSceneObject; } @@ -58,20 +64,24 @@ function decodeRegistry(checkout: string): PreparedObjectRegistry { if (!Array.isArray(catalogue)) throw new TypeError(`Invalid prepared catalogue: ${PREPARED_CATALOGUE.entries} exports no CATALOGUE_ENTRIES array.`); const overviews: unknown = load(resolve(checkout, PREPARED_CATALOGUE.overviews)); if (!Array.isArray(overviews)) throw new TypeError(`Invalid prepared catalogue overviews: ${PREPARED_CATALOGUE.overviews}.`); - const entries: Readonly>[] = [...catalogue, ...overviews].map(entry => { - if (!isRecord(entry)) throw new TypeError(`Invalid prepared catalogue entry in ${PREPARED_CATALOGUE.entries}.`); + const rows = (list: readonly unknown[], path: string): Readonly>[] => list.map(entry => { + if (!isRecord(entry)) throw new TypeError(`Invalid prepared catalogue entry in ${path}.`); return entry; }); + const entries = rows(catalogue, PREPARED_CATALOGUE.entries); const refuse = () => async (): Promise => { throw new Error('Preparation cannot mount a scene.'); }; const objects = defineObjects(entries.map(entry => catalogueObject(entry, refuse))); - const sceneObjects = Object.freeze(objects.filter(isSceneObject)); - for (const object of objects) if (object.kind !== 'scene' && !sceneObjects.some(host => host.id === object.sceneHostId)) { - throw new TypeError(`${object.kind === 'overview' ? 'Overview' : 'Prepared focus'} host is not a registered scene: ${object.id}`); - } - return Object.freeze({ entries: Object.freeze(entries), objects, sceneObjects, requireSceneObject(id: string) { + const levels = Object.freeze(rows(overviews, PREPARED_CATALOGUE.overviews).map(catalogueLevel).sort((a, b) => a.order - b.order)); + // An id names one page: an object's or a level's. + defineObjects<{ id: string; route: string }>([...objects, ...levels]); + const placedLevels = levels.flatMap((level): WorldBody[] => level.worldFrame && level.classification && level.systemName && level.color && level.distance && level.discovery + ? [{ id: level.id, name: level.name, classification: level.classification, ...(level.classificationLabel === undefined ? {} : { classificationLabel: level.classificationLabel }), + systemName: level.systemName, color: level.color, distance: level.distance, worldFrame: level.worldFrame, discovery: level.discovery }] : []); + const worldObjects = Object.freeze([...objects, ...placedLevels]); + return Object.freeze({ entries: Object.freeze(entries), objects, sceneObjects: objects, levels, worldObjects, requireSceneObject(id: string) { const object = objects.find(candidate => candidate.id === id); - if (!object) throw new Error(`Unknown cssEarth object: ${id}`); - if (!isSceneObject(object)) throw new TypeError(`Object ${id} is ${object.kind === 'overview' ? 'an overview' : 'a prepared focus'}, not a scene owner.`); - return object; + if (object) return object; + if (levels.some(level => level.id === id)) throw new TypeError(`${id} is a level of the zoom ladder: it has no scene of its own.`); + throw new Error(`Unknown cssEarth object: ${id}`); } }); } diff --git a/packages/objects/src/node/provenance/dataset-destination.test.mts b/packages/objects/src/node/provenance/dataset-destination.test.mts index bb6fbc343b1..937510c0e41 100644 --- a/packages/objects/src/node/provenance/dataset-destination.test.mts +++ b/packages/objects/src/node/provenance/dataset-destination.test.mts @@ -41,7 +41,7 @@ test('observation attribution is explicit and independent of descriptive process assert.equal(compileContributions([excludedParent], catalog, DATASET_ROUTES).edges.length, 0, 'a derived preview cannot reintroduce excluded illustration credit'); }); -test('both graph compilers give bodies and catalogue focuses the same page dataset URLs', () => { +test('both graph compilers give every object the same page dataset URLs', () => { const objects = [object('mercury', '/mercury/', 'src/objects/mercury'), object('m42', '/m42/', 'src/objects/m42')]; const usage = compileSourceUsage(objects, sources, DATASET_ROUTES), contributions = compileContributions(objects, catalog, DATASET_ROUTES); assert.deepEqual(usage.datasets, contributions.datasets); diff --git a/packages/objects/src/node/source-manifest.test.ts b/packages/objects/src/node/source-manifest.test.ts index 3ad00e79115..3f2d84d411e 100644 --- a/packages/objects/src/node/source-manifest.test.ts +++ b/packages/objects/src/node/source-manifest.test.ts @@ -25,6 +25,12 @@ test('document descriptions are optional without weakening generator identity', assert.throws(() => parse({ ...manifest, generatedIntermediates: [{ ...base.generatedIntermediates[0], generator: '' }] }), /generator/); }); +test('only a manifest whose owner prepares nothing from input files may list no inputs', () => { + const manifest = { ...sourceManifest({}), inputs: [] }; + assert.throws(() => validateSourceManifest('fixture', manifest), /inputs are empty/); + assert.deepEqual(validateSourceManifest('fixture', manifest, { inputs: 'optional' }).inputs, []); +}); + test("validates and verifies every authoritative source entry class", async () => { const root = await mkdtemp(join(tmpdir(), "cssearth-source-manifest-")); temporary.push(root); diff --git a/packages/objects/src/node/source-manifest.ts b/packages/objects/src/node/source-manifest.ts index 5068e156e37..33778561043 100644 --- a/packages/objects/src/node/source-manifest.ts +++ b/packages/objects/src/node/source-manifest.ts @@ -19,10 +19,11 @@ const COLLECTIONS = Object.freeze([ "documents", ] as const); -export async function createSourceManifest({ objectId, objectName, sourceRoot }: SourceManifestLocation) { +export async function createSourceManifest({ objectId, objectName, sourceRoot, inputs = 'required' }: SourceManifestLocation & { inputs?: 'required' | 'optional' }) { const manifest = validateSourceManifest( objectId, JSON.parse(await readFile(resolve(sourceRoot, "manifest.json"), "utf8")), + { inputs }, ); // A dataset may read an acquired input or a file this repository generates from one, such as a spectrum sampled here from the // archive's coefficients. Both are declared the same way, and both must be present before they are read. @@ -72,7 +73,9 @@ export async function createSourceManifest({ objectId, objectName, sourceRoot }: /** Every source manifest, of an object or of a shared kernel bank, has this one format name. */ export const SOURCE_MANIFEST_SCHEMA = "cssearth-authoritative-sources@3"; -export function validateSourceManifest(objectId: string, input: unknown): Readonly { +/** `inputs: 'optional'` is for an object whose recipe declares no surface: it prepares nothing from input files, and its + * datasets' inputs are their banks'. Every other manifest lists at least one input. */ +export function validateSourceManifest(objectId: string, input: unknown, { inputs = 'required' }: { inputs?: 'required' | 'optional' } = {}): Readonly { const value = input as SourceManifest; if (!value || typeof value !== "object" || isArray(value) || value.schema !== SOURCE_MANIFEST_SCHEMA) { @@ -83,7 +86,8 @@ export function validateSourceManifest(objectId: string, input: unknown): Readon throw new TypeError(`Object ${objectId} source manifest ${collection} is missing.`); } } - if (value.inputs.length === 0) { + + if (inputs === 'required' && value.inputs.length === 0) { throw new TypeError(`Object ${objectId} source manifest inputs are empty.`); } diff --git a/packages/objects/src/objects.test.ts b/packages/objects/src/objects.test.ts index 6ac4416b067..05ce629af29 100644 --- a/packages/objects/src/objects.test.ts +++ b/packages/objects/src/objects.test.ts @@ -63,6 +63,14 @@ describe('object descriptor boundary', () => { } }); + it('lets only an object with no solid surface declare no surfaces', () => { + const { sources, shape } = recipe(), bare = { schema: 'cssearth-authored-object@2', sources, shape, surfaces: [] }; + const authored = (classification: string) => ({ ...descriptor(), properties: { recipe: bare, catalog: { classification } } }); + assert.deepEqual(parseAuthoredObjectDescriptor(authored('galaxy')).recipe.surfaces, []); + assert.throws(() => parseAuthoredObjectDescriptor(authored('planet')), /recipe.surfaces is empty/); + assert.throws(() => parseAuthoredObjectDescriptor({ ...descriptor(), properties: { recipe: bare } }), /recipe.surfaces is empty/); + }); + it('requires a typed recipe at the authored object boundary', () => { assert.throws(() => parseAuthoredObjectDescriptor(descriptor()), /properties.recipe/); }); diff --git a/packages/objects/src/provenance/dataset-destination.ts b/packages/objects/src/provenance/dataset-destination.ts index 9f184405589..59b4304f558 100644 --- a/packages/objects/src/provenance/dataset-destination.ts +++ b/packages/objects/src/provenance/dataset-destination.ts @@ -4,7 +4,7 @@ import type { DatasetHost, DatasetRoutes } from './dataset-routes.js'; /** The application route that shows the context objects (the Sun's scene); preparation records it for each context package. */ export const CONTEXT_ROUTE = '/sun/'; -/** An object's dataset is selected on its own page, a scene's or a catalogue focus's alike. */ +/** An object's dataset is selected on its own page. */ export function datasetDestination(objectId: string, route: string, datasetId: string): string { sourceId(objectId); sourceId(datasetId); if (route !== `/${objectId}/`) throw new TypeError(`Invalid dataset object route ${route} for ${objectId}: an object's dataset is on its own page.`); diff --git a/packages/objects/src/registry/index.ts b/packages/objects/src/registry/index.ts index 41d58ae4c75..b521a4a6ce4 100644 --- a/packages/objects/src/registry/index.ts +++ b/packages/objects/src/registry/index.ts @@ -1,5 +1,5 @@ // The application registry's shared contracts: the object entry schema and catalogue decoding, the discovery, distance, -// arrival and prepared-focus parsers, the classification categories, the fact order, the destination-name normalisation +// arrival and zoom-level parsers, the classification categories, the fact order, the destination-name normalisation // preparation and search share, the context colour, and world-rotation validation. The host binds the registry to its // scene loader (`site/objects.mts`); nothing here loads a scene or reads a file. export { parseArrivalView, parseArrivalBillboard } from './arrival-view.js'; @@ -10,16 +10,16 @@ export { orderFacts } from './fact-order.js'; export { mapLabel } from './map-label.js'; export { distanceDescription, parseNavigationDistance } from './navigation-distance.js'; export type { NavigationDistance } from './navigation-distance.js'; -export { catalogEntry, catalogueObject } from './object-catalog.js'; +export { catalogEntry, catalogueLevel, catalogueObject } from './object-catalog.js'; export type { CatalogContext, CatalogEntry } from './object-catalog.js'; export { matchesObjectCategory, matchesObjectClassification } from './object-categories.js'; export { discoveryDescription, discoveryVisibility, isDiscoveryAnchor, offTheMap, parseObjectDiscovery } from './object-discovery.js'; export type { ObjectDiscovery } from './object-discovery.js'; -export { defineObject, defineObjects, OBJECT_CLASSIFICATIONS } from './object-schema.js'; +export { defineObject, defineObjects, EXTENDED_CLASSIFICATIONS, isExtendedClassification, isPlacedClassification, OBJECT_CLASSIFICATIONS, PLACED_CLASSIFICATIONS } from './object-schema.js'; export type { ObjectClassification, ObjectDefinitionInput, ObjectEntry, ObjectPositionM, ObjectWorldFrame } from './object-schema.js'; -export { definePreparedFocus, isSceneObject } from './prepared-focus-object.js'; -export type { NavigableObject, PreparedFocusObject } from './prepared-focus-object.js'; -export { defineOverview, overviewEntry, overviewHolding } from './overview-object.js'; +export { destinationSearchNames } from './navigable-object.js'; +export type { NavigableObject, WorldBody } from './navigable-object.js'; +export { overviewHolding, overviewLevel } from './overview-object.js'; export type { OverviewDistance, OverviewFrame, OverviewHolding, OverviewObject, OverviewZoom } from './overview-object.js'; export { validateWorldReflection, validateWorldRotation } from './world-rotation.js'; export type { WorldRotation } from './world-rotation.js'; diff --git a/packages/objects/src/registry/navigable-object.ts b/packages/objects/src/registry/navigable-object.ts new file mode 100644 index 00000000000..485275231ff --- /dev/null +++ b/packages/objects/src/registry/navigable-object.ts @@ -0,0 +1,34 @@ +import type { NavigationDistance } from './navigation-distance.js'; +import type { ObjectClassification, ObjectEntry, ObjectWorldFrame } from './object-schema.js'; +import type { ObjectDiscovery } from './object-discovery.js'; +import { normalizeDestinationQuery } from './destination-search.js'; + +/** + * An object of the one registry: a package with a catalogue entry. It has an identity, a place in the world and a scene of + * its own. A planet, a star, a galaxy and a cluster of galaxies are all this. There is no kind and no other shape; a level + * of the zoom ladder is a view of a scene (overview-object.ts), not an object. + */ +export type NavigableObject = ObjectEntry & { + readonly aliases: readonly string[]; + /** The names an object with alternate names is found by: its id, its name and each alias. */ + readonly searchNames?: readonly string[]; +}; + +/** What the world context draws, names and selects: every object, and a level that has a place of its own. */ +export interface WorldBody { + readonly id: string; + readonly name: string; + readonly classification: ObjectClassification; + readonly classificationLabel?: string; + readonly systemName: string; + readonly color: string; + readonly distance: NavigationDistance; + readonly worldFrame: ObjectWorldFrame; + readonly discovery: Readonly; +} + +/** The names an object is found by: its id, name and aliases, normalised, with and without spaces. */ +export const destinationSearchNames = (names: readonly string[]): string[] => [...new Set(names.flatMap(name => { + const normalized = normalizeDestinationQuery(name); + return [normalized, normalized.replaceAll(' ', '')]; +}))]; diff --git a/packages/objects/src/registry/object-catalog.ts b/packages/objects/src/registry/object-catalog.ts index 6666e9f7c0b..4cdbc3a86cb 100644 --- a/packages/objects/src/registry/object-catalog.ts +++ b/packages/objects/src/registry/object-catalog.ts @@ -1,13 +1,15 @@ +import { parseDistanceSubject } from '@cssearth/catalog'; import type { ObjectDiscovery } from './object-discovery.js'; import { parseObjectDiscovery } from './object-discovery.js'; import { isRecord } from '@cssearth/core'; -import { defineObject } from './object-schema.js'; +import { defineObject, OBJECT_CLASSIFICATIONS } from './object-schema.js'; import type { ObjectClassification, ObjectDefinitionInput, ObjectEntry } from './object-schema.js'; import type { NavigationDistance } from './navigation-distance.js'; import { parseNavigationDistance } from './navigation-distance.js'; -import { definePreparedFocus } from './prepared-focus-object.js'; -import type { NavigableObject } from './prepared-focus-object.js'; -import { defineOverview } from './overview-object.js'; +import { destinationSearchNames } from './navigable-object.js'; +import type { NavigableObject } from './navigable-object.js'; +import { overviewLevel } from './overview-object.js'; +import type { OverviewObject } from './overview-object.js'; /** `orbitsWithinAu`: a host's authored presentation range, the camera distance up to which its system draws every orbit. */ /** `labelPlacement: 'centre'` captions the body over its middle instead of below it (Sgr A*'s black shadow). */ @@ -16,11 +18,8 @@ export interface CatalogContext { name?: string; color?: string; order?: number; export type CatalogEntry = ObjectEntry & { readonly aliases: readonly string[]; order?: number; context?: CatalogContext }; function classification(value: unknown): ObjectClassification { - switch (value) { - case 'star': case 'planet': case 'satellite': case 'dwarf-planet': - case 'asteroid': case 'comet': case 'trans-neptunian': case 'interstellar': case 'exoplanet': case 'black-hole': return value; - default: throw new TypeError('Invalid catalogue classification.'); - } + if (!(OBJECT_CLASSIFICATIONS as readonly unknown[]).includes(value)) throw new TypeError(`Invalid catalogue classification: ${String(value)}.`); + return value as ObjectClassification; } function order(value: unknown): number | undefined { @@ -45,9 +44,11 @@ export function catalogEntry(input: unknown, loadScene: ObjectDef const catalog = input.properties.catalog; if (!isRecord(catalog)) throw new TypeError(`Missing catalogue entry: ${input.id}.`); const { name, systemName, color, distanceAu, description } = catalog; - const keys = ['name', 'systemName', 'classification', 'classificationLabel', 'color', 'distanceAu', 'description', 'aliases', 'order', 'context', 'featured', 'illustrationDatasets', 'orientationReference']; + const keys = ['name', 'systemName', 'classification', 'classificationLabel', 'color', 'distanceAu', 'description', 'aliases', 'order', 'context', 'featured', 'illustrationDatasets', 'orientationReference', 'distanceSubject']; if (Object.keys(catalog).some(key => !keys.includes(key)) || typeof name !== 'string' || typeof systemName !== 'string' || typeof color !== 'string' || typeof distanceAu !== 'number' || typeof description !== 'string') throw new TypeError(`Invalid catalogue metadata: ${input.id}.`); + // Another subject's distance the package adopts (a nebula placed at its star cluster's): navigation states it. + if (catalog.distanceSubject !== undefined) parseDistanceSubject(catalog.distanceSubject); if (catalog.orientationReference !== undefined && (!Number.isInteger(catalog.orientationReference) || Number(catalog.orientationReference) < 1)) throw new TypeError(`Invalid orientation reference: ${input.id}.`); if (catalog.featured !== undefined && typeof catalog.featured !== 'boolean' || catalog.illustrationDatasets !== undefined && (!Array.isArray(catalog.illustrationDatasets) || !catalog.illustrationDatasets.every(id => typeof id === 'string' && /^[a-z][a-z0-9-]*$/u.test(id)))) throw new TypeError(`Invalid discovery metadata: ${input.id}.`); @@ -75,16 +76,32 @@ export function catalogEntry(input: unknown, loadScene: ObjectDef order: order(catalog.order), ...(context ? { context } : {}) }; } -/** One entry of the prepared catalogue, as `prepare:catalog` writes it and `/objects//entry.json` serves it: a scene - * object's descriptor with its navigation distance and discovery, a prepared focus or an overview. `loadScene` binds a - * scene object's descriptor to the host's scene loader. The catalogue's order and context stay out of the object. */ +/** One entry of the prepared catalogue, as `prepare:catalog` writes it and `/objects//entry.json` serves it: a package's + * descriptor with its navigation distance and discovery. What the object is comes from the descriptor alone; `loadScene` + * binds its scene to the host's loader. The catalogue's order and context stay out of the object. */ export function catalogueObject(value: unknown, loadScene: (descriptor: Record) => ObjectDefinitionInput['loadScene']): NavigableObject { - if (!isRecord(value)) throw new TypeError('Invalid catalogue entry.'); - if (value.kind === 'prepared-focus') return definePreparedFocus(value); - if (value.kind === 'overview') return defineOverview(value); - if (value.kind !== 'scene' || !isRecord(value.descriptor)) throw new TypeError('Invalid catalogue entry.'); - const { order: _order, context: _context, ...object } = catalogEntry(value.descriptor, loadScene(value.descriptor), - parseNavigationDistance(value.distance), parseObjectDiscovery(value.discovery)); - return object; + if (!isRecord(value) || !isRecord(value.descriptor) || !isRecord(value.descriptor.properties) || typeof value.descriptor.id !== 'string') throw new TypeError('Invalid catalogue entry.'); + const descriptor = value.descriptor, id = value.descriptor.id; + if (value.descriptor.properties.catalog === undefined) throw new TypeError(`Invalid catalogue entry: ${id} has no catalogue entry.`); + if (overviewLevel(descriptor) !== null) throw new TypeError(`Invalid catalogue entry: ${id} is a level of the zoom ladder, not an object.`); + const { order: _order, context: _context, ...object } = catalogEntry(descriptor, loadScene(descriptor), parseNavigationDistance(value.distance), parseObjectDiscovery(value.discovery)); + // An object with alternate names is found by them: search matches its id, its name and each alias. + return object.aliases.length ? { ...object, searchNames: Object.freeze(destinationSearchNames([id, object.name, ...object.aliases])) } : object; +} + +/** One level of the zoom ladder, from its prepared row: its package's `properties.overview`, named by that block or, for a + * level with a place of its own (the Local Group), by its catalogue block, which also makes it a body of the world. */ +export function catalogueLevel(value: unknown): OverviewObject { + if (!isRecord(value) || !isRecord(value.descriptor) || !isRecord(value.descriptor.properties) || typeof value.descriptor.id !== 'string') throw new TypeError('Invalid level entry.'); + const descriptor = value.descriptor, id = value.descriptor.id, level = overviewLevel(descriptor); + if (level === null) throw new TypeError(`Invalid level entry: ${id} authors no overview.`); + const placed = value.descriptor.properties.catalog === undefined ? null + : catalogEntry(descriptor, async () => { throw new Error(`${id} is a level: it has no scene of its own.`); }, parseNavigationDistance(value.distance), parseObjectDiscovery(value.discovery)); + const { name: levelName, description: levelDescription, ...ladder } = level; + const name = placed?.name ?? levelName, description = placed?.description ?? levelDescription; + if (name === undefined || description === undefined) throw new TypeError(`Invalid level entry: ${id} has no name or description.`); + return Object.freeze({ id, name, description, route: `/${id}/`, ...ladder, + ...(placed ? { classification: placed.classification, ...(placed.classificationLabel === undefined ? {} : { classificationLabel: placed.classificationLabel }), + systemName: placed.systemName, color: placed.color, distance: placed.distance, worldFrame: placed.worldFrame, discovery: placed.discovery } : {}) }); } diff --git a/packages/objects/src/registry/object-discovery.ts b/packages/objects/src/registry/object-discovery.ts index a0e50f3334f..7000fdd7b17 100644 --- a/packages/objects/src/registry/object-discovery.ts +++ b/packages/objects/src/registry/object-discovery.ts @@ -1,3 +1,4 @@ +import { isPlacedClassification } from './object-schema.js'; import { isRecord } from '@cssearth/core'; import { matchesObjectClassification } from './object-categories.js'; import { parseArrivalView, type PreparedArrivalView } from './arrival-view.js'; @@ -37,7 +38,8 @@ export function offTheMap(object: { classification: string; discovery: ObjectDis } export function isDiscoveryAnchor(object: { classification: string }): boolean { - return object.classification === 'star' || object.classification === 'black-hole' || object.classification === 'planet'; + // A placed body (a star, a black hole, a galaxy, a cluster, a nebula) is a place of its own scale; a planet is one of its system. + return isPlacedClassification(object.classification) || object.classification === 'planet'; } type DiscoveryObjects = readonly { id: string; classification: string; discovery: ObjectDiscovery }[]; diff --git a/packages/objects/src/registry/object-schema.ts b/packages/objects/src/registry/object-schema.ts index 06c097f41cf..7f038383c31 100644 --- a/packages/objects/src/registry/object-schema.ts +++ b/packages/objects/src/registry/object-schema.ts @@ -17,7 +17,15 @@ export interface ObjectWorldFrame { readonly orbitUpReference?: ObjectPositionM; } -export type ObjectClassification = 'star' | 'planet' | 'satellite' | 'dwarf-planet' | 'asteroid' | 'comet' | 'trans-neptunian' | 'interstellar' | 'exoplanet' | 'black-hole'; +export type ObjectClassification = 'star' | 'planet' | 'satellite' | 'dwarf-planet' | 'asteroid' | 'comet' | 'trans-neptunian' | 'interstellar' | 'exoplanet' | 'black-hole' + | 'galaxy' | 'galaxy-cluster' | 'nebula' | 'globular-cluster'; +/** A body placed by its astrometry (a position and a distance) rather than an orbit: stars, black holes and the galaxies, + * clusters and nebulae beyond them. Each is a parentless world-context body; none draws a trajectory. */ +/** The placed bodies with no solid surface: their radius frames them, it occludes nothing. */ +export const EXTENDED_CLASSIFICATIONS: readonly ObjectClassification[] = Object.freeze(['galaxy', 'galaxy-cluster', 'nebula', 'globular-cluster']); +export const isExtendedClassification = (classification: string | undefined): boolean => (EXTENDED_CLASSIFICATIONS as readonly (string | undefined)[]).includes(classification); +export const PLACED_CLASSIFICATIONS: readonly ObjectClassification[] = Object.freeze(['star', 'black-hole', ...EXTENDED_CLASSIFICATIONS]); +export const isPlacedClassification = (classification: string | undefined): boolean => (PLACED_CLASSIFICATIONS as readonly (string | undefined)[]).includes(classification); /** `Scene` is what the host's loader resolves to and `Signal` how the host cancels it (an `AbortSignal`); the registry only * checks that a loader is bound. */ export interface ObjectDefinitionInput { @@ -27,7 +35,7 @@ export interface ObjectDefinitionInput { color: string; distance: NavigationDistance; route: string; description: string; loadScene(signal?: Signal): Promise; worldFrame: unknown; discovery?: ObjectDiscovery; } -export type ObjectEntry = Readonly, 'worldFrame' | 'discovery'> & { discovery: Readonly; kind: 'scene'; worldFrame: ObjectWorldFrame }>; +export type ObjectEntry = Readonly, 'worldFrame' | 'discovery'> & { discovery: Readonly; worldFrame: ObjectWorldFrame }>; const OBJECT_INPUT_KEYS = new Set([ "id", @@ -48,6 +56,7 @@ const OBJECT_INPUT_KEYS = new Set([ // list deliberately when a package introduces a new kind of body. export const OBJECT_CLASSIFICATIONS = Object.freeze([ "star", "planet", "satellite", "dwarf-planet", "asteroid", "trans-neptunian", "comet", "interstellar", "exoplanet", "black-hole", + "galaxy", "galaxy-cluster", "nebula", "globular-cluster", ]); export function defineObject(input: ObjectDefinitionInput): ObjectEntry { @@ -70,7 +79,6 @@ export function defineObject(input: ObjectDefinitionInput isArray(input) && input.length === length && Array.from(input).every(Number.isFinite); if (!isRecord(value) || Object.getPrototypeOf(value) !== Object.prototype || Object.keys(value).some(key => !fields.includes(key)) || diff --git a/packages/objects/src/registry/overview-object.test.ts b/packages/objects/src/registry/overview-object.test.ts index d4075808f4f..c85a744c3f3 100644 --- a/packages/objects/src/registry/overview-object.test.ts +++ b/packages/objects/src/registry/overview-object.test.ts @@ -1,54 +1,54 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { isDeepStrictEqual } from 'node:util'; -import { defineOverview, overviewEntry, overviewHolding } from './overview-object.js'; +import { overviewHolding, overviewLevel } from './overview-object.js'; const zoom = { enter: { distancePc: 5e6 }, returnBelow: { distancePc: 4e6 }, frame: { distance: { distancePc: 1e8 } } }; const authored = { name: 'Local Group', description: 'Our galaxy group.', order: 2, zoom, holds: [{ classifications: ['galaxy'], list: 'Galaxies' }] }; const descriptor = (overview: unknown, properties: Record = {}) => ({ schema: 'cssearth-object@2', id: 'local-group', type: 'galaxy-catalog', properties: { overview, ...properties } }); -const entry = (changes: Record = {}) => ({ kind: 'overview', id: 'local-group', name: 'Local Group', description: 'x', order: 2, zoom, - holds: [], packages: [], route: '/local-group/', sceneHostId: 'sun', ...changes }); +const level = (changes: Record = {}) => overviewLevel(descriptor({ ...authored, ...changes }))!; describe('overview entries', () => { - it('reads the overview a package authors, hosted by the scene preparation names', () => { - assert.deepEqual(overviewEntry(descriptor(authored), 'sun'), { kind: 'overview', id: 'local-group', name: 'Local Group', description: 'Our galaxy group.', - order: 2, zoom, holds: [{ classifications: ['galaxy'], list: 'Galaxies' }], packages: [], route: '/local-group/', sceneHostId: 'sun' }); + it('reads the level a package authors', () => { + assert.deepEqual(overviewLevel(descriptor(authored)), { name: 'Local Group', description: 'Our galaxy group.', + order: 2, zoom, holds: [{ classifications: ['galaxy'], list: 'Galaxies' }], packages: [] }); }); it('places a package with a volume at its origin', () => { - assert.deepEqual(overviewEntry(descriptor(authored, { volume: { originM: [1, 2, 3] } }), 'sun')?.originM, [1, 2, 3]); + assert.deepEqual(overviewLevel(descriptor(authored, { volume: { originM: [1, 2, 3] } }))?.originM, [1, 2, 3]); }); it('leaves a package without one out', () => { - assert.equal(overviewEntry({ schema: 'cssearth-object@2', id: 'm31', type: 'x', properties: {} }, 'sun'), null); + assert.equal(overviewLevel({ schema: 'cssearth-object@2', id: 'm31', type: 'x', properties: {} }), null); }); it('refuses an incomplete overview, naming the package', () => { - assert.throws(() => overviewEntry(descriptor({ ...authored, description: undefined }), 'sun'), /local-group/); - assert.throws(() => overviewEntry(descriptor({ ...authored, order: 0 }), 'sun'), /order from 1/); - assert.throws(() => overviewEntry(descriptor({ ...authored, colour: 'red' }), 'sun'), /names only its name/); + assert.throws(() => overviewLevel(descriptor({ ...authored, description: undefined })), /local-group/); + assert.throws(() => overviewLevel(descriptor({ ...authored, order: 0 })), /order from 1/); + assert.throws(() => overviewLevel(descriptor({ ...authored, colour: 'red' })), /names only its name/); }); - it('refuses a zoom ladder distance or frame outside its vocabulary, naming the field', () => { - assert.throws(() => defineOverview(entry({ zoom: { ...zoom, enter: { distancePc: -1 } } })), /local-group zoom\.enter/); - assert.throws(() => defineOverview(entry({ zoom: { ...zoom, returnBelow: { fade: 'galaxy', at: 'edge' } } })), /zoom\.returnBelow/); - assert.throws(() => defineOverview(entry({ zoom: { ...zoom, frame: { fit: 'everything' } } })), /zoom\.frame/); - assert.deepEqual(defineOverview(entry({ zoom: { ...zoom, frame: { between: [{ labels: 'galaxy-handoff-end' }, { fade: 'galaxy', at: 'middle' }] } } })).zoom.frame, { between: [{ labels: 'galaxy-handoff-end' }, { fade: 'galaxy', at: 'middle' }] }); + it('leaves a placed level\'s name and description to its catalogue entry', () => { + const { name: _name, description: _description, ...ladder } = authored; + assert.equal(overviewLevel(descriptor(ladder, { catalog: {} }))?.name, undefined); + assert.throws(() => overviewLevel(descriptor(authored, { catalog: {} })), /leaves those to it/); }); - it('refuses a holding that repeats a classification or has no classifications', () => { - assert.throws(() => defineOverview(entry({ holds: [{ classifications: ['nebula'] }, { classifications: ['nebula'] }] })), /repeats the classification nebula/); - assert.throws(() => defineOverview(entry({ holds: [{ classifications: [], list: 'Nothing' }] })), /holds\[0\]/); + it('refuses a zoom ladder distance or frame outside its vocabulary, naming the field', () => { + assert.throws(() => level({ zoom: { ...zoom, enter: { distancePc: -1 } } }), /local-group zoom\.enter/); + assert.throws(() => level({ zoom: { ...zoom, returnBelow: { fade: 'galaxy', at: 'edge' } } }), /zoom\.returnBelow/); + assert.throws(() => level({ zoom: { ...zoom, frame: { fit: 'everything' } } }), /zoom\.frame/); + assert.deepEqual(level({ zoom: { ...zoom, frame: { between: [{ labels: 'galaxy-handoff-end' }, { fade: 'galaxy', at: 'middle' }] } } }).zoom.frame, { between: [{ labels: 'galaxy-handoff-end' }, { fade: 'galaxy', at: 'middle' }] }); }); - it('refuses a prepared entry whose route is not its page', () => { - assert.throws(() => defineOverview(entry({ route: '/lg/' })), /route/); + it('refuses a holding that repeats a classification or has no classifications', () => { + assert.throws(() => level({ holds: [{ classifications: ['nebula'] }, { classifications: ['nebula'] }] }), /repeats the classification nebula/); + assert.throws(() => level({ holds: [{ classifications: [], list: 'Nothing' }] }), /holds\[0\]/); }); it('finds the level that holds a classification', () => { - const levels = [defineOverview(entry({ id: 'milky-way', route: '/milky-way/', order: 1, holds: [{ classifications: ['nebula', 'star'] }] })), - defineOverview(entry({ holds: [{ classifications: ['galaxy'] }] }))]; + const levels = [{ id: 'milky-way', ...level({ order: 1, holds: [{ classifications: ['nebula', 'star'] }] }) }, { id: 'local-group', ...level({ holds: [{ classifications: ['galaxy'] }] }) }]; assert.equal(overviewHolding(levels, 'nebula')?.id, 'milky-way'); assert.equal(overviewHolding(levels, 'galaxy')?.id, 'local-group'); assert.equal(overviewHolding(levels, 'galaxy-cluster'), undefined); diff --git a/packages/objects/src/registry/overview-object.ts b/packages/objects/src/registry/overview-object.ts index 549b3eac956..659da4a5a5e 100644 --- a/packages/objects/src/registry/overview-object.ts +++ b/packages/objects/src/registry/overview-object.ts @@ -1,4 +1,7 @@ import { isRecord } from '@cssearth/core'; +import type { ObjectClassification, ObjectWorldFrame } from './object-schema.js'; +import type { NavigationDistance } from './navigation-distance.js'; +import type { ObjectDiscovery } from './object-discovery.js'; /** * A distance on the zoom ladder, measured from the star the zoom is centred on: a fixed distance, a point of the world @@ -31,13 +34,11 @@ export interface OverviewZoom { export interface OverviewHolding { readonly classifications: readonly string[]; readonly list?: string } /** - * A level above the star systems (the Milky Way, the Local Group, the nearby and the observable universe): an object of - * the one registry whose page, `//`, is its host scene's with that overview selected. Like a catalogue focus it has - * no scene of its own. Its package authors it under `properties.overview`; preparation adds the host and, for a package - * with a volume, its origin. + * A level above the star systems (the Milky Way, the Local Group, the nearby and the observable universe). It is a view of + * a scene, not an object: the camera, far enough out from the star it is centred on, shows the level, and its page + * `//` is the world host's scene at that zoom. Its package authors it under `properties.overview`. */ export interface OverviewObject { - readonly kind: 'overview'; readonly id: string; readonly name: string; /** The card's introduction and the page's description. */ @@ -49,11 +50,17 @@ export interface OverviewObject { /** The context packages it draws besides its own (the stars of the Milky Way, the catalogue of galaxy clusters). */ readonly packages: readonly string[]; /** What it is as a member of a larger level (the Milky Way is a galaxy of the Local Group). */ - readonly classification?: string; + readonly classification?: ObjectClassification; + readonly classificationLabel?: string; /** Where it is, for a level that is a member of another: its package's volume origin, Sun-centred ICRF metres. */ readonly originM?: readonly [number, number, number]; + /** A level with a catalogue entry (the Local Group) has a place of its own: the world context draws and names it. */ + readonly worldFrame?: ObjectWorldFrame; + readonly systemName?: string; + readonly color?: string; + readonly distance?: NavigationDistance; + readonly discovery?: Readonly; readonly route: string; - readonly sceneHostId: string; } const text = (value: unknown) => typeof value === 'string' && value.trim().length > 0; @@ -100,42 +107,34 @@ function holds(value: unknown, at: string): readonly OverviewHolding[] { })); } -const KEYS = ['kind', 'id', 'name', 'description', 'order', 'zoom', 'holds', 'packages', 'classification', 'originM', 'route', 'sceneHostId']; - -/** Decode a prepared overview entry, as `prepare:catalog` writes it. */ -export function defineOverview(input: unknown): OverviewObject { - const id = isRecord(input) ? String(input.id) : typeof input; - if (!isRecord(input) || !only(input, KEYS) || input.kind !== 'overview' || !identifier(input.id) || input.route !== `/${input.id}/` - || !text(input.name) || !text(input.description) || !Number.isSafeInteger(input.order) || Number(input.order) < 1 - || !identifier(input.sceneHostId) || !Array.isArray(input.packages) || !input.packages.every(identifier) - || (input.classification !== undefined && !identifier(input.classification)) - || (input.originM !== undefined && !(Array.isArray(input.originM) && input.originM.length === 3 && input.originM.every(Number.isFinite)))) { - throw new TypeError(`Invalid overview entry: ${id}; it needs a name, description, order from 1, zoom, holds, packages, its host and the route //, with an optional classification and originM of three numbers.`); - } - return Object.freeze({ kind: 'overview', id: input.id, name: input.name as string, description: input.description as string, - order: input.order as number, zoom: zoom(input.zoom, `${id} zoom`), holds: holds(input.holds, `${id} holds`), - packages: Object.freeze([...input.packages as string[]]), - ...(input.classification === undefined ? {} : { classification: input.classification as string }), - ...(input.originM === undefined ? {} : { originM: Object.freeze([...input.originM as number[]]) as unknown as readonly [number, number, number] }), - route: input.route, sceneHostId: input.sceneHostId as string }); -} - -/** The overview a package's descriptor authors (`properties.overview`), hosted by `sceneHostId`; null when it authors none. - * A package with a volume is placed at its origin. */ -export function overviewEntry(descriptor: unknown, sceneHostId: string): OverviewObject | null { +/** The level a package's descriptor authors under `properties.overview`: its zoom, what it holds and the packages it draws. + * Null when it authors none. A level that is not placed names itself and describes itself there; a placed one is named by + * its catalogue block. A package with a volume is at its origin. */ +export function overviewLevel(descriptor: unknown): { readonly name?: string; readonly description?: string; readonly order: number; readonly zoom: OverviewZoom; + readonly holds: readonly OverviewHolding[]; readonly packages: readonly string[]; readonly classification?: ObjectClassification; + readonly originM?: readonly [number, number, number] } | null { if (!isRecord(descriptor) || !isRecord(descriptor.properties) || descriptor.properties.overview === undefined) return null; - const overview = descriptor.properties.overview, volume = descriptor.properties.volume; + const overview = descriptor.properties.overview, volume = descriptor.properties.volume, id = String(descriptor.id); if (!isRecord(overview) || !only(overview, ['name', 'description', 'order', 'zoom', 'holds', 'packages', 'classification'])) { - throw new TypeError(`Invalid overview metadata: ${String(descriptor.id)}; it names only its name, description, order, zoom, holds, packages and classification.`); + throw new TypeError(`Invalid overview metadata: ${id}; it names only its name, description, order, zoom, holds, packages and classification.`); + } + const placed = descriptor.properties.catalog !== undefined; + const originM = isRecord(volume) ? volume.originM : undefined; + if ((placed ? overview.name !== undefined || overview.description !== undefined : !text(overview.name) || !text(overview.description)) + || !Number.isSafeInteger(overview.order) || Number(overview.order) < 1 + || (overview.packages !== undefined && !(Array.isArray(overview.packages) && overview.packages.every(identifier))) + || (overview.classification !== undefined && (placed || !identifier(overview.classification))) + || (originM !== undefined && !(Array.isArray(originM) && originM.length === 3 && originM.every(Number.isFinite)))) { + throw new TypeError(`Invalid overview metadata: ${id}; it needs an order from 1, zoom and holds, with optional packages; a level without a catalogue entry also names and describes itself and may state its classification, and one with a catalogue entry leaves those to it.`); } - return defineOverview({ kind: 'overview', id: descriptor.id, name: overview.name, description: overview.description, order: overview.order, - zoom: overview.zoom, holds: overview.holds, packages: overview.packages ?? [], - ...(overview.classification === undefined ? {} : { classification: overview.classification }), - ...(isRecord(volume) && volume.originM !== undefined ? { originM: volume.originM } : {}), - route: `/${String(descriptor.id)}/`, sceneHostId }); + return Object.freeze({ ...(placed ? {} : { name: overview.name as string, description: overview.description as string }), + order: overview.order as number, zoom: zoom(overview.zoom, `${id} zoom`), holds: holds(overview.holds, `${id} holds`), + packages: Object.freeze([...(overview.packages ?? []) as string[]]), + ...(overview.classification === undefined ? {} : { classification: overview.classification as ObjectClassification }), + ...(originM === undefined ? {} : { originM: Object.freeze([...originM as number[]]) as unknown as readonly [number, number, number] }) }); } /** The level that holds registry subjects of `classification`, among `overviews`; undefined when none does. */ -export function overviewHolding(overviews: readonly OverviewObject[], classification: string): OverviewObject | undefined { +export function overviewHolding>(overviews: readonly Level[], classification: string): Level | undefined { return overviews.find(overview => overview.holds.some(group => group.classifications.includes(classification))); } diff --git a/packages/objects/src/registry/prepared-focus-object.ts b/packages/objects/src/registry/prepared-focus-object.ts deleted file mode 100644 index 2100b1566f0..00000000000 --- a/packages/objects/src/registry/prepared-focus-object.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { isRecord } from '@cssearth/core'; -import { parseNavigationDistance } from './navigation-distance.js'; -import type { NavigationDistance } from './navigation-distance.js'; -import type { ObjectEntry } from './object-schema.js'; -import type { OverviewObject } from './overview-object.js'; - -export interface PreparedFocusObject { - readonly kind: 'prepared-focus'; - readonly id: string; - readonly focusId: string; - readonly name: string; - readonly searchNames: readonly string[]; - readonly classification: 'galaxy' | 'galaxy-cluster' | 'nebula' | 'globular-cluster'; - readonly systemName: string; - readonly route: string; - readonly sceneHostId: string; - readonly distance: NavigationDistance; -} -/** Every entry of the one registry: a scene, or what a host scene draws without one (a catalogue focus, an overview). */ -export type NavigableObject = ObjectEntry | PreparedFocusObject | OverviewObject; -export const isSceneObject = (object: NavigableObject): object is ObjectEntry => object.kind === 'scene'; - -/** A focus reuses its host scene and camera; it has no scene loader. */ -export function definePreparedFocus(input: unknown): PreparedFocusObject { - if (!isRecord(input) || Object.keys(input).some(key => !['kind', 'id', 'focusId', 'name', 'searchNames', 'classification', 'systemName', 'route', 'sceneHostId', 'distance'].includes(key)) || - input.kind !== 'prepared-focus' || typeof input.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.+-]*$/u.test(input.id) || input.focusId !== input.id || - typeof input.sceneHostId !== 'string' || !/^[a-z][a-z0-9-]*$/u.test(input.sceneHostId) || - input.route !== `/${input.id}/` || - typeof input.name !== 'string' || !input.name || typeof input.systemName !== 'string' || !input.systemName || - (input.classification !== 'galaxy' && input.classification !== 'galaxy-cluster' && input.classification !== 'nebula' && input.classification !== 'globular-cluster') || - !Array.isArray(input.searchNames) || !input.searchNames.length || !input.searchNames.every(name => typeof name === 'string' && name)) { - throw new TypeError(`Invalid prepared focus destination: ${isRecord(input) ? String(input.id) : typeof input}; its route must be // and its fields complete.`); - } - return Object.freeze({ kind: input.kind, id: input.id, focusId: input.id, name: input.name, - searchNames: Object.freeze([...input.searchNames]), classification: input.classification, systemName: input.systemName, - route: input.route, sceneHostId: input.sceneHostId, distance: parseNavigationDistance(input.distance) }); -} diff --git a/packages/renderer/src/labels/universe-label-policy.ts b/packages/renderer/src/labels/universe-label-policy.ts index e134d7c95d1..1ce32078792 100644 --- a/packages/renderer/src/labels/universe-label-policy.ts +++ b/packages/renderer/src/labels/universe-label-policy.ts @@ -52,7 +52,8 @@ export function labelImportance(kind: string, major = false, orientationReferenc if (featured && (kind === 'star' || kind === 'black-hole')) return FEATURED_STAR_TIER; // A planet of another star is a planet of its system: the tier is the body's role in the system it belongs to, not whether // that system is the Sun's. Without this an imaged exoplanet loses its caption at the scale that frames its own orbit. - if (['star', 'black-hole', 'planet', 'exoplanet', 'environment', 'galaxy-cluster'].includes(kind)) return 3; + // A galaxy, a nebula or a cluster is a named place of its scale, as a star is of the galaxy's. + if (['star', 'black-hole', 'planet', 'exoplanet', 'environment', 'galaxy', 'galaxy-cluster', 'nebula', 'globular-cluster'].includes(kind)) return 3; if (major || kind === 'dwarf-planet') return 2; if (kind === 'asteroid') return 0; return 1; diff --git a/packages/renderer/src/navigation/index.ts b/packages/renderer/src/navigation/index.ts index cf72b4da561..c92cc4e35c7 100644 --- a/packages/renderer/src/navigation/index.ts +++ b/packages/renderer/src/navigation/index.ts @@ -5,7 +5,6 @@ export { parsePreparedWorldCameraFrame } from '../validation/world-frame.js'; export { worldCameraFromCenteredPresentation, worldCameraFromPresentation, presentWorldCamera, worldCameraViewport } from './world-camera.js'; export { cssCameraAxesFromOrientation, cssViewFromOrientation, validateWorldReflection, worldQuaternionFromRotation, worldRotationFromQuaternion, rotateWorldPosition } from './world-camera-math.js'; export type { PreparedWorldCameraFrame, WorldCameraPose, WorldCameraViewport } from './world-camera.js'; -export type { PreparedNavigationFocus, PreparedFocusFlightOptions } from './prepared-focus.js'; export { createWorldSelectionTarget } from './selection-target.js'; export { preparedDefaultViewRotation } from './prepared-arrival-view.js'; export { savedWorldCamera } from './saved-world-camera.js'; diff --git a/packages/renderer/src/navigation/object-orbit.test.ts b/packages/renderer/src/navigation/object-orbit.test.ts new file mode 100644 index 00000000000..78a4e897377 --- /dev/null +++ b/packages/renderer/src/navigation/object-orbit.test.ts @@ -0,0 +1,114 @@ +import { createCameraMotion } from './camera-motion.js'; +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import { isDeepStrictEqual } from 'node:util'; +import { getEventListeners } from 'node:events'; +import scene from '../../../../src/objects/mercury/prepared/scene.json' with { type: 'json' }; +import { createRetainedCubicSkyOrbit } from './object-orbit.js'; +import { createPerspectiveDolly } from './perspective-dolly.js'; +import { presentWorldCamera } from './world-camera.js'; +import { worldRotationCss, worldRotationFromQuaternion } from './world-camera-math.js'; + +const frame = Object.freeze({ referenceFrame: 'sun-icrf', epochJdTt: 1, originM: [3e7, 4e7, 5e7] as const, + presentationToReference: [0,1,0,1,0,0,0,0,1], metersPerUnit: 2, bodyRadiusM: 200 }); +const optics = { focalPixels: 1000, principalOffsetPixels: [-170, 0] as const, framingRadiusPixels: 200 }; + +function fixture(preparedSurfaceHitTest?: (clientX: number, clientY: number) => boolean, cameraPlan: any = scene.camera) { + class Surface extends EventTarget { + style: Record = { setProperty() {}, removeProperty() {} }; + dataset: Record = {}; + isConnected = true; + ownerDocument: any; + readonly x: number; + constructor(x = 0) { super(); this.x = x; } + getBoundingClientRect() { return { x: this.x, y: 0, left: this.x, top: 0, width: 1600, height: 900 }; } + system: { style: { transform: string } } | null = null; + querySelector() { return this.system; } + } + const frames = new Map(); + let nextFrame = 0, time = 0; + const view = Object.assign(new EventTarget(), { getComputedStyle: () => ({ perspective: '1000px', perspectiveOrigin: '800px 450px' }), + matchMedia: () => ({ matches: false }), performance: { now: () => time }, + requestAnimationFrame(callback: FrameRequestCallback) { frames.set(++nextFrame, callback); return nextFrame; }, + cancelAnimationFrame(id: number) { frames.delete(id); } }); + const roots = [new Surface(), new Surface(170), new Surface(), new Surface()]; + roots.forEach(root => { root.ownerDocument = { defaultView: view }; }); + const [stage, cameraElement, sceneElement, skyElement] = roots; + let rotation = [1,0,0,0,1,0,0,0,1]; + let physicalOwners = 0, publications = 0; + const callbacks: any = {}; + const control = { stop() {}, destroy() {}, update() {}, stats: () => ({}), invalidateTrackball() {} }; + const cameraMotion = createCameraMotion(); + const orbit = createRetainedCubicSkyOrbit({ cameraMotion, stage, inputSurface: stage, cameraElement, sceneElement, + viewport: { read: () => ({ bounds: stage.getBoundingClientRect(), focalPixels: 1000, previewTop: null, openArea: null }), subscribe: () => () => {}, destroy() {} }, + framePresenter: { present(request: any) { request.commit(); } }, + worldContext: { frame, bodyRadiusUnits: 100, kilometersPerUnit: .002, maximumExtentUnits: 1e8 }, + cameraPlan, objectId: 'unit', runtimePolicy: { MOBILE_VIEWPORT_QUERY: '(max-width: 500px)' }, + preparedSurfaceHitTest, + onPublish() { publications++; }, onError(error: unknown) { throw error; }, + } as any, { HTMLElement: Surface, + createPerspectiveDolly(options: any, orientation: any) { physicalOwners++; return createPerspectiveDolly(options, orientation); }, + createCameraOrientation() { + return { scene: () => worldRotationCss(rotation), sceneMatrix: () => ({ + m11: rotation[0], m21: rotation[1], m31: rotation[2], m12: rotation[3], m22: rotation[4], m32: rotation[5], + m13: rotation[6], m23: rotation[7], m33: rotation[8] }), + setSceneRotation(value: number[]) { rotation = [...value]; }, + sunViewDirection: () => null, + snapshot: () => ({ schema: 'cssearth-camera-pose@2', scene: worldRotationCss(rotation) }), + captureCounterRotation: () => () => worldRotationCss(rotation), + restore(pose: any) { const m = pose.scene.slice(9,-1).split(',').map(Number); rotation = [m[0],m[4],m[8],m[1],m[5],m[9],m[2],m[6],m[10]]; }, + rotate(delta: any) { if (!delta.rotation) return; + const next = worldRotationFromQuaternion(delta.rotation); + rotation = [0,1,2].flatMap(row => [0,1,2].map(column => [0,1,2].reduce((sum,k) => sum + next[row*3+k] * rotation[k*3+column], 0))); + } }; + }, + createUnboundedMatrixDragControls(options: any) { callbacks.drag = options; return control; }, + createPreparedWheelZoomControls(options: any) { callbacks.wheel = options; return { ...control, stop() {}, destroy() {} }; }, + bindResponsiveOrbitPolicy: () => ({ mobile: false, destroy() {} }), + selectPreparedResponsiveZoom: () => ({ zoom: 1, model: 'unit', widthShare: .5 }), + } as any); + const world = () => orbit.captureWorldCamera(frame); + let ticking: AbortSignal | undefined, started = 0; + const paint = () => { const pending = [...frames.values()]; frames.clear(); pending.forEach(callback => callback(time)); }; + return { orbit, callbacks, roots, world, view, + get physicalOwners() { return physicalOwners; }, get publications() { return publications; }, + tick(progress: number) { + if (ticking !== cameraMotion.signal) { ticking = cameraMotion.signal; started = time; paint(); } + time = started + progress * 1000; paint(); + }, + }; +} + +function close(actual: readonly number[], expected: readonly number[], tolerance = 1e-10) { + actual.forEach((value, axis) => assert.ok((Math.abs(value - expected[axis]) / Math.max(1, Math.abs(expected[axis]))) < tolerance)); +} + +it('uses prepared surface picking for detail flights', () => { + // The published level of detail gates picking; no lane needs to write `data-lod` on the stage. + const f = fixture((x, y) => x === 23 && y === 45); + const hit = f.callbacks.drag.surfaceFlyToHitTest; + assert.equal(hit(23, 45), true); + assert.equal(hit(24, 45), false); + f.orbit.destroy(); +}); + +it('holds a pole-held drag to the body pole without a stage level-of-detail attribute', () => { + // Earth's paged globe writes no `data-lod`; its drags still turn about the system node's +Z pole. + const f = fixture(undefined, { ...scene.camera, drag: { model: 'pole-held-tumble' } }); + f.roots[2]!.system = { style: { transform: 'rotateX(90deg)' } }; + assert.equal(f.roots[0]!.dataset.lod, undefined); + const pole = f.callbacks.drag.trackballMetrics().pole; + assert.ok(Array.isArray(pole), 'pole-held drag lost its pole'); + assert.ok(Math.abs(Math.hypot(...pole) - 1) < 1e-9); + f.orbit.destroy(); +}); + +it('holds its last frame while a flight passes through the body, and still refuses a camera placed inside it', async () => { + const f = fixture(); + const before = f.orbit.captureWorldCamera(frame); + const inside = { ...before, pose: { ...before.pose, positionM: [frame.originM[0] + 50, frame.originM[1], frame.originM[2]] as const } }; + await f.orbit.applyWorldCamera(inside, frame, new AbortController().signal); + assert.deepEqual(f.orbit.captureWorldCamera(frame).pose.positionM, before.pose.positionM, 'the flight step inside the body is not adopted'); + assert.throws(() => f.orbit.applyWorldCamera(inside, frame), /inside the focused body/u); + f.orbit.destroy(); +}); diff --git a/packages/renderer/src/navigation/object-orbit.ts b/packages/renderer/src/navigation/object-orbit.ts index 9e0f3860b47..811b315d670 100644 --- a/packages/renderer/src/navigation/object-orbit.ts +++ b/packages/renderer/src/navigation/object-orbit.ts @@ -16,8 +16,6 @@ import { bindWorldCameraPicking } from './world-camera-picking.js'; import { hitsProjectedBody } from './world-camera-hit.js'; import { prepareSurfaceTargetRotation } from './surface-target.js'; import type { PhysicalProjection } from '../prepared-data/physical-projection.js'; -import { prepareFocusFlight } from './prepared-focus.js'; -import type { PreparedNavigationFocus, PreparedFocusFlightOptions } from './prepared-focus.js'; export interface OrbitStateUpdate { pitch?: number; controlPitch?: number; controlYaw?: number; zoom?: number; distance?: number; distanceKilometers?: number; bodyCenterKilometers?: PositionM; pose?: CameraPose; projectionScale?: number; } export type OrbitState = { pitch: number; controlPitch: number; controlYaw: number; zoom: number; pose: CameraPose } & ReturnType; export interface OrbitPublication extends CameraAngles { worldCamera: WorldCameraPose; sceneMatrix: string; sunViewDirection: Vector3 | null; skySunViewDirection: Vector3 | null; counterRotation: string; counterRotationFor(localMatrix: string | DOMMatrix | null): string; zoom: number; projection: PhysicalProjection; distance: number; focal: number; viewportWidth: number; viewportHeight: number; stageViewport: WorldCameraViewport; principalOffset: readonly number[]; body: PerspectivePublication['body']; levelOfDetail: ReturnType; } @@ -216,10 +214,10 @@ export function createRetainedCubicSkyOrbit({ (x, y) => lodStage() === 'geometry' && surfaceHitTest(x, y))); // Every prepared body draws its system node first under the scene, with the body's north pole on its +Z axis. Drags // turn about that pole when the plan's drag model is "pole-held-tumble" (Earth's recipe) and the mesh is drawn; other - // bodies, the same body as a billboard or dot, and a focused neighbour keep the screen axes. + // bodies and the same body as a billboard or dot keep the screen axes. const poleHeld = cameraPlan.drag?.model === 'pole-held-tumble'; const bodyPole = (): { pole?: Vector3 } => { - if (!poleHeld || lodStage() !== 'geometry' || camera.focus()) return {}; + if (!poleHeld || lodStage() !== 'geometry') return {}; const system = sceneElement.querySelector(':scope > .polycss-mesh'); if (!system) return {}; const m = multiplyPreparedMatrix4(readPreparedMatrix4(camera.scene()), readPreparedTransform(system.style.transform || 'none')); @@ -237,11 +235,11 @@ export function createRetainedCubicSkyOrbit({ rotate: publishCameraDelta, minimumZoom: minimumZoom(), maximumZoom: maximumZoom(), - surfaceFlyToHitTest: (clientX, clientY) => !camera.focus() && surfaceHitTest(clientX, clientY), + surfaceFlyToHitTest: surfaceHitTest, // The prepared wheel dolly: the eye moves along its axis, with no // surface anchor to hold. dolly: Object.freeze({ stepPerDelta: cameraPlan.dolly.wheelStepPerDelta, - minimumDistance: () => camera.inputMinimumDistance() }), + minimumDistance: () => camera.minimumDistance() }), onStart() { interactionStarts += 1; onInteractionStart(); @@ -300,27 +298,6 @@ export function createRetainedCubicSkyOrbit({ initialResponsiveZoom: () => initialResponsiveZoom, currentResponsiveZoom: () => responsiveFit.zoom, setZoomOutCentering(enabled: boolean) { camera.setZoomOutCentering(enabled); }, - preparedFocus: () => camera.focus() ?? null, - setPreparedFocus(focus: PreparedNavigationFocus | null, frame: PreparedWorldCameraFrame) { - if (lifetime.disposed) return; - validateWorldFrame(frame); - cameraMotion.cancel(); - controls.stop(); - camera.setFocus(focus, frame); - publish(); - }, - async flyToPreparedFocus(focus: PreparedNavigationFocus, frame: PreparedWorldCameraFrame, - viewport: WorldCameraViewport & { framingRadiusPixels: number }, options: PreparedFocusFlightOptions = {}) { - if (lifetime.disposed) return Promise.resolve({ completed: false }); - validateWorldFrame(frame); - if (options.signal?.aborted) return Promise.resolve({ completed: false }); - const plan = prepareFocusFlight(camera, focus, frame, viewport, options.durationMilliseconds); - cameraMotion.cancel(); controls.stop(); - camera.setFocus(plan.focus, frame); - publish(); - const reduced = options.reducedMotion ?? windowTarget.matchMedia('(prefers-reduced-motion: reduce)').matches; - return flyCamera(plan.sample, reduced ? 0 : plan.durationMilliseconds, options.signal); - }, captureWorldCamera(frame: PreparedWorldCameraFrame): WorldCameraPose { validateWorldFrame(frame); return camera.capture(frame); @@ -342,7 +319,6 @@ export function createRetainedCubicSkyOrbit({ if (![controlPitch, controlYaw, controlRoll, zoom].every(Number.isFinite)) throw new TypeError("Invalid prepared camera destination."); cameraMotion.cancel(); controls.stop(); - camera.clearFocus(); const start = { ...camera.state }; const targetZoom = transition?.preserveZoom ? start.zoom : clamp(zoom, minimumZoom(), maximumZoom()); const viewport = perspective.viewport(); @@ -443,7 +419,6 @@ export function createRetainedCubicSkyOrbit({ responsiveWidthShare: responsiveFit.widthShare, responsiveBaseZoom: responsiveFit.zoom, publications, - preparedFocusId: camera.focus()?.id ?? null, flightActive: cameraMotion.signal !== undefined, framePublication: publicationState(), interactionStarts, diff --git a/packages/renderer/src/navigation/perspective-dolly.ts b/packages/renderer/src/navigation/perspective-dolly.ts index c585e216c07..be2250b0c82 100644 --- a/packages/renderer/src/navigation/perspective-dolly.ts +++ b/packages/renderer/src/navigation/perspective-dolly.ts @@ -365,7 +365,7 @@ export function createPerspectiveDolly({ ); const opticalCenterX = bounds.x + bounds.width / 2 + principalOffset[0]!; const opticalCenterY = bounds.y + bounds.height / 2 + principalOffset[1]!; - return camera.trackball(Object.freeze({ + return Object.freeze({ centerX, centerY, opticalCenterX, @@ -380,7 +380,7 @@ export function createPerspectiveDolly({ viewportCenterY: (stageBounds.top ?? 0) + stageBounds.height / 2, // Pointer samples are re-based to the centre: tumble everywhere. tumbleOnly: TUMBLE_DRAG_MODELS.includes(cameraPlan.drag?.model ?? ""), - })); + }); }, state() { const { distance, bodyCenterKilometers } = camera.detailState(); diff --git a/packages/renderer/src/navigation/prepared-camera.ts b/packages/renderer/src/navigation/prepared-camera.ts index 9ddfc19b199..971094bba28 100644 --- a/packages/renderer/src/navigation/prepared-camera.ts +++ b/packages/renderer/src/navigation/prepared-camera.ts @@ -6,8 +6,6 @@ import { distanceForSilhouetteRadius, rotationFromMatrix3d, silhouetteRadiusAtDi import { cameraProjectionScale, worldCameraViewport, presentWorldCamera, worldCameraFromCenteredPresentation, worldCameraFromPresentation } from './world-camera.js'; import type { PreparedWorldCameraFrame, WorldCameraPose, WorldCameraViewport } from './world-camera.js'; import { rotateWorldPosition, scaleWorldPosition, transposeWorldRotation, validateWorldPosition } from './world-camera-math.js'; -import { validatePreparedNavigationFocus } from './prepared-focus.js'; -import type { PreparedNavigationFocus } from './prepared-focus.js'; import type { PerspectiveWorldContext } from './perspective-dolly.js'; import type { Vector3 } from './types.js'; import { clamp } from '@cssearth/core'; @@ -127,8 +125,6 @@ export function createPreparedCamera(cameraPlan: PerspectiveCameraPlan, worldCon cameraState.distance = distance; aliasZoom = null; } - let active: { focus: PreparedNavigationFocus; frame: PreparedWorldCameraFrame; - offsetUnits: PositionM; rotatedOffset: PositionM; centerUnits: PositionM } | null = null; function capture(frame: PreparedWorldCameraFrame): WorldCameraPose { const rotation = readRotation(), bodyCenterUnits = bodyCenter; return bodyCenterUnits === null @@ -144,63 +140,8 @@ export function createPreparedCamera(cameraPlan: PerspectiveCameraPlan, worldCon const presentation = presentWorldCamera(world, frame, optics()); setBodyCenter(presentation.bodyCenterUnits); orientation.setSceneRotation(presentation.rotation); - if (active) { - active.rotatedOffset = rotateWorldPosition(presentation.rotation, active.offsetUnits); - active.centerUnits = add(presentation.bodyCenterUnits, active.rotatedOffset); - } - } - function preservePivot() { - if (!active) return; - const nextOffset = rotateWorldPosition(readRotation(), active.offsetUnits); - // Apply only the changed pivot offset. Subtracting two galaxy-scale positions - // when the rotation is unchanged would erase a nearby detail's metre-scale eye. - if (nextOffset.some((value, axis) => value !== active!.rotatedOffset[axis])) { - setBodyCenter(add(bodyCenter!, subtract(active.rotatedOffset, nextOffset))); - active.rotatedOffset = nextOffset; - } - } - function set(focus: PreparedNavigationFocus | null, frame: PreparedWorldCameraFrame) { - if (focus === null) { active = null; return; } - const validated = validatePreparedNavigationFocus(focus); - const presentation = presentWorldCamera(capture(frame), frame, optics()); - const offsetUnits = scaleWorldPosition(rotateWorldPosition(transposeWorldRotation(frame.presentationToReference), - subtract(validated.positionM, frame.originM)), 1 / frame.metersPerUnit); - const rotatedOffset = rotateWorldPosition(presentation.rotation, offsetUnits); - const centerUnits = add(presentation.bodyCenterUnits, rotatedOffset); - if (!(Math.hypot(...centerUnits) > 0)) throw new RangeError('The observer cannot orbit from the prepared focus centre.'); - // Materialise a centred detail dolly before changing its input pivot. - setBodyCenter(presentation.bodyCenterUnits); - active = { focus: validated, frame, offsetUnits, rotatedOffset, centerUnits }; - preservePivot(); - } - const focusZoom = () => { - if (!active) return detailState().zoom; - const distanceM = Math.hypot(...active.centerUnits) * active.frame.metersPerUnit; - // A framing bound is not a solid body: this alias remains defined inside it. - return Math.min(cameraPlan.maximumZoom, optics().focalPixels * active.focus.framingRadiusM / - distanceM * 2 / cameraPlan.logicalBodyDiameter * framingReferenceZoom); - }; - function inputState() { - return active ? { ...detailState(), distance: Math.hypot(...active.centerUnits), zoom: focusZoom() } : detailState(); - } - function updateInput(partial: CameraUpdate) { - if (!active) { updateDetail(partial); return; } - const oldDistance = Math.hypot(...active.centerUnits); - const requested = partial.distanceKilometers !== undefined ? partial.distanceKilometers * 1000 / active.frame.metersPerUnit - : partial.distance ?? (partial.zoom === undefined ? oldDistance : oldDistance * focusZoom() / partial.zoom); - if (!Number.isFinite(requested) || requested <= 0) throw new TypeError('Prepared focus distance must be positive and finite.'); - const { minimumDistanceM, maximumDistanceM } = active.focus.limits; - // Restored observers outside the authored interval remain stationary until dolly input. - if (requested !== oldDistance) { - const next = Math.max(Math.min(minimumDistanceM / active.frame.metersPerUnit, oldDistance), - Math.min(Math.max(maximumDistanceM / active.frame.metersPerUnit, oldDistance), requested)); - const nextCenter = scaleWorldPosition(active.centerUnits, next / oldDistance); - setBodyCenter(add(bodyCenter!, subtract(nextCenter, active.centerUnits))); - active.centerUnits = nextCenter; - } - updateDetail({ ...(partial.rotX === undefined ? {} : { rotX: partial.rotX }), - ...(partial.rotY === undefined ? {} : { rotY: partial.rotY }) }); } + const inputState = detailState, updateInput = updateDetail; return Object.freeze({ get projectionScale() { return projectionScale; }, setProjectionScale(value: number) { projectionScale = cameraProjectionScale(value); aliasZoom = null; }, @@ -212,16 +153,14 @@ export function createPreparedCamera(cameraPlan: PerspectiveCameraPlan, worldCon rotY: inputState().rotY + delta.controlYawDelta, zoom: delta.zoom, distance: delta.distance }); orientation.rotate({ renderedPitchDelta: preparedScenePitch(inputState().rotX, cameraPlan) - preparedScenePitch(previousPitch, cameraPlan), yawDelta: delta.controlYawDelta, rotation: delta.rotation }); - preservePivot(); }, - rebaseScene(change: DOMMatrix) { orientation.rebaseScene(change); preservePivot(); }, + rebaseScene(change: DOMMatrix) { orientation.rebaseScene(change); }, prepareFlight(target: CameraAngles, correction?: DOMMatrix) { const flight = orientation.prepareFlight(target, correction); return { angularDistance: flight.angularDistance, - sample(progress: number, update: CameraUpdate) { updateInput(update); flight.sample(progress); preservePivot(); } }; + sample(progress: number, update: CameraUpdate) { updateInput(update); flight.sample(progress); } }; }, restore(update: CameraUpdate, pose?: CameraPose, bodyCenterKilometers?: PositionM) { - active = null; const resetsOrientation = update.rotX !== undefined || update.rotY !== undefined; if (resetsOrientation || pose !== undefined) bodyCenter = null; updateDetail(update); @@ -234,8 +173,6 @@ export function createPreparedCamera(cameraPlan: PerspectiveCameraPlan, worldCon bodyCenter: () => bodyCenter, setZoomOutCentering(enabled: boolean) { zoomOutCentering = enabled; }, minimumZoom, maximumZoom, minimumDistance, maximumDistance, - /** The closest view input may dolly to, in the units of `state.distance`: a focus's own limit, else the body's. */ - inputMinimumDistance: () => active ? active.focus.limits.minimumDistanceM / active.frame.metersPerUnit : minimumDistance(), /** Only the original centred framing follows a resize; a restored observer stays put. */ reframe(zoom: number) { if (bodyCenter === null) updateDetail({ zoom }); }, detailState() { @@ -250,19 +187,6 @@ export function createPreparedCamera(cameraPlan: PerspectiveCameraPlan, worldCon sunDirection: orientation.sunViewDirection(), counterRotationFor: orientation.captureCounterRotation() }; }, adopt, contains, - setFocus: set, - focus: () => active?.focus ?? null, - clearFocus() { active = null; }, - trackball(base: TrackballMetrics): TrackballMetrics { - if (!active) return base; - const [x, y, z] = active.centerUnits, depth = -z; - const visible = depth > 0; - const radius = Math.max(base.viewportWidth / 5, Math.min(base.viewportWidth, - visible ? base.focalLength * active.focus.framingRadiusM / active.frame.metersPerUnit / depth : 0)); - return { ...base, centerX: visible ? (base.opticalCenterX ?? base.centerX) + base.focalLength * x / depth : base.viewportCenterX ?? base.centerX, - centerY: visible ? (base.opticalCenterY ?? base.centerY) + base.focalLength * y / depth : base.viewportCenterY ?? base.centerY, - radius, surfaceRadius: radius }; - }, }); } function add(a: PositionM, b: PositionM): PositionM { return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; } diff --git a/packages/renderer/src/navigation/prepared-focus.test.ts b/packages/renderer/src/navigation/prepared-focus.test.ts deleted file mode 100644 index fabf1f6ed99..00000000000 --- a/packages/renderer/src/navigation/prepared-focus.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { createCameraMotion } from './camera-motion.js'; -import { it } from 'node:test'; -import assert from 'node:assert/strict'; -import { isDeepStrictEqual } from 'node:util'; -import { getEventListeners } from 'node:events'; -import scene from '../../../../src/objects/mercury/prepared/scene.json' with { type: 'json' }; -import { createRetainedCubicSkyOrbit } from './object-orbit.js'; -import { createPerspectiveDolly } from './perspective-dolly.js'; -import { presentWorldCamera } from './world-camera.js'; -import { worldRotationCss, worldRotationFromQuaternion } from './world-camera-math.js'; -import type { PreparedNavigationFocus } from './prepared-focus.js'; - -const frame = Object.freeze({ referenceFrame: 'sun-icrf', epochJdTt: 1, originM: [3e7, 4e7, 5e7] as const, - presentationToReference: [0,1,0,1,0,0,0,0,1], metersPerUnit: 2, bodyRadiusM: 200 }); -const focus: PreparedNavigationFocus = { id: 'catalogue:7', positionM: [1e20, 2e20, -3e20], framingRadiusM: 1e18, - limits: { minimumDistanceM: 1e13, maximumDistanceM: 1e22 }, upReference: [0, 0, 1], arrivalDistanceM: 4e18 }; -const optics = { focalPixels: 1000, principalOffsetPixels: [-170, 0] as const, framingRadiusPixels: 200 }; - -function fixture(preparedSurfaceHitTest?: (clientX: number, clientY: number) => boolean, cameraPlan: any = scene.camera) { - class Surface extends EventTarget { - style: Record = { setProperty() {}, removeProperty() {} }; - dataset: Record = {}; - isConnected = true; - ownerDocument: any; - readonly x: number; - constructor(x = 0) { super(); this.x = x; } - getBoundingClientRect() { return { x: this.x, y: 0, left: this.x, top: 0, width: 1600, height: 900 }; } - system: { style: { transform: string } } | null = null; - querySelector() { return this.system; } - } - const frames = new Map(); - let nextFrame = 0, time = 0; - const view = Object.assign(new EventTarget(), { getComputedStyle: () => ({ perspective: '1000px', perspectiveOrigin: '800px 450px' }), - matchMedia: () => ({ matches: false }), performance: { now: () => time }, - requestAnimationFrame(callback: FrameRequestCallback) { frames.set(++nextFrame, callback); return nextFrame; }, - cancelAnimationFrame(id: number) { frames.delete(id); } }); - const roots = [new Surface(), new Surface(170), new Surface(), new Surface()]; - roots.forEach(root => { root.ownerDocument = { defaultView: view }; }); - const [stage, cameraElement, sceneElement, skyElement] = roots; - let rotation = [1,0,0,0,1,0,0,0,1]; - let physicalOwners = 0, publications = 0; - const callbacks: any = {}; - const control = { stop() {}, destroy() {}, update() {}, stats: () => ({}), invalidateTrackball() {} }; - const cameraMotion = createCameraMotion(); - const orbit = createRetainedCubicSkyOrbit({ cameraMotion, stage, inputSurface: stage, cameraElement, sceneElement, - viewport: { read: () => ({ bounds: stage.getBoundingClientRect(), focalPixels: 1000, previewTop: null, openArea: null }), subscribe: () => () => {}, destroy() {} }, - framePresenter: { present(request: any) { request.commit(); } }, - worldContext: { frame, bodyRadiusUnits: 100, kilometersPerUnit: .002, maximumExtentUnits: 1e8 }, - cameraPlan, objectId: 'unit', runtimePolicy: { MOBILE_VIEWPORT_QUERY: '(max-width: 500px)' }, - preparedSurfaceHitTest, - onPublish() { publications++; }, onError(error: unknown) { throw error; }, - } as any, { HTMLElement: Surface, - createPerspectiveDolly(options: any, orientation: any) { physicalOwners++; return createPerspectiveDolly(options, orientation); }, - createCameraOrientation() { - return { scene: () => worldRotationCss(rotation), sceneMatrix: () => ({ - m11: rotation[0], m21: rotation[1], m31: rotation[2], m12: rotation[3], m22: rotation[4], m32: rotation[5], - m13: rotation[6], m23: rotation[7], m33: rotation[8] }), - setSceneRotation(value: number[]) { rotation = [...value]; }, - sunViewDirection: () => null, - snapshot: () => ({ schema: 'cssearth-camera-pose@2', scene: worldRotationCss(rotation) }), - captureCounterRotation: () => () => worldRotationCss(rotation), - restore(pose: any) { const m = pose.scene.slice(9,-1).split(',').map(Number); rotation = [m[0],m[4],m[8],m[1],m[5],m[9],m[2],m[6],m[10]]; }, - rotate(delta: any) { if (!delta.rotation) return; - const next = worldRotationFromQuaternion(delta.rotation); - rotation = [0,1,2].flatMap(row => [0,1,2].map(column => [0,1,2].reduce((sum,k) => sum + next[row*3+k] * rotation[k*3+column], 0))); - } }; - }, - createUnboundedMatrixDragControls(options: any) { callbacks.drag = options; return control; }, - createPreparedWheelZoomControls(options: any) { callbacks.wheel = options; return { ...control, stop() {}, destroy() {} }; }, - bindResponsiveOrbitPolicy: () => ({ mobile: false, destroy() {} }), - selectPreparedResponsiveZoom: () => ({ zoom: 1, model: 'unit', widthShare: .5 }), - } as any); - const world = () => orbit.captureWorldCamera(frame); - const range = (point = focus.positionM) => Math.hypot(...world().pose.positionM.map((v, axis) => v - point[axis])); - const focusView = () => presentWorldCamera(world(), { ...frame, originM: focus.positionM, bodyRadiusM: focus.framingRadiusM }, optics); - let ticking: AbortSignal | undefined, started = 0; - const paint = () => { const pending = [...frames.values()]; frames.clear(); pending.forEach(callback => callback(time)); }; - return { orbit, callbacks, roots, world, range, focusView, view, - get physicalOwners() { return physicalOwners; }, get publications() { return publications; }, - tick(progress: number) { - if (ticking !== cameraMotion.signal) { ticking = cameraMotion.signal; started = time; paint(); } - time = started + progress * 1000; paint(); - }, - }; -} - -function close(actual: readonly number[], expected: readonly number[], tolerance = 1e-10) { - actual.forEach((value, axis) => assert.ok((Math.abs(value - expected[axis]) / Math.max(1, Math.abs(expected[axis]))) < tolerance)); -} - -it('uses prepared surface picking for detail flights and suppresses them while a catalogue focus owns input', () => { - // The published level of detail gates picking; no lane needs to write `data-lod` on the stage. - const f = fixture((x, y) => x === 23 && y === 45); - const hit = f.callbacks.drag.surfaceFlyToHitTest; - assert.equal(hit(23, 45), true); - assert.equal(hit(24, 45), false); - f.orbit.setPreparedFocus(focus, frame); - assert.equal(hit(23, 45), false); - f.orbit.setPreparedFocus(null, frame); - assert.equal(hit(23, 45), true); - f.orbit.destroy(); -}); - -it('holds a pole-held drag to the body pole without a stage level-of-detail attribute', () => { - // Earth's paged globe writes no `data-lod`; its drags still turn about the system node's +Z pole. - const f = fixture(undefined, { ...scene.camera, drag: { model: 'pole-held-tumble' } }); - f.roots[2]!.system = { style: { transform: 'rotateX(90deg)' } }; - assert.equal(f.roots[0]!.dataset.lod, undefined); - const pole = f.callbacks.drag.trackballMetrics().pole; - assert.ok(Array.isArray(pole), 'pole-held drag lost its pole'); - assert.ok(Math.abs(Math.hypot(...pole) - 1) < 1e-9); - f.orbit.destroy(); -}); - -it('flies, drags and dollies around a prepared focus while retaining the original detail frame and camera', async () => { - const f = fixture(), roots = [...f.roots], initial = f.world(); - const flight = f.orbit.flyToPreparedFocus(focus, frame, optics, { durationMilliseconds: 1000 }); - assert.equal(f.orbit.preparedFocus()?.id, focus.id); - close(f.world().pose.positionM, initial.pose.positionM); - f.tick(.35); - assert.notDeepEqual(f.world().pose.positionM, initial.pose.positionM); - assert.ok(f.range() > focus.arrivalDistanceM!); - f.tick(1); - assert.deepEqual((await flight), { completed: true }); - assert.ok(Math.abs((f.range() / focus.arrivalDistanceM!) - (1)) < 10 ** -12 / 2, `${(f.range() / focus.arrivalDistanceM!)} is not close to ${1}`); - const arrived = f.focusView().centerPixels!; - close(arrived, [0,0], 1e-9); - const oldDetailRange = f.range(frame.originM); - f.callbacks.drag.rotate({ controlPitchDelta: 0, controlYawDelta: 45, rotation: [0, Math.sin(Math.PI/8), 0, Math.cos(Math.PI/8)] }); - assert.ok(Math.abs((f.range() / focus.arrivalDistanceM!) - (1)) < 10 ** -12 / 2, `${(f.range() / focus.arrivalDistanceM!)} is not close to ${1}`); - close(f.focusView().centerPixels!, arrived, 1e-9); - assert.notEqual(f.range(frame.originM), oldDetailRange); - const metrics = f.callbacks.drag.trackballMetrics(); - assert.ok(Math.abs(metrics.centerX - (970)) < 10 ** -8 / 2, `${metrics.centerX} is not close to ${970}`); - assert.ok(Math.abs(metrics.centerY - (450)) < 10 ** -8 / 2, `${metrics.centerY} is not close to ${450}`); - const distance = f.callbacks.wheel.camera.state.distance; - f.callbacks.wheel.rotate({ controlPitchDelta: 0, controlYawDelta: 0, distance: distance * 2 }); - assert.ok(Math.abs((f.range() / (focus.arrivalDistanceM! * 2)) - (1)) < 10 ** -12 / 2, `${(f.range() / (focus.arrivalDistanceM! * 2))} is not close to ${1}`); - close(f.focusView().centerPixels!, arrived, 1e-9); - f.callbacks.wheel.rotate({ controlPitchDelta: 0, controlYawDelta: 0, distance: 1e30 }); - assert.ok(Math.abs((f.range() / focus.limits.maximumDistanceM) - (1)) < 10 ** -12 / 2, `${(f.range() / focus.limits.maximumDistanceM)} is not close to ${1}`); - assert.ok(Math.abs((f.orbit.sharedState().distanceKilometers! * 1000 / f.range(frame.originM)) - (1)) < 10 ** -12 / 2, `${(f.orbit.sharedState().distanceKilometers! * 1000 / f.range(frame.originM))} is not close to ${1}`); - assert.equal(f.physicalOwners, 1); - assert.deepEqual(f.roots, roots); - assert.deepEqual(frame.originM, [3e7,4e7,5e7]); - assert.ok(f.publications > 5); - f.orbit.destroy(); -}); - -it('restores a focus without moving the saved world pose, then clears it without a jump on return', async () => { - const f = fixture(); - await f.orbit.flyToPreparedFocus(focus, frame, optics, { reducedMotion: true }); - const saved = f.orbit.sharedState(), before = f.world(); - f.orbit.setState(saved); - assert.equal(f.orbit.preparedFocus(), null); - f.orbit.setPreparedFocus(focus, frame); - close(f.world().pose.positionM, before.pose.positionM); - close(f.world().pose.orientationXyzw, before.pose.orientationXyzw); - const translated = { ...before, pose: { ...before.pose, positionM: before.pose.positionM.map((v, i) => v + (i === 0 ? 1e18 : 0)) as any } }; - f.orbit.applyWorldCamera(translated, frame); - close(f.world().pose.positionM, translated.pose.positionM); - const currentRange = f.range(); - f.callbacks.wheel.rotate({ controlPitchDelta: 0, controlYawDelta: 0, distance: f.callbacks.wheel.camera.state.distance * .9 }); - assert.ok(Math.abs((f.range() / currentRange) - (.9)) < 10 ** -12 / 2, `${(f.range() / currentRange)} is not close to ${.9}`); - const returnStart = f.world(); - f.orbit.setPreparedFocus(null, frame); - close(f.world().pose.positionM, returnStart.pose.positionM); - const detailRange = f.range(frame.originM); - f.callbacks.drag.rotate({ controlPitchDelta: 0, controlYawDelta: 30, rotation: [0, Math.sin(Math.PI/12), 0, Math.cos(Math.PI/12)] }); - assert.ok(Math.abs((f.range(frame.originM) / detailRange) - (1)) < 10 ** -12 / 2, `${(f.range(frame.originM) / detailRange)} is not close to ${1}`); - assert.ok(!(Math.abs((f.range() / currentRange) - (.9)) < 10 ** -3 / 2)); - f.orbit.destroy(); -}); - -it('uses the retained motion owner for interruption, replacement and teardown, rejecting invalid focus before moving', async () => { - const f = fixture(), controller = new AbortController(); - const pending = f.orbit.flyToPreparedFocus(focus, frame, optics, { signal: controller.signal, durationMilliseconds: 1000 }); - f.tick(.2); - const interrupted = f.world(); - controller.abort(); - assert.deepEqual((await pending), { completed: false }); - assert.equal(getEventListeners(controller.signal, 'abort').length, 0); - close(f.world().pose.positionM, interrupted.pose.positionM); - assert.equal(f.orbit.preparedFocus()?.id, focus.id); - await assert.rejects(f.orbit.flyToPreparedFocus({ ...focus, limits: { minimumDistanceM: 1, maximumDistanceM: 0 } }, frame, optics), /metadata/); - close(f.world().pose.positionM, interrupted.pose.positionM); - const replacement = f.orbit.flyToPreparedFocus(focus, frame, optics, { durationMilliseconds: 1000 }); - // Catalogue ids such as the dwarf galaxy dw1343+58 carry a plus sign. - const final = f.orbit.flyToPreparedFocus({ ...focus, id: 'dw1343+58', positionM: [2e20, -1e20, 3e20] }, frame, optics); - assert.deepEqual((await replacement), { completed: false }); - f.orbit.destroy(); - assert.deepEqual((await final), { completed: false }); - for (const name of ['pointerdown','pointermove','pointerup','wheel','resize']) assert.equal(getEventListeners(f.view, name).length, 0); -}); - -it('arrives on the line of sight from the Sun, celestial north up, whatever the previous view faced', async () => { - const poses = []; - for (const turned of [false, true]) { - const f = fixture(); - // Face the opposite way first: the Crab bug arrived behind its nebula, looking back at the Sun. - if (turned) f.callbacks.drag.rotate({ controlPitchDelta: 0, controlYawDelta: 180, rotation: [0, 1, 0, 0] }); - const flight = f.orbit.flyToPreparedFocus(focus, frame, optics, { durationMilliseconds: 1000 }); - f.tick(1); - assert.deepEqual((await flight), { completed: true }); - poses.push(f.world().pose); - f.orbit.destroy(); - } - close(poses[1]!.positionM, poses[0]!.positionM, 1e-9); - close(poses[1]!.orientationXyzw, poses[0]!.orientationXyzw, 1e-9); - const { positionM, orientationXyzw } = poses[0]!; - const length = (v: readonly number[]) => Math.hypot(...v); - // Between the Sun and the focus, at the arrival distance. - assert.ok(length(positionM) < length(focus.positionM)); - const sight = focus.positionM.map(v => v / length(focus.positionM)); - const offset = focus.positionM.map((v, axis) => v - positionM[axis]!); - // The ray through the principal point, offset beside the panel, is the sightline; the view axis leaves it by that angle. - const principal = Math.cos(Math.atan2(Math.hypot(...optics.principalOffsetPixels), optics.focalPixels)); - assert.ok(Math.abs((offset.reduce((sum, v, axis) => sum + v * sight[axis]!, 0) / length(offset)) - (1)) < 10 ** -9 / 2, `${(offset.reduce((sum, v, axis) => sum + v * sight[axis]!, 0) / length(offset))} is not close to ${1}`); - const forward = [-worldRotationFromQuaternion(orientationXyzw)[2]!, -worldRotationFromQuaternion(orientationXyzw)[5]!, -worldRotationFromQuaternion(orientationXyzw)[8]!]; - assert.ok(Math.abs(forward.reduce((sum, v, axis) => sum + v * sight[axis]!, 0) - (principal)) < 10 ** -9 / 2, `${forward.reduce((sum, v, axis) => sum + v * sight[axis]!, 0)} is not close to ${principal}`); - // Camera +y (up) leans toward celestial north. - const axes = worldRotationFromQuaternion(orientationXyzw); - assert.ok(axes[7] > 0); -}); - -it('holds its last frame while a flight passes through the body, and still refuses a camera placed inside it', async () => { - const f = fixture(); - const before = f.orbit.captureWorldCamera(frame); - const inside = { ...before, pose: { ...before.pose, positionM: [frame.originM[0] + 50, frame.originM[1], frame.originM[2]] as const } }; - await f.orbit.applyWorldCamera(inside, frame, new AbortController().signal); - assert.deepEqual(f.orbit.captureWorldCamera(frame).pose.positionM, before.pose.positionM, 'the flight step inside the body is not adopted'); - assert.throws(() => f.orbit.applyWorldCamera(inside, frame), /inside the focused body/u); - f.orbit.destroy(); -}); diff --git a/packages/renderer/src/navigation/prepared-focus.ts b/packages/renderer/src/navigation/prepared-focus.ts deleted file mode 100644 index b6c6869c7ff..00000000000 --- a/packages/renderer/src/navigation/prepared-focus.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { cross3 as cross } from '@cssearth/core'; -import { createSelectionFlight, createSelectionFlightSample, sampleSelectionFlightInto } from '@cssearth/engine'; -import type { OrientationXyzw, PositionM } from '@cssearth/engine'; -import type { PreparedCamera } from './prepared-camera.js'; -import type { PreparedWorldCameraFrame, WorldCameraViewport } from './world-camera.js'; -import { scaleWorldPosition, validateWorldPosition, worldQuaternionFromRotation } from './world-camera-math.js'; -import { createWorldSelectionTarget } from './selection-target.js'; - -/** A prepared navigation bound, independent of the mounted detail's physical radius. */ -export interface PreparedNavigationFocus { - readonly id: string; - readonly positionM: PositionM; - readonly framingRadiusM: number; - readonly limits: { readonly minimumDistanceM: number; readonly maximumDistanceM: number }; - readonly upReference?: PositionM; - readonly arrivalDistanceM?: number; -} -export interface PreparedFocusFlightOptions { - readonly signal?: AbortSignal; - readonly reducedMotion?: boolean; - readonly durationMilliseconds?: number; -} - -export function prepareFocusFlight(camera: PreparedCamera, focus: PreparedNavigationFocus, frame: PreparedWorldCameraFrame, - viewport: WorldCameraViewport & { framingRadiusPixels: number }, milliseconds?: number) { - const checked = validatePreparedNavigationFocus(focus); - if (milliseconds !== undefined && (!Number.isFinite(milliseconds) || milliseconds <= 0)) throw new TypeError('Focus flight duration must be positive.'); - const from = camera.capture(frame); - // A prepared focus is a catalogue object every dataset shows as seen from here, so the flight arrives on that line of - // sight whatever the previous view faced. Keeping the previous direction could land behind it, looking back at the Sun. - if (frame.referenceFrame !== 'sun-icrf') throw new TypeError(`Prepared focus ${checked.id} needs a Sun-centred frame for its line of sight, not ${frame.referenceFrame}.`); - const sightline = { ...from, pose: { ...from.pose, orientationXyzw: sightlineOrientation(checked.positionM, checked.upReference, viewport) } }; - // This temporary framing input describes only the destination; the detail always uses frame. - const target = createWorldSelectionTarget(sightline, { ...frame, originM: checked.positionM, - bodyRadiusM: checked.framingRadiusM, orbitUpReference: checked.upReference }, viewport); - const offset = subtract(target.pose.positionM, checked.positionM); - const distance = Math.hypot(...offset); - const arrival = Math.max(checked.limits.minimumDistanceM, - Math.min(checked.limits.maximumDistanceM, checked.arrivalDistanceM ?? distance)); - const to = { ...target.pose, positionM: add(checked.positionM, scaleWorldPosition(offset, arrival / distance)) }; - const flight = createSelectionFlight({ from: from.pose, to, focusPositionM: checked.positionM, - ...(milliseconds === undefined ? {} : { durationS: milliseconds / 1000 }) }); - const value = createSelectionFlightSample(); - const sample = (progress: number) => { - sampleSelectionFlightInto(flight, progress * flight.durationS, value); - camera.adopt({ ...from, pose: { positionM: value.positionM, orientationXyzw: value.orientationXyzw } }, frame); - }; - return { focus: checked, sample, durationMilliseconds: flight.durationS * 1000 }; -} - -export function validatePreparedNavigationFocus(focus: PreparedNavigationFocus): PreparedNavigationFocus { - if (!focus || typeof focus.id !== 'string' || !/^[a-z0-9][a-z0-9:._+-]{0,127}$/iu.test(focus.id) || - !Number.isFinite(focus.framingRadiusM) || focus.framingRadiusM <= 0 || - !Number.isFinite(focus.limits?.minimumDistanceM) || focus.limits.minimumDistanceM <= 0 || - !Number.isFinite(focus.limits?.maximumDistanceM) || focus.limits.maximumDistanceM <= focus.limits.minimumDistanceM || - (focus.arrivalDistanceM !== undefined && (!Number.isFinite(focus.arrivalDistanceM) || focus.arrivalDistanceM <= 0))) { - throw new TypeError('Prepared navigation focus metadata is invalid.'); - } - validateWorldPosition(focus.positionM); - if (focus.upReference !== undefined) { - validateWorldPosition(focus.upReference); - if (Math.abs(Math.hypot(...focus.upReference) - 1) > 1e-9) throw new TypeError('Prepared focus up must be a unit reference direction.'); - } - return Object.freeze({ ...focus, positionM: Object.freeze([...focus.positionM]) as PositionM, - limits: Object.freeze({ ...focus.limits }), - ...(focus.upReference === undefined ? {} : { upReference: Object.freeze([...focus.upReference]) as PositionM }) }); -} -/** A camera at the Sun's side looking at `positionM` (Sun-centred ICRF), with `up` (celestial north unless the focus states one) - * up on screen: north up and east left, as a sky image shows it. Axes are +x right, +y up, +z toward the eye. */ -export function sightlineOrientation(positionM: PositionM, up: PositionM = [0, 0, 1], - viewport: Pick = { principalOffsetPixels: [0, 0], focalPixels: 1 }): OrientationXyzw { - const length = Math.hypot(...positionM); - if (!(length > 0)) throw new RangeError('A focus at the Sun has no line of sight.'); - const eye = scaleWorldPosition(positionM, -1 / length); - // A sightline along the pole has no north on screen; any perpendicular up keeps the pose defined. - const upward = Math.hypot(...cross(up, eye)) > 1e-9 ? up : [1, 0, 0] as PositionM; - const right = normalize(cross(upward, eye)), top = cross(eye, right); - const axes = [right[0], top[0], eye[0], right[1], top[1], eye[1], right[2], top[2], eye[2]]; - // The focus is drawn at the principal point, beside the panel, not on the view axis. Turn the camera so the ray through - // that point, not the axis, lies on the sightline: the object is then seen exactly as from here. - const [x, y] = viewport.principalOffsetPixels; - const ray = normalize([x, -y, viewport.focalPixels]); - const axis = cross(ray, [0, 0, 1]), sine = Math.hypot(...axis), cosine = ray[2]; - if (!(sine > 1e-12)) return worldQuaternionFromRotation(axes); - const [kx, ky, kz] = scaleWorldPosition(axis, 1 / sine), c = 1 - cosine; - const turn = [cosine + kx * kx * c, kx * ky * c - kz * sine, kx * kz * c + ky * sine, - ky * kx * c + kz * sine, cosine + ky * ky * c, ky * kz * c - kx * sine, - kz * kx * c - ky * sine, kz * ky * c + kx * sine, cosine + kz * kz * c]; - return worldQuaternionFromRotation([0, 1, 2].flatMap(row => [0, 1, 2].map(column => - axes[row * 3]! * turn[column]! + axes[row * 3 + 1]! * turn[3 + column]! + axes[row * 3 + 2]! * turn[6 + column]!))); -} -function normalize(a: PositionM): PositionM { return scaleWorldPosition(a, 1 / Math.hypot(...a)); } -function add(a: PositionM, b: PositionM): PositionM { return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; } -function subtract(a: PositionM, b: PositionM): PositionM { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; } diff --git a/packages/renderer/src/rendering/object-control-binding.ts b/packages/renderer/src/rendering/object-control-binding.ts index b137e2b2185..f5743473ebc 100644 --- a/packages/renderer/src/rendering/object-control-binding.ts +++ b/packages/renderer/src/rendering/object-control-binding.ts @@ -104,7 +104,7 @@ export function createObjectControlBinding({ stage, controls, initialSelection, } const settingsInputs = [...(settingsRoot?.querySelectorAll("input[name], button[name]") ?? [])] .filter(input => !SHELL_SETTING_NAMES.has(input.name)); - const panels = new Map(sectionElements(document, '[data-dataset-details], [data-focus-dataset-details]').map(panel => [panel.id, panel])); + const panels = new Map(sectionElements(document, '[data-dataset-details], [data-page-dataset-details]').map(panel => [panel.id, panel])); const details = datasetInputs.map(input => { const id = input.getAttribute('aria-controls'); const panel = id ? panels.get(id) ?? document.getElementById(id) : null; diff --git a/packages/renderer/src/runtime/object-runtime.ts b/packages/renderer/src/runtime/object-runtime.ts index ddc42e03c1f..16f922c7b61 100644 --- a/packages/renderer/src/runtime/object-runtime.ts +++ b/packages/renderer/src/runtime/object-runtime.ts @@ -179,18 +179,9 @@ export function createObjectRuntime(definition: ObjectRuntimeDefinition, service setAllowed(false); return getOrbit().applyWorldCamera(pose, worldFrame, options?.signal, options?.departing); }, - preparedFocus() { return getOrbit().preparedFocus(); }, // Every prepared group is connected and painted once: an arriving flight // may resume before the remaining readiness bookkeeping settles. detailActivated() { return phase !== 'mounting' && !lifetime.disposed; }, - setPreparedFocus(focus: Parameters[0]) { - if (!lifetime.disposed) getOrbit().setPreparedFocus(focus, worldFrame); - }, - flyToPreparedFocus(focus: Parameters[0], options?: Parameters[1]) { - if (lifetime.disposed) return Promise.resolve({ completed: false }); - setAllowed(false); - return getOrbit().flyToPreparedFocus(focus, worldFrame, this.optics(), options); - }, optics() { const state = getOrbit().state(); return { focalPixels: state.focal, projectionScale: state.projectionScale, principalOffsetPixels: [state.principalOffset[0], state.principalOffset[1]] as const, diff --git a/packages/renderer/src/runtime/world-navigation-types.ts b/packages/renderer/src/runtime/world-navigation-types.ts index 64376cd2ac2..55a0a801c48 100644 --- a/packages/renderer/src/runtime/world-navigation-types.ts +++ b/packages/renderer/src/runtime/world-navigation-types.ts @@ -1,5 +1,4 @@ import type { PreparedWorldCameraFrame, WorldCameraPose, WorldCameraViewport } from '../navigation/world-camera.js'; -import type { PreparedNavigationFocus, PreparedFocusFlightOptions } from '../navigation/prepared-focus.js'; export type ObjectWorldNavigationListener = (world: WorldCameraPose, viewport: WorldCameraViewport) => void; @@ -15,9 +14,6 @@ export interface ObjectWorldNavigation { holdPresentation?(): () => void; capture(): WorldCameraPose; apply(pose: WorldCameraPose, options?: { signal: AbortSignal; departing?: boolean }): void | Promise; - preparedFocus(): PreparedNavigationFocus | null; - setPreparedFocus(focus: PreparedNavigationFocus | null): void; - flyToPreparedFocus(focus: PreparedNavigationFocus, options?: PreparedFocusFlightOptions): Promise<{ completed: boolean }>; setZoomOutCentering?(enabled: boolean): void; /** True once every prepared detail group is connected and painted. */ detailActivated?(): boolean; diff --git a/packages/renderer/src/sky/prepared-sky-runtime.test.ts b/packages/renderer/src/sky/prepared-sky-runtime.test.ts index 3419c06ca3c..bf58b57b27d 100644 --- a/packages/renderer/src/sky/prepared-sky-runtime.test.ts +++ b/packages/renderer/src/sky/prepared-sky-runtime.test.ts @@ -6,7 +6,6 @@ import type { PreparedCssSky } from './types.js'; import type { PreparedCssVolume } from '../volume/types.js'; import type { PreparedCssImageLayers } from '../image-layers/loader.js'; import type { PreparedVolumeDatasets } from '../volume/prepared-volume-datasets.js'; -import type { PreparedNavigationFocus } from '../navigation/prepared-focus.js'; import type { WorldCameraPose } from '../navigation/world-camera.js'; import type { DensityVolumeFrame } from '@cssearth/objects'; import { stubGlobal, unstubAllGlobals, waitFor } from '@cssearth/objects/node/contract'; @@ -135,7 +134,6 @@ test('the galaxy is its bulge slices at every overview scope, with no disc plane pose: { positionM: [volume.frame.originM[0], volume.frame.originM[1], volume.frame.originM[2] + 2.5 * radiusM], orientationXyzw: [0, 0, 0, 1] } }; for (const scope of ['local-group', 'milky-way', 'system', 'milky-way', undefined]) { mounted.setOverview(scope !== undefined, scope); - mounted.selectGalaxy(null); mounted.publish(camera, viewport, spatialFrame); const nodes = descendants(image); assert.equal(nodes.filter(node => node.dataset.volumeImpostor !== undefined).length, 0); @@ -611,6 +609,8 @@ test('authoritative detailed close-up gates background fetch, painting and publi const base = new URL('../../../../src/', import.meta.url), parsecM = 3.085677581491367e16; const context = JSON.parse(readFileSync(new URL('objects/sun/prepared/world-context.json', base), 'utf8')); context.volume.opacityProfile = { model: 'logarithmic-distance', fadeStartDistanceM: 1e19, fullDistanceM: 1e21, nearOpacity: 0, fullOpacity: 1 }; + // The galaxy's volume is gated by the distance to the selected body, as for every body: the close-up is mid-fade. + context.volume.discHalfHeightM = .5 * parsecM; const volume = JSON.parse(readFileSync(new URL('objects/milky-way/prepared/volume.json', base), 'utf8')).data as PreparedCssVolume; const frame: PreparedCssVolume['frame'] = { referenceFrame: volume.frame.referenceFrame, epochJdTt: volume.frame.epochJdTt, originM: [context.focus.positionM[0], context.focus.positionM[1], context.focus.positionM[2] + 450 * parsecM], @@ -626,30 +626,44 @@ test('authoritative detailed close-up gates background fetch, painting and publi description: 'Prepared fixture', sourceUrl: 'https://example.org/nebula', volume: { ...small, id }, brightness: { overall: 1, x: 1, y: 1, z: 1 }, stars: { frame, points: [] } }] }); const banks = ['focus-bank', 'warm-bank', 'cold-bank'].map(bank); - const image: PreparedCssImageLayers = { ...small, id: 'image-bank', bankViews: small.stacks.map(stack => ({ axis: stack.axis, + // Each package the host draws is a body of the world context; one of them has no bank of its own. + const nebula = context.bodies.find((body: { id: string }) => body.id === 'm1'); + // The galaxy stands apart from the nebulae: a body inside a galaxy's framing sphere draws that galaxy's dots. + const imageFrame = { ...frame, originM: [frame.originM[0] + 100 * frame.metersPerUnit, frame.originM[1], frame.originM[2]] as [number, number, number] }; + for (const id of ['focus-bank', 'image-bank', 'no-bank']) context.bodies.push({ ...nebula, id, name: id, + positionM: id === 'image-bank' ? imageFrame.originM : frame.originM, radiusM: frame.metersPerUnit }); + const image: PreparedCssImageLayers = { ...small, frame: imageFrame, id: 'image-bank', bankViews: small.stacks.map(stack => ({ axis: stack.axis, normalUnits: stack.axis === 'x' ? [1, 0, 0] : stack.axis === 'y' ? [0, 1, 0] : [0, 0, 1], samplingStepUnits: 1 })) }; const loadVolumeDataset = mock.fn(async (id: string) => ({ payload: banks.find(bank => bank.id === id)!, resolveResource: (path: string) => `/bank/${id}/${path}` })); const loadImageLayer = mock.fn(async () => ({ payload: image, resolveResource: (path: string) => `/image/${path}` })); - catalogMount.mock.mockImplementation(() => ({ destroy() {}, select() {}, highlight() {}, resolve: (id: string) => ({ id, detailedObjectId: id === 'catalogue-only' ? undefined : id.replace('catalogue:', '') }), publish() {}, inspect() {} })); + catalogMount.mock.mockImplementation(() => ({ destroy() {}, select() {}, highlight() {}, resolve: (id: string) => ({ id, detailedObjectId: id === 'no-bank' ? undefined : id.replace('catalogue:', '') }), publish() {}, inspect() {} })); const document = new FakeDocument(), stage = document.createElement(); const fetchResource = mock.fn(async () => new Response(new Uint8Array([1]))); Object.assign(document.defaultView, { fetch: fetchResource }); const mounted = createPreparedUniverse({ context, volume, pointAppearance: readCanonicalPointField(), sprites: {}, resolveResource: path => `/volume/${path}`, resolvePointResource: path => `/stars/${path}`, ...datasetBanks(banks.map(bank => ({ id: bank.id, frame, contextVisibility: bank.contextVisibility, attachedTo: bank.attachedTo }))), loadVolumeDataset, - imageLayerBanks: [{ id: image.id, frame }], loadImageLayer, catalog: { payload: {}, fadeStartDistanceM: 10, fullDistanceM: 20 }, + imageLayerBanks: [{ id: image.id, frame: imageFrame }], loadImageLayer, catalog: { payload: {}, fadeStartDistanceM: 10, fullDistanceM: 20 }, }).mount(stage as unknown as HTMLElement); const root = mounted.root as unknown as FakeElement; const findBank = (id: string) => root.children.find(node => node.dataset.volumeDatasetObject === id)!; const mw = root.children.find(node => node.className === 'prepared-volume-context')!; const sky = root.children.find(node => node.className === 'prepared-celestial-sky')!; - const focus = (id = 'catalogue:focus-bank'): PreparedNavigationFocus => ({ id, positionM: frame.originM, framingRadiusM: frame.metersPerUnit, - limits: { minimumDistanceM: .01 * frame.metersPerUnit, maximumDistanceM: 1e25 } }); + // A bank package is a body of the world; its scene's dataset shows the bank as its companion. + let shown: string | null = null; + const select = (id: string) => { + if (shown) mounted.setVolumeDatasetEnabled(shown, false); + shown = mounted.focusBank(id) ? id : null; + const body = [context.focus, ...context.bodies].find((candidate: { id: string }) => candidate.id === id) as { positionM: [number, number, number]; radiusM: number }; + mounted.selectObject(id, { referenceFrame: frame.referenceFrame, epochJdTt: frame.epochJdTt, originM: body.positionM, + presentationToReference: [0, 1, 0, 1, 0, 0, 0, 0, 1], metersPerUnit: body.radiusM, bodyRadiusM: body.radiusM }); + if (shown) mounted.setVolumeDatasetEnabled(shown, true); + }; const camera = (radii: number): WorldCameraPose => ({ referenceFrame: frame.referenceFrame, epochJdTt: frame.epochJdTt, pose: { positionM: [frame.originM[0], frame.originM[1], frame.originM[2] + radii * frame.metersPerUnit], orientationXyzw: [0, 0, 0, 1] } }); try { await mounted.focusBank('focus-bank')!.load(); await mounted.focusBank('warm-bank')!.load(); - mounted.selectGalaxy('catalogue:focus-bank', focus()); + select('focus-bank'); const near = camera(6.1), savedPose = structuredClone(near); mounted.publish(near, viewport, spatialFrame); assert.deepEqual(loadVolumeDataset.mock.calls.map(({ arguments: [id] }) => id), ['focus-bank', 'warm-bank']); @@ -667,7 +681,7 @@ test('authoritative detailed close-up gates background fetch, painting and publi mounted.publish(camera(radii), viewport, spatialFrame); if (radii > 8) { await mounted.focusBank('cold-bank')!.load(); await mounted.focusBank(image.id)!.load(); mounted.publish(camera(radii), viewport, spatialFrame); } const originalOpacity = Number(mw.dataset.volumeOpacity), alpha = Number(mw.style.opacity); - assert.ok(originalOpacity > 0); assert.ok(originalOpacity < 1); + assert.ok(originalOpacity > 0); assert.ok(radii > 8 ? originalOpacity === 1 : originalOpacity < 1); const completedContribution = originalOpacity; assert.ok(Math.abs(alpha - (completedContribution * multiplier)) < 10 ** -12 / 2, `${alpha} is not close to ${completedContribution * multiplier}`); assert.ok(Math.abs(((1 - alpha) * Number(sky.style.opacity)) - (1 - completedContribution)) < 10 ** -12 / 2, `${((1 - alpha) * Number(sky.style.opacity))} is not close to ${1 - completedContribution}`); @@ -686,16 +700,16 @@ test('authoritative detailed close-up gates background fetch, painting and publi const before = all(findBank('warm-bank')).map(node => ({ ...node.style })); mounted.publish({ ...near, pose: { ...near.pose, orientationXyzw: [0, Math.SQRT1_2, 0, Math.SQRT1_2] } }, viewport, spatialFrame); assert.deepEqual(all(findBank('warm-bank')).map(node => ({ ...node.style })), before); - mounted.selectGalaxy('catalogue:image-bank', focus('catalogue:image-bank')); mounted.publish(near, viewport, spatialFrame); + select('image-bank'); + mounted.publish({ ...near, pose: { ...near.pose, positionM: [imageFrame.originM[0], near.pose.positionM[1], near.pose.positionM[2]] } }, viewport, spatialFrame); assert.equal(imageRoot.style.display, ''); assert.equal(findBank('focus-bank').style.display, 'none'); - mounted.selectGalaxy(null); mounted.publish(near, viewport, spatialFrame); + select(context.focus.id); mounted.publish(near, viewport, spatialFrame); assert.equal(mw.style.display, ''); await waitFor(() => { mounted.publish(near, viewport, spatialFrame); assert.equal(findBank('focus-bank').style.display, 'block'); }); - mounted.selectGalaxy('catalogue-only', focus('catalogue-only')); mounted.publish(near, viewport, spatialFrame); + select('no-bank'); mounted.publish(near, viewport, spatialFrame); assert.equal(mw.style.display, ''); - assert.throws(() => mounted.selectGalaxy('catalogue:focus-bank', focus('mismatch')), /focus/); } finally { mounted.destroy(); } }); diff --git a/packages/renderer/src/styles/world-context.css b/packages/renderer/src/styles/world-context.css index 12ac09f61e6..b2a189bb681 100644 --- a/packages/renderer/src/styles/world-context.css +++ b/packages/renderer/src/styles/world-context.css @@ -185,9 +185,8 @@ .prepared-world-context [data-context-indicator-hovered="true"][data-context-label-visible="true"] > .context-caption::after { opacity: 1; } -/* The galaxy catalogue (prepared-galaxy-catalog.ts): a zero-size root at the stage centre whose dots, R500 apertures, - kind markers and labels are placed from it. Their fades own opacity and visibility, the camera their transforms, an - aperture's ellipse its size, and a label's pick state its pointer events; the rest is fixed here. */ +/* The galaxy catalogue (prepared-galaxy-catalog.ts): a zero-size root at the stage centre whose dots are placed from it. + Their fade owns opacity and visibility and the camera their transforms; the rest is fixed here. */ .prepared-galaxy-catalog { position: absolute; left: 50%; @@ -196,25 +195,16 @@ height: 0; pointer-events: none; } -.prepared-galaxy-catalog [data-galaxy-dot], -.prepared-galaxy-catalog [data-galaxy-label] { +.prepared-galaxy-catalog [data-galaxy-dot] { position: absolute; left: 0; top: 0; pointer-events: none; -} -.prepared-galaxy-catalog [data-galaxy-dot] { width: 2px; height: 2px; border-radius: 50%; background: #c2ccd8; } -.prepared-galaxy-catalog [data-galaxy-label] { - cursor: default; -} -.prepared-galaxy-catalog [data-galaxy-label][data-object-navigate] { - cursor: pointer; -} /* Environment captions (environment-labels.ts): a zero-size root at the stage centre whose captions are placed from it. A caption's fade owns its visibility and opacity, and a link's fade its pointer events. */ .prepared-environment-labels { diff --git a/packages/renderer/src/universe/catalog-marker.test.ts b/packages/renderer/src/universe/catalog-marker.test.ts deleted file mode 100644 index 6b5216f6318..00000000000 --- a/packages/renderer/src/universe/catalog-marker.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { isDeepStrictEqual } from 'node:util'; -import { parsePreparedNebulaCatalog, parsePreparedGalaxyCatalog, parsePreparedClusterCatalog } from '@cssearth/catalog'; -import { catalogMarkerKind, catalogMarkerSvg, mountCatalogMarkerKind } from './catalog-marker.js'; - -test('shipped classifications select the correct marker without object ID exceptions', () => { - for (const [id, expected] of [['m45', 'open-cluster'], ['helix', 'planetary-nebula'], - ['m42', 'nebula'], ['m8', 'nebula'], ['m1', 'nebula'], ['m2-9', 'nebula']]) { - const catalog = parsePreparedNebulaCatalog(JSON.parse(readFileSync( - new URL('../../../../src/objects/' + id + '/source/nebula.json', import.meta.url), 'utf8'))); - assert.equal(catalogMarkerKind(catalog.objects[0]!), expected); - } - const galaxies = parsePreparedGalaxyCatalog(JSON.parse(readFileSync( - new URL('../../../../src/objects/local-group/prepared/catalogue.json', import.meta.url), 'utf8'))); - assert.equal(catalogMarkerKind(galaxies.objects[0]!), 'galaxy'); -}); - -test('outer scene markers share the ordinary circle while retaining classification metadata', () => { - const clusters = parsePreparedClusterCatalog(JSON.parse(readFileSync( - new URL('../../../../src/objects/galaxy-clusters/prepared/catalogue.json', import.meta.url), 'utf8'))); - assert.equal(clusters.objects.every(object => catalogMarkerKind(object) === 'galaxy-cluster'), true); - for (const kind of ['galaxy', 'nebula', 'planetary-nebula', 'open-cluster', 'globular-cluster', 'galaxy-group', 'galaxy-cluster'] as const) { - const marker = { className: '', dataset: { catalogMarkerKind: '' }, style: {}, innerHTML: '', setAttribute() {} }; - mountCatalogMarkerKind(marker as unknown as HTMLElement, kind); - assert.equal(marker.dataset.catalogMarkerKind, kind); - assert.equal(marker.className, 'prepared-context-marker'); - assert.equal(marker.innerHTML, ''); - } -}); - -test('globular-cluster kind selects a stellar-cluster marker independently of its display name', () => { - const catalog = parsePreparedNebulaCatalog(JSON.parse(readFileSync( - new URL('../../../../src/objects/m45/source/nebula.json', import.meta.url), 'utf8'))); - const cluster = { ...catalog.objects[0]!, kind: 'globular-cluster' as const }; - assert.equal(catalogMarkerKind(cluster), 'globular-cluster'); - assert.ok(catalogMarkerSvg('globular-cluster').includes(' = { - galaxy: '', - nebula: cloud, - 'planetary-nebula': cloud + '', - 'open-cluster': '', - 'globular-cluster': '', - 'galaxy-group': '', - 'galaxy-cluster': '', -}; - -/** Fixed decorative vector icons, not generated scene imagery. */ -export function mountCatalogMarker(marker: HTMLElement, object: PreparedCatalogObject): void { - const kind = catalogMarkerKind(object); - mountCatalogMarkerKind(marker, kind); -} - -export function mountCatalogMarkerKind(marker: HTMLElement, kind: CatalogMarkerKind): void { - marker.dataset.catalogMarkerKind = kind; - marker.className = 'prepared-context-marker'; - // Its box is a world-context.css rule; the fade owns opacity and visibility. - marker.style.cssText = 'opacity:0;visibility:hidden'; - marker.setAttribute('aria-hidden', 'true'); - marker.innerHTML = ''; -} - -export function catalogMarkerSvg(kind: CatalogMarkerKind): string { - return ''; -} diff --git a/packages/renderer/src/universe/catalogue-points.test.ts b/packages/renderer/src/universe/catalogue-points.test.ts index 3cca6afa758..49cf8b29e70 100644 --- a/packages/renderer/src/universe/catalogue-points.test.ts +++ b/packages/renderer/src/universe/catalogue-points.test.ts @@ -22,7 +22,7 @@ test('the prepared catalogues the app draws are valid banks of every selected ro // The published banks: what each recipe marks `published: true` and the app fetches. Their inputs (a survey's stars, // one catalogue's masers) are bake inputs in output/catalogue-points/, so they are not read here. for (const [object, id] of [['milky-way', 'globular-clusters'], ['milky-way', 'dots'], ['milky-way', 'old-star-dots'], ['nearby-universe', 'dots'], ['nearby-universe', 'bright-galaxy-dots'], - ['nearby-universe', 'quasar-dots'], ['m31', 'stars'], ['m31', 'dots'], ['m33', 'stars'], ['m33', 'dots'], ['m81', 'dots'], ['ngc-253', 'dots']]) { + ['nearby-universe', 'quasar-dots'], ['m31-layers', 'stars'], ['m31-layers', 'dots'], ['m33-layers', 'stars'], ['m33-layers', 'dots'], ['m81-layers', 'dots'], ['ngc-253-layers', 'dots']]) { const path = new URL(`../../../../src/objects/${object}/prepared/${id}.bin`, import.meta.url); const prepared = decodeCatalogueBankBinary(unpackPreparedBinary(readFileSync(path), path.pathname), path.pathname) as { points: number[][]; counts: { points: number; missingDistance?: number; selected?: number }; source?: string; appearance: { levels?: { points: number }[] } }; diff --git a/packages/renderer/src/universe/detailed-focus-context.ts b/packages/renderer/src/universe/detailed-focus-context.ts index b0b57bf0df1..9558e135116 100644 --- a/packages/renderer/src/universe/detailed-focus-context.ts +++ b/packages/renderer/src/universe/detailed-focus-context.ts @@ -1,14 +1,15 @@ -import type { PreparedNavigationFocus } from '../navigation/prepared-focus.js'; import { worldCameraViewport, type WorldCameraViewport } from '../navigation/world-camera.js'; import type { WorldCameraPose } from '../navigation/world-camera.js'; +/** The selected bank package: where it is and the radius its page frames. */ +export interface SelectedBank { readonly positionM: readonly [number, number, number]; readonly framingRadiusM: number; } /** Presentation only: normal focus arrival is about 6.1 authored radii. */ const HIDDEN_WITHIN_RADII = 8; const RESTORED_BY_RADII = 32; /** Fade surrounding detailed banks, without changing the observer or the Sun-distance sky policy. */ export function detailedFocusContextOpacity(world: WorldCameraPose, - focus: Pick | null): number { + focus: SelectedBank | null): number { if (!focus) return 1; const radii = Math.hypot(...world.pose.positionM.map((value, axis) => value - focus.positionM[axis])) / focus.framingRadiusM; const progress = Math.max(0, Math.min(1, (radii - HIDDEN_WITHIN_RADII) / (RESTORED_BY_RADII - HIDDEN_WITHIN_RADII))); diff --git a/packages/renderer/src/universe/galaxy-catalog-layout.test.ts b/packages/renderer/src/universe/galaxy-catalog-layout.test.ts index 0be62b7977a..932c7db2057 100644 --- a/packages/renderer/src/universe/galaxy-catalog-layout.test.ts +++ b/packages/renderer/src/universe/galaxy-catalog-layout.test.ts @@ -1,17 +1,10 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { isDeepStrictEqual } from 'node:util'; -import type { PreparedGalaxyRecord } from '@cssearth/catalog'; -import { admitGalaxyLabels, catalogVolumeCorners, projectCatalogBounds, projectCatalogPosition } from './galaxy-catalog-layout.js'; +import { projectCatalogPosition } from './galaxy-catalog-layout.js'; const world = { referenceFrame: 'test', epochJdTt: 1, pose: { positionM: [0, 0, 0] as const, orientationXyzw: [0, 0, 0, 1] as const } }; const viewport = { focalPixels: 100, principalOffsetPixels: [2, -3] as const }; -const rect = { left: 0, right: 30, top: 0, bottom: 12 }; -function candidate(id: string, distanceM: number, detailed = false) { - return { object: { id, ...(detailed ? { detailedObjectId: id } : {}) } as PreparedGalaxyRecord, - x: 0, y: 0, distanceM, labelRect: rect }; -} test('projects prepared metre coordinates in the shared observer including translation and the principal point', () => { // The identity pose looks down -z with +y up; a point above the axis lands above the principal point on a y-down screen. assert.partialDeepStrictEqual(projectCatalogPosition([10, 20, -100], world, viewport), { x: 12, y: -23 }); @@ -19,43 +12,3 @@ test('projects prepared metre coordinates in the shared observer including trans assert.partialDeepStrictEqual(projectCatalogPosition([10, 20, -100], moved, viewport), { x: 2, y: -23 }); assert.equal(projectCatalogPosition([0, 0, 100], world, viewport), null); }); -test('foreground exclusions beat all catalogue labels; major objects beat minor neighbours and selection wins', () => { - const near = candidate('near', 100), far = candidate('far', 200, true); - assert.deepEqual(admitGalaxyLabels([far, near], [], null), [{ ...far, placement: 0 }]); - assert.deepEqual(admitGalaxyLabels([far, near], [rect], null), []); - assert.deepEqual(admitGalaxyLabels([far, near], [], 'near'), [{ ...near, placement: 0 }]); - assert.deepEqual(admitGalaxyLabels([candidate('far', 200), near], [], null), [{ ...near, placement: 0 }]); -}); - -test('a clickable target beats a nearer disabled label even when its prepared class has lower priority', () => { - const disabled = { ...candidate('disabled', 1, true), navigable: false }; - const clickable = { ...candidate('clickable', 100), navigable: true }; - assert.deepEqual(admitGalaxyLabels([disabled, clickable], [], null), [{ ...clickable, placement: 0 }]); -}); - -test('a clickable label tries another side before yielding its space to disabled names', () => { - const alternate = { left: -50, right: -20, top: 0, bottom: 12 }; - const target = { ...candidate('target', 100), navigable: true, alternateLabelRects: [alternate] }; - const disabled = { ...candidate('disabled', 1), navigable: false, labelRect: alternate }; - assert.deepEqual(admitGalaxyLabels([disabled, target], [rect], null), [{ ...target, labelRect: alternate, placement: 1 }]); -}); -test('catalogues use the shared desktop budget, including a distant selected label, with stable ties', () => { - const rows = Array.from({ length: 40 }, (_, i) => ({ ...candidate(String(i).padStart(2, '0'), 100 + i), - labelRect: { left: i * 40, right: i * 40 + 30, top: 0, bottom: 12 } })); - const accepted = admitGalaxyLabels(rows, [], '39'); - assert.equal(accepted.length, 24); assert.equal(accepted[0]!.object.id, '39'); - assert.deepEqual(admitGalaxyLabels([...rows].reverse(), [], '39'), accepted); -}); -test('cloud bounds follow physical scale and orientation instead of its central catalogue marker', () => { - const corners = catalogVolumeCorners({ referenceFrame: 'test', epochJdTt: 1, - originM: [0, 0, -100], metersPerUnit: 2, localToReferenceXyzw: [0, 0, Math.SQRT1_2, Math.SQRT1_2], - boundsUnits: { min: [-10, -2, -3], max: [10, 2, 3] } }); - const bounds = projectCatalogBounds(corners, world, viewport)!; - assert.ok(Math.abs(bounds.top - (-3 - 2000 / 94)) < 10 ** -2 / 2, `${bounds.top} is not close to ${-3 - 2000 / 94}`); - assert.ok(Math.abs(bounds.right - (2 + 400 / 94)) < 10 ** -2 / 2, `${bounds.right} is not close to ${2 + 400 / 94}`); - const rolled = { ...world, pose: { ...world.pose, orientationXyzw: [0, 0, Math.SQRT1_2, Math.SQRT1_2] as const } }; - const orbitBounds = projectCatalogBounds(corners, rolled, viewport)!; - assert.ok(Math.abs(orbitBounds.top - (-3 - 400 / 94)) < 10 ** -2 / 2, `${orbitBounds.top} is not close to ${-3 - 400 / 94}`); - assert.ok(Math.abs(orbitBounds.right - (2 + 2000 / 94)) < 10 ** -2 / 2, `${orbitBounds.right} is not close to ${2 + 2000 / 94}`); - assert.equal(projectCatalogBounds(corners, { ...world, pose: { ...world.pose, positionM: [0, 0, -100] } }, viewport), null); -}); diff --git a/packages/renderer/src/universe/galaxy-catalog-layout.ts b/packages/renderer/src/universe/galaxy-catalog-layout.ts index e34b1b066ad..1a4198d7339 100644 --- a/packages/renderer/src/universe/galaxy-catalog-layout.ts +++ b/packages/renderer/src/universe/galaxy-catalog-layout.ts @@ -1,24 +1,5 @@ -import { isNavigableCatalogObject, isPreparedCluster, isPreparedNebula } from '@cssearth/catalog'; -import type { PreparedCatalogObject } from '@cssearth/catalog'; -import type { DensityVolumeFrame } from '@cssearth/objects'; import type { WorldCameraPose, WorldCameraViewport } from '../navigation/world-camera.js'; -import { cssCameraAxesFromOrientation, worldRotationFromQuaternion } from '../navigation/world-camera-math.js'; -import { labelRectsOverlap } from '../labels/screen-label-layout.js'; -import type { LabelScreenRect } from '../labels/screen-label-layout.js'; -import { admitStableLabels } from '../labels/stable-label-layout.js'; -import { createLabelBudget, labelImportance, type LabelBudget } from '../labels/universe-label-policy.js'; - -export interface ProjectedGalaxy { - readonly navigable?: boolean; - readonly shown?: boolean; - readonly placement?: number; - readonly object: PreparedCatalogObject; - readonly x: number; - readonly y: number; - readonly distanceM: number; - readonly labelRect: LabelScreenRect; - readonly alternateLabelRects?: readonly LabelScreenRect[]; -} +import { cssCameraAxesFromOrientation } from '../navigation/world-camera-math.js'; /** Only the observer projection is runtime work; every astronomical position is prepared. */ export function projectCatalogPosition(positionM: readonly number[], world: WorldCameraPose, viewport: WorldCameraViewport) { @@ -34,57 +15,3 @@ export function projectCatalogPosition(positionM: readonly number[], world: Worl y: viewport.principalOffsetPixels[1] + viewport.focalPixels * y / depth, distanceM: Math.hypot(dx, dy, dz), depthM: depth }; } - -/** Resolve the prepared cloud bounds once; catalogue stars do not enlarge this box. */ -export function catalogVolumeCorners(frame: DensityVolumeFrame): readonly (readonly number[])[] { - const r = worldRotationFromQuaternion(frame.localToReferenceXyzw); - return [0, 1, 2, 3, 4, 5, 6, 7].map(index => { - const [x, y, z] = [0, 1, 2].map(axis => - (index & (1 << axis) ? frame.boundsUnits.max[axis]! : frame.boundsUnits.min[axis]!) * frame.metersPerUnit); - return [frame.originM[0] + r[0] * x! + r[1] * y! + r[2] * z!, - frame.originM[1] + r[3] * x! + r[4] * y! + r[5] * z!, - frame.originM[2] + r[6] * x! + r[7] * y! + r[8] * z!]; - }); -} - -/** Keep the label above the whole projected cloud, including during an orbit. */ -export function projectCatalogBounds(cornersM: readonly (readonly number[])[], world: WorldCameraPose, viewport: WorldCameraViewport) { - const points = cornersM.map(corner => projectCatalogPosition(corner, world, viewport)); - // A cloud crossing the observer plane has no finite screen bounds. - if (!points.length || points.some(point => point === null)) return null; - return { left: Math.min(...points.map(point => point!.x)), right: Math.max(...points.map(point => point!.x)), - top: Math.min(...points.map(point => point!.y)), bottom: Math.max(...points.map(point => point!.y)) }; -} - -/** A prepared object's label priority is fixed by its record: each one is computed once and kept with it, never - * inside the per-frame sort. */ -const priorities = new WeakMap(); -/** Above every catalogue tier (labelImportance tops out at the orientation references, single digits). */ -const HIGHLIGHT_PRIORITY = 1000; -function priority(object: PreparedCatalogObject): number { - let value = priorities.get(object); - if (value === undefined) { - value = object.status === 'candidate' ? 0 - : labelImportance(isPreparedCluster(object) ? 'galaxy-cluster' : isPreparedNebula(object) ? 'nebula' : 'galaxy', !isPreparedCluster(object) && Boolean(object.detailedObjectId)); - priorities.set(object, value); - } - return value; -} - -/** Foreground exclusions win; selection, major objects, then stable distance/id ties. */ -export function admitGalaxyLabels(candidates: readonly ProjectedGalaxy[], blockers: readonly LabelScreenRect[], selectedId: string | null, - budget: LabelBudget = createLabelBudget(Infinity, Infinity, [], blockers), highlighted: ReadonlySet = new Set()) { - // One priority read per candidate; the comparator reads numbers. The id comparison runs only on an exact distance tie. - // A highlighted category (its header pill) ranks ahead of every other label. - const ranked = candidates.map(candidate => ({ candidate, priority: priority(candidate.object) + (highlighted.has(candidate.object.id) ? HIGHLIGHT_PRIORITY : 0) })) - .sort((a, b) => b.priority - a.priority || a.candidate.distanceM - b.candidate.distanceM || a.candidate.object.id.localeCompare(b.candidate.object.id)) - .map(({ candidate }) => candidate); - const stable = ranked.map((candidate, index) => ({ - id: candidate.object.id, candidate, navigable: candidate.navigable ?? isNavigableCatalogObject(candidate.object), - pinned: Number(candidate.object.id === selectedId), priority: ranked.length - index, - shown: candidate.shown ?? false, previousPlacement: candidate.placement ?? 0, - placements: [candidate.labelRect, ...candidate.alternateLabelRects ?? []].map((rect, slot) => ({ slot, rect })), - })); - return admitStableLabels(stable, budget, (_, rect) => !blockers.some(blocker => labelRectsOverlap(blocker, rect))) - .map(({ candidate: { candidate }, placement, rect }) => ({ ...candidate, placement, labelRect: rect })); -} diff --git a/packages/renderer/src/universe/image-mesh.ts b/packages/renderer/src/universe/image-mesh.ts index 50c7f39fbc3..4ceda8feaaf 100644 --- a/packages/renderer/src/universe/image-mesh.ts +++ b/packages/renderer/src/universe/image-mesh.ts @@ -144,7 +144,7 @@ export function mountImageMesh({ host, before, interiorBefore = before, labelHos if (next !== limbTransform) { limbTransform = next; if (next) limb.style.transform = next; limb.style.display = next ? '' : 'none'; } } const placement = captioned && shape && labelSize && placeSelectedBodyLabel(publication.world, publication.viewport, { positionM: centreM, radiusM }, - { overview: false, focused: false, preview: undefined, edge: () => silhouetteBottom(shape) }, labelSize[0], labelSize[1]); + { overview: false, preview: undefined, edge: () => silhouetteBottom(shape) }, labelSize[0], labelSize[1]); const nextLabel = placement ? `translate(${format(placement.left)}px,${format(placement.top)}px) translate(-50%,0)` : labelTransform; if (nextLabel !== labelTransform) label.style.transform = labelTransform = nextLabel; const nextOpacity = String(placement ? Number((alpha * DEFAULT_CONTEXT_LABEL_OPACITY).toFixed(3)) : 0); @@ -153,7 +153,7 @@ export function mountImageMesh({ host, before, interiorBefore = before, labelHos return Object.freeze({ root, /** Publishes the camera and returns how much the mesh covers what lies inside it, so that can give way as it closes over: * its opacity, or nothing while it is cut open. `captioned` is false while another subject owns the view's caption (a - * catalogue focus): the mesh then names nothing. */ + * selection preview, a galaxy shown as its scene's subject): the mesh then names nothing. */ publish(publication: VolumeCameraPublication, shown = 1, captioned = true): number { if (destroyed) return 0; lastShown = shown; lastCaptioned = captioned; latest = publication; diff --git a/packages/renderer/src/universe/prepared-focus-bank.ts b/packages/renderer/src/universe/prepared-focus-bank.ts index 301005bec99..102bb79b65c 100644 --- a/packages/renderer/src/universe/prepared-focus-bank.ts +++ b/packages/renderer/src/universe/prepared-focus-bank.ts @@ -29,3 +29,14 @@ export function createImageFocusBank(objectId: string, frame: DensityVolumeFrame subscribe: () => () => {}, }; } + +/** A bank of catalogue dots: one dataset, its members, with nothing to decode before it shows. */ +export function createPointFocusBank(objectId: string): PreparedFocusBank { + const state: PreparedFocusDatasets = { objectId, id: 'members', defaultDataset: 'members', selectedDataset: 'members', starsVisible: false, + datasets: [{ id: 'members', label: 'Members', title: 'Catalogue members', description: 'Prepared catalogue dots', sourceUrl: '' }] }; + return { + objectId, framingRadiusM: () => { throw new TypeError('A bank of dots is framed by its scene.'); }, state: () => state, load: () => Promise.resolve(), + selectDataset(id) { if (id !== 'members') throw new TypeError('Unknown catalogue-point dataset.'); }, + subscribe: () => () => {}, + }; +} diff --git a/packages/renderer/src/universe/prepared-galaxy-catalog.test.ts b/packages/renderer/src/universe/prepared-galaxy-catalog.test.ts index 54e0af739df..7689aa2c7c1 100644 --- a/packages/renderer/src/universe/prepared-galaxy-catalog.test.ts +++ b/packages/renderer/src/universe/prepared-galaxy-catalog.test.ts @@ -1,9 +1,7 @@ import { readFileSync } from 'node:fs'; -import { test, mock } from 'node:test'; +import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { isDeepStrictEqual } from 'node:util'; import { mountPreparedGalaxyCatalog } from './prepared-galaxy-catalog.js'; -import { screenPicking } from '../navigation/screen-picking.js'; class Window { time = 0; next = 0; frames = new Map void>(); @@ -26,293 +24,47 @@ class Element extends EventTarget { class Document { count = 0; defaultView = new Window(); createElement() { this.count++; return new Element(this); } } const read = (path: string) => JSON.parse(readFileSync(new URL(`../../../../src/objects/${path}`, import.meta.url), 'utf8')); -test('alternate catalogue labels keep their side when space opens and throughout rejection fade', () => { - const payload = { ...read('local-group/prepared/catalogue.json'), objects: [] }; - const nebulae = read('m42/source/nebula.json'), object = nebulae.objects[0]; +type Row = { id: string; name?: string; membership: { group: string }; detailedObjectId?: string }; +const mount = (extra: { galaxySample?: unknown } = {}) => { + const payload = read('local-group/prepared/catalogue.json'); const document = new Document(), host = document.createElement(), before = document.createElement(); host.append(before); - const runtime = mountPreparedGalaxyCatalog({ host: host as unknown as HTMLElement, - before: before as unknown as HTMLElement, payload, nebulae, onSelect() {} }); + const runtime = mountPreparedGalaxyCatalog({ host: host as unknown as HTMLElement, before: before as unknown as HTMLElement, payload, ...extra }); + const world = { ...payload.frame, pose: { positionM: [0, 0, 1e24] as const, orientationXyzw: [0, 0, 0, 1] as const } }; const viewport = { focalPixels: 600, principalOffsetPixels: [0, 0] as const, widthPixels: 800, heightPixels: 600 }; - const world = { ...nebulae.frame, pose: { positionM: [object.positionM[0], object.positionM[1], object.positionM[2] + 1e17] as const, - orientationXyzw: [0, 0, 0, 1] as const } }; - const label = runtime.inspect().labels[object.id]!; - runtime.publish(world, viewport, 0, [{ left: -150, right: 150, top: 5, bottom: 50 }], 0, 0, undefined, 1); - document.defaultView.advance(250); - const alternate = label.style.transform; - assert.ok(Number(label.style.opacity) > 0); - runtime.publish(world, viewport, 0, [], 0, 0, undefined, 1); - assert.equal(label.style.transform, alternate); - runtime.publish(world, viewport, 0, [{ left: -400, right: 400, top: -300, bottom: 300 }], 0, 0, undefined, 1); - document.defaultView.advance(300); - assert.ok(Number(label.style.opacity) > 0); - assert.equal(label.style.transform, alternate); - document.defaultView.advance(500); - assert.equal(Number(label.style.opacity), 0); - runtime.destroy(); -}); - -test('nearby nebula labels wake and follow the camera while both extragalactic fades are zero', () => { - const payload = { ...read('local-group/prepared/catalogue.json'), objects: [] }; - const nebulae = read('m42/source/nebula.json'), object = nebulae.objects[0]; - const document = new Document(), host = document.createElement(), before = document.createElement(); host.append(before); - const onSelect = mock.fn(() => {}), runtime = mountPreparedGalaxyCatalog({ host: host as unknown as HTMLElement, - before: before as unknown as HTMLElement, payload, nebulae, onSelect }); - const viewport = { focalPixels: 600, principalOffsetPixels: [0,0] as const, widthPixels: 800, heightPixels: 600 }; - const camera = (z: number, x = 0) => ({ ...nebulae.frame, pose: { - positionM: [object.positionM[0] + x, object.positionM[1], object.positionM[2] + z] as const, - orientationXyzw: [0,0,0,1] as const } }); - const label = runtime.inspect().labels[object.id]!, nodes = document.count; - runtime.publish(camera(-1e17), viewport, 0, [], 0); - assert.equal(label.style.pointerEvents, 'none'); - runtime.publish(camera(1e17), viewport, 0, [], 0); document.defaultView.advance(250); - assert.equal(label.style.pointerEvents, 'none'); - // Stellar context admits nebulae; planetary context does not. - runtime.publish(camera(1e17), viewport, 0, [], 0, 0, undefined, 1); document.defaultView.advance(500); - assert.equal(label.style.pointerEvents, 'auto'); assert.equal(Number(label.style.opacity), .65); - const transform = label.style.transform; - runtime.publish(camera(1e17, 1e16), viewport, 0, [], 0, 0, undefined, 1); - assert.notEqual(label.style.transform, transform); - label.dispatchEvent(new Event('click')); assert.ok(onSelect.mock.calls.some(call => isDeepStrictEqual(call.arguments, [object]))); - assert.equal(document.count, nodes); runtime.destroy(); -}); - -test('nebula label and its single-click target sit below the prepared cloud', () => { - const payload = { ...read('local-group/prepared/catalogue.json'), objects: [] }; - const nebulae = read('m42/source/nebula.json'), object = nebulae.objects[0]; - const document = new Document(), host = document.createElement(), before = document.createElement(); host.append(before); - const onSelect = mock.fn(() => {}), runtime = mountPreparedGalaxyCatalog({ host: host as unknown as HTMLElement, - before: before as unknown as HTMLElement, payload, nebulae, onSelect, - nebulaFrames: new Map([[object.detailedObjectId, { ...nebulae.frame, originM: object.positionM, - localToReferenceXyzw: [0, 0, 0, 1], metersPerUnit: 1e15, - boundsUnits: { min: [-5, -10, -2], max: [5, 10, 2] } }]]) }); - const viewport = { focalPixels: 600, principalOffsetPixels: [0,0] as const, widthPixels: 800, heightPixels: 600 }; - const pose = { ...nebulae.frame, pose: { positionM: [object.positionM[0], object.positionM[1], object.positionM[2] + 1e17] as const, - orientationXyzw: [0, 0, 0, 1] as const } }; - runtime.select(object.id); - const rectangles = runtime.publish(pose, viewport, 0, [], 0, 0, undefined, 1), bottom = 600 * 1e16 / 9.8e16; - assert.equal(rectangles.length, 1); - assert.ok(Math.abs(rectangles[0]!.top - (bottom + 8)) < 10 ** -2 / 2, `${rectangles[0]!.top} is not close to ${bottom + 8}`); - const picking = screenPicking(host as unknown as HTMLElement), label = runtime.inspect().labels[object.id]!; - assert.equal(picking.pick(0, bottom + 12), label); - assert.equal(picking.pick(0, -12), null); - label.dispatchEvent(new Event('click')); assert.ok(onSelect.mock.calls.some(call => isDeepStrictEqual(call.arguments, [object]))); - runtime.destroy(); -}); - -test('one retained catalogue combines both classes; cluster fades and source-aware focus follow the same observer, and a cluster is named without a marker or outline', () => { - const payload = read('local-group/prepared/catalogue.json'), clusters = read('galaxy-clusters/prepared/catalogue.json'); - // Local Group members and every galaxy with an object of its own (M81, NGC 253, M87 are not members). - const galaxyCount = payload.objects.filter((row: { membership: { group: string }; detailedObjectId?: string }) => row.membership.group === 'local-group' || row.detailedObjectId).length; - const document = new Document(), host = document.createElement(), before = document.createElement(), pickingHost = document.createElement(); host.append(before); - const picking = screenPicking(pickingHost as unknown as HTMLElement); - const onSelect = mock.fn(() => {}); - const runtime = mountPreparedGalaxyCatalog({ host: host as unknown as HTMLElement, before: before as unknown as HTMLElement, payload, clusters, onSelect, - pickingHost: pickingHost as unknown as HTMLElement }); - assert.equal(runtime.inspect().count, galaxyCount + clusters.objects.length); - assert.equal(runtime.inspect().clusterCount, clusters.objects.length); - const object = clusters.objects[0], nodes = document.count; - const pose = { referenceFrame: clusters.frame.referenceFrame, epochJdTt: clusters.frame.epochJdTt, - pose: { positionM: [object.positionM[0], object.positionM[1], object.positionM[2] + object.aperture.comovingRadiusM * 4] as const, - orientationXyzw: [0,0,0,1] as const } }; - const viewport = { focalPixels: 600, principalOffsetPixels: [0,0] as const, widthPixels: 800, heightPixels: 600 }; - const label = runtime.inspect().labels[object.id]!, root = runtime.root as unknown as Element; - assert.equal(root.children.some(node => node.dataset.clusterAperture !== undefined || node.dataset.galaxyMarker === object.id), false, 'no outline or marker'); - runtime.select(object.id); - runtime.publish(pose, viewport, 1, [], 0); document.defaultView.advance(250); - assert.equal(Number(label.style.opacity), 0); assert.equal(label.style.pointerEvents, 'none'); - assert.notEqual(picking.pick(0, 15), label); - runtime.publish(pose, viewport, 1, [], 1); document.defaultView.advance(350); - assert.equal(picking.pick(0, 15), label); - assert.equal(screenPicking(host as unknown as HTMLElement).pick(0, 15), null); - assert.ok(Math.abs(Number(label.style.opacity) - (.325)) < 10 ** -2 / 2, `${Number(label.style.opacity)} is not close to ${.325}`); - const firstTransform = label.style.transform; - const shifted = { ...pose, pose: { ...pose.pose, positionM: [pose.pose.positionM[0] + object.aperture.comovingRadiusM, ...pose.pose.positionM.slice(1)] as [number,number,number] } }; - const blockers = runtime.publish(shifted, viewport, 1, [], 1); - assert.notEqual(label.style.transform, firstTransform); assert.ok(blockers.length > 0); - runtime.publish(shifted, viewport, 1, [{ left: -400, right: 400, top: -300, bottom: 300 }], 1); - assert.equal(label.style.pointerEvents, 'none'); - assert.notEqual(picking.pick(-150, 15), label); - label.dispatchEvent(new Event('click')); assert.equal(onSelect.mock.callCount(), 0); - document.defaultView.advance(400); assert.ok(Number(label.style.opacity) > 0); assert.ok(Number(label.style.opacity) < .325); - runtime.publish(shifted, viewport, 1, [], 1); document.defaultView.advance(600); - assert.equal(Number(label.style.opacity), .65); label.dispatchEvent(new Event('click')); - assert.ok(onSelect.mock.calls.some(call => isDeepStrictEqual(call.arguments, [object]))); assert.partialDeepStrictEqual(runtime.resolve(object.id), {kind: 'galaxy-cluster'}); - assert.equal(document.count, nodes); - runtime.publish({ ...pose, pose: { ...pose.pose, positionM: [object.positionM[0], object.positionM[1], object.positionM[2] - object.aperture.comovingRadiusM * 4] } }, viewport, 1, [], 1); - document.defaultView.advance(800); assert.equal(Number(label.style.opacity), 0); - assert.notEqual(picking.pick(0, 15), label); - runtime.publish(pose, viewport, 1, [], 1); - assert.equal(picking.pick(0, 15), label); - runtime.destroy(); assert.equal(document.defaultView.frames.size, 0); assert.deepEqual(host.children, [before]); - assert.equal(picking.pick(0, 15), null); -}); - -test('only labels that show or are still fading out follow the camera', () => { - const payload = read('local-group/prepared/catalogue.json'), clusters = read('galaxy-clusters/prepared/catalogue.json'); - const document = new Document(), host = document.createElement(), before = document.createElement(); host.append(before); - const runtime = mountPreparedGalaxyCatalog({ host: host as unknown as HTMLElement, before: before as unknown as HTMLElement, payload, clusters }); - const viewport = { focalPixels: 600, principalOffsetPixels: [0,0] as const, widthPixels: 800, heightPixels: 600 }; - const observer = (x: number) => ({ referenceFrame: payload.frame.referenceFrame, epochJdTt: payload.frame.epochJdTt, - pose: { positionM: [x, 0, 1e23] as const, orientationXyzw: [0,0,0,1] as const } }); - const root = runtime.root as unknown as Element, labels = Object.values(runtime.inspect().labels) as unknown as Element[]; - const ids = (list: Element[]) => list.map(label => label.dataset.galaxyLabel); - const transforms = () => labels.map(label => label.style.transform); - runtime.publish(observer(0), viewport, 1); document.defaultView.advance(300); - const shown = labels.filter(label => Number(label.style.opacity) > 0); - assert.equal(shown.length, Number(root.dataset.visibleLabels)); - // A cluster is named without a marker; every other shown label has one. - const clusterIds = new Set(clusters.objects.map((object: { id: string }) => object.id)); - assert.ok(root.children.filter(node => node.dataset.galaxyMarker && node.style.transform).length >= shown.filter(label => !clusterIds.has(label.dataset.galaxyLabel)).length); - assert.deepEqual(ids(labels.filter(label => label.style.transform)), ids(shown)); - // Covering the screen hides every label. Labels still fading out keep following their galaxies. - const cover = [{ left: -400, right: 400, top: -300, bottom: 300 }], previous = transforms(); - runtime.publish(observer(1e21), viewport, 1, cover); - const fading = transforms(); - assert.deepEqual(ids(labels.filter((_, index) => fading[index] !== previous[index])), ids(shown)); - document.defaultView.advance(900); - runtime.publish(observer(2e21), viewport, 1, cover); - assert.deepEqual(transforms(), fading); - runtime.destroy(); -}); + return { payload, document, runtime, world, viewport, root: runtime.root as unknown as Element }; +}; -test('catalogue-only rows remain dots without marker captions while saved catalogue links still resolve', () => { - const payload = read('local-group/prepared/catalogue.json'); - const unsupported = payload.objects.find((row: {membership:{group:string};detailedObjectId?:string}) => row.membership.group === 'local-group' && !row.detailedObjectId); - const supported = payload.objects.find((row: {detailedObjectId?:string}) => row.detailedObjectId); - assert.ok(unsupported); assert.ok(supported); - const document = new Document(), host = document.createElement(), before = document.createElement(); host.append(before); - const runtime = mountPreparedGalaxyCatalog({host:host as unknown as HTMLElement, before:before as unknown as HTMLElement, - payload, renderedObjectIds:new Set([supported.detailedObjectId])}); - assert.deepEqual(runtime.resolve(unsupported.id), unsupported); - assert.notEqual(runtime.inspect().labels[unsupported.id], undefined); - assert.deepEqual(runtime.resolve(supported.id), supported); - assert.deepEqual(runtime.resolve(supported.detailedObjectId), supported); - assert.equal(runtime.inspect().count, payload.objects.filter((row: {membership:{group:string};detailedObjectId?:string}) => row.membership.group === 'local-group' || row.detailedObjectId).length); - const viewport = { focalPixels: 600, principalOffsetPixels: [0,0] as const, widthPixels: 800, heightPixels: 600 }; - const world = { ...payload.frame, pose: { positionM: [0,0,1e24] as const, orientationXyzw: [0,0,0,1] as const } }; +test('the catalogue draws one dot for each Local Group row without a package, and nothing else', () => { + const { payload, document, runtime, world, viewport, root } = mount(); + const rows: Row[] = payload.objects, packaged = rows.filter(row => row.detailedObjectId); + assert.ok(packaged.length > 0); + assert.equal(runtime.inspect().count, rows.filter(row => row.name && !row.detailedObjectId && row.membership.group === 'local-group').length); + // A row with a package is a body of the world context: the catalogue draws no dot, marker or caption for it. + for (const row of packaged) assert.equal(root.children.some(node => node.dataset.galaxyDot === row.id), false); + assert.equal(root.children.every(node => node.dataset.galaxyDot !== undefined), true); + for (const id of ['hydra_1', 'leo_a', 'sagittarius_1']) assert.equal(root.children.some(node => node.dataset.galaxyDot === id), true); runtime.publish(world, viewport, 1); document.defaultView.advance(300); - const root = runtime.root as unknown as Element; - const dots = root.children.filter(node => node.dataset.galaxyDot && Number(node.style.opacity) > 0); + const dots = root.children.filter(node => Number(node.style.opacity) > 0); assert.ok(dots.length > 10); - assert.ok(Number(root.dataset.visibleLabels) <= 12); - const unsupportedLabel = runtime.inspect().labels[unsupported.id]!; - const unsupportedMarker = root.children.find(node => node.dataset.galaxyMarker === unsupported.id); - assert.equal(unsupportedLabel.style.pointerEvents, 'none'); - assert.equal(Number(unsupportedLabel.style.opacity), 0); - assert.equal(root.children.some(node => node.dataset.galaxyLabel === unsupported.id), false); - assert.equal(unsupportedMarker, undefined); - for (const id of ['hydra_1', 'leo_a', 'sagittarius_1']) { - const row = payload.objects.find((object: {id: string}) => object.id === id); - assert.equal(row?.detailedObjectId, undefined); - assert.equal(root.children.some(node => node.dataset.galaxyDot === id), true); - assert.equal(root.children.some(node => node.dataset.galaxyLabel === id || node.dataset.galaxyMarker === id), false); - } - assert.equal(root.children.some(node => node.dataset.galaxyLabel === supported.id), true); + assert.equal(dots.every(dot => Number(dot.style.opacity) <= .4), true); runtime.publish(world, viewport, 0); document.defaultView.advance(600); assert.equal(dots.every(dot => Number(dot.style.opacity) === 0), true); runtime.destroy(); + assert.equal(root.parent, null); }); -test('baked sparse sample shows dots before names without enabling unsupported navigation', () => { - const payload = read('local-group/prepared/catalogue.json'); +test('the baked display sample limits the dots to its rows', () => { const galaxySample = read('local-group/prepared/display-sample.json'); - const document = new Document(), host = document.createElement(), before = document.createElement(); host.append(before); - const onSelect = mock.fn(() => {}); - const runtime = mountPreparedGalaxyCatalog({ host: host as unknown as HTMLElement, before: before as unknown as HTMLElement, - payload, galaxySample, onSelect }); + const { payload, runtime } = mount({ galaxySample }); + const rows: Row[] = payload.objects; assert.equal(galaxySample.ids.length, 48); - // The sample, and every galaxy with an object of its own. - assert.equal(runtime.inspect().count, 48 + payload.objects.filter((row: { detailedObjectId?: string }) => row.detailedObjectId).length); - const world = { ...payload.frame, pose: { positionM: [0,0,1e24] as const, orientationXyzw: [0,0,0,1] as const } }; - const viewport = { focalPixels: 600, principalOffsetPixels: [0,0] as const, widthPixels: 800, heightPixels: 600 }; - runtime.publish(world, viewport, 0, [], 0, 1); document.defaultView.advance(300); - const root = runtime.root as unknown as Element; - assert.ok(root.children.filter(node => node.dataset.galaxyDot && Number(node.style.opacity) > 0).length > 20); - assert.equal(Number(root.dataset.visibleLabels), 0); - runtime.publish(world, viewport, 1, [], 0, 1); document.defaultView.advance(600); - assert.ok(Number(root.dataset.visibleLabels) > 0); - assert.ok(Number(root.dataset.visibleLabels) <= 12); - for (const id of galaxySample.ids) { - assert.equal(runtime.resolve(id)?.id, id); - assert.equal(runtime.inspect().labels[id]!.dataset.objectNavigate, undefined); - // Without data-object-navigate the label keeps the stylesheet's default cursor (world-context.css), never an inline one. - assert.equal(runtime.inspect().labels[id]!.style.cursor, undefined); - runtime.inspect().labels[id]!.dispatchEvent(new Event('click')); - } - assert.equal(onSelect.mock.callCount(), 0); runtime.destroy(); - const css = readFileSync(new URL('../styles/world-context.css', import.meta.url), 'utf8'); - assert.match(css, /\.prepared-galaxy-catalog \[data-galaxy-label\] \{\s*cursor: default;\s*\}/u); - assert.match(css, /\.prepared-galaxy-catalog \[data-galaxy-label\]\[data-object-navigate\] \{\s*cursor: pointer;\s*\}/u); -}); - -test('a focused catalogue-only row outside the display sample does not fabricate a marker and caption', () => { - const payload = read('local-group/prepared/catalogue.json'); - const galaxySample = read('local-group/prepared/display-sample.json'); - const object = payload.objects.find((row: { id: string }) => row.id === 'draco_2'); - assert.ok(object); assert.ok(!galaxySample.ids.includes('draco_2')); - const document = new Document(), host = document.createElement(), before = document.createElement(); host.append(before); - const runtime = mountPreparedGalaxyCatalog({ host: host as unknown as HTMLElement, before: before as unknown as HTMLElement, payload, galaxySample, onSelect() {} }); - const captions = () => { const found: Element[] = []; const walk = (node: Element) => { if (node.textContent === 'Draco II') found.push(node); node.children.forEach(walk); }; walk(host); return found; }; - const viewport = { focalPixels: 600, principalOffsetPixels: [0, 0] as const, widthPixels: 800, heightPixels: 600 }; - const world = { ...payload.frame, pose: { positionM: [object.positionM[0], object.positionM[1], object.positionM[2] + 3.5e19] as const, orientationXyzw: [0, 0, 0, 1] as const } }; - const shown = () => captions().some(label => Number(label.style.opacity) > 0); - runtime.publish(world, viewport, 1); document.defaultView.advance(400); - assert.equal(shown(), false); - runtime.select('draco_2'); - runtime.publish(world, viewport, 1); document.defaultView.advance(800); - assert.equal(shown(), false); - runtime.select(null); - runtime.publish(world, viewport, 1); document.defaultView.advance(900); document.defaultView.advance(1400); - assert.equal(shown(), false); + assert.equal(runtime.inspect().count, galaxySample.ids.filter((id: string) => !rows.find(row => row.id === id)!.detailedObjectId).length); runtime.destroy(); + assert.throws(() => mount({ galaxySample: { schema: 'cssearth-galaxy-display-sample@1', ids: ['not-a-row'] } }), /Invalid baked galaxy sample/u); }); -test('a galaxy with a published stellar extent hangs its caption under its drawn sphere and hides it inside the extent', () => { - const payload = read('local-group/prepared/catalogue.json'), lmc = payload.objects.find((row: { id: string }) => row.id === 'lmc'); - const document = new Document(), host = document.createElement(), before = document.createElement(); host.append(before); - const kpc = 3.0856775814913673e19; - const frame = { referenceFrame: payload.frame.referenceFrame, epochJdTt: payload.frame.epochJdTt, originM: lmc.positionM, - localToReferenceXyzw: [0, 0, 0, 1] as const, metersPerUnit: kpc, boundsUnits: { min: [-8, -8, -8] as const, max: [8, 8, 8] as const } }; - const runtime = mountPreparedGalaxyCatalog({ host: host as unknown as HTMLElement, before: before as unknown as HTMLElement, payload, - renderedObjectIds: new Set(['lmc']), billboardedObjectIds: new Set(['lmc']), - galaxyCaptions: new Map([['lmc', { frame, drawnRadiusUnits: 5, extentRadiusUnits: 18.5 }]]) }); - const viewport = { focalPixels: 600, principalOffsetPixels: [0, 0] as const, widthPixels: 1600, heightPixels: 1200 }; - const camera = (kpcAway: number) => ({ ...payload.frame, pose: { positionM: [lmc.positionM[0], lmc.positionM[1], lmc.positionM[2] + kpcAway * kpc] as const, orientationXyzw: [0, 0, 0, 1] as const } }); - const label = runtime.inspect().labels.lmc!; - // 5 kpc seen from 200 kpc at a 600 px focal length drops 15 px; the caption's bottom is 8 px gap and 14 px of text below. - runtime.publish(camera(200), viewport, 1); - document.defaultView.advance(250); - assert.equal(label.style.transform, 'translate(0px,37px) translate(-50%,-100%)'); - assert.ok(Number(label.style.opacity) > 0); - // Inside the 18.5 kpc extent the caption hides. - runtime.publish(camera(10), viewport, 1); - document.defaultView.advance(500); - assert.equal(Number(label.style.opacity), 0); - runtime.destroy(); -}); -test('a header pill category shows its labels at full strength and fades the other classes to a third', () => { - const payload = { ...read('local-group/prepared/catalogue.json'), objects: [] }, clusters = read('galaxy-clusters/prepared/catalogue.json'); - const nebulae = read('m42/source/nebula.json'); - const document = new Document(), host = document.createElement(), before = document.createElement(); host.append(before); - const runtime = mountPreparedGalaxyCatalog({ host: host as unknown as HTMLElement, before: before as unknown as HTMLElement, payload, clusters, nebulae }); - const object = clusters.objects[0], label = runtime.inspect().labels[object.id]!, root = runtime.root as unknown as Element; - const pose = { referenceFrame: clusters.frame.referenceFrame, epochJdTt: clusters.frame.epochJdTt, - pose: { positionM: [object.positionM[0], object.positionM[1], object.positionM[2] + object.aperture.comovingRadiusM * 4] as const, - orientationXyzw: [0, 0, 0, 1] as const } }; - const viewport = { focalPixels: 600, principalOffsetPixels: [0, 0] as const, widthPixels: 800, heightPixels: 600 }; - const settle = (time: number) => { runtime.publish(pose, viewport, 1, [], 1); document.defaultView.advance(time); return Number(label.style.opacity); }; - assert.equal(settle(400), .65); - runtime.highlight('galaxy-cluster'); - assert.equal(root.dataset.highlighted, String(clusters.objects.length)); - assert.equal(settle(800), 1); - // A category this layer does not draw leaves it as it was; the body markers carry that highlight. - runtime.highlight('galaxy'); - assert.equal(root.dataset.highlighted, '0'); - assert.equal(settle(1200), .65); - runtime.highlight('nebula'); - assert.equal(root.dataset.highlighted, String(nebulae.objects.length)); - assert.ok(Math.abs(settle(1600) - (.65 * .3)) < 10 ** -2 / 2, `${settle(1600)} is not close to ${.65 * .3}`); - runtime.highlight(null); - assert.equal(settle(2000), .65); +test('a camera in another frame is refused', () => { + const { runtime, world, viewport } = mount(); + assert.throws(() => runtime.publish({ ...world, epochJdTt: world.epochJdTt + 1 }, viewport, 1), /share a reference frame and epoch/u); runtime.destroy(); }); diff --git a/packages/renderer/src/universe/prepared-galaxy-catalog.ts b/packages/renderer/src/universe/prepared-galaxy-catalog.ts index 2e89d838f2a..2af3c276c4c 100644 --- a/packages/renderer/src/universe/prepared-galaxy-catalog.ts +++ b/packages/renderer/src/universe/prepared-galaxy-catalog.ts @@ -1,60 +1,21 @@ -import { mountCatalogMarker } from './catalog-marker.js'; -import { catalogueClassification, isPreparedCluster, isPreparedNebula, parsePreparedGalaxyCatalog, parsePreparedClusterCatalog, parsePreparedNebulaCatalog } from '@cssearth/catalog'; -import { matchesObjectClassification } from '@cssearth/objects'; +import { parsePreparedGalaxyCatalog, parsePreparedClusterCatalog, parsePreparedNebulaCatalog } from '@cssearth/catalog'; import type { PreparedCatalogObject } from '@cssearth/catalog'; -import type { DensityVolumeFrame } from '@cssearth/objects'; import type { WorldCameraPose, WorldCameraViewport } from '../navigation/world-camera.js'; -import type { LabelScreenRect } from '../labels/screen-label-layout.js'; import { createOpacityFader } from '../stars/opacity-fader.js'; -import { admitGalaxyLabels, catalogVolumeCorners, projectCatalogBounds, projectCatalogPosition } from './galaxy-catalog-layout.js'; -import type { ProjectedGalaxy } from './galaxy-catalog-layout.js'; -import { screenPicking } from '../navigation/screen-picking.js'; -import { DEFAULT_CONTEXT_LABEL_OPACITY } from '../labels/label-presentation.js'; -import type { ScreenPickTarget } from '../navigation/screen-picking.js'; -import { createLabelBudget, labelEligible, type LabelBudget } from '../labels/universe-label-policy.js'; +import { projectCatalogPosition } from './galaxy-catalog-layout.js'; -import { projectGalaxyCaptionAnchor } from './galaxy-caption-anchor.js'; -interface Entry { - object: PreparedCatalogObject; - readonly marker: HTMLElement; - readonly dot: HTMLElement | null; - readonly navigable: boolean; - readonly label: HTMLElement; - readonly activate: (event: Event) => void; - readonly cornersM: readonly (readonly number[])[] | null; - /** The object's drawn sphere and published stellar extent: its caption hangs under the one, hides inside the other. */ - readonly caption: GalaxyCaptionSource | null; - placement: number; shown: boolean; - width: number; - height: number; - /** This frame's projected label anchor; written to the label only while it shows or fades. */ - labelX: number; - labelY: number; - /** Last published label interactivity; null until the first publication. */ - interactive: boolean | null; - /** The marker's and dot's last written transform, so an unmoved marker writes nothing. */ - transform: string; -} - -export interface GalaxyCaptionSource { readonly frame: DensityVolumeFrame; readonly drawnRadiusUnits: number; readonly extentRadiusUnits: number } +interface Dot { readonly object: PreparedCatalogObject; readonly element: HTMLElement; transform: string } -/** One fixed catalogue bank, shared by every detailed scene and every camera focus. */ -export function mountPreparedGalaxyCatalog({ host, before, payload, clusters, galaxySample, nebulae, nebulaFrames, renderedObjectIds, billboardedObjectIds, galaxyCaptions, onSelect = () => {}, pickingHost = host }: { - host: HTMLElement; before: Element; payload: unknown; clusters?: unknown; nebulae?: unknown; onSelect?: (object: PreparedCatalogObject) => void; pickingHost?: HTMLElement; - nebulaFrames?: ReadonlyMap; - renderedObjectIds?: ReadonlySet; - /** Objects whose own image marks them from afar: their caption stands without a ring over that image. */ - billboardedObjectIds?: ReadonlySet; - /** Objects with a published stellar extent, by detailed object id: their caption hangs under their drawn sphere and - * hides while the camera is inside the extent. */ - galaxyCaptions?: ReadonlyMap; - galaxySample?: unknown; +/** The spatial catalogues as the universe draws them: one unobtrusive dot for each sampled Local Group galaxy that has no + * package of its own. A catalogue is data: its rows are never named, marked, picked or selected here. A galaxy, cluster or + * nebula with a package is a body of the world context (prepared-world-context.ts), drawn and named like any other. */ +export function mountPreparedGalaxyCatalog({ host, before, payload, clusters, galaxySample, nebulae }: { + host: HTMLElement; before: Element; payload: unknown; clusters?: unknown; nebulae?: unknown; galaxySample?: unknown; }) { const catalog = parsePreparedGalaxyCatalog(payload), document = host.ownerDocument; const clusterCatalog = clusters === undefined ? null : parsePreparedClusterCatalog(clusters); - if (clusterCatalog && (clusterCatalog.frame.referenceFrame !== catalog.frame.referenceFrame || clusterCatalog.frame.epochJdTt !== catalog.frame.epochJdTt)) throw new TypeError('Prepared catalogues must share one frame and epoch.'); const nebulaCatalog = nebulae === undefined ? null : parsePreparedNebulaCatalog(nebulae); - if (nebulaCatalog && (nebulaCatalog.frame.referenceFrame !== catalog.frame.referenceFrame || nebulaCatalog.frame.epochJdTt !== catalog.frame.epochJdTt)) throw new TypeError('Prepared catalogues must share one frame and epoch.'); + for (const other of [clusterCatalog, nebulaCatalog]) if (other && (other.frame.referenceFrame !== catalog.frame.referenceFrame || other.frame.epochJdTt !== catalog.frame.epochJdTt)) throw new TypeError('Prepared catalogues must share one frame and epoch.'); let sampleIds: Set | undefined; if (galaxySample !== undefined) { if (!galaxySample || typeof galaxySample !== 'object' || !('schema' in galaxySample) || galaxySample.schema !== 'cssearth-galaxy-display-sample@1' || @@ -62,207 +23,45 @@ export function mountPreparedGalaxyCatalog({ host, before, payload, clusters, ga !galaxySample.ids.every(id => typeof id === 'string' && catalog.objects.some(row => row.id === id && row.membership.group === 'local-group'))) throw new TypeError('Invalid baked galaxy sample.'); sampleIds = new Set(galaxySample.ids); } - // Every galaxy with an object of its own is named (M81, NGC 253, M87 are not Local Group members); of the rest, the Local - // Group's sampled members. - const objects: readonly PreparedCatalogObject[] = [...catalog.objects.filter(object => object.detailedObjectId || object.membership.group === 'local-group' && (!sampleIds || sampleIds.has(object.id))), ...clusterCatalog?.objects ?? [], ...nebulaCatalog?.objects ?? []].filter(object => !isPreparedNebula(object) || - Boolean(object.detailedObjectId && (!renderedObjectIds || renderedObjectIds.has(object.detailedObjectId)))); - if (new Set(objects.map(object => object.id)).size !== objects.length) throw new TypeError('Prepared catalogue focus identifiers must be unique.'); const root = document.createElement('div'); root.className = 'prepared-galaxy-catalog'; - // Zero-size root at the stage centre, children placed from it (world-context.css): a full-screen box above the globe - // became a full-screen layer. Dots and labels keep their fixed look there too; only their place, size, fade - // and pointer state are inline. + // Zero-size root at the stage centre, children placed from it (world-context.css). host.insertBefore(root, before); - const picking = screenPicking(pickingHost); const fader = createOpacityFader(document.defaultView!, undefined, { hideAtZero: true }); - // The source bank also preserves Local Volume nonmembers for future scopes. - // Membership is a prepared scientific fact, never a runtime distance cut. - const createEntry = (object: PreparedCatalogObject): Entry => { - const marker = document.createElement('span'), label = document.createElement('span'); - const navigable = isPreparedCluster(object) || Boolean(object.detailedObjectId && (!renderedObjectIds || renderedObjectIds.has(object.detailedObjectId))); - const dot = !isPreparedCluster(object) && !isPreparedNebula(object) ? document.createElement('span') : null; - if (dot) { - dot.dataset.galaxyDot = object.id; - dot.style.cssText = 'opacity:0;visibility:hidden'; - root.append(dot); - } - marker.dataset.galaxyMarker = object.id; - mountCatalogMarker(marker, object); - label.dataset.galaxyLabel = object.id; - label.className = 'prepared-context-label'; - if (navigable) label.dataset.objectNavigate = object.id; - label.dataset.objectNavigateActivation = 'click'; - label.textContent = object.name; - label.title = isPreparedCluster(object) ? `${object.name} — MCXC-II centre` : object.status === 'candidate' ? `${object.name} — candidate galaxy` : object.name; - label.style.cssText = 'opacity:0;visibility:hidden'; - const activate = (event: Event) => { - if (label.style.pointerEvents !== 'auto') return; - event.preventDefault(); event.stopPropagation(); onSelect(entry.object); - }; - label.addEventListener(label.dataset.objectNavigateActivation, activate); - label.addEventListener('keydown', event => { if (event.key === 'Enter') activate(event); }); - // A navigable label's pointer cursor follows its data-object-navigate attribute (world-context.css). - if (navigable) label.setAttribute('role', 'button'); - label.tabIndex = -1; - // Catalogue-only rows have no prepared scene. Keep their dots, but do not - // mount inert marker/caption nodes that can resemble a destination. A cluster is its member galaxies' dots: its name - // alone marks it. - if (navigable) root.append(...isPreparedCluster(object) ? [label] : [marker, label]); - const frame = isPreparedNebula(object) ? nebulaFrames?.get(object.detailedObjectId ?? object.id) : undefined; - const caption = !isPreparedCluster(object) && object.detailedObjectId !== undefined ? galaxyCaptions?.get(object.detailedObjectId) ?? null : null; - const entry: Entry = { object, marker, dot, navigable, label, activate, cornersM: frame && !caption ? catalogVolumeCorners(frame) : null, caption, - placement: 0, shown: false, width: 0, height: 0, labelX: 0, labelY: 0, interactive: null, transform: '' }; - return entry; - }; - const entries: Entry[] = objects.filter(object => labelEligible({ named: Boolean(object.name), notable: isPreparedCluster(object) || Boolean(object.detailedObjectId) })).map(createEntry); - // Every catalogue row is a focus destination, but only the display sample is - // drawn as context. One retained entry carries the focused row that has no - // entry of its own, so the destination always shows its marker and caption. - const unsampled = catalog.objects.filter(object => object.name && !object.detailedObjectId && !entries.some(entry => entry.object.id === object.id)); - const focusEntry = unsampled[0] ? createEntry(unsampled[0]) : null; - let focusBound = false; - let destroyed = false, selectedId: string | null = null; - // A header pill's category: its entries rank first in label admission. - let highlighted: ReadonlySet = new Set(); - let exclusions: readonly LabelScreenRect[] = []; - let dormant = false; - // Labels are measured when the catalogue first wakes, not at mount: reading - // their sizes then forced a layout of the whole starting page for labels that - // only appear at intergalactic range. Font changes mark the sizes stale. - let measured = false; - const measure = () => { measured = false; }; - document.fonts?.addEventListener('loadingdone', measure); + // Membership is a prepared scientific fact, never a runtime distance cut. A row with a package is drawn by the world context. + const dots: Dot[] = catalog.objects.filter(object => object.name && !object.detailedObjectId && object.membership.group === 'local-group' && (!sampleIds || sampleIds.has(object.id))).map(object => { + const element = document.createElement('span'); + element.dataset.galaxyDot = object.id; + element.style.cssText = 'opacity:0;visibility:hidden'; + root.append(element); + return { object, element, transform: '' }; + }); + let destroyed = false, dormant = false; return Object.freeze({ root, catalog, - select(id: string | null) { - selectedId = entries.find(entry => entry.object.id === id || (!isPreparedCluster(entry.object) && entry.object.detailedObjectId === id))?.object.id ?? id; - if (!focusEntry) return; - const row = unsampled.find(object => object.id === selectedId); - if (row && row !== focusEntry.object) { - focusEntry.object = row; focusEntry.label.textContent = row.name; - focusEntry.label.title = row.status === 'candidate' ? `${row.name} — candidate galaxy` : row.name; - focusEntry.shown = false; focusEntry.placement = 0; focusEntry.width = 0; - } - if (!row && focusBound) for (const element of [focusEntry.label, focusEntry.marker, focusEntry.dot!]) fader.set(element, 0, 200); - focusBound = Boolean(row); - }, - highlight(classification: string | null) { - highlighted = new Set(classification === null ? [] : entries.filter(entry => - matchesObjectClassification(catalogueClassification(entry.object), classification)).map(entry => entry.object.id)); - root.dataset.highlighted = String(highlighted.size); - }, - resolve(id: string) { return [...catalog.objects, ...clusterCatalog?.objects ?? [], ...nebulaCatalog?.objects ?? []].find(object => object.id === id || (!isPreparedCluster(object) && object.detailedObjectId === id)) ?? null; }, - publish(world: WorldCameraPose, viewport: WorldCameraViewport, opacity: number, blockerRects: readonly LabelScreenRect[] = [], clusterOpacity = opacity, dotOpacity = opacity, - budget?: LabelBudget, nebulaOpacity = opacity) { - if (destroyed) return exclusions; + publish(world: WorldCameraPose, viewport: WorldCameraViewport, opacity: number) { + if (destroyed) return; if (world.referenceFrame !== catalog.frame.referenceFrame || world.epochJdTt !== catalog.frame.epochJdTt) { throw new TypeError('Galaxy catalogue and observer must share a reference frame and epoch.'); } + const alpha = Math.max(0, Math.min(1, opacity)) * .4; + // The dots sleep with their fade. + if (alpha === 0) { if (dormant) return; dormant = true; } else dormant = false; const width = viewport.widthPixels ?? host.clientWidth, height = viewport.heightPixels ?? host.clientHeight; - const candidates: ProjectedGalaxy[] = []; - const visible = new Set(); - const alpha = Math.max(0, Math.min(1, opacity)); - const clusterAlpha = Math.max(0, Math.min(1, clusterOpacity)); - const objectOpacity = (object: PreparedCatalogObject) => - isPreparedCluster(object) ? clusterAlpha : isPreparedNebula(object) ? nebulaOpacity : alpha; - // Only the extragalactic populations sleep with their fades. Nearby - // nebulae keep following the camera and remain available as fly-to targets. - if (alpha === 0 && dotOpacity === 0 && clusterAlpha === 0 && !nebulaCatalog?.objects.length) { - if (dormant) return exclusions; - dormant = true; - } else dormant = false; - if (!measured && !dormant) { - for (const entry of entries) { entry.width = entry.label.offsetWidth; entry.height = entry.label.offsetHeight; } - measured = true; - } - const drawn = focusBound && focusEntry ? [...entries, focusEntry] : entries; - if (focusBound && focusEntry && !focusEntry.width) { focusEntry.width = focusEntry.label.offsetWidth; focusEntry.height = focusEntry.label.offsetHeight; } - for (const entry of drawn) { - const point = projectCatalogPosition(entry.object.positionM, world, viewport); - if (!point || !(width > 0 && height > 0)) continue; - const anchor = entry.caption ? projectGalaxyCaptionAnchor(world, viewport, entry.caption.frame, entry.caption.drawnRadiusUnits, entry.caption.extentRadiusUnits) : null; - // Inside its published extent the camera is within the galaxy: no caption names it from outside. - if (entry.caption && !anchor) continue; - const reach = anchor ? anchor.y - anchor.centreY : 4, centre = anchor ? { x: anchor.x, y: anchor.centreY } : point; - if (Math.abs(centre.x) > width / 2 + reach || Math.abs(centre.y) > height / 2 + reach) continue; - visible.add(entry.object.id); - // Written to both from one string, never read back from the style (a read serialises it). - const transform = `translate(${point.x}px,${point.y}px) translate(-50%,-50%)`; - if (transform !== entry.transform) { - entry.marker.style.transform = transform; - if (entry.dot) entry.dot.style.transform = transform; - entry.transform = transform; - } - const bounds = anchor ? { left: anchor.x - reach, right: anchor.x + reach, top: anchor.centreY - reach, bottom: anchor.y } - : entry.cornersM ? projectCatalogBounds(entry.cornersM, world, viewport) : null; - if (entry.cornersM && !bounds) continue; - const x = bounds ? (bounds.left + bounds.right) / 2 : point.x; - const y = (bounds?.bottom ?? point.y) + 8 + entry.height; - const labelRect = { left: x - entry.width / 2, right: x + entry.width / 2, - top: y - entry.height, bottom: y }; - const objectAlpha = objectOpacity(entry.object); - { - const above = (bounds?.top ?? point.y) - 8; - const alternateLabelRects = entry.navigable ? [ - { left: labelRect.left, right: labelRect.right, top: above - entry.height, bottom: above }, - { left: (bounds?.right ?? point.x) + 12, right: (bounds?.right ?? point.x) + 12 + entry.width, - top: point.y - entry.height / 2, bottom: point.y + entry.height / 2 }, - { left: (bounds?.left ?? point.x) - 12 - entry.width, right: (bounds?.left ?? point.x) - 12, - top: point.y - entry.height / 2, bottom: point.y + entry.height / 2 }, - ] : []; - const previousRect = [labelRect, ...alternateLabelRects][entry.placement] ?? labelRect; - // A rejected label keeps following the same side throughout its fade. - entry.labelX = (previousRect.left + previousRect.right) / 2; entry.labelY = previousRect.bottom; - // A marker and caption promise an available destination. Catalogue-only - // rows remain as unobtrusive dots, but never enter the annotation layout. - if (objectAlpha > 0 && entry.navigable) candidates.push({ object: entry.object, navigable: true, shown: entry.shown, placement: entry.placement, - ...point, labelRect, alternateLabelRects }); - } - } - const admitted = admitGalaxyLabels(candidates, blockerRects, selectedId, budget ?? createLabelBudget(width, height), highlighted); - // With a category highlighted its labels and markers show at full strength and the rest at a third, as the body markers do. - const emphasised = (entry: Entry, base: number) => highlighted.size === 0 ? base : highlighted.has(entry.object.id) ? 1 : base * .3; - const admittedById = new Map(admitted.map(entry => [entry.object.id, entry])); - const pickTargets: ScreenPickTarget[] = []; - for (const entry of drawn) { - const objectAlpha = objectOpacity(entry.object); - const projected = admittedById.get(entry.object.id); - entry.shown = Boolean(projected); - if (projected) { entry.placement = projected.placement ?? 0; entry.labelX = (projected.labelRect.left + projected.labelRect.right) / 2; entry.labelY = projected.labelRect.bottom; } - const labelOpacity = projected ? objectAlpha * emphasised(entry, DEFAULT_CONTEXT_LABEL_OPACITY) : 0; - fader.set(entry.label, labelOpacity, 200); - // Only a shown or still-fading label follows its galaxy. Moving every on-screen - // candidate restyled each hidden label on every camera frame. - if (visible.has(entry.object.id) && (projected || fader.current(entry.label) > 0)) { - entry.label.style.transform = `translate(${entry.labelX}px,${entry.labelY}px) translate(-50%,-100%)`; - } - const billboarded = !isPreparedCluster(entry.object) && entry.object.detailedObjectId !== undefined && billboardedObjectIds?.has(entry.object.detailedObjectId) === true; - fader.set(entry.marker, projected && !billboarded ? objectAlpha * emphasised(entry, .45) : 0, 200); - // Interactivity flips rarely; rewriting it for every galaxy each frame reflected three attributes. - if (entry.dot) fader.set(entry.dot, visible.has(entry.object.id) ? Math.max(0, Math.min(1, dotOpacity)) * (1 - (projected ? objectAlpha : 0)) * .4 : 0, 200); - const interactive = entry.navigable && labelOpacity > .1; - if (entry.interactive !== interactive) { - entry.label.style.pointerEvents = interactive ? 'auto' : 'none'; - entry.label.tabIndex = interactive ? 0 : -1; - entry.label.ariaHidden = interactive ? 'false' : 'true'; - entry.interactive = interactive; + for (const dot of dots) { + const point = width > 0 && height > 0 ? projectCatalogPosition(dot.object.positionM, world, viewport) : null; + const visible = point !== null && Math.abs(point.x) <= width / 2 + 4 && Math.abs(point.y) <= height / 2 + 4; + if (visible) { + // Never read back from the style (a read serialises it). + const transform = `translate(${point.x}px,${point.y}px) translate(-50%,-50%)`; + if (transform !== dot.transform) { dot.element.style.transform = transform; dot.transform = transform; } } - // Catalogue labels remain behind the focus point and detailed bodies. - if (projected && interactive) pickTargets.push({ element: entry.label, rank: -2, - shape: { kind: 'rect', ...projected.labelRect } }); + fader.set(dot.element, visible ? alpha : 0, 200); } - picking.publish(root, pickTargets); - exclusions = admitted.map(entry => entry.labelRect); - root.dataset.visibleLabels = String(admitted.length); - root.dataset.catalogueCount = String(entries.length); - return exclusions; + root.dataset.catalogueCount = String(dots.length); }, - inspect() { return { count: entries.length, clusterCount: clusterCatalog?.objects.length ?? 0, selectedId, - labels: Object.fromEntries(entries.map(entry => [entry.object.id, entry.label])) }; }, + inspect() { return { count: dots.length, clusterCount: clusterCatalog?.objects.length ?? 0 }; }, destroy() { if (destroyed) return; destroyed = true; fader.destroy(); - picking.remove(root); - document.fonts?.removeEventListener('loadingdone', measure); - for (const entry of entries) entry.label.removeEventListener(entry.label.dataset.objectNavigateActivation!, entry.activate); root.remove(); }, }); diff --git a/packages/renderer/src/universe/prepared-universe-runtime.ts b/packages/renderer/src/universe/prepared-universe-runtime.ts index 6bdb8e40bc4..7701ae9957e 100644 --- a/packages/renderer/src/universe/prepared-universe-runtime.ts +++ b/packages/renderer/src/universe/prepared-universe-runtime.ts @@ -16,9 +16,9 @@ import type { PreparedCssSurfaceShell } from '../shell/types.js'; import { mountPreparedCssSurfaceShell } from '../shell/prepared-shell-runtime.js'; import { mountEnvironmentLabels } from './environment-labels.js'; import { isPreparedCluster, type PreparedCatalogObject } from '@cssearth/catalog'; -import type { PreparedNavigationFocus } from '../navigation/prepared-focus.js'; import type { PreparedLabelEdge } from '../navigation/prepared-label-edge.js'; import { detailedFocusContextOpacity, selectedBodyContextOpacity } from './detailed-focus-context.js'; +import type { SelectedBank } from './detailed-focus-context.js'; import { DEFAULT_POINT_VISIBILITY } from '../volume/projected-volume-visibility.js'; import type { WorldContextFrame } from './world-context/world-context-frame.js'; import { createWorldContextPlannerClient } from './world-context/world-context-planner-client.js'; @@ -126,8 +126,8 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol for (const layer of layers) layer.addBodies(plan, nextSprites); for (const planner of planners) planner.extend(plan); }, - mount(stage: HTMLElement, { onSelectGalaxy, requestPublication, presentationHost = stage }: { - onSelectGalaxy?: (object: PreparedCatalogObject) => void; requestPublication?: () => boolean; + mount(stage: HTMLElement, { requestPublication, presentationHost = stage }: { + requestPublication?: () => boolean; /** Stationary world presentation, outside the selected detail's CSS scope. */ presentationHost?: HTMLElement; } = {}) { @@ -191,7 +191,7 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol const catalogBanks = createUniverseCatalogBanks({ root, end, stage, lifetime, declarations: declaredImageLayers, initialImages: initialImageLayers, volumeDeclarations: volumeDatasetBanks, pointBanks, - initialCatalog: catalog, catalogBank, loadCatalog, loadImageLayer, onSelect: onSelectGalaxy, requestPublication, billboards: datasetBillboards, stellarExtents, prepareBillboardAtlas }); + initialCatalog: catalog, catalogBank, loadCatalog, loadImageLayer, requestPublication, billboards: datasetBillboards, stellarExtents, prepareBillboardAtlas }); let labelBudget = createLabelBudget(0, 0); let labelBlockers: readonly LabelScreenRect[] = []; let overview = false; @@ -206,10 +206,12 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol let previewCaption: typeof selected | null = null; let selectedEdge: PreparedLabelEdge | undefined, previewEdge: PreparedLabelEdge | undefined; const caption = () => previewCaption ?? captionBody; - const captionFlags = () => ({ overview: selectionPreview ? false : overview, focused: detailedFocus !== null, preview: selectionPreview, edge: previewCaption ? previewEdge : selectedEdge }); - let detailedFocus: { objectId: string; focus: PreparedNavigationFocus } | null = null; - const pointBankIds = new Set(pointBanks.map(bank => bank.id)); - let selectedPoints: string | undefined; + const captionFlags = () => ({ overview: selectionPreview ? false : overview, preview: selectionPreview, edge: previewCaption ? previewEdge : selectedEdge }); + // The bank the mounted scene's dataset shows as its companion: its subject, drawn whole while it is shown. + let companion: string | null = null; + // The banks that are a scene's whole subject: a galaxy's image layers, and a volume that is not attached to a body. + const subjectBanks = new Set([...declaredImageLayers.map(bank => bank.id), ...volumeDatasetBanks.filter((_, index) => !datasetFacts[index]!.attached).map(bank => bank.id)]); + const bankOf = (id: string) => datasets.focusBank(id) ?? catalogBanks.focusBank(id); background.mount(); catalogBanks.loadInitialImages(); for (const shell of shells) shellLayers.push(own(mountPreparedCssSurfaceShell({ host: root, before: end, ...shell }))); @@ -241,31 +243,20 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol environmentLabels.addShell(shell.payload); requestPublication?.(); }, - selectGalaxy(id: string | null, focus: PreparedNavigationFocus | null = null) { - if (focus && (focus.id !== id || !Number.isFinite(focus.framingRadiusM) || focus.framingRadiusM <= 0 || - focus.positionM.length !== 3 || !focus.positionM.every(Number.isFinite))) { - throw new TypeError('Prepared context focus must match its selection and have finite authored framing.'); - } - const record = id ? catalogBanks.catalog?.resolve(id) : null; - const objectId = record && !isPreparedCluster(record) ? record.detailedObjectId : undefined; - const next = focus && objectId && [...declaredImageLayers, ...volumeDatasetBanks].some(bank => bank.id === objectId) - ? { objectId, focus } : null; - const changed = next?.objectId !== detailedFocus?.objectId || next?.focus.framingRadiusM !== detailedFocus?.focus.framingRadiusM || - next?.focus.positionM.some((value, axis) => value !== detailedFocus?.focus.positionM[axis]); - detailedFocus = next; - // A package of catalogue dots draws them for its selected row, without the detailed focus's context fade. - const points = record?.detailedObjectId !== undefined && pointBankIds.has(record.detailedObjectId) ? record.detailedObjectId : undefined; - const pointsChanged = points !== selectedPoints; - selectedPoints = points; - catalogBanks.catalog?.select(id); - if (pointsChanged) requestPublication?.(); - if (changed) requestPublication?.(); - }, - resolveGalaxy(id: string) { return catalogBanks.catalog?.resolve(id) ?? null; }, ensureGalaxyCatalog: catalogBanks.ensureCatalog, - focusBank(id: string) { return datasets.focusBank(id) ?? catalogBanks.focusBank(id); }, - setVolumeDatasetEnabled: datasets.setEnabled, - selectVolumeDataset: datasets.select, + focusBank: bankOf, + setVolumeDatasetEnabled(id: string, enabled: boolean) { + if (lifetime.disposed) return; + if (!bankOf(id)) throw new TypeError(`Unknown prepared bank: ${id}.`); + datasets.setEnabled(id, enabled); + if (enabled) companion = id; else if (companion === id) companion = null; + requestPublication?.(); + }, + selectVolumeDataset(id: string, dataset: string) { + const bank = bankOf(id); + if (!bank) throw new TypeError(`Unknown prepared bank: ${id}.`); + bank.selectDataset(dataset); + }, captureFrame(world: WorldCameraPose, viewport: WorldCameraViewport) { // The context's labels keep clear of the selected body's caption, placed for the same camera. const rect = selectedLabel.rect(world, viewport, caption(), captionFlags()); @@ -293,7 +284,6 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol setNavigationInFlight(active: boolean) { spatial.setNavigationInFlight(active); focusPoint?.setNavigationEnabled(!active); }, /** A header pill's category: the galaxy, cluster and nebula catalogue emphasises its members; the body markers take * theirs through `setBodyVisibility`. */ - setHighlightedClassification: catalogBanks.setHighlightedClassification, /** Label suppression follows the selection here; callers set the other flags. */ setBodyVisibility(next: Omit) { spatial.setBodyVisibility(next); }, setRotationActive(active: boolean) { spatial.setRotationActive(active); }, @@ -325,6 +315,12 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol if (lifetime.disposed) return; opacityClock.batch(() => { const distanceM = Math.hypot(...world.pose.positionM.map((value, axis) => value - plan.focus.positionM[axis])); + // A companion that is its scene's whole subject (a galaxy's layers, a nebula's volume) is framed as a body is, by + // the selected body's place and radius, and the context gives way to it. A volume attached to a body (a star's + // disc) and a bank of dots (a cluster's members) show beside what is there and dim nothing. Until the scene's + // own body is selected the companion has nothing to be framed by. + const detailedFocus: { objectId: string; focus: SelectedBank } | null = companion === null || !subjectBanks.has(companion) || selected === plan.focus ? null + : { objectId: companion, focus: { positionM: selected.positionM as SelectedBank['positionM'], framingRadiusM: selected.radiusM } }; const detailContextOpacity = detailedFocusContextOpacity(world, detailedFocus?.focus ?? null); if (detailContextOpacity > 0) background.prefetch(distanceM); const outsideGalaxy = galaxyOutsideFade( @@ -346,7 +342,7 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol const insideGalaxy = catalogBanks.imageBankContaining(selected.positionM); catalogBanks.publishImages(world, viewport, volumeOpacity, detailedFocus?.objectId, insideGalaxy === undefined || insideGalaxy === detailedFocus?.objectId ? undefined : { objectId: insideGalaxy, opacity: logarithmicFade(Math.hypot(...world.pose.positionM.map((value, axis) => value - selected.positionM[axis]!)), plan.stars.fadeStartDistanceM, plan.stars.fullDistanceM) }); - catalogBanks.publishPoints(world, viewport, selectedPoints, selectedSystem); + catalogBanks.publishPoints(world, viewport, companion ?? undefined, selectedSystem); datasets.publish(world, viewport, volumeOpacity, detailContextOpacity, detailedFocus?.objectId, selectedBodyContextOpacity(world, viewport, captionBody)); for (const [index, shell] of shellLayers.entries()) { @@ -363,13 +359,9 @@ export function createPreparedUniverse({ context, volume, pointAppearance, resol shellStats: shellLayers.map(shell => shell.stats()), blockerRects: foregroundRects, labelBudget }); const catalogPresentation = catalogBanks.presentation; if (catalogPresentation) { - const galaxyOpacity = localAnnotations * logarithmicFade(distanceM, catalogPresentation.fadeStartDistanceM, catalogPresentation.fullDistanceM); - // The clusters lie inside the cosmic microwave background: their names give way as it closes over them. - const clusterOpacity = (catalogPresentation.clusters ? logarithmicFade(distanceM, catalogPresentation.clusters.fadeStartDistanceM, catalogPresentation.clusters.fullDistanceM) : 0) * (1 - meshCover); const dotOpacity = (1 - logarithmicFade(distanceM, 30e6 * 3.085677581491367e16, 120e6 * 3.085677581491367e16)) * logarithmicFade(distanceM, catalogPresentation.fadeStartDistanceM, catalogPresentation.fullDistanceM); - const nebulaOpacity = (1 - fade) * logarithmicFade(distanceM, plan.stars.fadeStartDistanceM, plan.stars.fullDistanceM); - if (!catalogBanks.catalog && Math.max(galaxyOpacity, clusterOpacity, dotOpacity, nebulaOpacity) > 0) void catalogBanks.ensureCatalog().catch(() => {}); - catalogBanks.catalog?.publish(world, viewport, galaxyOpacity, [...foregroundRects, ...environmentRects], clusterOpacity, dotOpacity, labelBudget, nebulaOpacity); + if (!catalogBanks.catalog && dotOpacity > 0) void catalogBanks.ensureCatalog().catch(() => {}); + catalogBanks.catalog?.publish(world, viewport, dotOpacity); } const emphasizedId = selectionPreview === undefined ? (overview ? null : selected.id) : selectionPreview; focusPoint?.publish(world, viewport, { opacity: (1 - fade) * (emphasizedId !== null && emphasizedId !== plan.focus.id ? .75 : 1), selectedDetail: selected.id === plan.focus.id, diff --git a/packages/renderer/src/universe/selected-body-label.ts b/packages/renderer/src/universe/selected-body-label.ts index 7e695a2d791..d922a26a843 100644 --- a/packages/renderer/src/universe/selected-body-label.ts +++ b/packages/renderer/src/universe/selected-body-label.ts @@ -14,7 +14,7 @@ const VIEWPORT_EDGE_PX = 4; const HEADER_CLEARANCE_PX = 64; const FOOTER_CLEARANCE_PX = 30; -export interface SelectedLabelFlags { overview: boolean; focused: boolean; preview: string | null | undefined; edge?: PreparedLabelEdge } +export interface SelectedLabelFlags { overview: boolean; preview: string | null | undefined; edge?: PreparedLabelEdge } interface SelectedLabelPlacement { left: number; top: number; rect: LabelScreenRect } /** What a caption is placed from: a body's centre and radius, and where its package asks for the caption. */ @@ -23,8 +23,8 @@ export type CaptionedBody = Pick /** Where the caption of `body` sits for this camera, or null when it is hidden: below the body's disc or point, clear of the * header and footer, or over the body's middle when its package asks. */ export function placeSelectedBodyLabel(world: WorldCameraPose, viewport: WorldCameraViewport, body: CaptionedBody, - { overview, focused, preview, edge }: SelectedLabelFlags, width: number, height: number): SelectedLabelPlacement | null { - if (overview || focused || preview !== undefined && preview !== body.id) return null; + { overview, preview, edge }: SelectedLabelFlags, width: number, height: number): SelectedLabelPlacement | null { + if (overview || preview !== undefined && preview !== body.id) return null; const widthPixels = viewport.widthPixels, heightPixels = viewport.heightPixels; // The caption stays below the shell header where the viewport measures one, else below a fixed clearance. const headerClearance = viewport.coveredTopPixels ?? HEADER_CLEARANCE_PX; @@ -101,7 +101,7 @@ export function mountSelectedBodyLabel(host: HTMLElement, opacityClock: OpacityC }, publish(world: WorldCameraPose, viewport: WorldCameraViewport, body: PreparedContextPoint, flags: SelectedLabelFlags): LabelScreenRect | null { const hide = () => { fader.set(label, 0); return null; }; - if (flags.overview || flags.focused || flags.preview !== undefined && flags.preview !== body.id) return hide(); + if (flags.overview || flags.preview !== undefined && flags.preview !== body.id) return hide(); measure(body); if (measuredId !== body.id) return hide(); const placement = placeSelectedBodyLabel(world, viewport, body, flags, width, height); diff --git a/packages/renderer/src/universe/universe-catalog-banks.ts b/packages/renderer/src/universe/universe-catalog-banks.ts index 9bfc30b3ffb..83e9757c6a3 100644 --- a/packages/renderer/src/universe/universe-catalog-banks.ts +++ b/packages/renderer/src/universe/universe-catalog-banks.ts @@ -1,4 +1,4 @@ -import { createImageFocusBank } from './prepared-focus-bank.js'; +import { createImageFocusBank, createPointFocusBank } from './prepared-focus-bank.js'; import type { SceneLifetime } from '@cssearth/engine'; import type { PreparedCatalogObject } from '@cssearth/catalog'; import type { DensityVolumeFrame } from '@cssearth/objects'; @@ -29,7 +29,7 @@ interface ImageBank { /** Catalogue and image layers remain descriptor-only until visibility or navigation admits them. */ export function createUniverseCatalogBanks({ root, end, stage, lifetime, declarations, initialImages, volumeDeclarations, - initialCatalog, catalogBank, loadCatalog, loadImageLayer, onSelect, requestPublication, billboards: prepared, stellarExtents = {}, prepareBillboardAtlas, + initialCatalog, catalogBank, loadCatalog, loadImageLayer, requestPublication, billboards: prepared, stellarExtents = {}, prepareBillboardAtlas, pointBanks = [] }: { root: HTMLElement; end: Element; stage: HTMLElement; lifetime: SceneLifetime; declarations: readonly { id: string; frame: DensityVolumeFrame }[]; @@ -39,7 +39,6 @@ export function createUniverseCatalogBanks({ root, end, stage, lifetime, declara catalogBank: PreparedUniverseOptions['catalogBank']; loadCatalog: PreparedUniverseOptions['loadCatalog']; loadImageLayer: PreparedUniverseOptions['loadImageLayer']; - onSelect?: (object: PreparedCatalogObject) => void; requestPublication?: () => boolean; prepareBillboardAtlas: () => boolean; /** Published stellar extents in metres by object id (PreparedUniverseOptions.stellarExtents). */ @@ -50,8 +49,6 @@ export function createUniverseCatalogBanks({ root, end, stage, lifetime, declara pointBanks?: PreparedUniverseOptions['pointBanks']; }) { let catalog: ReturnType | null = null; - // The header pill's category, applied again when the catalogue mounts after it was pressed. - let highlightedClassification: string | null = null; let catalogPayload = initialCatalog, catalogLoading: Promise | null = null; let billboardCount = 0; const images: ImageBank[] = declarations.map(bank => { @@ -100,24 +97,9 @@ export function createUniverseCatalogBanks({ root, end, stage, lifetime, declara if (catalog || lifetime.disposed) return; globalThis.performance?.mark?.('cssEarth:catalog:mount'); catalog = mountPreparedGalaxyCatalog({ host: root, before: end, payload: bank.payload, galaxySample: bank.galaxySample, - clusters: bank.clusters?.payload, nebulae: bank.nebulae, - renderedObjectIds: new Set([...declarations.map(image => image.id), ...volumeDeclarations.map(dataset => dataset.id)]), - billboardedObjectIds: new Set([...prepared?.plan.banks.values() ?? []].filter(bank => bank.billboard).map(bank => bank.id)), - galaxyCaptions: galaxyCaptions(), - nebulaFrames: new Map(volumeDeclarations.map(dataset => [dataset.id, dataset.frame])), onSelect, pickingHost: stage }); - catalog.highlight(highlightedClassification); + clusters: bank.clusters?.payload, nebulae: bank.nebulae }); publishResidency(); } - /** Every bank with a published stellar extent: its caption hangs under its authored framing sphere (else its - * billboard's), and hides inside the extent. */ - function galaxyCaptions() { - return new Map([...declarations, ...volumeDeclarations].flatMap(bank => { - const radiusM = stellarExtents[bank.id], facts = prepared?.plan.banks.get(bank.id); - const extentRadiusUnits = radiusM === undefined ? 0 : radiusM / bank.frame.metersPerUnit; - const drawnRadiusUnits = facts?.framingRadiusUnits ?? facts?.billboard?.radiusUnits ?? extentRadiusUnits; - return radiusM === undefined ? [] : [[bank.id, { frame: bank.frame, drawnRadiusUnits, extentRadiusUnits }] as const]; - })); - } function ensureCatalog(): Promise { if (lifetime.disposed || catalog) return Promise.resolve(); if (catalogPayload) { mountCatalog(catalogPayload); return Promise.resolve(); } @@ -156,11 +138,6 @@ export function createUniverseCatalogBanks({ root, end, stage, lifetime, declara return { get catalog() { return catalog; }, - setHighlightedClassification(classification: string | null) { - highlightedClassification = classification; - catalog?.highlight(classification); - requestPublication?.(); - }, get presentation() { return catalogPayload ?? catalogBank; }, ensureCatalog, publishResidency, @@ -170,7 +147,9 @@ export function createUniverseCatalogBanks({ root, end, stage, lifetime, declara mountInitialCatalog() { if (catalogPayload) mountCatalog(catalogPayload); }, focusBank(id: string) { const bank = byId.get(id); - return bank ? createImageFocusBank(bank.id, bank.frame, () => ensureImage(bank)) : null; + if (bank) return createImageFocusBank(bank.id, bank.frame, () => ensureImage(bank)); + // A bank of dots has one dataset, its members; the dots mount when they are first shown. + return points.some(point => point.id === id) ? createPointFocusBank(id) : null; }, /** Draw the selected package's catalogue dots, and those of the selected body's system (`systemIds`: the body and the * centre it orbits), and hide every other's. */ @@ -196,9 +175,9 @@ export function createUniverseCatalogBanks({ root, end, stage, lifetime, declara inside?: { readonly objectId: string; readonly opacity: number }) { if (lifetime.disposed) return; for (const bank of images) { - // A galaxy's slices paint only for the observer who selected it. - const presentationOpacity = bank.id === detailedObjectId ? 1 : 0; - const opacity = presentationOpacity * volumeOpacity * projectedVolumeOpacity(world, viewport, bank.frame, bank.radiusUnits); + // A galaxy's slices paint only for the observer who selected it, at any distance from it: the context's distance + // fade is measured from the selected body, which is the galaxy itself. + const opacity = bank.id === detailedObjectId ? projectedVolumeOpacity(world, viewport, bank.frame, bank.radiusUnits) : 0; // Its billboard shows it from everywhere else, and gives way as the loaded slices fade in. if (billboards && bank.billboardIndex >= 0) { const context = bank.independent ? 1 : volumeOpacity; diff --git a/packages/renderer/src/universe/world-context/context-scale.ts b/packages/renderer/src/universe/world-context/context-scale.ts index a7c9c9d154d..d6092b9687a 100644 --- a/packages/renderer/src/universe/world-context/context-scale.ts +++ b/packages/renderer/src/universe/world-context/context-scale.ts @@ -33,6 +33,17 @@ export function logarithmicFade(distanceM: number, startM: number, endM: number) return t * t * (3 - 2 * t); } +const PARSEC_M = 3.085677581491367e16; +/** How far gone a galaxy or a cluster of galaxies is, 0 to 1, by the camera's distance from the focus; null for any other + * body. Each is named at its own scale (UI thresholds, the ones the catalogue drew them by): a galaxy from 1 to 10 kpc out, + * giving way over 12 to 40 Mpc; a cluster from 3 to 10 Mpc out. A nebula or a globular cluster is a body of the Milky Way + * and fades as its stars do. */ +export function extendedRetirement(classification: string | undefined, cameraFromFocusM: number): number | null { + if (classification === 'galaxy') return 1 - logarithmicFade(cameraFromFocusM, 1e3 * PARSEC_M, 10e3 * PARSEC_M) * (1 - logarithmicFade(cameraFromFocusM, 12e6 * PARSEC_M, 40e6 * PARSEC_M)); + if (classification === 'galaxy-cluster') return 1 - logarithmicFade(cameraFromFocusM, 3e6 * PARSEC_M, 10e6 * PARSEC_M); + return null; +} + /** Each planetary system fades with the camera's distance from its own star: the Sun's * and every placed star with orbiting bodies. The context retires once all of them have. */ export function createSystemFade(plan: Pick) { diff --git a/packages/renderer/src/universe/world-context/world-context-planner.test.ts b/packages/renderer/src/universe/world-context/world-context-planner.test.ts index 6c77207115b..ea3875f09e8 100644 --- a/packages/renderer/src/universe/world-context/world-context-planner.test.ts +++ b/packages/renderer/src/universe/world-context/world-context-planner.test.ts @@ -1,3 +1,4 @@ +import { isExtendedClassification } from '@cssearth/objects'; import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -736,12 +737,14 @@ test('a notable star beyond the Solar System is named before the Solar System co test('past the Local Group scale the stars give their names to the galaxies', async () => { const { calculate, input } = await namedAlphaCentauri(); - const named = (parsecs: number) => { + const labelled = (parsecs: number) => { input.world.pose.positionM = [0, 0, parsecs * 3.085677581491367e16]; - return calculate(input).projectedBodies.filter(body => body.labelShown).map(body => [plan.focus, ...plan.bodies][body.index]!.id); + return calculate(input).projectedBodies.filter(body => body.labelShown).map(body => [plan.focus, ...plan.bodies][body.index]!); }; + const named = (parsecs: number) => labelled(parsecs).filter(body => !('classification' in body && isExtendedClassification(body.classification))).map(body => body.id); { const values = named(200e3); assert.ok(['sun', 'sgr-a-star'].every(item => values.includes(item)), 'from 200 kpc, short of the Local Group scale, the Sun and Sgr A* keep their names'); } assert.deepEqual(named(1e6), [], 'from 1 Mpc no star or planet is named'); + { const values = labelled(1e6).map(body => body.id); assert.ok(['lmc', 'smc'].every(item => values.includes(item)), 'from 1 Mpc the galaxies are'); } }); test('past the Solar System only the featured stars and the references keep a dot; past the Local Group no body does', () => { @@ -750,13 +753,15 @@ test('past the Solar System only the featured stars and the references keep a do const calculate = createWorldContextPlanner(plan, { [featured]: FEATURED_STAR_TIER }), input = view(); const dotted = (parsecs: number) => { input.world.pose.positionM = [0, 0, parsecs * 3.085677581491367e16]; - return calculate(input).projectedBodies.filter(body => body.markerOpacity > 0).map(body => [plan.focus, ...plan.bodies][body.index]!.id); + // The galaxies, clusters and nebulae are bodies too, and keep their own dots: this is about the stars and their planets. + return calculate(input).projectedBodies.filter(body => body.markerOpacity > 0).map(body => [plan.focus, ...plan.bodies][body.index]!) + .filter(body => !('classification' in body && isExtendedClassification(body.classification))).map(body => body.id); }; const nearby = dotted(3e3); { const values = nearby; assert.ok(['sun', featured].every(item => values.includes(item)), 'from 3 kpc above the Sun the Sun and the featured star stay (Sgr A* is out of frame)'); } - assert.ok(nearby.length < 10, 'from 3 kpc the other stars have given way to the catalogue dots'); + assert.ok(nearby.length < 10, 'from 3 kpc the other stars have given way'); { const values = dotted(200e3); assert.ok(['sgr-a-star', 'sun'].every(item => values.includes(item)), 'from 200 kpc the references keep a dot'); } - assert.deepEqual(dotted(1e6), [], 'from 1 Mpc, in the overview, no body keeps a dot'); + assert.deepEqual(dotted(1e6), [], 'from 1 Mpc, in the overview, no star or planet keeps a dot'); }); test('a body beyond the Local Group keeps its dot at the scale of its cluster, and loses it from the Milky Way', () => { diff --git a/packages/renderer/src/universe/world-context/world-context-planner.ts b/packages/renderer/src/universe/world-context/world-context-planner.ts index e61681d294c..655cdc102ec 100644 --- a/packages/renderer/src/universe/world-context/world-context-planner.ts +++ b/packages/renderer/src/universe/world-context/world-context-planner.ts @@ -1,5 +1,6 @@ -import { createSystemFade, logarithmicFade, BODY_INDICATOR_DIAMETER, CONTEXT_LINE_WIDTH } from './context-scale.js'; +import { createSystemFade, extendedRetirement, logarithmicFade, BODY_INDICATOR_DIAMETER, CONTEXT_LINE_WIDTH } from './context-scale.js'; import type { PositionM } from '@cssearth/engine'; +import { isExtendedClassification } from '@cssearth/objects'; import type { PreparedContextOrbit, PreparedContextOrbitGeometry, PreparedWorldContext, PreparedWorldContextGeometry } from '../../prepared-data/world-context.js'; import type { WorldCameraPose, WorldCameraViewport } from '../../navigation/world-camera.js'; import { cssViewFromOrientation } from '../../navigation/world-camera-math.js'; @@ -98,17 +99,16 @@ const pathLevels = (orbit: PreparedContextOrbitGeometry) => [{ vertices: orbit.v export function createWorldContextPlanner(plan: PreparedWorldContext | PreparedWorldContextGeometry, annotationPriorities: Readonly> = {}, annotationLandmarks: readonly string[] = []) { const wantedOrbits = new Set(), landmarkMoonIds = new Set(annotationLandmarks), declutterMarkers = createMarkerDeclutter(annotationPriorities); - const points = [plan.focus, ...plan.bodies], byId = new Map(points.map(point => [point.id, point])); + const points = [plan.focus, ...plan.bodies].map(body => 'classification' in body && isExtendedClassification(body.classification) ? { ...body, radiusM: 0 } : body), byId = new Map(points.map(point => [point.id, point])); const indexById = new Map(points.map((point, index) => [point.id, index] as const).reverse()); // Each id's first slot, looked up per frame. let systemFade = createSystemFade(plan); const entryOf = (body: (typeof points)[number]) => { const orbit: PlannerOrbit | null = 'orbit' in body ? body.orbit ?? null : null; const levels = orbit && hasPath(orbit) ? pathLevels(orbit) : []; - // A star placed by its own record, not one on an orbit: only such a star can belong to another galaxy's field. - const placedStar = orbit === null && 'classification' in body && body.classification === 'star'; + const placedStar = orbit === null && 'classification' in body && body.classification === 'star'; // Placed by its own record: only such a star can be of another galaxy's field. const distanceFromSunM = Math.hypot(...body.positionM.map((value, axis) => value - plan.focus.positionM[axis]!)); return { body, orbit, levels, parent: orbit ? byId.get(orbit.centerBodyId) ?? null : null, scale: markerScale(distanceFromSunM, placedStar ? plan.volume : undefined), - galaxyField: !placedStar || inGalaxyField(distanceFromSunM, plan.volume), + galaxyField: !placedStar || inGalaxyField(distanceFromSunM, plan.volume), kind: 'classification' in body ? body.classification : undefined, extended: 'classification' in body && isExtendedClassification(body.classification), closedOrbit: orbit?.fullTrail === true, drawnRadiusM: body.radiusM * ('billboard' in body && body.billboard ? Math.max(1, billboardImageScale(body.billboard, body.radiusM)) : 1), orbitProjection: createRetainedRingProjection(orbit ? orbit.vertexCount * 2 : 0), // A hidden body is the same retired stub every frame: no projection, no allocation, no packet. @@ -165,7 +165,7 @@ export function createWorldContextPlanner(plan: PreparedWorldContext | PreparedW const orbitFocus = (selectionPreview ? bodies[indexById.get(selectionPreview) ?? -1] : undefined) ?? selectedEntry; const orbitOverview = selectionPreview ? false : overview; const frame = createWorldFrameProjection(plan.focus, orbitFocus.body, toEye, project); - const selectedEye = frame.eye(selected); + const selectedEye = frame.eye(selected), fromFocus = Math.hypot(...frame.eye(plan.focus)); // Past a system (bodies hidden, then ten times that), a star that is not featured gives way to the catalogue dots. const galaxyHandoff = logarithmicFade(Math.hypot(...selectedEye), plan.system.hiddenDistanceM, plan.system.hiddenDistanceM * 10); const selectedDiameter = selectedEye[2] < -selected.radiusM @@ -264,7 +264,7 @@ export function createWorldContextPlanner(plan: PreparedWorldContext | PreparedW const prominentOrbiter = entry.orbit !== null && systemFade.isSystemStar(entry.orbit.centerBodyId) && (annotationPriorities[body.id] ?? 0) >= 3; const inFrame = depth > body.radiusM && Math.abs(bodyX) < width / 2 && Math.abs(bodyY) < height / 2; - const cover = inFrame ? occlusion.cover(eye, prepared[entry.index]!.drawnRadiusM, body.id) : null, visible = inFrame && cover !== true; + const cover = inFrame ? occlusion.cover(eye, prepared[entry.index]!.drawnRadiusM, body.id) : null, visible = inFrame && cover !== true && !(isSelected && entry.extended && !overview); // Up close a selected galaxy's image is its marker: a ring would cover what sits at its centre. // Planet circles and captions are orientation landmarks, not physical // sprites. Keep them through occultation and pin an off-screen planet to // the nearest stage edge; the body sprite itself remains truthful below. @@ -343,16 +343,13 @@ export function createWorldContextPlanner(plan: PreparedWorldContext | PreparedW (annotationPriorities[body.id] ?? 0) >= 3; const proxyOpacity = highlighted || hostedPlanet ? 1 : 1 - bodyLod.markerOpacity * (1 - appearance.opacity); const flightDestination = navigationInFlight && body.id === emphasizedId; - // Past the Local Group scale the galaxies are the objects: a body's dot fades with its distance from the camera over - // the band where the overview becomes the Local Group, as its name does below. A star in no system has no other fade, - // so without this every star of the Milky Way stayed a dot from intergalactic distances. A body beyond it uses the cluster scale. - const beyondLocalGroup = logarithmicFade(Math.hypot(...eye), entry.scale.returnDistanceM, entry.scale.enterDistanceM); - // The focus, a featured star, a star outside the Milky Way and a black hole keep their dots; planets fade with their system. + // Past the Local Group scale the galaxies are the objects: a body's dot fades with its distance from the camera, as its name + // does below (a star in no system has no other fade); a galaxy or a cluster fades at its own scale (extendedRetirement). + const beyondLocalGroup = extendedRetirement(entry.kind, fromFocus) ?? logarithmicFade(Math.hypot(...eye), entry.scale.returnDistanceM, entry.scale.enterDistanceM); const galaxyHost = body.id !== plan.focus.id && entry.orbit === null && body.classification === 'star' && entry.galaxyField && (annotationPriorities[body.id] ?? 0) < FEATURED_STAR_TIER; - // A retired galaxy level takes its own stars with it; a star of another galaxy (a Magellanic Cloud's, or one beyond the - // Local Group at the cluster scale) stays for that galaxy's view. - const atGalaxyScale = view.galaxyRetired === true && body.id !== plan.focus.id && entry.orbit === null && entry.galaxyField ? 1 : galaxyHost ? galaxyHandoff : 0; + // A retired galaxy level takes its own stars and nebulae with it; another galaxy's star, a galaxy and a cluster keep their dots. + const atGalaxyScale = view.galaxyRetired === true && body.id !== plan.focus.id && entry.orbit === null && entry.galaxyField && entry.kind !== 'galaxy' && entry.kind !== 'galaxy-cluster' ? 1 : galaxyHost ? galaxyHandoff : 0; const markerOpacity = (flightDestination ? bodyLod.proxyOpacity : ownsDetail ? lod.proxyOpacity : 1) * (isLocator ? 1 : systemOpacity * (ownsDetail || flightDestination ? 1 : proxyOpacity)) * (isSelected || flightDestination ? 1 : (1 - beyondLocalGroup) * (1 - atGalaxyScale)); @@ -361,7 +358,7 @@ export function createWorldContextPlanner(plan: PreparedWorldContext | PreparedW // A flight destination keeps its circle until the preview hands off to detail. const circle = (flightDestination ? systemOpacity * bodyLod.proxyOpacity > (entry.indicatorShown ? 0 : ANNOTATION_ENTRY_MARGIN) : (isLocator || hostedPlanet) && overview || bodyLod.markerOpacity > (entry.indicatorShown ? 0 : ANNOTATION_ENTRY_MARGIN)) && - (!entry.indicatorHidden || hovered || isSelected); + (!entry.indicatorHidden || hovered || isSelected) && !(isSelected && entry.extended && !overview); const primary = !entry.orbit || systemFade.isSystemStar(entry.orbit.centerBodyId); const priority = (isAnchor ? 1000 : isLocator ? 500 : 0) + (primary ? 100 : 0) + body.radiusM / plan.focus.radiusM; const projected = (prepared[entry.index]!.projected ??= { entry, x: 0, y: 0, depth: 0, diameter: 0, markerOpacity: 0, circle: false, visible: false, @@ -437,7 +434,7 @@ export function createWorldContextPlanner(plan: PreparedWorldContext | PreparedW // read as neighbourhood: an inner planet is not less part of its system because the camera frames the outer one. // Past the Local Group scale the galaxies are named, not the stars inside them: a name fades with the body's distance // from the camera over the band where the overview becomes the Local Group (the cluster scale beyond it). - const galactic = 1 - logarithmicFade(Math.hypot(...frame.eye(body)), entry.scale.returnDistanceM, entry.scale.enterDistanceM); + const galactic = 1 - (extendedRetirement(entry.kind, fromFocus) ?? logarithmicFade(Math.hypot(...frame.eye(body)), entry.scale.returnDistanceM, entry.scale.enterDistanceM)); const alpha = flightDestination ? 1 : referenceAnnotationOnly ? galactic : targeted ? markerOpacity : Math.min(markerOpacity, galactic, resolvedDisc || hostedPlanet ? 1 : labelExtentOpacity(localExtent)); // Naming policy, decided before any slot is contested: suppressed, unresolved, too faint // or out of context here, and the body is not one this camera names at all. diff --git a/packages/telescope-cli/README.md b/packages/telescope-cli/README.md index 44ad7fe1815..57165626626 100644 --- a/packages/telescope-cli/README.md +++ b/packages/telescope-cli/README.md @@ -93,7 +93,7 @@ telescope export MEASUREMENT/output.product.json --output body-map --geometry na telescope export MAP/map.fits.product.json --output sphere --out SPHERE telescope export src/objects/stellar-neighbourhood/object.json --output points --out stars telescope export src/objects/milky-way/object.json --output volume --out galaxy -telescope export src/objects/lmc/object.json --output volume-dataset-bank --out lmc-datasets +telescope export src/objects/lmc-volume/object.json --output volume-dataset-bank --out lmc-datasets ``` The navigation file pins SPICE kernels. The sphere is a standalone HTML file with no JavaScript. See the [navigation contract](../../docs/virtual-telescopes.md#projection-and-sphere). Physical handoffs copy prepared renderer files and credits; restore missing inputs with `setup:prepared --object=ID`. diff --git a/packages/telescope-cli/src/families/f09-astrometry.mts b/packages/telescope-cli/src/families/f09-astrometry.mts index 288ba263093..c0d5a52ba29 100644 --- a/packages/telescope-cli/src/families/f09-astrometry.mts +++ b/packages/telescope-cli/src/families/f09-astrometry.mts @@ -28,4 +28,4 @@ export function readAstrometryCsv(text:string):AstrometryRow[]{ } export function describeAstrometry(input:AstrometryDescription):ProductDescriptor{const facts=inspectAstrometry(input.rows),epoch=input.epochJulianYear??(input.rows.every(row=>row.epochJulianYear===input.rows[0]!.epochJulianYear)?input.rows[0]!.epochJulianYear:undefined),columns=[{id:'id',name:'id',role:'row-identity',datatype:'string',nullable:false,variableLength:false},{id:'ra',name:'ra_deg',role:'right-ascension',datatype:'float64',unit:'deg',nullable:false,variableLength:false},{id:'dec',name:'dec_deg',role:'declination',datatype:'float64',unit:'deg',nullable:false,variableLength:false},...(facts.hasProperMotion?[{id:'pmra',name:'pm_ra_cosdec_mas_yr',role:'proper-motion-ra-cos-dec',datatype:'float64',unit:'mas/yr',nullable:false,variableLength:false},{id:'pmdec',name:'pm_dec_mas_yr',role:'proper-motion-dec',datatype:'float64',unit:'mas/yr',nullable:false,variableLength:false}] as const:[]),...(facts.hasParallax?[{id:'parallax',name:'parallax_mas',role:'parallax',datatype:'float64',unit:'mas',nullable:true,variableLength:false},{id:'parallax-error',name:'parallax_error_mas',role:'parallax-standard-deviation',datatype:'float64',unit:'mas',nullable:true,variableLength:false}] as const:[]),...(facts.hasPositionErrors?[{id:'ra-error',name:'ra_error_mas',role:'right-ascension-cos-dec-standard-deviation',datatype:'float64',unit:'mas',nullable:true,variableLength:false},{id:'dec-error',name:'dec_error_mas',role:'declination-standard-deviation',datatype:'float64',unit:'mas',nullable:true,variableLength:false}] as const:[]),...(facts.hasRaDecCorrelation?[{id:'ra-dec-correlation',name:'ra_dec_corr',role:'right-ascension-declination-correlation',datatype:'float64',nullable:true,variableLength:false}] as const:[]),...(facts.hasRadialVelocity?[{id:'radial-velocity',name:'radial_velocity_km_s',role:'radial-velocity',datatype:'float64',unit:'km/s',nullable:false,variableLength:false}] as const:[]),...(facts.hasCovariance?[{id:'covariance',name:'covariance',role:'astrometric-covariance',datatype:'float64',nullable:false,variableLength:false,shape:[facts.covarianceLength!]}] as const:[])];return descriptor({schema:'cssearth-telescope-product-descriptor@1',dataset:{id:stable(input.id,'astrometry id'),...(input.target?{target:input.target}:{}),acquisition:{kind:'repository',identity:input.member.path},producingRecord:input.producingRecord,sourceClassifications:[{term:'astrometry',vocabulary:'cssEarth observational families',version:'1',status:'mapped'}],families:['F09'],profiles:[{handlerId:'f09-astrometry',profileId:'icrs-astrometry-table@1'}]},members:[input.member],components:[{id:'astrometry',name:'astrometric positions',families:['F09'],locations:[{memberId:input.member.id}],representation:{kind:'table',rows:input.rows.length},axes:[],columns,quantity:{name:'celestial position',semantics:'Source identities and astrometric quantities in their stated reference frame and epoch.'},calibration:{state:'archive-calibrated',basis:input.calibrationBasis},uncertainty:facts.hasCovariance?{form:'covariance',columnId:'covariance',basis:'Supplied per-row astrometric covariance.'}:{form:'unknown',basis:'Available scalar errors are retained; no complete covariance is supplied.'},flags:[],frame:{kind:'celestial',name:input.frame,referencePosition:'barycentric',...(epoch!==undefined?{epoch:`J${epoch}`}:{})},dependencyIds:[]}],dependencies:[],issues:[...(!facts.hasCovariance?[{scope:'component' as const,identity:'astrometry',state:'missing' as const,reason:'No complete covariance is supplied.'}]:[]),...(epoch===undefined?[{scope:'component' as const,identity:'astrometry',state:'missing' as const,reason:'No common coordinate epoch is supplied; propagation is unavailable.'}]:[]),...(!facts.hasProperMotion?[{scope:'component' as const,identity:'astrometry',state:'missing' as const,reason:'Both proper-motion components are required for propagation.'}]:[])]});} const ops=(component:any):FamilyOperation[]=>{const cols=new Set(component.columns.map((c:any)=>c.id)),canPropagate=component.frame?.name?.toUpperCase()==='ICRS'&&component.frame.epoch!==undefined&&cols.has('pmra')&&cols.has('pmdec');return[{id:'astrometry-inspect',label:'Inspect astrometry',owner:'inspectAstrometry',available:true,reason:'Positions, identities, frame and epoch are exposed.'},{id:'astrometry-export',label:'Export astrometry',owner:'exportAstrometryCsv',available:true,reason:'Exports retained astrometric columns.'},{id:'astrometry-select-rows',label:'Select astrometric rows',owner:'selectAstrometryRows',available:true,reason:'Explicit source IDs select retained rows.'},{id:'astrometry-track-data',label:'Prepare sky/track view',owner:'astrometryTrackData',available:true,reason:'ICRS positions and declared uncertainties are available; row order is not promoted to a trajectory.'},{id:'astrometry-track-preview',label:'Render sky-position preview',owner:'previewAstrometryTrack',available:true,reason:'ICRS positions retain row identity in a static scatter preview.'},{id:'astrometry-propagate',label:'Propagate astrometry',owner:'propagateAstrometry',available:canPropagate,reason:canPropagate?'ICRS epoch and both proper-motion components are present.':'Propagation requires ICRS, a source epoch and both proper-motion components.'}].map(op=>({id:op.id,label:op.label,handlerId:'f09-astrometry',componentId:component.id,owner:{module:'packages/telescope-cli/src/families/f09-astrometry.mts',export:op.owner},available:op.available,reason:op.reason,fixedArguments:{},parameters:[...(op.id==='astrometry-propagate'?[{id:'epoch',option:'--epoch',kind:'number-list' as const,required:true,count:2,description:'Explicit source and destination Julian years.'}]:op.id==='astrometry-select-rows'?[{id:'rows',option:'--rows',kind:'input-path' as const,required:true,description:'Exact astrometric source IDs.'}]:[]),{id:'out',option:'--out',kind:'output-directory' as const,required:true,description:'New output directory.'}],limitations:['Propagation is linear tangent-plane proper motion; it does not claim full 6D perspective propagation.','Propagation carries each row\'s covariance unchanged; it does not grow position uncertainty with proper-motion error.'] }));}; -export const F09_ASTROMETRY_HANDLER:FamilyHandler={id:'f09-astrometry',families:['F09'],profiles:[{id:'icrs-astrometry-table@1',format:'ICRS astrometry table',version:'1',families:['F09'],publicBaseline:true,evidence:[{path:'src/objects/m1/source/stellar-field.json',establishes:'Pinned real Gaia DR3 positions, proper motions, parallax/errors, identities, query and epoch.',status:'complete'}]}],recognizes:members=>members.some(m=>/astrometr|stellar-field/iu.test(m.path))?['icrs-astrometry-table@1']:[],operations:d=>d.components.filter(c=>c.families.includes('F09')).flatMap(ops)}; +export const F09_ASTROMETRY_HANDLER:FamilyHandler={id:'f09-astrometry',families:['F09'],profiles:[{id:'icrs-astrometry-table@1',format:'ICRS astrometry table',version:'1',families:['F09'],publicBaseline:true,evidence:[{path:'src/objects/m1-volume/source/stellar-field.json',establishes:'Pinned real Gaia DR3 positions, proper motions, parallax/errors, identities, query and epoch.',status:'complete'}]}],recognizes:members=>members.some(m=>/astrometr|stellar-field/iu.test(m.path))?['icrs-astrometry-table@1']:[],operations:d=>d.components.filter(c=>c.families.includes('F09')).flatMap(ops)}; diff --git a/packages/telescope-cli/src/families/f09-astrometry.test.mts b/packages/telescope-cli/src/families/f09-astrometry.test.mts index 107a9136842..5ba0fb0bad9 100644 --- a/packages/telescope-cli/src/families/f09-astrometry.test.mts +++ b/packages/telescope-cli/src/families/f09-astrometry.test.mts @@ -1,6 +1,6 @@ import assert from'node:assert/strict';import{test}from'node:test';import{readFile}from'node:fs/promises';import{resolve}from'node:path';import{member}from'./common.mts';import{astrometryTrackData,describeAstrometry,readAstrometryCsv,exportAstrometryCsv,F09_ASTROMETRY_HANDLER,propagateAstrometry,selectAstrometryRows}from'./f09-astrometry.mts'; import { WORKSPACE } from '@cssearth/telescope/node'; -const root=WORKSPACE,path='src/objects/m1/source/stellar-field.json',bytes=await readFile(resolve(root,path)),source=JSON.parse(bytes.toString()) as any,rows=source.stars.map((star:any)=>({id:star.sourceId,raDeg:star.raDeg,decDeg:star.decDeg,epochJulianYear:source.coordinateEpochJulianYear,pmRaCosDecMasYr:star.pmRaMasYr,pmDecMasYr:star.pmDecMasYr,parallaxMas:star.parallaxMas,parallaxErrorMas:star.parallaxErrorMas})); +const root=WORKSPACE,path='src/objects/m1-volume/source/stellar-field.json',bytes=await readFile(resolve(root,path)),source=JSON.parse(bytes.toString()) as any,rows=source.stars.map((star:any)=>({id:star.sourceId,raDeg:star.raDeg,decDeg:star.decDeg,epochJulianYear:source.coordinateEpochJulianYear,pmRaCosDecMasYr:star.pmRaMasYr,pmDecMasYr:star.pmDecMasYr,parallaxMas:star.parallaxMas,parallaxErrorMas:star.parallaxErrorMas})); test('F09 describes and exports the pinned Gaia field with IDs, frame, epoch and uncertainties',()=>{const product=describeAstrometry({id:'m1-gaia-field',target:'m1',member:member('gaia-field',path,'science',bytes,'application/json'),rows,frame:'ICRS',epochJulianYear:source.coordinateEpochJulianYear,producingRecord:path,calibrationBasis:[source.provenance.catalogue,source.provenance.query]});assert.equal(product.components[0]!.representation.kind,'table');assert.equal(product.components[0]!.frame?.epoch,'J2016');assert.equal(product.issues.some(issue=>/covariance/u.test(issue.reason)),true);assert.match(exportAstrometryCsv(rows.slice(0,1)),new RegExp(rows[0]!.id,'u'));assert.equal(F09_ASTROMETRY_HANDLER.operations(product).find(op=>op.id==='astrometry-propagate')?.available,true);}); test('F09 propagation is explicit and refuses missing prerequisites',()=>{const one=rows[0]!,moved=propagateAstrometry([one],{fromJulianYear:2016,toJulianYear:2026,frame:'ICRS'})[0]!;assert.notEqual(moved.raDeg,one.raDeg);assert.equal(moved.epochJulianYear,2026);assert.throws(()=>propagateAstrometry([{id:'x',raDeg:1,decDeg:2}],{fromJulianYear:2016,toJulianYear:2026,frame:'ICRS'}),/proper-motion/u);assert.throws(()=>propagateAstrometry([one],{fromJulianYear:2016,toJulianYear:2026,frame:'GALACTIC'}),/ICRS/u);}); test('F09 row selection and sky view retain identifiers and stated coordinates',()=>{const selected=selectAstrometryRows(rows,[rows[0]!.id]);assert.equal(selected.length,1);assert.equal(astrometryTrackData(selected)[0]!.id,rows[0]!.id);assert.throws(()=>selectAstrometryRows(rows,['missing']),/absent/u);}); diff --git a/packages/telescope-cli/src/families/fixtures/telescope-family-examples/descriptors/F09.json b/packages/telescope-cli/src/families/fixtures/telescope-family-examples/descriptors/F09.json index 5cb80d227f5..428e18def52 100644 --- a/packages/telescope-cli/src/families/fixtures/telescope-family-examples/descriptors/F09.json +++ b/packages/telescope-cli/src/families/fixtures/telescope-family-examples/descriptors/F09.json @@ -5,9 +5,9 @@ "target": "m1", "acquisition": { "kind": "repository", - "identity": "../../../../../../../src/objects/m1/source/stellar-field.json" + "identity": "../../../../../../../src/objects/m1-volume/source/stellar-field.json" }, - "producingRecord": "src/objects/m1/source/stellar-field.json", + "producingRecord": "src/objects/m1-volume/source/stellar-field.json", "sourceClassifications": [ { "term": "astrometry", @@ -29,7 +29,7 @@ "members": [ { "id": "gaia-field", - "path": "../../../../../../../src/objects/m1/source/stellar-field.json", + "path": "../../../../../../../src/objects/m1-volume/source/stellar-field.json", "role": "science", "mediaType": "application/json" } diff --git a/packages/telescope-cli/src/families/fixtures/telescope-family-examples/manifest.json b/packages/telescope-cli/src/families/fixtures/telescope-family-examples/manifest.json index 61e77a48d52..df77e4979f5 100644 --- a/packages/telescope-cli/src/families/fixtures/telescope-family-examples/manifest.json +++ b/packages/telescope-cli/src/families/fixtures/telescope-family-examples/manifest.json @@ -195,7 +195,7 @@ "requiredCases": [] }, "source": { - "path": "src/objects/m1/source/stellar-field.json", + "path": "src/objects/m1-volume/source/stellar-field.json", "url": "https://dc.zah.uni-heidelberg.de/tableinfo/gedr3dist.litewithdist" }, "command": "telescope family-run packages/telescope-cli/src/families/fixtures/telescope-family-examples/descriptors/F09.json astrometry-export --out example-output/F09", diff --git a/packages/telescope-cli/src/family-operation.test.mts b/packages/telescope-cli/src/family-operation.test.mts index 2b9a4abf1c1..9520d980e67 100644 --- a/packages/telescope-cli/src/family-operation.test.mts +++ b/packages/telescope-cli/src/family-operation.test.mts @@ -52,7 +52,7 @@ test('public execution view exposes only allowlisted ids and refuses mismatched test('F03, F05 and F09 public writers reopen real pinned JSON members',async()=>{const work=await temporary();try{ const spectrumSource=resolve(root,'src/objects/mercury/source/spectrum/mascs-global-area-weighted-mean.json'),spectrumBytes=await readFile(spectrumSource),spectrumRaw=JSON.parse(spectrumBytes.toString()),spectrumFile=resolve(work,'spectrum.json');await writeFile(spectrumFile,spectrumBytes);const spectrumSamples=spectrumRaw.wavelengthNanometers.map((wavelength:number,index:number)=>({id:String(index),segment:'visible',wavelength,value:spectrumRaw.reflectanceIOverF[index]})),spectrum=describeStandaloneSpectrum({id:'mercury-mascs',member:member('spectrum','spectrum.json','science',spectrumBytes),samples:spectrumSamples,wavelengthUnit:'nm',valueName:'reflectance',producingRecord:'pinned MASCS',calibrationBasis:[]}),spectrumPath=await save(work,spectrum),spectrumResult=await executeFamilyOperation(spectrumPath,{operationId:'spectrum-export'},resolve(work,'spectrum-out'));assert.match(await readFile(spectrumResult.product,'utf8'),/0,visible,350/u); const photoSource=resolve(root,'src/objects/haumea/source/photometry/disc-color.json'),photoBytes=await readFile(photoSource),photoRaw=JSON.parse(photoBytes.toString()),photoFile=resolve(work,'photometry.json');await writeFile(photoFile,photoBytes);const point={id:'geometric-albedo-v',band:photoRaw.geometricAlbedo.band,system:photoRaw.object.system,effectiveWavelength:photoRaw.effectiveWavelengths.nanometres.V,value:photoRaw.geometricAlbedo.value,uncertainty:photoRaw.geometricAlbedo.uncertainty},photo=describePhotometry({id:'haumea',member:member('photo','photometry.json','science',photoBytes),points:[point],wavelengthUnit:'nm',quantity:'geometric albedo',unit:'1',producingRecord:'pinned photometry',calibrationBasis:[]}),photoPath=resolve(work,'photo-descriptor.json');await writeFile(photoPath,JSON.stringify(photo));const photoResult=await executeFamilyOperation(photoPath,{operationId:'photometry-export'},resolve(work,'photo-out'));assert.match(await readFile(photoResult.product,'utf8'),/0\.51,0\.02/u); - const starsSource=resolve(root,'src/objects/m1/source/stellar-field.json'),starsBytes=await readFile(starsSource),starsRaw=JSON.parse(starsBytes.toString()),starsFile=resolve(work,'stars.json');await writeFile(starsFile,starsBytes);const rows=starsRaw.stars.map((star:any)=>({id:star.sourceId,raDeg:star.raDeg,decDeg:star.decDeg,epochJulianYear:starsRaw.coordinateEpochJulianYear,pmRaCosDecMasYr:star.pmRaMasYr,pmDecMasYr:star.pmDecMasYr,parallaxMas:star.parallaxMas,parallaxErrorMas:star.parallaxErrorMas})),stars=describeAstrometry({id:'m1',member:member('stars','stars.json','science',starsBytes),rows,frame:'ICRS',epochJulianYear:2016,producingRecord:'pinned Gaia',calibrationBasis:[]}),starsPath=resolve(work,'stars-descriptor.json');await writeFile(starsPath,JSON.stringify(stars));const moved=await executeFamilyOperation(starsPath,{operationId:'astrometry-propagate',epoch:[2016,2026]},resolve(work,'stars-out'));assert.match(await readFile(moved.product,'utf8'),/2026/u); + const starsSource=resolve(root,'src/objects/m1-volume/source/stellar-field.json'),starsBytes=await readFile(starsSource),starsRaw=JSON.parse(starsBytes.toString()),starsFile=resolve(work,'stars.json');await writeFile(starsFile,starsBytes);const rows=starsRaw.stars.map((star:any)=>({id:star.sourceId,raDeg:star.raDeg,decDeg:star.decDeg,epochJulianYear:starsRaw.coordinateEpochJulianYear,pmRaCosDecMasYr:star.pmRaMasYr,pmDecMasYr:star.pmDecMasYr,parallaxMas:star.parallaxMas,parallaxErrorMas:star.parallaxErrorMas})),stars=describeAstrometry({id:'m1',member:member('stars','stars.json','science',starsBytes),rows,frame:'ICRS',epochJulianYear:2016,producingRecord:'pinned Gaia',calibrationBasis:[]}),starsPath=resolve(work,'stars-descriptor.json');await writeFile(starsPath,JSON.stringify(stars));const moved=await executeFamilyOperation(starsPath,{operationId:'astrometry-propagate',epoch:[2016,2026]},resolve(work,'stars-out'));assert.match(await readFile(moved.product,'utf8'),/2026/u); }finally{await rm(work,{recursive:true,force:true});}}); test('F06 explicit selection and F18 whole closure write reusable pinned products',async()=>{const work=await temporary();try{ @@ -72,7 +72,7 @@ test('remaining family baseline operations execute through the public dispatcher const series=JSON.parse(await readFile((await run('F06',{operationId:'time-series-plot-data'},'series')).product,'utf8'));assert.ok(series.length>1000); const spectrum=JSON.parse(await readFile((await run('F07',{operationId:'dynamic-spectrum-spectrum',timeIndex:0},'dynamic-spectrum')).product,'utf8'));assert.equal(spectrum.frequencyHz.length,256);const time=JSON.parse(await readFile((await run('F07',{operationId:'dynamic-spectrum-time-series',frequencyIndex:0},'dynamic-time')).product,'utf8'));assert.equal(time.epochsTt2000.length,1856); const scatter=JSON.parse(await readFile((await run('F08',{operationId:'table-scatter-data',columns:['TIME','PDCSAP_FLUX']},'scatter')).product,'utf8'));assert.ok(scatter.length>1000);const histogram=JSON.parse(await readFile((await run('F08',{operationId:'table-histogram',histogram:{column:'PDCSAP_FLUX',edges:[0,10000,20000,30000]}},'histogram')).product,'utf8'));assert.equal(histogram.counts.length,3); - const stars=JSON.parse(await readFile(resolve(root,'src/objects/m1/source/stellar-field.json'),'utf8')),id=String(stars.stars[0].sourceId),selected=await readFile((await run('F09',{operationId:'astrometry-select-rows',rows:[id]},'selected-star')).product,'utf8');assert.match(selected,new RegExp(id,'u'));const track=JSON.parse(await readFile((await run('F09',{operationId:'astrometry-track-data'},'track')).product,'utf8'));assert.ok(track.length>100); + const stars=JSON.parse(await readFile(resolve(root,'src/objects/m1-volume/source/stellar-field.json'),'utf8')),id=String(stars.stars[0].sourceId),selected=await readFile((await run('F09',{operationId:'astrometry-select-rows',rows:[id]},'selected-star')).product,'utf8');assert.match(selected,new RegExp(id,'u'));const track=JSON.parse(await readFile((await run('F09',{operationId:'astrometry-track-data'},'track')).product,'utf8'));assert.ok(track.length>100); const subset=await readFile((await run('F12',{operationId:'oifits-subset',subset:{OI_VIS2:[0],OI_T3:[0]}},'oifits-subset')).product);assert.equal(subset.subarray(0,6).toString(),'SIMPLE'); const radar=JSON.parse(await readFile((await run('F15',{operationId:'radar-coordinate-view'},'radar-coordinates')).product,'utf8'));assert.deepEqual(radar.shape,[127,64]);const spatial=JSON.parse(await readFile((await run('F16',{operationId:'spatial-select-components',components:['points']},'spatial-components')).product,'utf8'));assert.equal(spatial[0].id,'points'); const calibration=JSON.parse(await readFile((await run('F17',{operationId:'near-msi-inspect'},'near-msi')).product,'utf8'));assert.equal(calibration.unit,'I/F');assert.ok(calibration.acceptedPixels>100000);const bundle=await run('F17',{operationId:'export-bundle'},'near-msi-bundle'),bundleRecord=await readProductRecord(bundle.record);assert.equal(bundleRecord?.outputs.length,4); diff --git a/packages/telescope-cli/src/new-object/m31-cepheids.mts b/packages/telescope-cli/src/new-object/m31-cepheids.mts index 8c84a6a46db..fccea39a9b8 100644 --- a/packages/telescope-cli/src/new-object/m31-cepheids.mts +++ b/packages/telescope-cli/src/new-object/m31-cepheids.mts @@ -42,7 +42,7 @@ export interface GalaxyCepheid { readonly name: string; readonly target: string; const LVDB = { credit: 'the Local Volume Database v1.1.1 (Pace 2025)', url: 'https://doi.org/10.33232/001c.144859' }; /** The disc the app draws `galaxy` on, from its package's recipe: the same construction its layers and catalogue dots use. */ export async function galaxyDisc(root: string, galaxy: CepheidGalaxy) { - const recipe = JSON.parse(await readFile(resolve(root, 'src/objects', galaxy.objectId, 'source/recipe.json'), 'utf8')) as Parameters[0]; + const recipe = JSON.parse(await readFile(resolve(root, 'src/objects', `${galaxy.objectId}-layers`, 'source/recipe.json'), 'utf8')) as Parameters[0]; return { disc: imageLayerDisc(recipe), recipe }; } /** A star of a galaxy is placed in the galaxy as it is drawn: where its sight line crosses the disc's midplane, as the galaxy's @@ -52,7 +52,7 @@ export async function galaxyDisc(root: string, galaxy: CepheidGalaxy) { export function discPlacement(galaxy: CepheidGalaxy, placed: Awaited>, raDeg: number, decDeg: number, measured: string) { const parsecs = Math.round(imageLayerDiscDistanceKpc(placed.disc, raDeg, decDeg) * 1000), { target, geometry } = placed.recipe; return { value: parsecs, url: LVDB.url, - source: `Placed in ${galaxy.name} as the app draws it, where the star's sight line crosses the disc's midplane: ${parsecs.toLocaleString('en-US')} pc (src/objects/${galaxy.objectId}/source/recipe.json: centre ${Math.round(target.distancePc).toLocaleString('en-US')} pc from ${LVDB.credit}, inclination ${geometry.inclinationDeg} deg, line of nodes ${geometry.lineOfNodesPaDeg} deg). ${measured}` }; + source: `Placed in ${galaxy.name} as the app draws it, where the star's sight line crosses the disc's midplane: ${parsecs.toLocaleString('en-US')} pc (src/objects/${galaxy.objectId}-layers/source/recipe.json: centre ${Math.round(target.distancePc).toLocaleString('en-US')} pc from ${LVDB.credit}, inclination ${geometry.inclinationDeg} deg, line of nodes ${geometry.lineOfNodesPaDeg} deg). ${measured}` }; } /** One Cepheid's draft in `galaxy`: `name` as a reader meets it, `found` the sentence of who measured it. */ diff --git a/packages/telescope-cli/src/sky/sky-band-composite.test.mts b/packages/telescope-cli/src/sky/sky-band-composite.test.mts index 2a5d80a78eb..56506f712ad 100644 --- a/packages/telescope-cli/src/sky/sky-band-composite.test.mts +++ b/packages/telescope-cli/src/sky/sky-band-composite.test.mts @@ -96,8 +96,8 @@ test('WISE bands mosaic pinned atlas tiles through a pinned tile list', async () tiles: tiles.map((bytes, i) => ({ coaddId: `056${4 + i}p242_ac51`, bytes: bytes.length })) })); for (const [i, bytes] of tiles.entries()) await writeFile(join(cache, 'wise-atlas', `056${4 + i}p242_ac51-w4-int-3.fits.gz`), bytes); const composite = parseSkyBandComposite({ schema: 'cssearth-sky-band-composite@1', grid, backgroundPercentile: 5, peakPercentile: 100, - bands: [{ band: 'W4', tiles: { path: 'src/objects/m45/source/wise-atlas/w4.json' } }], display: { minimum: 0, stretch: 1, softening: 8 } }); - const listPath = 'src/objects/m45/source/wise-atlas/w4.json'; + bands: [{ band: 'W4', tiles: { path: 'src/objects/m45-volume/source/wise-atlas/w4.json' } }], display: { minimum: 0, stretch: 1, softening: 8 } }); + const listPath = 'src/objects/m45-volume/source/wise-atlas/w4.json'; const result = await composeSkyBands(composite, { input: async path => { assert.equal(path, listPath); return pinsList; }, cache }); const acquisition = result.evidence.bands[0]!.acquisition as { kind: string; backgroundMatching: { medianOverlapStepDnBefore: number; medianOverlapStepDnAfter: number } }; assert.equal(acquisition.kind, 'wise-atlas'); diff --git a/public/navigation/body-fornax-cluster@2x.webp b/public/navigation/body-fornax-cluster@2x.webp new file mode 100644 index 00000000000..5f56a244b7c Binary files /dev/null and b/public/navigation/body-fornax-cluster@2x.webp differ diff --git a/public/navigation/body-helix@2x.webp b/public/navigation/body-helix@2x.webp new file mode 100644 index 00000000000..2a5dd4ea65d Binary files /dev/null and b/public/navigation/body-helix@2x.webp differ diff --git a/public/navigation/body-lmc@2x.webp b/public/navigation/body-lmc@2x.webp new file mode 100644 index 00000000000..1436375ca86 Binary files /dev/null and b/public/navigation/body-lmc@2x.webp differ diff --git a/public/navigation/body-m1@2x.webp b/public/navigation/body-m1@2x.webp new file mode 100644 index 00000000000..eeded2c1a3a Binary files /dev/null and b/public/navigation/body-m1@2x.webp differ diff --git a/public/navigation/body-m2-9@2x.webp b/public/navigation/body-m2-9@2x.webp new file mode 100644 index 00000000000..ba0a939fb21 Binary files /dev/null and b/public/navigation/body-m2-9@2x.webp differ diff --git a/public/navigation/body-m31@2x.webp b/public/navigation/body-m31@2x.webp new file mode 100644 index 00000000000..61dee94eb77 Binary files /dev/null and b/public/navigation/body-m31@2x.webp differ diff --git a/public/navigation/body-m33@2x.webp b/public/navigation/body-m33@2x.webp new file mode 100644 index 00000000000..aa7ae3a3041 Binary files /dev/null and b/public/navigation/body-m33@2x.webp differ diff --git a/public/navigation/body-m42@2x.webp b/public/navigation/body-m42@2x.webp new file mode 100644 index 00000000000..b0016f68bdd Binary files /dev/null and b/public/navigation/body-m42@2x.webp differ diff --git a/public/navigation/body-m45@2x.webp b/public/navigation/body-m45@2x.webp new file mode 100644 index 00000000000..9ab24698197 Binary files /dev/null and b/public/navigation/body-m45@2x.webp differ diff --git a/public/navigation/body-m81@2x.webp b/public/navigation/body-m81@2x.webp new file mode 100644 index 00000000000..db0ee9cebee Binary files /dev/null and b/public/navigation/body-m81@2x.webp differ diff --git a/public/navigation/body-m83@2x.webp b/public/navigation/body-m83@2x.webp new file mode 100644 index 00000000000..06bebee2537 Binary files /dev/null and b/public/navigation/body-m83@2x.webp differ diff --git a/public/navigation/body-m87@2x.webp b/public/navigation/body-m87@2x.webp new file mode 100644 index 00000000000..e29b6aaa141 Binary files /dev/null and b/public/navigation/body-m87@2x.webp differ diff --git a/public/navigation/body-m8@2x.webp b/public/navigation/body-m8@2x.webp new file mode 100644 index 00000000000..865c159d103 Binary files /dev/null and b/public/navigation/body-m8@2x.webp differ diff --git a/public/navigation/body-markers-06@2x.webp b/public/navigation/body-markers-06@2x.webp index 118e6905823..13c09505d99 100644 Binary files a/public/navigation/body-markers-06@2x.webp and b/public/navigation/body-markers-06@2x.webp differ diff --git a/public/navigation/body-markers-07@2x.webp b/public/navigation/body-markers-07@2x.webp index 061b1f04d06..374775fc11d 100644 Binary files a/public/navigation/body-markers-07@2x.webp and b/public/navigation/body-markers-07@2x.webp differ diff --git a/public/navigation/body-markers-08@2x.webp b/public/navigation/body-markers-08@2x.webp index 44fab5f24df..7f197fc97b7 100644 Binary files a/public/navigation/body-markers-08@2x.webp and b/public/navigation/body-markers-08@2x.webp differ diff --git a/public/navigation/body-markers-09@2x.webp b/public/navigation/body-markers-09@2x.webp index 2e22cca83cc..1a9c684e835 100644 Binary files a/public/navigation/body-markers-09@2x.webp and b/public/navigation/body-markers-09@2x.webp differ diff --git a/public/navigation/body-markers-10@2x.webp b/public/navigation/body-markers-10@2x.webp index 3e2672154ae..642f82637a7 100644 Binary files a/public/navigation/body-markers-10@2x.webp and b/public/navigation/body-markers-10@2x.webp differ diff --git a/public/navigation/body-markers-11@2x.webp b/public/navigation/body-markers-11@2x.webp index f6cfa4da3f8..4c02767d5ba 100644 Binary files a/public/navigation/body-markers-11@2x.webp and b/public/navigation/body-markers-11@2x.webp differ diff --git a/public/navigation/body-markers-12@2x.webp b/public/navigation/body-markers-12@2x.webp index 8ad66cb7b1e..4216b806cd9 100644 Binary files a/public/navigation/body-markers-12@2x.webp and b/public/navigation/body-markers-12@2x.webp differ diff --git a/public/navigation/body-markers-13@2x.webp b/public/navigation/body-markers-13@2x.webp index 265203aafe5..4610545632e 100644 Binary files a/public/navigation/body-markers-13@2x.webp and b/public/navigation/body-markers-13@2x.webp differ diff --git a/public/navigation/body-markers-15@2x.webp b/public/navigation/body-markers-15@2x.webp index badbeecad2b..b475341fbd6 100644 Binary files a/public/navigation/body-markers-15@2x.webp and b/public/navigation/body-markers-15@2x.webp differ diff --git a/public/navigation/body-ngc-253@2x.webp b/public/navigation/body-ngc-253@2x.webp new file mode 100644 index 00000000000..739701e9bc1 Binary files /dev/null and b/public/navigation/body-ngc-253@2x.webp differ diff --git a/public/navigation/body-ngc-300@2x.webp b/public/navigation/body-ngc-300@2x.webp new file mode 100644 index 00000000000..adc64e54bc8 Binary files /dev/null and b/public/navigation/body-ngc-300@2x.webp differ diff --git a/public/navigation/body-omega-centauri@2x.webp b/public/navigation/body-omega-centauri@2x.webp new file mode 100644 index 00000000000..605d5a910cb Binary files /dev/null and b/public/navigation/body-omega-centauri@2x.webp differ diff --git a/public/navigation/body-smc@2x.webp b/public/navigation/body-smc@2x.webp new file mode 100644 index 00000000000..e7e61cd69ce Binary files /dev/null and b/public/navigation/body-smc@2x.webp differ diff --git a/public/navigation/body-virgo-cluster@2x.webp b/public/navigation/body-virgo-cluster@2x.webp new file mode 100644 index 00000000000..0f74a2695bb Binary files /dev/null and b/public/navigation/body-virgo-cluster@2x.webp differ diff --git a/public/navigation/focus-helix-eso-vista@2x.webp b/public/navigation/focus-helix-volume-eso-vista@2x.webp similarity index 100% rename from public/navigation/focus-helix-eso-vista@2x.webp rename to public/navigation/focus-helix-volume-eso-vista@2x.webp diff --git a/public/navigation/focus-helix-eso-wfi@2x.webp b/public/navigation/focus-helix-volume-eso-wfi@2x.webp similarity index 100% rename from public/navigation/focus-helix-eso-wfi@2x.webp rename to public/navigation/focus-helix-volume-eso-wfi@2x.webp diff --git a/public/navigation/focus-helix-eso-wide@2x.webp b/public/navigation/focus-helix-volume-eso-wide@2x.webp similarity index 100% rename from public/navigation/focus-helix-eso-wide@2x.webp rename to public/navigation/focus-helix-volume-eso-wide@2x.webp diff --git a/public/navigation/focus-lmc-horalek-widefield@2x.webp b/public/navigation/focus-lmc-volume-horalek-widefield@2x.webp similarity index 100% rename from public/navigation/focus-lmc-horalek-widefield@2x.webp rename to public/navigation/focus-lmc-volume-horalek-widefield@2x.webp diff --git a/public/navigation/focus-lmc-vista-infrared@2x.webp b/public/navigation/focus-lmc-volume-vista-infrared@2x.webp similarity index 100% rename from public/navigation/focus-lmc-vista-infrared@2x.webp rename to public/navigation/focus-lmc-volume-vista-infrared@2x.webp diff --git a/public/navigation/focus-lmc-wise-wide-infrared@2x.webp b/public/navigation/focus-lmc-volume-wise-wide-infrared@2x.webp similarity index 100% rename from public/navigation/focus-lmc-wise-wide-infrared@2x.webp rename to public/navigation/focus-lmc-volume-wise-wide-infrared@2x.webp diff --git a/public/navigation/focus-m1-chandra-xray@2x.webp b/public/navigation/focus-m1-volume-chandra-xray@2x.webp similarity index 100% rename from public/navigation/focus-m1-chandra-xray@2x.webp rename to public/navigation/focus-m1-volume-chandra-xray@2x.webp diff --git a/public/navigation/focus-m1-hubble-optical@2x.webp b/public/navigation/focus-m1-volume-hubble-optical@2x.webp similarity index 100% rename from public/navigation/focus-m1-hubble-optical@2x.webp rename to public/navigation/focus-m1-volume-hubble-optical@2x.webp diff --git a/public/navigation/focus-m1-spitzer-infrared@2x.webp b/public/navigation/focus-m1-volume-spitzer-infrared@2x.webp similarity index 100% rename from public/navigation/focus-m1-spitzer-infrared@2x.webp rename to public/navigation/focus-m1-volume-spitzer-infrared@2x.webp diff --git a/public/navigation/focus-m1-vla-radio@2x.webp b/public/navigation/focus-m1-volume-vla-radio@2x.webp similarity index 100% rename from public/navigation/focus-m1-vla-radio@2x.webp rename to public/navigation/focus-m1-volume-vla-radio@2x.webp diff --git a/public/navigation/focus-m1-webb-components@2x.webp b/public/navigation/focus-m1-volume-webb-components@2x.webp similarity index 100% rename from public/navigation/focus-m1-webb-components@2x.webp rename to public/navigation/focus-m1-volume-webb-components@2x.webp diff --git a/public/navigation/focus-m1-webb-infrared@2x.webp b/public/navigation/focus-m1-volume-webb-infrared@2x.webp similarity index 100% rename from public/navigation/focus-m1-webb-infrared@2x.webp rename to public/navigation/focus-m1-volume-webb-infrared@2x.webp diff --git a/public/navigation/focus-m2-9-hst-optical@2x.webp b/public/navigation/focus-m2-9-volume-hst-optical@2x.webp similarity index 100% rename from public/navigation/focus-m2-9-hst-optical@2x.webp rename to public/navigation/focus-m2-9-volume-hst-optical@2x.webp diff --git a/public/navigation/focus-m31-optical@2x.webp b/public/navigation/focus-m31-layers-optical@2x.webp similarity index 100% rename from public/navigation/focus-m31-optical@2x.webp rename to public/navigation/focus-m31-layers-optical@2x.webp diff --git a/public/navigation/focus-m33-optical@2x.webp b/public/navigation/focus-m33-layers-optical@2x.webp similarity index 100% rename from public/navigation/focus-m33-optical@2x.webp rename to public/navigation/focus-m33-layers-optical@2x.webp diff --git a/public/navigation/focus-m42-eso-optical@2x.webp b/public/navigation/focus-m42-volume-eso-optical@2x.webp similarity index 100% rename from public/navigation/focus-m42-eso-optical@2x.webp rename to public/navigation/focus-m42-volume-eso-optical@2x.webp diff --git a/public/navigation/focus-m42-eso-vista@2x.webp b/public/navigation/focus-m42-volume-eso-vista@2x.webp similarity index 100% rename from public/navigation/focus-m42-eso-vista@2x.webp rename to public/navigation/focus-m42-volume-eso-vista@2x.webp diff --git a/public/navigation/focus-m45-noirlab-optical@2x.webp b/public/navigation/focus-m45-volume-noirlab-optical@2x.webp similarity index 100% rename from public/navigation/focus-m45-noirlab-optical@2x.webp rename to public/navigation/focus-m45-volume-noirlab-optical@2x.webp diff --git a/public/navigation/focus-m45-optical-composite@2x.webp b/public/navigation/focus-m45-volume-optical-composite@2x.webp similarity index 100% rename from public/navigation/focus-m45-optical-composite@2x.webp rename to public/navigation/focus-m45-volume-optical-composite@2x.webp diff --git a/public/navigation/focus-m45-spitzer-irac-mips@2x.webp b/public/navigation/focus-m45-volume-spitzer-irac-mips@2x.webp similarity index 100% rename from public/navigation/focus-m45-spitzer-irac-mips@2x.webp rename to public/navigation/focus-m45-volume-spitzer-irac-mips@2x.webp diff --git a/public/navigation/focus-m45-spitzer-irac@2x.webp b/public/navigation/focus-m45-volume-spitzer-irac@2x.webp similarity index 100% rename from public/navigation/focus-m45-spitzer-irac@2x.webp rename to public/navigation/focus-m45-volume-spitzer-irac@2x.webp diff --git a/public/navigation/focus-m45-wise-four-band@2x.webp b/public/navigation/focus-m45-volume-wise-four-band@2x.webp similarity index 100% rename from public/navigation/focus-m45-wise-four-band@2x.webp rename to public/navigation/focus-m45-volume-wise-four-band@2x.webp diff --git a/public/navigation/focus-m8-eso-optical@2x.webp b/public/navigation/focus-m8-volume-eso-optical@2x.webp similarity index 100% rename from public/navigation/focus-m8-eso-optical@2x.webp rename to public/navigation/focus-m8-volume-eso-optical@2x.webp diff --git a/public/navigation/focus-m8-eso-vista@2x.webp b/public/navigation/focus-m8-volume-eso-vista@2x.webp similarity index 100% rename from public/navigation/focus-m8-eso-vista@2x.webp rename to public/navigation/focus-m8-volume-eso-vista@2x.webp diff --git a/public/navigation/focus-m8-spitzer-mid-infrared@2x.webp b/public/navigation/focus-m8-volume-spitzer-mid-infrared@2x.webp similarity index 100% rename from public/navigation/focus-m8-spitzer-mid-infrared@2x.webp rename to public/navigation/focus-m8-volume-spitzer-mid-infrared@2x.webp diff --git a/public/navigation/focus-m81-optical@2x.webp b/public/navigation/focus-m81-layers-optical@2x.webp similarity index 100% rename from public/navigation/focus-m81-optical@2x.webp rename to public/navigation/focus-m81-layers-optical@2x.webp diff --git a/public/navigation/focus-m83-optical@2x.webp b/public/navigation/focus-m83-layers-optical@2x.webp similarity index 100% rename from public/navigation/focus-m83-optical@2x.webp rename to public/navigation/focus-m83-layers-optical@2x.webp diff --git a/public/navigation/focus-m87-volume-eso-optical@2x.webp b/public/navigation/focus-m87-volume-eso-optical@2x.webp new file mode 100644 index 00000000000..9dda42f8394 Binary files /dev/null and b/public/navigation/focus-m87-volume-eso-optical@2x.webp differ diff --git a/public/navigation/focus-ngc-253-optical@2x.webp b/public/navigation/focus-ngc-253-layers-optical@2x.webp similarity index 100% rename from public/navigation/focus-ngc-253-optical@2x.webp rename to public/navigation/focus-ngc-253-layers-optical@2x.webp diff --git a/public/navigation/focus-ngc-300-optical@2x.webp b/public/navigation/focus-ngc-300-layers-optical@2x.webp similarity index 100% rename from public/navigation/focus-ngc-300-optical@2x.webp rename to public/navigation/focus-ngc-300-layers-optical@2x.webp diff --git a/public/navigation/focus-omega-centauri-eso0844a@2x.webp b/public/navigation/focus-omega-centauri-volume-eso0844a@2x.webp similarity index 100% rename from public/navigation/focus-omega-centauri-eso0844a@2x.webp rename to public/navigation/focus-omega-centauri-volume-eso0844a@2x.webp diff --git a/public/navigation/focus-omega-centauri-eso1119b@2x.webp b/public/navigation/focus-omega-centauri-volume-eso1119b@2x.webp similarity index 100% rename from public/navigation/focus-omega-centauri-eso1119b@2x.webp rename to public/navigation/focus-omega-centauri-volume-eso1119b@2x.webp diff --git a/public/navigation/focus-smc-smc-allwise-wide@2x.webp b/public/navigation/focus-smc-volume-smc-allwise-wide@2x.webp similarity index 100% rename from public/navigation/focus-smc-smc-allwise-wide@2x.webp rename to public/navigation/focus-smc-volume-smc-allwise-wide@2x.webp diff --git a/public/navigation/focus-smc-smc-dss2@2x.webp b/public/navigation/focus-smc-volume-smc-dss2@2x.webp similarity index 100% rename from public/navigation/focus-smc-smc-dss2@2x.webp rename to public/navigation/focus-smc-volume-smc-dss2@2x.webp diff --git a/public/navigation/focus-smc-smc-horalek-wide@2x.webp b/public/navigation/focus-smc-volume-smc-horalek-wide@2x.webp similarity index 100% rename from public/navigation/focus-smc-smc-horalek-wide@2x.webp rename to public/navigation/focus-smc-volume-smc-horalek-wide@2x.webp diff --git a/public/navigation/focus-smc-smc-smash@2x.webp b/public/navigation/focus-smc-volume-smc-smash@2x.webp similarity index 100% rename from public/navigation/focus-smc-smc-smash@2x.webp rename to public/navigation/focus-smc-volume-smc-smash@2x.webp diff --git a/public/navigation/focus-smc-smc-vista@2x.webp b/public/navigation/focus-smc-volume-smc-vista@2x.webp similarity index 100% rename from public/navigation/focus-smc-smc-vista@2x.webp rename to public/navigation/focus-smc-volume-smc-vista@2x.webp diff --git a/public/navigation/fornax-cluster-context.webp b/public/navigation/fornax-cluster-context.webp new file mode 100644 index 00000000000..28c7d27e623 Binary files /dev/null and b/public/navigation/fornax-cluster-context.webp differ diff --git a/public/navigation/helix-context.webp b/public/navigation/helix-context.webp new file mode 100644 index 00000000000..695ffab880c Binary files /dev/null and b/public/navigation/helix-context.webp differ diff --git a/public/navigation/lmc-context.webp b/public/navigation/lmc-context.webp new file mode 100644 index 00000000000..6245582c168 Binary files /dev/null and b/public/navigation/lmc-context.webp differ diff --git a/public/navigation/m1-context.webp b/public/navigation/m1-context.webp new file mode 100644 index 00000000000..b2757db831d Binary files /dev/null and b/public/navigation/m1-context.webp differ diff --git a/public/navigation/m2-9-context.webp b/public/navigation/m2-9-context.webp new file mode 100644 index 00000000000..b59db47743c Binary files /dev/null and b/public/navigation/m2-9-context.webp differ diff --git a/public/navigation/m31-context.webp b/public/navigation/m31-context.webp new file mode 100644 index 00000000000..5c39b6e8c22 Binary files /dev/null and b/public/navigation/m31-context.webp differ diff --git a/public/navigation/m33-context.webp b/public/navigation/m33-context.webp new file mode 100644 index 00000000000..ea4462ad1f5 Binary files /dev/null and b/public/navigation/m33-context.webp differ diff --git a/public/navigation/m42-context.webp b/public/navigation/m42-context.webp new file mode 100644 index 00000000000..fa6ce239458 Binary files /dev/null and b/public/navigation/m42-context.webp differ diff --git a/public/navigation/m45-context.webp b/public/navigation/m45-context.webp new file mode 100644 index 00000000000..93645a613c0 Binary files /dev/null and b/public/navigation/m45-context.webp differ diff --git a/public/navigation/m8-context.webp b/public/navigation/m8-context.webp new file mode 100644 index 00000000000..9b69d430f18 Binary files /dev/null and b/public/navigation/m8-context.webp differ diff --git a/public/navigation/m81-context.webp b/public/navigation/m81-context.webp new file mode 100644 index 00000000000..d24183f8e41 Binary files /dev/null and b/public/navigation/m81-context.webp differ diff --git a/public/navigation/m83-context.webp b/public/navigation/m83-context.webp new file mode 100644 index 00000000000..ac86716e262 Binary files /dev/null and b/public/navigation/m83-context.webp differ diff --git a/public/navigation/m87-context.webp b/public/navigation/m87-context.webp new file mode 100644 index 00000000000..643a92102de Binary files /dev/null and b/public/navigation/m87-context.webp differ diff --git a/public/navigation/ngc-253-context.webp b/public/navigation/ngc-253-context.webp new file mode 100644 index 00000000000..7dcab079d54 Binary files /dev/null and b/public/navigation/ngc-253-context.webp differ diff --git a/public/navigation/ngc-300-context.webp b/public/navigation/ngc-300-context.webp new file mode 100644 index 00000000000..9f98d2584e1 Binary files /dev/null and b/public/navigation/ngc-300-context.webp differ diff --git a/public/navigation/omega-centauri-context.webp b/public/navigation/omega-centauri-context.webp new file mode 100644 index 00000000000..1dd374b3f58 Binary files /dev/null and b/public/navigation/omega-centauri-context.webp differ diff --git a/public/navigation/search/fornax-cluster@2x.webp b/public/navigation/search/fornax-cluster@2x.webp new file mode 100644 index 00000000000..e630d5812f8 Binary files /dev/null and b/public/navigation/search/fornax-cluster@2x.webp differ diff --git a/public/navigation/search/helix@2x.webp b/public/navigation/search/helix@2x.webp new file mode 100644 index 00000000000..12c54eb6f03 Binary files /dev/null and b/public/navigation/search/helix@2x.webp differ diff --git a/public/navigation/search/lmc@2x.webp b/public/navigation/search/lmc@2x.webp new file mode 100644 index 00000000000..47d33f64394 Binary files /dev/null and b/public/navigation/search/lmc@2x.webp differ diff --git a/public/navigation/search/m1@2x.webp b/public/navigation/search/m1@2x.webp new file mode 100644 index 00000000000..829bc46fc78 Binary files /dev/null and b/public/navigation/search/m1@2x.webp differ diff --git a/public/navigation/search/m2-9@2x.webp b/public/navigation/search/m2-9@2x.webp new file mode 100644 index 00000000000..00870bf9f20 Binary files /dev/null and b/public/navigation/search/m2-9@2x.webp differ diff --git a/public/navigation/search/m31@2x.webp b/public/navigation/search/m31@2x.webp new file mode 100644 index 00000000000..66aff6aeca2 Binary files /dev/null and b/public/navigation/search/m31@2x.webp differ diff --git a/public/navigation/search/m33@2x.webp b/public/navigation/search/m33@2x.webp new file mode 100644 index 00000000000..731703aafce Binary files /dev/null and b/public/navigation/search/m33@2x.webp differ diff --git a/public/navigation/search/m42@2x.webp b/public/navigation/search/m42@2x.webp new file mode 100644 index 00000000000..23417f39796 Binary files /dev/null and b/public/navigation/search/m42@2x.webp differ diff --git a/public/navigation/search/m45@2x.webp b/public/navigation/search/m45@2x.webp new file mode 100644 index 00000000000..85a47ea71c7 Binary files /dev/null and b/public/navigation/search/m45@2x.webp differ diff --git a/public/navigation/search/m81@2x.webp b/public/navigation/search/m81@2x.webp new file mode 100644 index 00000000000..b0490acc611 Binary files /dev/null and b/public/navigation/search/m81@2x.webp differ diff --git a/public/navigation/search/m83@2x.webp b/public/navigation/search/m83@2x.webp new file mode 100644 index 00000000000..1d723b98124 Binary files /dev/null and b/public/navigation/search/m83@2x.webp differ diff --git a/public/navigation/search/m87@2x.webp b/public/navigation/search/m87@2x.webp new file mode 100644 index 00000000000..2f6848bcd2a Binary files /dev/null and b/public/navigation/search/m87@2x.webp differ diff --git a/public/navigation/search/m8@2x.webp b/public/navigation/search/m8@2x.webp new file mode 100644 index 00000000000..f9e92800fc6 Binary files /dev/null and b/public/navigation/search/m8@2x.webp differ diff --git a/public/navigation/search/ngc-253@2x.webp b/public/navigation/search/ngc-253@2x.webp new file mode 100644 index 00000000000..770f5259dbc Binary files /dev/null and b/public/navigation/search/ngc-253@2x.webp differ diff --git a/public/navigation/search/ngc-300@2x.webp b/public/navigation/search/ngc-300@2x.webp new file mode 100644 index 00000000000..d22ae535ddd Binary files /dev/null and b/public/navigation/search/ngc-300@2x.webp differ diff --git a/public/navigation/search/omega-centauri@2x.webp b/public/navigation/search/omega-centauri@2x.webp new file mode 100644 index 00000000000..c52d86a0292 Binary files /dev/null and b/public/navigation/search/omega-centauri@2x.webp differ diff --git a/public/navigation/search/smc@2x.webp b/public/navigation/search/smc@2x.webp new file mode 100644 index 00000000000..e044376e17a Binary files /dev/null and b/public/navigation/search/smc@2x.webp differ diff --git a/public/navigation/search/virgo-cluster@2x.webp b/public/navigation/search/virgo-cluster@2x.webp new file mode 100644 index 00000000000..0eb7658aa3c Binary files /dev/null and b/public/navigation/search/virgo-cluster@2x.webp differ diff --git a/public/navigation/sidebar-thumbnails.json b/public/navigation/sidebar-thumbnails.json index 23cfcb0603d..3c07d3aa979 100644 --- a/public/navigation/sidebar-thumbnails.json +++ b/public/navigation/sidebar-thumbnails.json @@ -55,183 +55,191 @@ "bytes": 4052 }, { - "path": "src/objects/helix/prepared/presentation.json", - "bytes": 4697 + "path": "src/objects/helix-volume/prepared/presentation.json", + "bytes": 3733 }, { - "path": "public/scenes/helix/datasets/eso-vista.webp", + "path": "public/scenes/helix-volume/datasets/eso-vista.webp", "bytes": 39332 }, { - "path": "public/scenes/helix/datasets/eso-wfi.webp", + "path": "public/scenes/helix-volume/datasets/eso-wfi.webp", "bytes": 9410 }, { - "path": "public/scenes/helix/datasets/eso-wide.webp", + "path": "public/scenes/helix-volume/datasets/eso-wide.webp", "bytes": 8158 }, { - "path": "src/objects/lmc/prepared/presentation.json", - "bytes": 4804 + "path": "src/objects/lmc-volume/prepared/presentation.json", + "bytes": 3840 }, { - "path": "public/scenes/lmc/datasets/horalek-widefield.webp", + "path": "public/scenes/lmc-volume/datasets/horalek-widefield.webp", "bytes": 17694 }, { - "path": "public/scenes/lmc/datasets/vista-infrared.webp", + "path": "public/scenes/lmc-volume/datasets/vista-infrared.webp", "bytes": 26292 }, { - "path": "public/scenes/lmc/datasets/wise-wide-infrared.webp", + "path": "public/scenes/lmc-volume/datasets/wise-wide-infrared.webp", "bytes": 2868 }, { - "path": "src/objects/m1/prepared/presentation.json", - "bytes": 10248 + "path": "src/objects/m1-volume/prepared/presentation.json", + "bytes": 8333 }, { - "path": "public/scenes/m1/datasets/hubble-optical.webp", + "path": "public/scenes/m1-volume/datasets/hubble-optical.webp", "bytes": 33070 }, { - "path": "public/scenes/m1/datasets/webb-infrared.webp", + "path": "public/scenes/m1-volume/datasets/webb-infrared.webp", "bytes": 24098 }, { - "path": "public/scenes/m1/datasets/webb-components.webp", + "path": "public/scenes/m1-volume/datasets/webb-components.webp", "bytes": 23986 }, { - "path": "public/scenes/m1/datasets/spitzer-infrared.webp", + "path": "public/scenes/m1-volume/datasets/spitzer-infrared.webp", "bytes": 3424 }, { - "path": "public/scenes/m1/datasets/vla-radio.webp", + "path": "public/scenes/m1-volume/datasets/vla-radio.webp", "bytes": 5446 }, { - "path": "public/scenes/m1/datasets/chandra-xray.webp", + "path": "public/scenes/m1-volume/datasets/chandra-xray.webp", "bytes": 4464 }, { - "path": "src/objects/m2-9/prepared/presentation.json", - "bytes": 1842 + "path": "src/objects/m2-9-volume/prepared/presentation.json", + "bytes": 1512 }, { - "path": "public/scenes/m2-9/datasets/hst-optical.webp", + "path": "public/scenes/m2-9-volume/datasets/hst-optical.webp", "bytes": 14756 }, { - "path": "src/objects/m31/prepared/presentation.json", - "bytes": 1760 + "path": "src/objects/m31-layers/prepared/presentation.json", + "bytes": 1376 }, { - "path": "public/scenes/m31/datasets/optical.webp", + "path": "public/scenes/m31-layers/datasets/optical.webp", "bytes": 3062 }, { - "path": "src/objects/m33/prepared/presentation.json", - "bytes": 1688 + "path": "src/objects/m33-layers/prepared/presentation.json", + "bytes": 1709 }, { - "path": "public/scenes/m33/datasets/optical.webp", + "path": "public/scenes/m33-layers/datasets/optical.webp", "bytes": 15530 }, { - "path": "src/objects/m42/prepared/presentation.json", - "bytes": 3234 + "path": "src/objects/m42-volume/prepared/presentation.json", + "bytes": 2587 }, { - "path": "public/scenes/m42/datasets/eso-optical.webp", + "path": "public/scenes/m42-volume/datasets/eso-optical.webp", "bytes": 10124 }, { - "path": "public/scenes/m42/datasets/eso-vista.webp", + "path": "public/scenes/m42-volume/datasets/eso-vista.webp", "bytes": 16000 }, { - "path": "src/objects/m45/prepared/presentation.json", - "bytes": 8154 + "path": "src/objects/m45-volume/prepared/presentation.json", + "bytes": 6556 }, { - "path": "public/scenes/m45/datasets/optical-composite.webp", + "path": "public/scenes/m45-volume/datasets/optical-composite.webp", "bytes": 14636 }, { - "path": "public/scenes/m45/datasets/noirlab-optical.webp", + "path": "public/scenes/m45-volume/datasets/noirlab-optical.webp", "bytes": 11712 }, { - "path": "public/scenes/m45/datasets/spitzer-irac.webp", + "path": "public/scenes/m45-volume/datasets/spitzer-irac.webp", "bytes": 14772 }, { - "path": "public/scenes/m45/datasets/spitzer-irac-mips.webp", + "path": "public/scenes/m45-volume/datasets/spitzer-irac-mips.webp", "bytes": 13854 }, { - "path": "public/scenes/m45/datasets/wise-four-band.webp", + "path": "public/scenes/m45-volume/datasets/wise-four-band.webp", "bytes": 20398 }, { - "path": "src/objects/m8/prepared/presentation.json", - "bytes": 5263 + "path": "src/objects/m8-volume/prepared/presentation.json", + "bytes": 4299 }, { - "path": "public/scenes/m8/datasets/eso-optical.webp", + "path": "public/scenes/m8-volume/datasets/eso-optical.webp", "bytes": 21732 }, { - "path": "public/scenes/m8/datasets/eso-vista.webp", + "path": "public/scenes/m8-volume/datasets/eso-vista.webp", "bytes": 51638 }, { - "path": "public/scenes/m8/datasets/spitzer-mid-infrared.webp", + "path": "public/scenes/m8-volume/datasets/spitzer-mid-infrared.webp", "bytes": 36602 }, { - "path": "src/objects/m81/prepared/presentation.json", - "bytes": 1549 + "path": "src/objects/m81-layers/prepared/presentation.json", + "bytes": 1165 }, { - "path": "public/scenes/m81/datasets/optical.webp", + "path": "public/scenes/m81-layers/datasets/optical.webp", "bytes": 5842 }, { - "path": "src/objects/m83/prepared/presentation.json", - "bytes": 1426 + "path": "src/objects/m83-layers/prepared/presentation.json", + "bytes": 1042 }, { - "path": "public/scenes/m83/datasets/optical.webp", + "path": "public/scenes/m83-layers/datasets/optical.webp", "bytes": 14088 }, { - "path": "src/objects/ngc-253/prepared/presentation.json", - "bytes": 1528 + "path": "src/objects/m87-volume/prepared/presentation.json", + "bytes": 1013 + }, + { + "path": "public/scenes/m87-volume/datasets/eso-optical.webp", + "bytes": 33532 }, { - "path": "public/scenes/ngc-253/datasets/optical.webp", + "path": "src/objects/ngc-253-layers/prepared/presentation.json", + "bytes": 1549 + }, + { + "path": "public/scenes/ngc-253-layers/datasets/optical.webp", "bytes": 5324 }, { - "path": "src/objects/ngc-300/prepared/presentation.json", - "bytes": 1403 + "path": "src/objects/ngc-300-layers/prepared/presentation.json", + "bytes": 1424 }, { - "path": "public/scenes/ngc-300/datasets/optical.webp", + "path": "public/scenes/ngc-300-layers/datasets/optical.webp", "bytes": 15188 }, { - "path": "src/objects/omega-centauri/prepared/presentation.json", - "bytes": 3983 + "path": "src/objects/omega-centauri-volume/prepared/presentation.json", + "bytes": 3228 }, { - "path": "public/scenes/omega-centauri/datasets/eso1119b.webp", + "path": "public/scenes/omega-centauri-volume/datasets/eso1119b.webp", "bytes": 54638 }, { - "path": "public/scenes/omega-centauri/datasets/eso0844a.webp", + "path": "public/scenes/omega-centauri-volume/datasets/eso0844a.webp", "bytes": 74642 }, { @@ -243,27 +251,27 @@ "bytes": 5604 }, { - "path": "src/objects/smc/prepared/presentation.json", - "bytes": 7624 + "path": "src/objects/smc-volume/prepared/presentation.json", + "bytes": 6026 }, { - "path": "public/scenes/smc/datasets/smc-horalek-wide.webp", + "path": "public/scenes/smc-volume/datasets/smc-horalek-wide.webp", "bytes": 19112 }, { - "path": "public/scenes/smc/datasets/smc-vista.webp", + "path": "public/scenes/smc-volume/datasets/smc-vista.webp", "bytes": 23910 }, { - "path": "public/scenes/smc/datasets/smc-smash.webp", + "path": "public/scenes/smc-volume/datasets/smc-smash.webp", "bytes": 31424 }, { - "path": "public/scenes/smc/datasets/smc-dss2.webp", + "path": "public/scenes/smc-volume/datasets/smc-dss2.webp", "bytes": 27364 }, { - "path": "public/scenes/smc/datasets/smc-allwise-wide.webp", + "path": "public/scenes/smc-volume/datasets/smc-allwise-wide.webp", "bytes": 3552 }, { @@ -292,7 +300,7 @@ }, { "path": "src/objects/local-group/prepared/catalogue.json", - "bytes": 702193 + "bytes": 703991 } ], "images": { @@ -377,281 +385,290 @@ "credit": "NASA/ESA/CSA JWST, MAST", "sourceUrl": "https://www.stsci.edu/jwst/phase2-public/2780.pdf" }, - "helix/eso-vista": { - "url2x": "/navigation/focus-helix-eso-vista@2x.webp", + "helix-volume/eso-vista": { + "url2x": "/navigation/focus-helix-volume-eso-vista@2x.webp", "inputs": [ - "src/objects/helix/prepared/presentation.json", - "public/scenes/helix/datasets/eso-vista.webp" + "src/objects/helix-volume/prepared/presentation.json", + "public/scenes/helix-volume/datasets/eso-vista.webp" ], "credit": "ESO/VISTA/J. Emerson. Acknowledgment: Cambridge Astronomical Survey Unit", "sourceUrl": "https://www.eso.org/public/images/eso1205a/" }, - "helix/eso-wfi": { - "url2x": "/navigation/focus-helix-eso-wfi@2x.webp", + "helix-volume/eso-wfi": { + "url2x": "/navigation/focus-helix-volume-eso-wfi@2x.webp", "inputs": [ - "src/objects/helix/prepared/presentation.json", - "public/scenes/helix/datasets/eso-wfi.webp" + "src/objects/helix-volume/prepared/presentation.json", + "public/scenes/helix-volume/datasets/eso-wfi.webp" ], "credit": "ESO", "sourceUrl": "https://www.eso.org/public/images/eso0907a/" }, - "helix/eso-wide": { - "url2x": "/navigation/focus-helix-eso-wide@2x.webp", + "helix-volume/eso-wide": { + "url2x": "/navigation/focus-helix-volume-eso-wide@2x.webp", "inputs": [ - "src/objects/helix/prepared/presentation.json", - "public/scenes/helix/datasets/eso-wide.webp" + "src/objects/helix-volume/prepared/presentation.json", + "public/scenes/helix-volume/datasets/eso-wide.webp" ], "credit": "ESO", "sourceUrl": "https://www.eso.org/public/images/helix/" }, - "lmc/horalek-widefield": { - "url2x": "/navigation/focus-lmc-horalek-widefield@2x.webp", + "lmc-volume/horalek-widefield": { + "url2x": "/navigation/focus-lmc-volume-horalek-widefield@2x.webp", "inputs": [ - "src/objects/lmc/prepared/presentation.json", - "public/scenes/lmc/datasets/horalek-widefield.webp" + "src/objects/lmc-volume/prepared/presentation.json", + "public/scenes/lmc-volume/datasets/horalek-widefield.webp" ], "credit": "NOIRLab/NSF/AURA/P. Horálek (Institute of Physics in Opava)", "sourceUrl": "https://noirlab.edu/public/images/iotw2547a/" }, - "lmc/vista-infrared": { - "url2x": "/navigation/focus-lmc-vista-infrared@2x.webp", + "lmc-volume/vista-infrared": { + "url2x": "/navigation/focus-lmc-volume-vista-infrared@2x.webp", "inputs": [ - "src/objects/lmc/prepared/presentation.json", - "public/scenes/lmc/datasets/vista-infrared.webp" + "src/objects/lmc-volume/prepared/presentation.json", + "public/scenes/lmc-volume/datasets/vista-infrared.webp" ], "credit": "ESO/VMC Survey", "sourceUrl": "https://www.eso.org/public/images/eso1914a/" }, - "lmc/wise-wide-infrared": { - "url2x": "/navigation/focus-lmc-wise-wide-infrared@2x.webp", + "lmc-volume/wise-wide-infrared": { + "url2x": "/navigation/focus-lmc-volume-wise-wide-infrared@2x.webp", "inputs": [ - "src/objects/lmc/prepared/presentation.json", - "public/scenes/lmc/datasets/wise-wide-infrared.webp" + "src/objects/lmc-volume/prepared/presentation.json", + "public/scenes/lmc-volume/datasets/wise-wide-infrared.webp" ], "credit": "IPAC/NASA; color HiPS by CDS (CNRS/Unistra)", "sourceUrl": "https://irsa.ipac.caltech.edu/onlinehelp/wise/wise/overview.html" }, - "m1/hubble-optical": { - "url2x": "/navigation/focus-m1-hubble-optical@2x.webp", + "m1-volume/hubble-optical": { + "url2x": "/navigation/focus-m1-volume-hubble-optical@2x.webp", "inputs": [ - "src/objects/m1/prepared/presentation.json", - "public/scenes/m1/datasets/hubble-optical.webp" + "src/objects/m1-volume/prepared/presentation.json", + "public/scenes/m1-volume/datasets/hubble-optical.webp" ], "credit": "NASA, ESA and Allison Loll/Jeff Hester (Arizona State University). Acknowledgement: Davide De Martin (ESA/Hubble)", "sourceUrl": "https://esahubble.org/images/heic0515a/" }, - "m1/webb-infrared": { - "url2x": "/navigation/focus-m1-webb-infrared@2x.webp", + "m1-volume/webb-infrared": { + "url2x": "/navigation/focus-m1-volume-webb-infrared@2x.webp", "inputs": [ - "src/objects/m1/prepared/presentation.json", - "public/scenes/m1/datasets/webb-infrared.webp" + "src/objects/m1-volume/prepared/presentation.json", + "public/scenes/m1-volume/datasets/webb-infrared.webp" ], "credit": "NASA, ESA, CSA, STScI, T. Temim (Princeton University)", "sourceUrl": "https://esawebb.org/images/weic2326a/" }, - "m1/webb-components": { - "url2x": "/navigation/focus-m1-webb-components@2x.webp", + "m1-volume/webb-components": { + "url2x": "/navigation/focus-m1-volume-webb-components@2x.webp", "inputs": [ - "src/objects/m1/prepared/presentation.json", - "public/scenes/m1/datasets/webb-components.webp" + "src/objects/m1-volume/prepared/presentation.json", + "public/scenes/m1-volume/datasets/webb-components.webp" ], "credit": "NASA, ESA, CSA, STScI, T. Temim (Princeton University)", "sourceUrl": "https://esawebb.org/images/weic2417a/" }, - "m1/spitzer-infrared": { - "url2x": "/navigation/focus-m1-spitzer-infrared@2x.webp", + "m1-volume/spitzer-infrared": { + "url2x": "/navigation/focus-m1-volume-spitzer-infrared@2x.webp", "inputs": [ - "src/objects/m1/prepared/presentation.json", - "public/scenes/m1/datasets/spitzer-infrared.webp" + "src/objects/m1-volume/prepared/presentation.json", + "public/scenes/m1-volume/datasets/spitzer-infrared.webp" ], "credit": "JPL/Caltech", "sourceUrl": "https://esahubble.org/images/potw1720c/" }, - "m1/vla-radio": { - "url2x": "/navigation/focus-m1-vla-radio@2x.webp", + "m1-volume/vla-radio": { + "url2x": "/navigation/focus-m1-volume-vla-radio@2x.webp", "inputs": [ - "src/objects/m1/prepared/presentation.json", - "public/scenes/m1/datasets/vla-radio.webp" + "src/objects/m1-volume/prepared/presentation.json", + "public/scenes/m1-volume/datasets/vla-radio.webp" ], "credit": "NRAO/AUI/NSF", "sourceUrl": "https://esahubble.org/images/potw1720b/" }, - "m1/chandra-xray": { - "url2x": "/navigation/focus-m1-chandra-xray@2x.webp", + "m1-volume/chandra-xray": { + "url2x": "/navigation/focus-m1-volume-chandra-xray@2x.webp", "inputs": [ - "src/objects/m1/prepared/presentation.json", - "public/scenes/m1/datasets/chandra-xray.webp" + "src/objects/m1-volume/prepared/presentation.json", + "public/scenes/m1-volume/datasets/chandra-xray.webp" ], "credit": "CXC", "sourceUrl": "https://esahubble.org/images/potw1720f/" }, - "m2-9/hst-optical": { - "url2x": "/navigation/focus-m2-9-hst-optical@2x.webp", + "m2-9-volume/hst-optical": { + "url2x": "/navigation/focus-m2-9-volume-hst-optical@2x.webp", "inputs": [ - "src/objects/m2-9/prepared/presentation.json", - "public/scenes/m2-9/datasets/hst-optical.webp" + "src/objects/m2-9-volume/prepared/presentation.json", + "public/scenes/m2-9-volume/datasets/hst-optical.webp" ], "credit": "Bruce Balick (University of Washington), Vincent Icke (Leiden University, The Netherlands), Garrelt Mellema (Stockholm University), and NASA/ESA", "sourceUrl": "https://apod.nasa.gov/apod/ap040201.html" }, - "m31/optical": { - "url2x": "/navigation/focus-m31-optical@2x.webp", + "m31-layers/optical": { + "url2x": "/navigation/focus-m31-layers-optical@2x.webp", "inputs": [ - "src/objects/m31/prepared/presentation.json", - "public/scenes/m31/datasets/optical.webp" + "src/objects/m31-layers/prepared/presentation.json", + "public/scenes/m31-layers/datasets/optical.webp" ], "credit": "NOIRLab · ESA/Hubble", "sourceUrl": "https://noirlab.edu/public/images/noao-m31lgs_ubvIha/" }, - "m33/optical": { - "url2x": "/navigation/focus-m33-optical@2x.webp", + "m33-layers/optical": { + "url2x": "/navigation/focus-m33-layers-optical@2x.webp", "inputs": [ - "src/objects/m33/prepared/presentation.json", - "public/scenes/m33/datasets/optical.webp" + "src/objects/m33-layers/prepared/presentation.json", + "public/scenes/m33-layers/datasets/optical.webp" ], "credit": "ESO", "sourceUrl": "https://www.eso.org/public/images/eso1424a/" }, - "m42/eso-optical": { - "url2x": "/navigation/focus-m42-eso-optical@2x.webp", + "m42-volume/eso-optical": { + "url2x": "/navigation/focus-m42-volume-eso-optical@2x.webp", "inputs": [ - "src/objects/m42/prepared/presentation.json", - "public/scenes/m42/datasets/eso-optical.webp" + "src/objects/m42-volume/prepared/presentation.json", + "public/scenes/m42-volume/datasets/eso-optical.webp" ], "credit": "ESO/G. Beccari", "sourceUrl": "https://www.eso.org/public/images/eso1723a/" }, - "m42/eso-vista": { - "url2x": "/navigation/focus-m42-eso-vista@2x.webp", + "m42-volume/eso-vista": { + "url2x": "/navigation/focus-m42-volume-eso-vista@2x.webp", "inputs": [ - "src/objects/m42/prepared/presentation.json", - "public/scenes/m42/datasets/eso-vista.webp" + "src/objects/m42-volume/prepared/presentation.json", + "public/scenes/m42-volume/datasets/eso-vista.webp" ], "credit": "ESO/J. Emerson/VISTA. Acknowledgment: Cambridge Astronomical Survey Unit", "sourceUrl": "https://www.eso.org/public/images/eso1006a/" }, - "m45/optical-composite": { - "url2x": "/navigation/focus-m45-optical-composite@2x.webp", + "m45-volume/optical-composite": { + "url2x": "/navigation/focus-m45-volume-optical-composite@2x.webp", "inputs": [ - "src/objects/m45/prepared/presentation.json", - "public/scenes/m45/datasets/optical-composite.webp" + "src/objects/m45-volume/prepared/presentation.json", + "public/scenes/m45-volume/datasets/optical-composite.webp" ], "credit": "Taavi Niittee / Tõrva Astronomy Club", "sourceUrl": "https://commons.wikimedia.org/wiki/File:Plejades.jpg" }, - "m45/noirlab-optical": { - "url2x": "/navigation/focus-m45-noirlab-optical@2x.webp", + "m45-volume/noirlab-optical": { + "url2x": "/navigation/focus-m45-volume-noirlab-optical@2x.webp", "inputs": [ - "src/objects/m45/prepared/presentation.json", - "public/scenes/m45/datasets/noirlab-optical.webp" + "src/objects/m45-volume/prepared/presentation.json", + "public/scenes/m45-volume/datasets/noirlab-optical.webp" ], "credit": "NOIRLab/NSF/AURA/T.A. Rector (University of Alaska Anchorage), R. Cool (University of Arizona) and WIYN", "sourceUrl": "https://noirlab.edu/public/images/noao-m45/" }, - "m45/spitzer-irac": { - "url2x": "/navigation/focus-m45-spitzer-irac@2x.webp", + "m45-volume/spitzer-irac": { + "url2x": "/navigation/focus-m45-volume-spitzer-irac@2x.webp", "inputs": [ - "src/objects/m45/prepared/presentation.json", - "public/scenes/m45/datasets/spitzer-irac.webp" + "src/objects/m45-volume/prepared/presentation.json", + "public/scenes/m45-volume/datasets/spitzer-irac.webp" ], "credit": "NASA/JPL-Caltech/J. Stauffer (SSC-Caltech)", "sourceUrl": "https://science.nasa.gov/photojournal/pink-pleiades/" }, - "m45/spitzer-irac-mips": { - "url2x": "/navigation/focus-m45-spitzer-irac-mips@2x.webp", + "m45-volume/spitzer-irac-mips": { + "url2x": "/navigation/focus-m45-volume-spitzer-irac-mips@2x.webp", "inputs": [ - "src/objects/m45/prepared/presentation.json", - "public/scenes/m45/datasets/spitzer-irac-mips.webp" + "src/objects/m45-volume/prepared/presentation.json", + "public/scenes/m45-volume/datasets/spitzer-irac-mips.webp" ], "credit": "NASA/JPL-Caltech/J. Stauffer (SSC-Caltech)", "sourceUrl": "https://science.nasa.gov/photojournal/the-seven-sisters-pose-for-spitzer/" }, - "m45/wise-four-band": { - "url2x": "/navigation/focus-m45-wise-four-band@2x.webp", + "m45-volume/wise-four-band": { + "url2x": "/navigation/focus-m45-volume-wise-four-band@2x.webp", "inputs": [ - "src/objects/m45/prepared/presentation.json", - "public/scenes/m45/datasets/wise-four-band.webp" + "src/objects/m45-volume/prepared/presentation.json", + "public/scenes/m45-volume/datasets/wise-four-band.webp" ], "credit": "NASA/JPL-Caltech/UCLA", "sourceUrl": "https://science.nasa.gov/photojournal/seven-sisters-get-wise/" }, - "m8/eso-optical": { - "url2x": "/navigation/focus-m8-eso-optical@2x.webp", + "m8-volume/eso-optical": { + "url2x": "/navigation/focus-m8-volume-eso-optical@2x.webp", "inputs": [ - "src/objects/m8/prepared/presentation.json", - "public/scenes/m8/datasets/eso-optical.webp" + "src/objects/m8-volume/prepared/presentation.json", + "public/scenes/m8-volume/datasets/eso-optical.webp" ], "credit": "ESO", "sourceUrl": "https://www.eso.org/public/images/eso0936a/" }, - "m8/eso-vista": { - "url2x": "/navigation/focus-m8-eso-vista@2x.webp", + "m8-volume/eso-vista": { + "url2x": "/navigation/focus-m8-volume-eso-vista@2x.webp", "inputs": [ - "src/objects/m8/prepared/presentation.json", - "public/scenes/m8/datasets/eso-vista.webp" + "src/objects/m8-volume/prepared/presentation.json", + "public/scenes/m8-volume/datasets/eso-vista.webp" ], "credit": "ESO/VVV. Acknowledgment: Cambridge Astronomical Survey Unit", "sourceUrl": "https://www.eso.org/public/images/eso1101d/" }, - "m8/spitzer-mid-infrared": { - "url2x": "/navigation/focus-m8-spitzer-mid-infrared@2x.webp", + "m8-volume/spitzer-mid-infrared": { + "url2x": "/navigation/focus-m8-volume-spitzer-mid-infrared@2x.webp", "inputs": [ - "src/objects/m8/prepared/presentation.json", - "public/scenes/m8/datasets/spitzer-mid-infrared.webp" + "src/objects/m8-volume/prepared/presentation.json", + "public/scenes/m8-volume/datasets/spitzer-mid-infrared.webp" ], "credit": "NASA/JPL-Caltech; CDS", "sourceUrl": "https://irsa.ipac.caltech.edu/data/SPITZER/docs/irac/" }, - "m81/optical": { - "url2x": "/navigation/focus-m81-optical@2x.webp", + "m81-layers/optical": { + "url2x": "/navigation/focus-m81-layers-optical@2x.webp", "inputs": [ - "src/objects/m81/prepared/presentation.json", - "public/scenes/m81/datasets/optical.webp" + "src/objects/m81-layers/prepared/presentation.json", + "public/scenes/m81-layers/datasets/optical.webp" ], "credit": "NOIRLab", "sourceUrl": "https://noirlab.edu/public/images/noao-m81m82/" }, - "m83/optical": { - "url2x": "/navigation/focus-m83-optical@2x.webp", + "m83-layers/optical": { + "url2x": "/navigation/focus-m83-layers-optical@2x.webp", "inputs": [ - "src/objects/m83/prepared/presentation.json", - "public/scenes/m83/datasets/optical.webp" + "src/objects/m83-layers/prepared/presentation.json", + "public/scenes/m83-layers/datasets/optical.webp" ], "credit": "ESO/IDA/Danish 1.5 m/R. Gendler, S. Guisard and C. Thöne", "sourceUrl": "https://www.eso.org/public/images/m83/" }, - "ngc-253/optical": { - "url2x": "/navigation/focus-ngc-253-optical@2x.webp", + "m87-volume/eso-optical": { + "url2x": "/navigation/focus-m87-volume-eso-optical@2x.webp", + "inputs": [ + "src/objects/m87-volume/prepared/presentation.json", + "public/scenes/m87-volume/datasets/eso-optical.webp" + ], + "credit": "Chris Mihos/ESO", + "sourceUrl": "https://www.eso.org/public/images/eso1525a/" + }, + "ngc-253-layers/optical": { + "url2x": "/navigation/focus-ngc-253-layers-optical@2x.webp", "inputs": [ - "src/objects/ngc-253/prepared/presentation.json", - "public/scenes/ngc-253/datasets/optical.webp" + "src/objects/ngc-253-layers/prepared/presentation.json", + "public/scenes/ngc-253-layers/datasets/optical.webp" ], "credit": "ESO", "sourceUrl": "https://www.eso.org/public/images/eso0902c/" }, - "ngc-300/optical": { - "url2x": "/navigation/focus-ngc-300-optical@2x.webp", + "ngc-300-layers/optical": { + "url2x": "/navigation/focus-ngc-300-layers-optical@2x.webp", "inputs": [ - "src/objects/ngc-300/prepared/presentation.json", - "public/scenes/ngc-300/datasets/optical.webp" + "src/objects/ngc-300-layers/prepared/presentation.json", + "public/scenes/ngc-300-layers/datasets/optical.webp" ], "credit": "ESO", "sourceUrl": "https://www.eso.org/public/images/eso1037a/" }, - "omega-centauri/eso1119b": { - "url2x": "/navigation/focus-omega-centauri-eso1119b@2x.webp", + "omega-centauri-volume/eso1119b": { + "url2x": "/navigation/focus-omega-centauri-volume-eso1119b@2x.webp", "inputs": [ - "src/objects/omega-centauri/prepared/presentation.json", - "public/scenes/omega-centauri/datasets/eso1119b.webp" + "src/objects/omega-centauri-volume/prepared/presentation.json", + "public/scenes/omega-centauri-volume/datasets/eso1119b.webp" ], "credit": "ESO/INAF-VST/OmegaCAM. Acknowledgement: A. Grado, L. Limatola/INAF-Capodimonte Observatory", "sourceUrl": "https://www.eso.org/public/images/eso1119b/" }, - "omega-centauri/eso0844a": { - "url2x": "/navigation/focus-omega-centauri-eso0844a@2x.webp", + "omega-centauri-volume/eso0844a": { + "url2x": "/navigation/focus-omega-centauri-volume-eso0844a@2x.webp", "inputs": [ - "src/objects/omega-centauri/prepared/presentation.json", - "public/scenes/omega-centauri/datasets/eso0844a.webp" + "src/objects/omega-centauri-volume/prepared/presentation.json", + "public/scenes/omega-centauri-volume/datasets/eso0844a.webp" ], "credit": "ESO", "sourceUrl": "https://www.eso.org/public/images/eso0844a/" @@ -665,47 +682,47 @@ "credit": "ALMA (ESO/NAOJ/NRAO)", "sourceUrl": "https://almascience.eso.org/aq/?projectCode=2018.A.00030.S" }, - "smc/smc-horalek-wide": { - "url2x": "/navigation/focus-smc-smc-horalek-wide@2x.webp", + "smc-volume/smc-horalek-wide": { + "url2x": "/navigation/focus-smc-volume-smc-horalek-wide@2x.webp", "inputs": [ - "src/objects/smc/prepared/presentation.json", - "public/scenes/smc/datasets/smc-horalek-wide.webp" + "src/objects/smc-volume/prepared/presentation.json", + "public/scenes/smc-volume/datasets/smc-horalek-wide.webp" ], "credit": "NOIRLab/NSF/AURA/P. Horálek (Institute of Physics in Opava)", "sourceUrl": "https://noirlab.edu/public/images/iotw2615a/" }, - "smc/smc-vista": { - "url2x": "/navigation/focus-smc-smc-vista@2x.webp", + "smc-volume/smc-vista": { + "url2x": "/navigation/focus-smc-volume-smc-vista@2x.webp", "inputs": [ - "src/objects/smc/prepared/presentation.json", - "public/scenes/smc/datasets/smc-vista.webp" + "src/objects/smc-volume/prepared/presentation.json", + "public/scenes/smc-volume/datasets/smc-vista.webp" ], "credit": "ESO/VISTA VMC", "sourceUrl": "https://www.eso.org/public/images/eso1714a/" }, - "smc/smc-smash": { - "url2x": "/navigation/focus-smc-smc-smash@2x.webp", + "smc-volume/smc-smash": { + "url2x": "/navigation/focus-smc-volume-smc-smash@2x.webp", "inputs": [ - "src/objects/smc/prepared/presentation.json", - "public/scenes/smc/datasets/smc-smash.webp" + "src/objects/smc-volume/prepared/presentation.json", + "public/scenes/smc-volume/datasets/smc-smash.webp" ], "credit": "CTIO/NOIRLab/NSF/AURA/SMASH/D. Nidever (Montana State University); image processing Travis Rector (University of Alaska Anchorage), Mahdi Zamani and Davide de Martin", "sourceUrl": "https://noirlab.edu/public/images/noirlab2030b/" }, - "smc/smc-dss2": { - "url2x": "/navigation/focus-smc-smc-dss2@2x.webp", + "smc-volume/smc-dss2": { + "url2x": "/navigation/focus-smc-volume-smc-dss2@2x.webp", "inputs": [ - "src/objects/smc/prepared/presentation.json", - "public/scenes/smc/datasets/smc-dss2.webp" + "src/objects/smc-volume/prepared/presentation.json", + "public/scenes/smc-volume/datasets/smc-dss2.webp" ], "credit": "ESA/Hubble and Digitized Sky Survey 2; acknowledgement Davide De Martin (ESA/Hubble)", "sourceUrl": "https://esahubble.org/images/heic0514c/" }, - "smc/smc-allwise-wide": { - "url2x": "/navigation/focus-smc-smc-allwise-wide@2x.webp", + "smc-volume/smc-allwise-wide": { + "url2x": "/navigation/focus-smc-volume-smc-allwise-wide@2x.webp", "inputs": [ - "src/objects/smc/prepared/presentation.json", - "public/scenes/smc/datasets/smc-allwise-wide.webp" + "src/objects/smc-volume/prepared/presentation.json", + "public/scenes/smc-volume/datasets/smc-allwise-wide.webp" ], "credit": "IPAC/NASA; colour HiPS CDS (CNRS/Unistra)", "sourceUrl": "https://alasky.cds.unistra.fr/MocServer/query?ID=CDS%2FP%2FallWISE%2Fcolor&get=record&fmt=json" @@ -736,22 +753,23 @@ "betelgeuse-shell": "betelgeuse-shell/zimpol-v", "eps-eridani-disc": "eps-eridani-disc/dust", "hd-181327-disc": "hd-181327-disc/reflectance", - "helix": "helix/eso-wfi", - "lmc": "lmc/horalek-widefield", - "m1": "m1/hubble-optical", - "m2-9": "m2-9/hst-optical", - "m31": "m31/optical", - "m33": "m33/optical", - "m42": "m42/eso-optical", - "m45": "m45/optical-composite", - "m8": "m8/eso-optical", - "m81": "m81/optical", - "m83": "m83/optical", - "ngc-253": "ngc-253/optical", - "ngc-300": "ngc-300/optical", - "omega-centauri": "omega-centauri/eso1119b", + "helix-volume": "helix-volume/eso-wfi", + "lmc-volume": "lmc-volume/horalek-widefield", + "m1-volume": "m1-volume/hubble-optical", + "m2-9-volume": "m2-9-volume/hst-optical", + "m31-layers": "m31-layers/optical", + "m33-layers": "m33-layers/optical", + "m42-volume": "m42-volume/eso-optical", + "m45-volume": "m45-volume/optical-composite", + "m8-volume": "m8-volume/eso-optical", + "m81-layers": "m81-layers/optical", + "m83-layers": "m83-layers/optical", + "m87-volume": "m87-volume/eso-optical", + "ngc-253-layers": "ngc-253-layers/optical", + "ngc-300-layers": "ngc-300-layers/optical", + "omega-centauri-volume": "omega-centauri-volume/eso1119b", "pds-70-disc": "pds-70-disc/dust", - "smc": "smc/smc-horalek-wide", + "smc-volume": "smc-volume/smc-horalek-wide", "sun-cor1-density": "sun-cor1-density/electron-density", "milky-way": "milky-way/volume" } diff --git a/public/navigation/smc-context.webp b/public/navigation/smc-context.webp new file mode 100644 index 00000000000..7cadaf96ec5 Binary files /dev/null and b/public/navigation/smc-context.webp differ diff --git a/public/navigation/virgo-cluster-context.webp b/public/navigation/virgo-cluster-context.webp new file mode 100644 index 00000000000..fb6dfb21d40 Binary files /dev/null and b/public/navigation/virgo-cluster-context.webp differ diff --git a/site/application-world-context.mts b/site/application-world-context.mts index a0996f0dc84..78f468e55c1 100644 --- a/site/application-world-context.mts +++ b/site/application-world-context.mts @@ -8,7 +8,6 @@ import type { PreparedWorldCameraFrame } from '@cssearth/renderer/navigation/wor import { PREPARED_WORLD_PRESENTATION } from './prepared-world-presentation.mts'; import { APPLICATION_WORLD_CONTEXT as applicationContext, streamWorldSystems } from './world-context-plan.mts'; import { DIAGNOSTICS_ENABLED } from './diagnostics-policy.mts'; -import { createPreparedContextNavigation } from './prepared-context-navigation.mts'; import { CONTEXT_AVAILABILITY } from './context-availability.mts'; import { suppressMinorMoonOrbitPaint } from './moon-orbit-policy.mts'; import { mountCatalogueMoonLabels } from './catalogue-moon-labels.mts'; @@ -23,7 +22,7 @@ import type { ApplicationWorldLayer } from './application-world-types.mts'; export function createApplicationWorldContext() { return { - async mount({ stage, viewport, signal, onSelectFocus, windowTarget = stage.ownerDocument.defaultView }: { stage: HTMLElement; viewport: ReturnType; signal?: AbortSignal; onSelectFocus(id: string): void; windowTarget?: Window | null }) { + async mount({ stage, viewport, signal, windowTarget = stage.ownerDocument.defaultView }: { stage: HTMLElement; viewport: ReturnType; signal?: AbortSignal; windowTarget?: Window | null }) { const target = stage.ownerDocument.defaultView; if (!target || !windowTarget) throw new Error('World context requires a window.'); const lifetime = createSceneLifetime(); @@ -49,15 +48,11 @@ export function createApplicationWorldContext() { const presentationHost = stage.closest('.object-world-stage') ?? stage; const layer = own(prepared.mount(stage, { presentationHost, requestPublication: () => refreshWorld(), - onSelectGalaxy: object => { if (!lifetime.disposed) onSelectFocus(object.id); }, })); const occlusion = labelOcclusionFor(stage.ownerDocument); const updateOcclusion = () => { if (!lifetime.disposed) layer.setLabelBlockers(occlusion.read()); }; updateOcclusion(); lifetime.onDispose(occlusion.subscribe(updateOcclusion)); - const contextNavigation = own(createPreparedContextNavigation({ layer, presentation: PREPARED_WORLD_PRESENTATION.galaxies, - unavailableObjectIds: Object.entries(CONTEXT_AVAILABILITY).filter(([, state]) => !state.available).map(([id]) => id), - sources: prepared.catalogSources, windowTarget })); lifetime.onDispose(suppressMinorMoonOrbitPaint(presentationHost, worldVisibilityPolicy.minorMoonIds)); const planner = own(prepared.createFramePlanner()); // The systems this page does not show arrive after its first view (startup-gate.ts), in a few batches; each joins @@ -95,8 +90,6 @@ export function createApplicationWorldContext() { present: frames.present, createFramePresenter: frames.createFramePresenter, setNavigationInFlight: frames.setNavigationInFlight, - connectNavigation: contextNavigation.connect, - applyFocus: contextNavigation.apply, previewSelection(id?: string | null, framingScale?: number, edge?: PreparedLabelEdge) { if (!lifetime.disposed) layer.previewSelection(id, framingScale, edge); }, diff --git a/site/application-world-resources.mts b/site/application-world-resources.mts index 9d6a350ce09..e31779a3717 100644 --- a/site/application-world-resources.mts +++ b/site/application-world-resources.mts @@ -12,7 +12,7 @@ import { CONTEXT_OBJECT_ASSET_URLS, CONTEXT_OBJECT_DESCRIPTORS } from './prepare import { CONTEXT_AVAILABILITY } from './context-availability.mts'; import { PREPARED_WORLD_PRESENTATION } from './prepared-world-presentation.mts'; import { createInFlightLoader } from './in-flight-loader.mts'; -import { loadFocusCatalogs } from './focus-catalog.mts'; +import { loadDotCatalogues } from './dot-catalogues.mts'; import { worldVisibilityPolicy } from './application-world-visibility.mts'; import { STELLAR_EXTENTS } from './stellar-extents.mts'; import { readPageDatasets, selectedPageDataset } from './page-datasets.mts'; @@ -35,22 +35,21 @@ const ASTEROID_MINIMUM_PIXELS = 2, PLAIN_DOT_MINIMUM_PIXELS = 1.5; const { annotationOpacities, annotationPriorities, asteroidIds, ordinaryAsteroidIds, plainDotIds, compact: phone } = worldVisibilityPolicy; const ASTRONOMICAL_UNIT_M = 149_597_870_700, PARSEC_M = 3.085677581491367e16; -// Published catalogues drawn through a volume bank, with its opacity (src/objects/m87/README.md). +// Published catalogues drawn through a volume bank, with its opacity (src/objects/m87-volume/README.md). const VOLUME_CATALOGUE_POINTS: Readonly> = { - m87: ['dots'], + 'm87-volume': ['dots'], }; // Inventory of prepared resources, not navigation entries or runtime generators. type ApplicationUniverse = ReturnType & { loadShells(): Promise<{ payload: Awaited>; resolveResource(path: string): string }[]>; - catalogSources(): Awaited>['galaxies']['sources']; }; let universePromise: Promise | null = null; export function loadApplicationUniverse(): Promise { universePromise ??= (async () => { - let catalogs: Awaited> | null = null; - let catalogsLoading: Promise>> | null = null; - const loadCatalogs = () => catalogs ? Promise.resolve(catalogs) : catalogsLoading ??= loadFocusCatalogs(location.origin) + let catalogs: Awaited> | null = null; + let catalogsLoading: Promise>> | null = null; + const loadCatalogs = () => catalogs ? Promise.resolve(catalogs) : catalogsLoading ??= loadDotCatalogues(location.origin) .then(value => catalogs = value).finally(() => { catalogsLoading = null; }); // Only the context objects' folders are globbed; bodies share src/objects but are not world resources. const descriptors = CONTEXT_OBJECT_DESCRIPTORS, assets = CONTEXT_OBJECT_ASSET_URLS; @@ -187,7 +186,6 @@ export function loadApplicationUniverse(): Promise { firstPlanner = null; return planner; }, - catalogSources: () => catalogs ? [...catalogs.galaxies.sources, ...catalogs.clusters.sources, ...catalogs.nebulae.sources] : [], }; })().catch(error => { universePromise = null; throw error; }); return universePromise; diff --git a/site/application-world-visibility.mts b/site/application-world-visibility.mts index 9da1399b98f..a146156148d 100644 --- a/site/application-world-visibility.mts +++ b/site/application-world-visibility.mts @@ -76,7 +76,7 @@ export const worldVisibilityPolicy = { }; /** Visibility of retained world bodies, labels and highlights. */ -export function createApplicationWorldVisibility(layer: Pick, lifetime: SceneLifetime) { +export function createApplicationWorldVisibility(layer: Pick, lifetime: SceneLifetime) { let illustrations = false; let highlighted: string | null = null; let selectedCometId: string | null = null; @@ -122,7 +122,6 @@ export function createApplicationWorldVisibility(layer: Pick. An object with no surface has no picture of its own: its +// list marker is drawn from the bank its default dataset shows. This writes that picture as the object's marker source, +// `source/presentation/context.png`, which its source manifest records with this generator. +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import sharp from 'sharp'; +import { companionThumbnails } from './surfaceless-scene.ts'; + +const root = resolve(import.meta.dirname, '../../..'), [id] = process.argv.slice(2); +if (!id || !/^[a-z][a-z0-9-]*$/u.test(id)) throw new TypeError('Usage: companion-context.mts '); +const objectDirectory = resolve(root, 'src/objects', id), presentation = resolve(objectDirectory, 'source/presentation'); +const content = JSON.parse(await readFile(resolve(objectDirectory, 'source/content/object.json'), 'utf8')) as { datasets: { defaultDataset: string; controls: { id: string; thumbnail?: string; volume?: unknown }[] } }; +const shown = content.datasets.controls.find(control => control.id === content.datasets.defaultDataset); +if (!shown?.volume) throw new TypeError(`src/objects/${id}/source/content/object.json: the default dataset shows no companion bank to picture.`); +// The companion's picture, as the dataset thumbnail draws it, saved as PNG beside the other presentation sources. +await companionThumbnails({ objectDirectory, publicDirectory: presentation, content: { datasets: { controls: [{ ...shown, thumbnail: 'context.webp' }] } } }); +await sharp(resolve(presentation, 'context.webp')).png().toFile(resolve(presentation, 'context.png')); +await (await import('node:fs/promises')).rm(resolve(presentation, 'context.webp')); +console.log(`src/objects/${id}/source/presentation/context.png`); diff --git a/site/build/prepare/prepare-authored.ts b/site/build/prepare/prepare-authored.ts index edcadb5cf07..71a43a3c515 100644 --- a/site/build/prepare/prepare-authored.ts +++ b/site/build/prepare/prepare-authored.ts @@ -79,9 +79,11 @@ export async function prepareAuthoredObject({ objectDirectory, publicDirectory, const result = await prepareAuthoredStages({ objectDirectory, publicDirectory, outputDirectory, write, replaceReviewedImages, reuseImages, acceptChanged }); if (write || !result.definition) return result; const { prepareSurfaceMinimaps } = await import('@cssearth/bake/surface-previews'); - // Minimaps render from the raw imagery; a reuse-images stage already carries the published ones. - if (!reuseImages) await prepareSurfaceMinimaps({ objectDirectory, publicDirectory, outputDirectory, solarGeometry: await solarGeometry() }); - if (!reuseImages) await assertMapsStartAtSurfaceMapEdge(objectDirectory, outputDirectory); + // Minimaps render from the raw imagery; a reuse-images stage already carries the published ones. An object with no + // surface has no map to draw. + const surfaced = result.descriptor.recipe.surfaces.length > 0; + if (!reuseImages && surfaced) await prepareSurfaceMinimaps({ objectDirectory, publicDirectory, outputDirectory, solarGeometry: await solarGeometry() }); + if (!reuseImages && surfaced) await assertMapsStartAtSurfaceMapEdge(objectDirectory, outputDirectory); const prepared = await prepareWorldNavigationDefinition({ objectDirectory, definition: result.definition as Record }); await assertDefaultViewsFaceDatasets(objectDirectory, prepared.definition as Record, prepared.frame); const scene = await writeWorldNavigationArtifacts(outputDirectory, prepared, result.scene as Record | undefined); @@ -278,6 +280,19 @@ async function prepareAuthoredStages({ objectDirectory, publicDirectory, outputD const genericLaneOnly = () => { if (descriptor.recipe.features) throw new TypeError('Surface features are prepared by the generic authored lane only.'); }; await mkdir(outputDirectory, { recursive: true }); const sourceDirectory = resolve(objectDirectory, 'source'); + if (!descriptor.recipe.surfaces.length) { + // No surface to bake: the scene is the camera, sky and world frame, and its datasets show companion banks. + const { prepareSurfacelessScene, companionThumbnails } = await import('./surfaceless-scene.ts'); + // Each dataset's thumbnail is a picture of the bank it shows, published as the object's own file. + await companionThumbnails({ objectDirectory, publicDirectory, content: required(sources, 'content').value }); + const content = await prepareObjectContentAssets({ sourceDirectory, publicDirectory, outputDirectory, config: { contentPath: relative(sourceDirectory, required(sources, 'content').path) } }); + const prepared = await prepareSurfacelessScene({ source: physicalSolarSource(required(sources, 'solar-system').value), controls: content.controls as never, solarGeometry: await solarGeometry() as never }); + await writeFile(resolve(outputDirectory, 'runtime.json'), `${JSON.stringify(prepared.definition)}\n`); + await prepareRuntimeManifest({ id: descriptor.id, publicRoot: publicDirectory, objectDirectory: write ? objectDirectory : outputDirectory, + preparedDirectory: outputDirectory, values: [prepared.definition, content] }); + if (write) await writePreparedObject(descriptor.id, prepared.definition as unknown as Record); + return Object.freeze({ descriptor, sources, definition: prepared.definition, scene: prepared.scene }) as never; + } if ((source(sources, 'geometry')?.value as Record | undefined)?.schema === 'cssearth-layered-oblate-preparation@2') { genericLaneOnly(); const { prepareLayeredOblateObject } = await import('@cssearth/bake/objects/layers/material-composition'); diff --git a/site/build/prepare/prepare-catalog.mts b/site/build/prepare/prepare-catalog.mts index e90543272ab..e2fdba3104c 100644 --- a/site/build/prepare/prepare-catalog.mts +++ b/site/build/prepare/prepare-catalog.mts @@ -4,7 +4,7 @@ import { pathToFileURL } from 'node:url'; import { defineObjects, type CatalogEntry } from '@cssearth/objects'; import { PREPARED_CATALOGUE, preparedCatalogueModule, readCatalog, readContextObjects, readObjectDescriptors, readOverviews } from '@cssearth/objects/node'; import { hasErrorCode, isRecord } from '@cssearth/core'; -import { prepareSceneDistance, readPreparedFocusObjects } from '@cssearth/bake/navigation'; +import { prepareSceneDistance } from '@cssearth/bake/navigation'; import { prepareObjectDiscovery } from './prepare-object-discovery.mts'; import { BODIES, M_PER_PC } from '@cssearth/astronomy'; @@ -53,7 +53,7 @@ export async function contextObjectAssetUrls(contexts: readonly { id: string }[] return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right, 'en'))); } -/** The frame of every volume a body shows through one of its datasets, so an opening camera can fit it. */ +/** The frame of every bank a body shows through one of its datasets, when the bank declares one, so an opening camera can fit it. */ async function readDatasetVolumes(entries: readonly CatalogEntry[], projectRoot: string) { const volumes: Record = {}; for (const { id } of entries) { @@ -65,10 +65,9 @@ async function readDatasetVolumes(entries: readonly CatalogEntry[], projectRoot: const volumeId = isRecord(control) && isRecord(control.volume) ? control.volume.objectId : undefined; if (typeof volumeId !== 'string' || volumes[volumeId]) continue; const descriptor: unknown = JSON.parse(await readFile(resolve(projectRoot, 'src/objects', volumeId, 'object.json'), 'utf8')); - if (!isRecord(descriptor) || !isRecord(descriptor.properties) || !isRecord(descriptor.properties.frame)) { - throw new TypeError(`src/objects/${volumeId}/object.json: the volume ${id} shows has no properties.frame.`); - } - volumes[volumeId] = descriptor.properties.frame; + if (!isRecord(descriptor) || !isRecord(descriptor.properties)) throw new TypeError(`src/objects/${volumeId}/object.json: the bank ${id} shows has no properties.`); + // A bank of catalogue dots declares no frame: its object's own radius frames it. + if (isRecord(descriptor.properties.frame)) volumes[volumeId] = descriptor.properties.frame; } } return volumes; @@ -107,10 +106,11 @@ async function readLocalGroupGalaxies(projectRoot: string) { if (detail.focusRadiusM === undefined || members && !members.has(detail.id)) continue; if (typeof detail.focusRadiusM !== 'number' || !(detail.focusRadiusM > 0)) throw new TypeError(`${recipePath}: detailObjects.${row}.focusRadiusM is ${String(detail.focusRadiusM)}, not a positive number.`); const descriptor: unknown = JSON.parse(await readFile(resolve(projectRoot, 'src/objects', detail.id, 'object.json'), 'utf8')); - if (!isRecord(descriptor) || !isRecord(descriptor.properties) || !isRecord(descriptor.properties.frame)) { - throw new TypeError(`src/objects/${detail.id}/object.json: the Local Group galaxy ${row} has no properties.frame.`); + // A galaxy is an object of the world: its world frame places it. + if (!isRecord(descriptor) || !isRecord(descriptor.properties) || !isRecord(descriptor.properties.worldFrame)) { + throw new TypeError(`src/objects/${detail.id}/object.json: the Local Group galaxy ${row} has no properties.worldFrame.`); } - const originM = descriptor.properties.frame.originM; + const originM = descriptor.properties.worldFrame.originM; if (within !== undefined && Array.isArray(originM) && Math.hypot(...originM.map(Number)) > within * M_PER_PC) continue; galaxies[detail.id] = { originM, radiusM: detail.focusRadiusM }; } @@ -150,13 +150,13 @@ async function readStellarExtents(entries: readonly { id: string }[], projectRoo export async function prepareCatalog({ projectRoot = root } = {}) { // Every descriptor once, shared by each read below. const descriptors = await readObjectDescriptors(resolve(projectRoot, 'src/objects')); - const entries = await readCatalog(resolve(projectRoot, 'src/objects'), prepareSceneDistance, descriptors); - const host = entries.find(entry => entry.classification === 'star' && entry.distance.meters === 0); - if (!host) throw new TypeError('Prepared focus destinations need a shared world host.'); - const focuses = await readPreparedFocusObjects(resolve(projectRoot, 'src/objects'), host.id); - // The levels above the star systems are drawn by the same shared world host as every catalogue focus. - const overviews = await readOverviews(resolve(projectRoot, 'src/objects'), host.id, descriptors); - defineObjects<{ id: string; route: string }>([...entries, ...focuses, ...overviews]); + const catalogued = await readCatalog(resolve(projectRoot, 'src/objects'), prepareSceneDistance, descriptors); + // A level of the zoom ladder is a package's entry like any other; one that is placed too (the Local Group) is in both reads. + const overviews = await readOverviews(resolve(projectRoot, 'src/objects'), descriptors); + const levelIds = new Set(overviews.map(overview => overview.id)); + const entries = catalogued.filter(entry => !levelIds.has(entry.id)); + // The spatial catalogues are data: they name no destination. Every page is a package's own entry. + defineObjects<{ id: string; route: string }>([...entries, ...overviews.map(({ id }) => ({ id, route: `/${id}/` }))]); const discoveries = await Promise.all(entries.map(async ({ id }) => { return prepareObjectDiscovery(descriptors.get(id), resolve(projectRoot, 'src/objects', id)); })); @@ -168,9 +168,21 @@ export async function prepareCatalog({ projectRoot = root } = {}) { if (entry.classification !== 'star' || discovery.imagery) continue; if ([...withImagery].some(child => parents[child]?.parent === entry.id)) discovery.hostsImagery = true; } - await writeGenerated(resolve(projectRoot, PREPARED_CATALOGUE.entries), - preparedCatalogueModule(entries.map(({ id, distance }, index) => ({ descriptor: descriptors.get(id), distance, discovery: discoveries[index]! })), focuses)); - await writeGenerated(resolve(projectRoot, PREPARED_CATALOGUE.overviews), JSON.stringify(overviews) + '\n'); + // One row shape for every object: its descriptor, its distance and its discovery. + await writeGenerated(resolve(projectRoot, PREPARED_CATALOGUE.entries), preparedCatalogueModule(entries.map(({ id, distance }, index) => { + const descriptor = descriptors.get(id); + return { descriptor, distance, discovery: discoveries[index]! }; + }))); + // Every page reads the levels, so their rows are apart and carry only what an entry reads of a descriptor. + await writeGenerated(resolve(projectRoot, PREPARED_CATALOGUE.overviews), JSON.stringify(overviews.map(({ id, descriptor }) => { + const { schema, type, properties } = descriptor as { schema: unknown; type: unknown; properties: Record }; + const placed = catalogued.find(entry => entry.id === id), volume = properties.volume; + return { descriptor: { schema, id, type, properties: { overview: properties.overview, + ...(properties.catalog === undefined ? {} : { catalog: properties.catalog, worldFrame: properties.worldFrame }), + ...(isRecord(volume) && volume.originM !== undefined ? { volume: { originM: volume.originM } } : {}) } }, + // A placed level is a landmark of the scale above it, without imagery of its own. + ...(placed ? { distance: placed.distance, discovery: { featured: true, imagery: false, illustration: false } } : {}) }; + })) + '\n'); await writeGenerated(resolve(projectRoot, 'site/prepared-dataset-volumes.json'), JSON.stringify(await readDatasetVolumes(entries, projectRoot)) + '\n'); await writeGenerated(resolve(projectRoot, 'site/prepared-local-group-galaxies.json'), JSON.stringify(await readLocalGroupGalaxies(projectRoot)) + '\n'); const contexts = await readContextObjects(resolve(projectRoot, 'src/objects'), descriptors); diff --git a/site/build/prepare/prepare-facilities.mts b/site/build/prepare/prepare-facilities.mts index d6e6c2753e9..d929a31c8b3 100644 --- a/site/build/prepare/prepare-facilities.mts +++ b/site/build/prepare/prepare-facilities.mts @@ -33,7 +33,7 @@ export const explorationCompilerClosure = [ 'site/build/prepare/prepare-facilities.mts', 'packages/bake/src/sources/spatial-source-citations.ts', 'packages/catalog/src/spatial.ts', 'packages/catalog/src/spatial-relations.ts', 'packages/catalog/src/clusters.ts', 'packages/objects/src/provenance/exploration-catalog.ts', 'packages/objects/src/provenance/exploration-contributions.ts', 'packages/objects/src/provenance/prepared-exploration.ts', 'packages/objects/src/provenance/object-lineage.ts', 'packages/objects/src/provenance/product-input-evidence.ts', 'packages/objects/src/node/prepared-registry.ts', 'packages/objects/src/registry/object-schema.ts', 'packages/objects/src/registry/object-catalog.ts', 'site/prepared-catalogue.mjs', 'site/build/prepare/prepare-catalog.mts', 'packages/objects/src/node/catalog-directory.ts', - 'packages/objects/src/registry/prepared-focus-object.ts', 'packages/objects/src/registry/navigation-distance.ts', 'packages/bake/src/navigation/navigation-destinations.ts', + 'packages/objects/src/registry/navigable-object.ts', 'packages/objects/src/registry/navigation-distance.ts', 'packages/bake/src/navigation/navigation-destinations.ts', 'packages/objects/src/registry/overview-object.ts', 'site/prepared-overview-objects.json', 'site/source/facilities/catalog.json', 'site/source/facilities/render-library.json', 'site/source/facilities/emblem-library.json', 'site/source/agency-logos.json', 'packages/bake/src/sources/read-source-catalogue.ts', diff --git a/site/build/prepare/prepare-object-discovery.mts b/site/build/prepare/prepare-object-discovery.mts index 89a9f907c18..8edde87acab 100644 --- a/site/build/prepare/prepare-object-discovery.mts +++ b/site/build/prepare/prepare-object-discovery.mts @@ -96,9 +96,28 @@ export async function preparedRuntimeCamera(path: string): Promise { } finally { await handle.close(); } } +/** What a bank shows is its observations: images and volumes built from them are imagery; a bank of catalogue points is + * measured, not pictured. */ +const DATASET_PACKAGE_IMAGERY: Readonly> = { 'image-layer-bank': true, 'volume-dataset-bank': true, 'catalogue-point-bank': false }; + export async function prepareObjectDiscovery(descriptor: unknown, objectDirectory: string) { + if (isRecord(descriptor) && isRecord(descriptor.properties) && isRecord(descriptor.properties.recipe) && Array.isArray(descriptor.properties.recipe.surfaces) && !descriptor.properties.recipe.surfaces.length) { + // An object with no surface shows the banks its datasets name: it is pictured when one of them is imagery. It is a + // place on the map by itself, so it is featured. + const content: unknown = JSON.parse(await readFile(resolve(objectDirectory, 'source/content/object.json'), 'utf8')); + const controls = isRecord(content) && isRecord(content.datasets) && Array.isArray(content.datasets.controls) ? content.datasets.controls : []; + const banks = new Set(controls.flatMap(control => isRecord(control) && isRecord(control.volume) && typeof control.volume.objectId === 'string' ? [control.volume.objectId] : [])); + let imagery = false; + for (const bank of banks) { + const companion: unknown = JSON.parse(await readFile(resolve(objectDirectory, '..', bank, 'object.json'), 'utf8')); + if (!isRecord(companion) || typeof companion.type !== 'string' || !Object.hasOwn(DATASET_PACKAGE_IMAGERY, companion.type)) throw new TypeError(`src/objects/${bank}/object.json: ${String(descriptor.id)} shows it as a dataset, but it is not a bank package.`); + imagery ||= DATASET_PACKAGE_IMAGERY[companion.type]!; + } + const policy = discoveryPolicy(descriptor.properties.catalog); + return { imagery, illustration: false, featured: true, ...(policy.orientationReference === undefined ? {} : { orientationReference: policy.orientationReference }) }; + } if (!isRecord(descriptor) || !isRecord(descriptor.properties) || !isRecord(descriptor.properties.recipe) || - !Array.isArray(descriptor.properties.recipe.sources)) throw new TypeError('Missing discovery recipe sources.'); + !Array.isArray(descriptor.properties.recipe.sources)) throw new TypeError(`Missing discovery recipe sources: ${isRecord(descriptor) ? String(descriptor.id) : 'unknown object'}.`); const inputs: unknown[] = []; for (const source of descriptor.properties.recipe.sources) { if (!isRecord(source) || typeof source.id !== 'string' || typeof source.path !== 'string') throw new TypeError('Invalid discovery recipe source.'); diff --git a/site/build/prepare/prepare-spatial-context.ts b/site/build/prepare/prepare-spatial-context.ts index f16c225feee..58f998f89a7 100644 --- a/site/build/prepare/prepare-spatial-context.ts +++ b/site/build/prepare/prepare-spatial-context.ts @@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { BODIES, EXOPLANET_IDS, HOSTED_PLANET_IDS, M_PER_AU, M_PER_KM, SOLAR_EFFECTIVE_TEMPERATURE_K, SOLAR_RADIUS_M, STAR_IDS, isSceneSatellite, sceneSatelliteStateKm, starAstrometry } from '@cssearth/astronomy'; import type { StarId } from '@cssearth/astronomy'; -import { mapLabel, parseObjectDescriptor } from '@cssearth/objects'; +import { isPlacedClassification, mapLabel, parseObjectDescriptor } from '@cssearth/objects'; import { isRecord } from '@cssearth/core'; import { packPreparedBinary, readCatalog, readPreparedObjects } from '@cssearth/objects/node'; import { worldOrbitBankRegions } from '@cssearth/renderer'; @@ -79,15 +79,15 @@ export async function prepareSpatialContext(options: SpatialContextPreparationOp // the colour its marker, orbit and caption take (its swatch, else its catalogue colour lifted for caption contrast, // @cssearth/objects `contextColour`), capitals for a star, black hole or planet's caption, and its classification and system name. { - const prepared = readPreparedObjects(process.cwd()), registry = prepared.sceneObjects; + // Every placed object the world draws: the scenes and the packages the world's host draws. + const prepared = readPreparedObjects(process.cwd()), registry = prepared.worldObjects; const { contextColour } = await import('@cssearth/objects'); const { contextAnnotationOpacity } = await import('@cssearth/renderer/navigation/marker-presentation.ts'); const { isJplMissionTarget } = await import(pathToFileURL(resolve(process.cwd(), 'site/build/prepare/jpl-mission-targets.mts')).href) as typeof import('./jpl-mission-targets.mts'); const objectsRoot = options.objectsDirectory ?? dirname(dirname(dirname(dirname(options.sourcePath)))); const byId = new Map(registry.map(object => [object.id, object])); // The catalogue step's discovery records, as the prepared catalogue holds them: what the world's visibility reads per body. - const discoveries: Record = Object.fromEntries(prepared.entries.flatMap(entry => - entry.kind === 'scene' && isRecord(entry.descriptor) && typeof entry.descriptor.id === 'string' ? [[entry.descriptor.id, entry.discovery]] : [])); + const discoveries: Record = Object.fromEntries(registry.map(object => [object.id, object.discovery])); const present = async (body: Record) => { const object = byId.get(String(body.id)); if (!object) return; @@ -127,7 +127,7 @@ export async function prepareSpatialContext(options: SpatialContextPreparationOp const source = parseWorldContextSource(input); const geometry = await loadSolarGeometry(options.solarGeometryPath); // The application registry owns classification; preparation bakes its orbit presentation. - const SCENE_OBJECTS = readPreparedObjects(process.cwd()).sceneObjects; + const SCENE_OBJECTS = readPreparedObjects(process.cwd()).worldObjects; const planetIds = new Set(SCENE_OBJECTS.filter(body => body.classification === 'planet').map(body => body.id)); const classifications = new Map(SCENE_OBJECTS.map(body => [body.id, body.classification])); // A planet of another star closes its orbit. A star on a hosted orbit (an S-star around Sgr A*) draws the half-orbit trail a @@ -158,7 +158,7 @@ export async function prepareSpatialContext(options: SpatialContextPreparationOp // A star other than the focus is placed, not orbiting: the context carries its position and radius and draws no trajectory. // A planet of another star closes its orbit around that star, which makes the star the root of its own planetary system. const classification = classifications.get(body.id); - facts[body.id] = { radiusM, orbitStyle: hostedStarIds.has(body.id) ? 'trail' : hostedIds.has(body.id) ? 'closed' : classification === 'star' || classification === 'black-hole' ? 'none' : planetIds.has(body.id) || classification === 'exoplanet' ? 'closed' : 'trail', classification }; + facts[body.id] = { radiusM, orbitStyle: hostedStarIds.has(body.id) ? 'trail' : hostedIds.has(body.id) ? 'closed' : isPlacedClassification(classification) ? 'none' : planetIds.has(body.id) || classification === 'exoplanet' ? 'closed' : 'trail', classification }; } // A star measured to be bound to another with no measured orbit carries the pair's centre of mass, weighted by the // published masses (as gravitational parameters) at the two prepared positions. @@ -335,7 +335,8 @@ function orbits(value: unknown): Readonly> { const orbit = record(value, `Solar geometry orbit ${id}`); if ((orbit.centerBodyId === undefined) !== (orbit.centerPositionAu === undefined)) throw new TypeError(`${id} orbit parent and centre must be declared together.`); const semiMajorAxisAu = number(orbit.semiMajorAxisAu, `${id} semi-major axis`), orbitEccentricity = eccentricity(orbit.eccentricity, id); - if (!(orbitEccentricity < 1 ? semiMajorAxisAu > 0 : semiMajorAxisAu < 0)) throw new TypeError(`${id} semi-major axis and eccentricity are incompatible.`); + // A straight path (eccentricity exactly 1, a placed body moving along its line of sight or held still) is bound or unbound. + if (!(orbitEccentricity < 1 ? semiMajorAxisAu > 0 : orbitEccentricity === 1 ? semiMajorAxisAu !== 0 : semiMajorAxisAu < 0)) throw new TypeError(`${id} semi-major axis ${semiMajorAxisAu} AU and eccentricity ${orbitEccentricity} are incompatible.`); return [id, { semiMajorAxisAu, eccentricity: orbitEccentricity, heliocentricDistanceAu: positive(orbit.heliocentricDistanceAu, `${id} distance`), perihelionDirection: vector3(orbit.perihelionDirection, `${id} perihelion`), trueAnomalyDegrees: number(orbit.trueAnomalyDegrees, `${id} anomaly`), ...(orbit.centerBodyId === undefined ? {} : { centerBodyId: text(orbit.centerBodyId, `${id} orbit parent`), centerPositionAu: vector3(orbit.centerPositionAu, `${id} orbit centre`) }), diff --git a/site/build/prepare/prepare-volume-presentation.mts b/site/build/prepare/prepare-volume-presentation.mts index e30d4107281..20f63a22c37 100644 --- a/site/build/prepare/prepare-volume-presentation.mts +++ b/site/build/prepare/prepare-volume-presentation.mts @@ -114,11 +114,12 @@ export interface HostedDatasets { objectId: string; name: string; route: string; datasets: Record; } /** The body an attached volume's delivery names, and which of the body's own datasets shows each of the volume's datasets. */ -async function hostedDatasets(root: string, base: string, objectId: string, datasetIds: readonly string[], input: (path: string) => Promise): Promise { +async function hostedDatasets(root: string, base: string, objectId: string, datasetIds: readonly string[], input: (path: string) => Promise, declared?: string): Promise { + // The bank's descriptor names its host (`properties.host`); a bank attached to a body is hosted by that body. const deliveryPath = `${base}/source/delivery.json`; - const exists = await readFile(resolve(root, deliveryPath)).then(() => true, (error: unknown) => { if (hasErrorCode(error, 'ENOENT')) return false; throw error; }); - if (!exists) return undefined; - const host = sourceObject(json(await input(deliveryPath))).attachedTo; + const attached = declared !== undefined ? undefined : await readFile(resolve(root, deliveryPath)).then(() => true, (error: unknown) => { if (hasErrorCode(error, 'ENOENT')) return false; throw error; }) + ? sourceObject(json(await input(deliveryPath))).attachedTo : undefined; + const host = declared ?? attached; if (host === undefined) return undefined; const hostId = sourceId(host), content = sourceObject(json(await input(`src/objects/${hostId}/source/content/object.json`))); const datasets: Record = {}; @@ -144,7 +145,7 @@ export async function readPreparedVolumes({ root = process.cwd(), input = path = const lineage = volumeLineage(record, manifest, descriptor.type === 'image-layer-bank'); const prepared = parsePreparedVolumePresentation(json(await input(`${base}/prepared/presentation.json`)), { id: record.objectId, defaultDataset: record.defaultDataset, datasets: record.datasets }, lineage.sources); - const hostedBy = await hostedDatasets(root, base, record.objectId, prepared.controls.map(control => control.id), input); + const hostedBy = await hostedDatasets(root, base, record.objectId, prepared.controls.map(control => control.id), input, typeof descriptor.properties.host === 'string' ? descriptor.properties.host : undefined); results.push({ id: record.objectId, name: record.name, route: hostedBy?.route ?? `/${record.objectId}/`, base, controls: prepared.controls, defaultDataset: prepared.defaultDataset, lineage, outputs: [], ...(hostedBy ? { hostedBy } : {}) }); } @@ -276,7 +277,7 @@ export async function prepareVolumePresentations({ root = process.cwd(), objectI : [...(current?.assets.filter(asset => asset.location === 'prepared' && !preparedOutputs.some(output => output.filename === asset.filename)) ?? []), ...preparedOutputs]; const next = mergeInventory(mergeInventory(current, 'public', publicAssets), 'prepared', preparedAssets); outputs.push({ path: resolve(root, `${base}/inventory.json`), text: inventoryText(next) }); - const hostedBy = await hostedDatasets(root, base, record.objectId, record.datasets.map(dataset => dataset.id), input); + const hostedBy = await hostedDatasets(root, base, record.objectId, record.datasets.map(dataset => dataset.id), input, typeof descriptor.properties.host === 'string' ? descriptor.properties.host : undefined); results.push({ id: record.objectId, name: record.name, route: hostedBy?.route ?? `/${record.objectId}/`, base, controls, defaultDataset: record.defaultDataset, lineage, outputs, ...(hostedBy === undefined ? {} : { hostedBy }) }); } diff --git a/site/build/prepare/prepare-world-navigation.ts b/site/build/prepare/prepare-world-navigation.ts index 508faad5c3a..ff8e17d4541 100644 --- a/site/build/prepare/prepare-world-navigation.ts +++ b/site/build/prepare/prepare-world-navigation.ts @@ -29,6 +29,23 @@ export async function prepareWorldNavigationDefinition({ objectDirectory, defini // The generated module satisfies the frame preparers' contract as it is; typing it by the module keeps drift a type error. const geometry: SolarGeometry = solar as typeof import('../../../src/platform/solar-geometry.mts'); const ecliptic = prepareEclipticPresentationFrame(geometry, descriptor.id); + if (!descriptor.recipe.surfaces.length) { + // No surface to orient: the scene stands in the ecliptic presentation frame at the object's place, framed at the + // radius its solar-system source authors, and the sky rides that frame. + const bodyToReference = solar.requireBodyFixedToIcrf(descriptor.id) as Matrix3; + const distanceM = solar.requireBodyOrbit(descriptor.id).heliocentricDistanceAu * solar.ASTRONOMICAL_UNIT_KILOMETERS * 1000; + const authored = sources.get('solar-system') as { bodyRadiusUnits: number; geometryScale?: number }; + const renderedRadiusUnits = authored.bodyRadiusUnits * (authored.geometryScale ?? 1); + const frame = preparePhysicalWorldFrame({ referenceFrame: 'sun-icrf', epochJdTt: solar.SOLAR_GEOMETRY_EPOCH_JD_TT, + originM: transform(bodyToReference, solar.requireBodyFixedSunDirection(descriptor.id) as Vector3).map(component => -component * distanceM) as unknown as Vector3, + bodyToReference, bodyToPresentation: ecliptic.basis.flat() as unknown as Matrix3, + orbitUpReference: transform(bodyToReference, solar.requireBodyFixedEclipticNorth(descriptor.id)), + physicalRadiusM: descriptor.recipe.shape.radiusKm * 1000, renderedRadiusUnits }); + const sky = { ...definition.sky, sceneRegistration: matrixCss(transpose(frame.presentationToReference)), + sceneRegistrationModel: 'icrf-in-authored-presentation-frame', sceneRegistrationEpoch: solar.SOLAR_GEOMETRY_EPOCH_LABEL }; + return { definition: { ...definition, sky } as Input, frame, systemTransform: null, defaultCamera: null, receipt: { schema: 'cssearth-world-navigation-preparation@1', id: descriptor.id, + frame, renderedRadiusUnits, model: 'ecliptic-presentation-frame', ephemerisSource: 'src/platform/solar-geometry.mts' } }; + } const intended = ecliptic.basis.flat() as unknown as Matrix3, placement = await surfacePlacement(objectDirectory, bound, sources.get('features')); assertAtlasOrigins(descriptor.id, sources.get('raster'), placement); // The body is drawn in its ecliptic presentation frame: the outermost mesh node is solved through whatever the lane placed below it. diff --git a/site/build/prepare/prepare-world-presentation.mts b/site/build/prepare/prepare-world-presentation.mts index 4d7c2d2ec12..2d8893fd402 100644 --- a/site/build/prepare/prepare-world-presentation.mts +++ b/site/build/prepare/prepare-world-presentation.mts @@ -9,9 +9,7 @@ import { dirname, resolve } from 'node:path'; import majorMoons from '../../source/major-moons.json' with { type: 'json' }; import galaxies from '../../../src/objects/local-group/source/presentation.json' with { type: 'json' }; import clusters from '../../../src/objects/galaxy-clusters/source/presentation.json' with { type: 'json' }; -import { discoveryVisibility, matchesObjectClassification, type ObjectDiscovery } from '@cssearth/objects'; -import { catalogueClassification, isNavigableCatalogObject, isPreparedCluster, isPreparedNebula, type PreparedCatalogObject } from '@cssearth/catalog'; -import { readPreparedCatalogObjects } from '@cssearth/bake/navigation'; +import { discoveryVisibility, type ObjectDiscovery } from '@cssearth/objects'; import { WORLD_OBJECTS } from '../../world-objects.mts'; import { APPLICATION_WORLD_CONTEXT, WORLD_SYSTEM_HOSTS } from '../../world-context-plan.mts'; import { systemFramingRadii } from '../../system-framing-radii.mts'; @@ -99,13 +97,11 @@ export function notableBodies(worldObjects: readonly { id: string; discovery: { } /** Every category's frame, from the members its pill marks: the world's bodies it highlights with the default settings (the same - * discoveryVisibility the browser runs), and the catalogue rows the galaxy layer draws (packages/renderer/src/universe/ - * prepared-galaxy-catalog.ts: the Local Group's galaxies with a page, the clusters, and the nebulae with a page). A category whose + * discoveryVisibility the browser runs): the galaxies, clusters and nebulae with a package are bodies among them. A category whose * notable bodies (notableBodies) are only some of its members, at least two, is marked and framed by those alone, listed as its * memberIds: the 40 notable exoplanets and stars, not all 865 and 2,174. hostIds lists the placed stars whose systems hold the - * marked members: from afar a planet is drawn inside its star's dot, so the pill marks that star too. The world and the catalogues - * share the Sun-centred ICRF frame and epoch. */ -export function prepareCategoryFrames(worldObjects: typeof WORLD_OBJECTS, catalogue: readonly PreparedCatalogObject[], + * marked members: from afar a planet is drawn inside its star's dot, so the pill marks that star too. */ +export function prepareCategoryFrames(worldObjects: typeof WORLD_OBJECTS, defaultFeatures: ReadonlySet, orbitFeatures: ReadonlySet, notable: ReadonlySet, centreOf: ReadonlyMap, worldHostId: string) { // Where a member is drawn from afar: inside the dot of the placed star its orbits lead to. The world's own host (the Sun) is @@ -116,18 +112,16 @@ export function prepareCategoryFrames(worldObjects: typeof WORLD_OBJECTS, catalo for (let steps = 0; steps <= centreOf.size && centreOf.has(at); steps++) at = centreOf.get(at)!; return at !== id && placedStars.has(at) ? at : null; }; - const drawn = catalogue.filter(object => isNavigableCatalogObject(object) && (isPreparedCluster(object) || isPreparedNebula(object) || object.membership.group === 'local-group')); - const classifications = [...new Set([...worldObjects.map(object => object.classification), ...drawn.map(catalogueClassification)])].sort(); + const classifications = [...new Set(worldObjects.map(object => object.classification))].sort(); return Object.fromEntries(classifications.flatMap(classification => { const marked = new Set(discoveryVisibility(worldObjects, { illustrations: false, highlighted: classification, defaultFeatures, orbitFeatures }).highlightedBodies); const bodies = worldObjects.filter(object => marked.has(object.id)), notables = bodies.filter(object => notable.has(object.id)); const narrowed = notables.length >= 2 && notables.length < bodies.length; - const frame = prepareCategoryFrame([...(narrowed ? notables : bodies).map(object => { + const frame = prepareCategoryFrame((narrowed ? notables : bodies).map(object => { // Preparation reads every system's file (site/world-context-plan.mts), so every body is placed. if (!object.worldFrame) throw new TypeError(`${object.id} has no world position; the world context read here lacks its system.`); return object.worldFrame.originM; - }), - ...drawn.filter(object => matchesObjectClassification(catalogueClassification(object), classification)).map(object => object.positionM)]); + })); const markedBodies = narrowed ? notables : bodies; const hostIds = [...new Set(markedBodies.flatMap(object => hostOf(object.id) ?? []))]; return frame ? [[classification, { ...frame, ...(narrowed ? { memberIds: notables.map(object => object.id) } : {}), ...(hostIds.length ? { hostIds } : {}) }] as const] : []; @@ -140,7 +134,7 @@ function orbitCentres(context: typeof APPLICATION_WORLD_CONTEXT): ReadonlyMap [id, centre.centerBodyId] as const)]); } -export function prepareWorldPresentation(satelliteSystemIntroductions: Readonly>, catalogue: readonly PreparedCatalogObject[]) { +export function prepareWorldPresentation(satelliteSystemIntroductions: Readonly>) { const minor = minorMoonOrbitIds(APPLICATION_WORLD_CONTEXT.bodies); const defaultFeatureIds = SCENE_OBJECTS.filter(isDefaultContextFeature).map(object => object.id); const orbitFeatureIds = SCENE_OBJECTS.filter(orbitFeature).map(object => object.id); @@ -155,7 +149,7 @@ export function prepareWorldPresentation(satelliteSystemIntroductions: Readonly< galaxies: { fadeStartDistanceM: galaxies.fadeStartDistanceM, fullDistanceM: galaxies.fullDistanceM, maximumDistanceM: galaxies.maximumDistanceM, minimumDistanceRadii: galaxies.minimumDistanceRadii, defaultFocusRadiusM: galaxies.defaultFocusRadiusM, metersPerParsec: galaxies.metersPerParsec }, clusters: { fadeStartDistanceM: clusters.fadeStartDistanceM, fullDistanceM: clusters.fullDistanceM }, - categoryFrames: prepareCategoryFrames(WORLD_OBJECTS, catalogue, new Set(defaultFeatureIds), new Set(orbitFeatureIds), + categoryFrames: prepareCategoryFrames(WORLD_OBJECTS, new Set(defaultFeatureIds), new Set(orbitFeatureIds), notableBodies(WORLD_OBJECTS, new Set(defaultFeatureIds), orbitCentres(APPLICATION_WORLD_CONTEXT)), orbitCentres(APPLICATION_WORLD_CONTEXT), APPLICATION_WORLD_CONTEXT.focus.id), // Every system whose bodies are their own file, framed before a page reads it (site/system-framing.mts). @@ -167,7 +161,7 @@ export function prepareWorldPresentation(satelliteSystemIntroductions: Readonly< export async function writeWorldPresentation() { const catalogue = await readSourceCatalog(resolve(import.meta.dirname, '../../..')); const introductions = prepareSystemIntroductions(systemText, allSatelliteSystems().map(system => system.hostId), new Set(catalogue.records.map(record => record.id))); - const text = `${JSON.stringify(prepareWorldPresentation(introductions, await readPreparedCatalogObjects(resolve(import.meta.dirname, '../../../src/objects'))))}\n`; + const text = `${JSON.stringify(prepareWorldPresentation(introductions))}\n`; await mkdir(dirname(output), { recursive: true }); if (await readFile(output, 'utf8').catch(() => null) !== text) await writeFile(output, text); } diff --git a/site/build/prepare/surfaceless-scene.ts b/site/build/prepare/surfaceless-scene.ts new file mode 100644 index 00000000000..c9432d7dee2 --- /dev/null +++ b/site/build/prepare/surfaceless-scene.ts @@ -0,0 +1,82 @@ +// The scene of an authored object with no surface (`recipe.surfaces: []`): a galaxy, a nebula, a cluster of galaxies. It is +// the scene every body has with no body in it: the camera, sky and world frame the shared scene preparers give its +// astronomy record. Its datasets show context banks as their companions; the world draws those, the scene is where the +// page stands. prepare-authored.ts runs it as one of its lanes. +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { parsePreparedObjectRuntime, requireControls } from '@cssearth/renderer'; +import { CUBIC_SKY_CAMERA_PRESENTATION_STANDARD, prepareCubicSky } from '@cssearth/bake/presentation'; +import { prepareSolarSystemScene } from '@cssearth/bake/objects/scene'; + +type SolarGeometry = Parameters[0]; +export interface SurfacelessSource { readonly bodyId: string; readonly bodyRadiusUnits: number; readonly bodyRadiusKilometers: number; + readonly defaultZoom?: number; readonly geometryScale?: number; readonly maximumZoom?: number } + +export async function prepareSurfacelessScene({ source, controls, solarGeometry }: { source: SurfacelessSource; controls: { datasets: { controls: readonly { id: string }[] } | null; settings: unknown }; solarGeometry: SolarGeometry }) { + const id = source.bodyId; + const scene = await prepareSolarSystemScene(solarGeometry, { ...source, bodyId: id as never, light: 'self', + starfield: prepareCubicSky({ objectId: id, cameraContract: CUBIC_SKY_CAMERA_PRESENTATION_STANDARD }) } as never); + // The world-navigation stage gives the scene its world frame and registers the sky in it, as it does for every body. + const sky = scene.starfield; + requireControls(controls as never); + const camera = scene.camera; + const node = (parent: number, className: string, style = '', attributes: Record = {}) => ({ parent, tag: 'div', className, style, properties: [], attributes }); + const definition = { schema: 'cssearth-object-runtime@5', camera, sky, sun: null, + assets: { entries: [], pools: [{ id: 'material', capacity: 8, concurrency: 8, retention: 'selection', reuse: false }], startup: [] }, + tree: { nodes: [node(-1, 'polycss-camera object-render-root'), + node(0, 'polycss-scene', `transform:${camera.defaultTransform}`, { 'aria-hidden': 'true', 'data-polycss-lighting': 'baked' }), + node(1, `polycss-mesh ${id}-system`, `transform:${scene.systemTransform}`)], + properties: [{ name: 'scale', value: '1', custom: false }], camera: 0, scene: 1, stageClasses: [], activationGroups: [], textureBindings: [] }, + variants: (controls.datasets?.controls ?? []).map(control => ({ when: { datasetId: control.id }, required: [], writes: [], materials: [] })), + materials: [], viewBindings: [{ kind: 'view-attribute', target: -1, property: 'data-lod', source: 'level-of-detail-stage', precision: null }], + animations: [], id, controls, motion: [] }; + parsePreparedObjectRuntime(definition, { parsedJson: true }); + return { definition, scene }; +} + +/** A bank of dots as seen from the Sun: each dot in its own colour on the plane across the line of sight to the bank's + * middle, north up and east left as the sky is drawn, fitted to the picture. Returns the picture's public address. */ +export async function dotsPicture(bankDirectory: string, outputPath: string): Promise { + const { unpackPreparedBinary } = await import('@cssearth/objects/node'); + const { decodeCatalogueBankBinary } = await import('@cssearth/renderer/prepared-data/catalogue-bank-binary.ts'); + const bank = decodeCatalogueBankBinary(unpackPreparedBinary(await readFile(resolve(bankDirectory, 'prepared/dots.bin'))), `${bankDirectory}/prepared/dots.bin`); + const points = bank.points as readonly (readonly number[])[], appearance = bank.appearance as { colorCss: string; palette?: readonly string[] }; + if (!Array.isArray(points) || !points.length) throw new TypeError(`${bankDirectory}/prepared/dots.bin: a bank of dots has points.`); + const centre = [0, 1, 2].map(axis => points.reduce((sum, point) => sum + point[axis]!, 0) / points.length); + const distance = Math.hypot(...centre), sight: number[] = centre.map(value => value / distance); + // East and north on the sky at the bank's middle, from the ICRF pole. + const east: number[] = [-sight[1]!, sight[0]!, 0].map(value => value / Math.hypot(sight[0]!, sight[1]!)); + const north: number[] = [sight[1]! * east[2]! - sight[2]! * east[1]!, sight[2]! * east[0]! - sight[0]! * east[2]!, sight[0]! * east[1]! - sight[1]! * east[0]!]; + const along = (offset: readonly number[], direction: readonly number[]) => offset.reduce((sum: number, value: number, axis: number) => sum + value * direction[axis]!, 0); + const flat = points.map(point => { const offset = point.slice(0, 3).map((value: number, axis: number) => value - centre[axis]!); + return { x: -along(offset, east), y: -along(offset, north), palette: point[3] }; }); + const SIZE = 160, reach = Math.max(...flat.map(dot => Math.max(Math.abs(dot.x), Math.abs(dot.y)))) * 1.05, pixels = Buffer.alloc(SIZE * SIZE * 3); + for (const dot of flat) { + const colour = (dot.palette === undefined ? appearance.colorCss : appearance.palette?.[dot.palette]) ?? appearance.colorCss; + const column = Math.round((dot.x / reach + 1) / 2 * (SIZE - 1)), row = Math.round((dot.y / reach + 1) / 2 * (SIZE - 1)); + for (let channel = 0; channel < 3; channel++) pixels[(row * SIZE + column) * 3 + channel] = Number.parseInt(colour.slice(1 + channel * 2, 3 + channel * 2), 16); + } + const sharp = (await import('sharp')).default, { mkdir } = await import('node:fs/promises'); + await mkdir(dirname(outputPath), { recursive: true }); + await sharp(pixels, { raw: { width: SIZE, height: SIZE, channels: 3 } }).webp({ lossless: true }).toFile(outputPath); +} + +/** The thumbnail of each dataset that shows a companion bank, written to the object's public directory under the name the + * dataset authors: a bank of dots is drawn as it lies on the sky; a bank that publishes a picture of its dataset lends it. */ +export async function companionThumbnails({ objectDirectory, publicDirectory, content }: { objectDirectory: string; publicDirectory: string; content: unknown }) { + const controls = ((content as { datasets?: { controls?: unknown } }).datasets?.controls ?? []) as { id: string; thumbnail?: string; volume?: { objectId: string; datasetId: string } }[]; + const { copyFile, mkdir } = await import('node:fs/promises'); + for (const control of controls) { + if (!control.volume || !control.thumbnail || control.thumbnail.startsWith('/')) continue; + const bankDirectory = resolve(objectDirectory, '..', control.volume.objectId), output = resolve(publicDirectory, control.thumbnail); + const bank = JSON.parse(await readFile(resolve(bankDirectory, 'object.json'), 'utf8')) as { prepared?: { format?: string } }; + if (bank.prepared?.format === 'cssearth-catalogue-points-bin@1') { await dotsPicture(bankDirectory, output); continue; } + const presentation = JSON.parse(await readFile(resolve(bankDirectory, 'prepared/presentation.json'), 'utf8')) as { controls: { id: string; thumbnailUrl: string }[] }; + const picture = presentation.controls.find(candidate => candidate.id === control.volume!.datasetId)?.thumbnailUrl; + const prefix = `/scenes/${control.volume.objectId}/`; + if (!picture?.startsWith(prefix)) throw new TypeError(`${bankDirectory}/prepared/presentation.json: dataset ${control.volume.datasetId} names no picture under ${prefix}.`); + await mkdir(dirname(output), { recursive: true }); + // The bank's public files are the checkout's, whatever directory this run stages the object's own into. + await copyFile(resolve(objectDirectory, '../../../public/scenes', control.volume.objectId, picture.slice(prefix.length)), output); + } +} diff --git a/site/build/prerender-focus-pages.mts b/site/build/prerender-level-pages.mts similarity index 81% rename from site/build/prerender-focus-pages.mts rename to site/build/prerender-level-pages.mts index 2db478fb546..c5e958f0eb8 100644 --- a/site/build/prerender-focus-pages.mts +++ b/site/build/prerender-level-pages.mts @@ -1,9 +1,9 @@ -// Entry script: node site/build/prerender-focus-pages.mts (in build:deploy, after bundle-netlify-functions.mts) +// Entry script: node site/build/prerender-level-pages.mts (in build:deploy, after bundle-netlify-functions.mts) /** - * The page of anything the world draws (a catalogue subject, an overview) is the world host's page with it selected - * (site/pages/[id].astro). The search function renders that selection (the scene, card and datasets, and a focus's - * camera; an overview's camera is framed on arrival) into the page for any query URL. This renders it into each such - * static page once, through the same bundled function, so the page opens on its subject with or without JavaScript. + * A level of the zoom ladder has no scene of its own: its page is the world host's page framed on it + * (site/pages/[id].astro). The search function renders that selection into the page for any query URL. This renders it + * into each level's static page once, through the same bundled function, so the page opens on its card with or without + * JavaScript. */ import { readFile, writeFile } from 'node:fs/promises'; import { extname, resolve, sep } from 'node:path'; @@ -33,7 +33,7 @@ if (typeof loaded !== 'object' || loaded === null || !('default' in loaded) || t throw new Error('netlify/functions-bundled/search.mjs has no default export handler; run site/build/bundle-netlify-functions.mts first.'); } const handler = loaded.default as (request: Request) => Promise; -const pages = readPreparedObjects(root).objects.filter(object => object.kind !== 'scene').map(object => object.id); +const pages = readPreparedObjects(root).levels.map(level => level.id); for (const id of pages) { const response = await handler(new Request(`${origin}/.netlify/functions/search?object=${encodeURIComponent(id)}`)); if (!response.ok) throw new Error(`${id}: the search function answered ${response.status} for /${id}/: ${(await response.text()).slice(0, 300)}`); diff --git a/site/built-pages.mts b/site/built-pages.mts index 1a06c5d4ee5..ffe98937e26 100644 --- a/site/built-pages.mts +++ b/site/built-pages.mts @@ -1,8 +1,8 @@ // `CSSEARTH_BUILD_PAGES=/,/earth/ astro build`: a production build that prerenders only the named object pages and the // scene routes they mount, for a cold test that opens a few pages, not all of them. Unset, every route builds as before. -/** What a page needs of an `OBJECTS` entry: a scene mounts itself; anything else mounts its host scene. */ -interface PageObject { readonly id: string; readonly kind: string; readonly sceneHostId?: string } +/** A page: an object's mounts its own scene; a level's mounts the scene of `sceneHostId`, the world host, with it selected. */ +interface PageObject { readonly id: string; readonly sceneHostId?: string } interface IdPath { readonly params: { readonly id: string } } /** The page paths `CSSEARTH_BUILD_PAGES` names (`/` and `//`, comma-separated), or null when every page builds. */ @@ -47,7 +47,7 @@ export function builtScenePaths(paths: Path[], objects: rea if (!named) return paths; const root = objects.find(object => object.id === rootId); if (!root) throw new TypeError(`The root object ${rootId} is not in OBJECTS.`); - const scene = (object: PageObject) => (object.kind === 'scene' ? object.id : object.sceneHostId); + const scene = (object: PageObject) => object.sceneHostId ?? object.id; const mounted = new Set([...named, ...(namedPages(env)?.has('/') ? [root] : [])].map(scene)); return paths.filter(path => mounted.has(path.params.id)); } diff --git a/site/components/DatasetList.astro b/site/components/DatasetList.astro index 12a20f083b5..908cc7e3071 100644 --- a/site/components/DatasetList.astro +++ b/site/components/DatasetList.astro @@ -8,15 +8,13 @@ import { sidebarThumbnail } from '../sidebar-thumbnails.mts'; interface Props { objectId: string; datasets: Pick, 'controls' | 'defaultDataset'>; - /** The datasets of something the scene draws (a catalogue focus, an overview), rather than of the scene's own object. */ - focus?: boolean; /** The drawn page these datasets belong to (an overview's id): their buttons select its dataset on its page (page-datasets.mts). */ page?: string; /** Null leaves the form without an action, so it submits to the page it is spliced into. */ route?: string | null; thumbnailObjectId?: string; } -const { objectId, datasets, focus = false, page, route = `/${objectId}/`, thumbnailObjectId = objectId } = Astro.props; +const { objectId, datasets, page, route = `/${objectId}/`, thumbnailObjectId = objectId } = Astro.props; const previewUrl = (dataset: NonNullable['controls'][number]) => sidebarThumbnail(thumbnailObjectId, dataset.id)?.url2x ?? dataset.thumbnailUrl; // A dataset that names a companion cloud keeps the plates of another dataset, so the body on screen is drawn in that @@ -40,7 +38,7 @@ const defaultListedId = defaultControl?.step ? opening(stepMembers(defaultContro import Detached from './Detached.astro'; ---
-
@@ -55,7 +53,7 @@ import Detached from './Detached.astro'; )} - {datasets.controls.filter(listed).map(dataset => )} @@ -71,7 +69,6 @@ import Detached from './Detached.astro'; type="submit" form={`${objectId}-datasets`} name="dataset" - data-focus-dataset={focus ? true : undefined} value={dataset.id} aria-pressed={dataset.id === datasets.defaultDataset ? "true" : "false"} aria-controls={`${objectId}-${dataset.id}-details`} @@ -95,9 +92,9 @@ import Detached from './Detached.astro'; {datasets.controls.map((dataset) => ( } - {dataset.step && !dataset.texture && } + {dataset.step && !dataset.texture && } {borrowedLegend(dataset) && } diff --git a/site/components/ObjectPage.astro b/site/components/ObjectPage.astro index b9c238d0cc8..62c8c7b7abd 100644 --- a/site/components/ObjectPage.astro +++ b/site/components/ObjectPage.astro @@ -10,7 +10,7 @@ import shellCss from '../object-shell.css?inline'; import { loadPreparedSceneMarkup } from '../server/load-prepared-scene.mts'; import { rewriteSceneCss, resolveSceneAddressesDeep, withPreparedAssetOrigin } from '../asset-origin.mts'; -/** `objectId` owns the scene; `pageId` is the object the page is, a catalogue focus that scene draws when it differs. */ +/** `objectId` owns the scene; `pageId` is what the page is: the object, or a level of the zoom ladder that scene shows when it differs. */ interface Props { objectId: string; pageId?: string; navigation?: boolean; home?: boolean; } const { objectId, pageId = objectId, navigation = false, home = false } = Astro.props; const preparedPage = await loadObjectPageData(objectId); diff --git a/site/components/ObjectResult.astro b/site/components/ObjectResult.astro index 4da792d2981..68e9658e93b 100644 --- a/site/components/ObjectResult.astro +++ b/site/components/ObjectResult.astro @@ -4,6 +4,7 @@ import type { ObjectEntry } from '../objects.mts'; import { objectTypeLabel } from '../object-classification-label.mts'; import { sourceDocumentation } from '../source-documentation.mts'; import { objectResultMarkup } from '../server/object-result-markup.mts'; +import { objectResultMarker } from '../search/prepared-catalogue-index.mts'; interface Props { object: ObjectEntry; } const { object } = Astro.props; const source = sourceDocumentation(object.id, object.name); @@ -15,7 +16,7 @@ const markup = objectResultMarkup({ detail: { text: `${value} ${object.distance.unit}`, value, unit: object.distance.unit, title: description, ariaLabel: `${value} ${object.distance.unit}. ${description}` }, source: { subject: `object:${object.id}`, document: source.href, label: source.label }, - marker: { kind: 'scene', id: object.id, color: object.color }, + marker: objectResultMarker(object), }); --- diff --git a/site/components/ObjectShell.astro b/site/components/ObjectShell.astro index e6a5bada599..9d2062d9b94 100644 --- a/site/components/ObjectShell.astro +++ b/site/components/ObjectShell.astro @@ -1,8 +1,6 @@ --- import { DIAGNOSTICS_ENABLED } from "../diagnostics-policy.mts"; import ObjectInformationPanel from "./ObjectInformationPanel.astro"; -import PreparedClusterFacts from './PreparedClusterFacts.astro'; -import { CONTEXT_AVAILABILITY } from '../context-availability.mts'; import type { Props } from "../object-shell-types"; import { OVERVIEWS, SCENE_OBJECTS } from "../objects.mts"; import { PREPARED_SHELL_TITLES } from "../prepared-shell-titles.mjs"; @@ -17,7 +15,6 @@ import ObjectCard from './ObjectCard.astro'; import SystemHeaders from './SystemHeaders.astro'; import SystemBodies from './SystemBodies.astro'; import ObjectBreadcrumbs from './ObjectBreadcrumbs.astro'; -import InformationTabs from './InformationTabs.astro'; import Accordion from './Accordion.astro'; import { SOLAR_SYSTEM_ID, systemOfObject } from '../object-systems.mts'; import { SEARCH_QUERY_LIMIT } from '../search/object-search.mts'; @@ -232,36 +229,7 @@ import Detached from './Detached.astro'; }
- {!navigation &&
- {/* A page that opens on an overview or a catalogue focus shows that card; the body's waits off the page. */} + {/* A page that opens on an overview shows that card; the body's waits off the page. */}
diff --git a/site/components/OverviewCard.astro b/site/components/OverviewCard.astro index 34299be37cf..58b2006d75e 100644 --- a/site/components/OverviewCard.astro +++ b/site/components/OverviewCard.astro @@ -13,42 +13,37 @@ import { overviewResult } from '../server/overview-result.mts'; import { PREPARED_CATALOGUE_INDEX } from '../search/prepared-catalogue-index.mts'; import ObjectFactsheet from './ObjectFactsheet.astro'; import DatasetList from './DatasetList.astro'; -import { OBJECTS, SCENE_OBJECTS } from '../objects.mts'; -import { FOCUS_CATALOG_DATA } from '../focus-catalog-data.mts'; +import { OBJECTS, OVERVIEWS, SCENE_OBJECTS } from '../objects.mts'; +import { WORLD_HOST_ID } from '../navigation/navigation-scope.mts'; import { pageDatasetList } from '../page-datasets-content.mts'; import overviewFacts from '../source/overview-facts.json'; /** One overview's card, entirely from its registry entry (overview-object.ts): its name, description and breadcrumbs, its * page's datasets, a tab for each group of subjects it holds with a list label, and its factsheet when * site/source/overview-facts.json has one. Each held group with a list label lists the registry's - * subjects of those classifications (catalogue focuses, member overviews), each with its distance from the others. */ + * objects of those classifications and its member levels, each with its distance from the others. */ interface Props { overview: OverviewObject } const { overview } = Astro.props; const PARSEC_M = 3.085677581491367e16; -const catalogue = new Map(FOCUS_CATALOG_DATA.flatMap(({ data }) => data.objects.map(object => [object.id, object] as const))); const distance = (pc: number) => { const ly = pc * 3.261563777, divisor = ly >= 1e6 ? 1e6 : ly >= 1e3 ? 1e3 : 1; return { value: new Intl.NumberFormat('en', { maximumSignificantDigits: 3 }).format(ly / divisor), unit: divisor === 1e6 ? 'Mly' : divisor === 1e3 ? 'kly' : 'ly' }; }; const slug = (label: string) => label.toLocaleLowerCase('en').replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, ''); type Member = { id: string; name: string; positionM: readonly number[]; distancePc: number; holdsStars: boolean }; -const members = (classifications: readonly string[]): Member[] => OBJECTS.filter(object => object.id !== overview.id).flatMap((object): Member[] => { - if (object.kind === 'prepared-focus') { - if (!classifications.includes(object.classification)) return []; - const record = catalogue.get(object.id); - if (!record) throw new Error(`${overview.id} lists ${object.id}, which no prepared catalogue places.`); - return [{ id: object.id, name: object.name, positionM: record.positionM, distancePc: record.distance.valuePc, holdsStars: false }]; - } - if (object.kind === 'overview') { - if (!object.classification || !classifications.includes(object.classification)) return []; - if (!object.originM) throw new Error(`${overview.id} lists ${object.id}, whose package has no volume origin to place it.`); - return [{ id: object.id, name: object.name, positionM: object.originM, distancePc: Math.hypot(...object.originM) / PARSEC_M, - holdsStars: object.holds.some(group => group.classifications.includes('star')) }]; - } - if (!classifications.includes(object.classification) || !object.worldFrame) return []; - return [{ id: object.id, name: object.name, positionM: object.worldFrame.originM, distancePc: object.distance.meters / PARSEC_M, holdsStars: false }]; +// A level lists the objects of the classifications it holds, and the levels that are members of it (the Milky Way is a +// galaxy of the Local Group). +const members = (classifications: readonly string[]): Member[] => [ + ...OVERVIEWS.filter(level => level.id !== overview.id && level.classification && classifications.includes(level.classification)).map((level): Member => { + const originM = level.worldFrame?.originM ?? level.originM; + if (!originM) throw new Error(`${overview.id} lists ${level.id}, whose package has neither a world frame nor a volume origin to place it.`); + return { id: level.id, name: level.name, positionM: originM, distancePc: Math.hypot(...originM) / PARSEC_M, + holdsStars: level.holds.some(group => group.classifications.includes('star')) }; + }), + ...OBJECTS.filter(object => classifications.includes(object.classification)).map((object): Member => + ({ id: object.id, name: object.name, positionM: object.worldFrame.originM, distancePc: object.distance.meters / PARSEC_M, holdsStars: false })), // A level lists only the subjects whose own zoom reaches it (M87 is in Virgo, not the Local Group). -}).filter(member => overviewsReachableFrom(member.positionM).some(level => level.id === overview.id)).sort((a, b) => a.distancePc - b.distancePc); +].filter(member => overviewsReachableFrom(member.positionM).some(level => level.id === overview.id)).sort((a, b) => a.distancePc - b.distancePc); const lists = overview.holds.flatMap(group => { if (!group.list) return []; return [{ id: slug(group.list), label: group.list, members: members(group.classifications) }]; @@ -59,13 +54,13 @@ const facts = isRecord(factsEntry) ? { facts: factsEntry.facts as ObjectProps['f // Unlabelled held scene groups expose a small featured navigation list, using the search's exact rows. const classifications = overview.holds.filter(group => !group.list && group.classifications.includes('star')).flatMap(group => group.classifications); const sceneMembers = SCENE_OBJECTS.filter(object => classifications.includes(object.classification) - && (object.id === overview.sceneHostId || object.discovery.featured) + && (object.id === WORLD_HOST_ID || object.discovery.featured) && (!overview.zoom.centreWithin || object.distance.meters < overviewDistanceM(overview.zoom.centreWithin))) - .sort((a, b) => Number(b.id === overview.sceneHostId) - Number(a.id === overview.sceneHostId) || a.distance.meters - b.distance.meters); + .sort((a, b) => Number(b.id === WORLD_HOST_ID) - Number(a.id === WORLD_HOST_ID) || a.distance.meters - b.distance.meters); const resultRows = new Map(PREPARED_CATALOGUE_INDEX.entries.filter(row => row.kind !== 'system').map(row => [row.id, row])); const memberMarkup = (member: Member) => { - const object = OBJECTS.find(object => object.id === member.id); - const row = object?.kind === 'overview' ? overviewResult(object) : resultRows.get(member.id); + const level = OVERVIEWS.find(level => level.id === member.id); + const row = level ? overviewResult(level) : resultRows.get(member.id); if (!row) throw new Error(`Missing search row for ${overview.id} member ${member.id}.`); const { value, unit } = distance(member.distancePc); const text = `≈${value} ${unit}`, title = 'Published catalogue distance from the Solar System'; @@ -92,7 +87,7 @@ import Detached from './Detached.astro'; role="region" aria-label="Datasets" data-page-datasets={overview.id} data-default-dataset={datasets.defaultDataset} data-dataset-views={JSON.stringify(datasets.views)}> + datasets={{ controls: datasets.controls, defaultDataset: datasets.defaultDataset }} /> } {sceneMembers.length > 0 &&
    {sceneMembers.map(object => )}
diff --git a/site/components/PreparedClusterFacts.astro b/site/components/PreparedClusterFacts.astro deleted file mode 100644 index 7211dca6b2c..00000000000 --- a/site/components/PreparedClusterFacts.astro +++ /dev/null @@ -1,14 +0,0 @@ ---- -import { parsePreparedClusterCatalog } from '@cssearth/catalog'; -import catalogue from '../../src/objects/galaxy-clusters/prepared/catalogue.json'; -import ObjectFactsheet from './ObjectFactsheet.astro'; -const clusters = parsePreparedClusterCatalog(catalogue); -const format = new Intl.NumberFormat('en', { maximumSignificantDigits: 4 }); ---- -{clusters.objects.map(object => )} diff --git a/site/components/PreparedFocusDatasets.astro b/site/components/PreparedFocusDatasets.astro deleted file mode 100644 index 5e9caeb615d..00000000000 --- a/site/components/PreparedFocusDatasets.astro +++ /dev/null @@ -1,59 +0,0 @@ ---- -import DatasetList from './DatasetList.astro'; -import DatasetContextPanels from './DatasetContextPanels.astro'; -import ObjectFactsheet from './ObjectFactsheet.astro'; -import { parsePreparedVolumePresentation } from '../volume-presentation.mts'; -import { imageFocusDatasets } from '@cssearth/renderer/universe'; -import { lineageSource } from '@cssearth/objects/provenance'; -import { sourceArray, sourceObject } from '@cssearth/objects/sources'; -import type { PreparedVolumeDatasetBank } from '@cssearth/renderer/volume/prepared-volume-datasets.ts'; -import { CONTEXT_OBJECT_DESCRIPTORS, CONTEXT_OBJECT_ASSET_URLS } from '../prepared-context-objects.mts'; -import { CONTEXT_OBJECT_PREPARED_JSON, CONTEXT_OBJECT_SOURCE_MANIFESTS } from '../prepared-context-json.mts'; -import { CONTEXT_AVAILABILITY } from '../context-availability.mts'; -import { resolveSceneAddressesDeep } from '../asset-origin.mts'; -import { datasetSourceDetail } from '../dataset-context.mts'; -import { EXPLORATION } from '../exploration-catalog.mts'; -// Page-neutral: the banks render once into the shared focus fragment, so dataset forms submit to the current page. -interface Props { factsOnly?: boolean } -const { factsOnly = false } = Astro.props; - -type Descriptor = { id: string; type: string; prepared?: { url: string } }; -// Bodies share src/objects; only the generated context objects' prepared JSON is read here. -const descriptors = CONTEXT_OBJECT_DESCRIPTORS as Record; -const prepared = CONTEXT_OBJECT_PREPARED_JSON as Record; -const presentations = CONTEXT_OBJECT_PREPARED_JSON; -const banks = await Promise.all(Object.entries(descriptors).filter(([, descriptor]) => (descriptor.type === 'volume-dataset-bank' && CONTEXT_AVAILABILITY[descriptor.id]?.available) || descriptor.type === 'image-layer-bank').map(async ([path, descriptor]) => { - const bank = descriptor.type === 'image-layer-bank' - ? { ...imageFocusDatasets(descriptor.id), id: descriptor.id } - : prepared[path.replace(/object\.json$/u, descriptor.prepared?.url ?? '')]?.data; - if (!bank || bank.id !== descriptor.id) throw new TypeError(`Prepared focus datasets unavailable: ${descriptor.id}.`); - // The dataset rows credit each dataset's own source: the manifest input that names the dataset. - const sources = sourceArray(sourceObject(CONTEXT_OBJECT_SOURCE_MANIFESTS[path.replace(/object\.json$/u, 'source/manifest.json')]).inputs, raw => lineageSource(raw)); - // presentation.json (a context object's own prepared JSON, not a Vite asset import) can carry - // bare `/scenes//datasets/.webp` dataset-preview addresses that Vite never rewrites. - const parsedPresentation = await resolveSceneAddressesDeep(parsePreparedVolumePresentation(presentations[path.replace(/object\.json$/u, 'prepared/presentation.json')], bank, sources)); - const presentation = { ...parsedPresentation, controls: parsedPresentation.controls.map(dataset => { - const { detail: _preparedDetail, ...control } = dataset; - const detail = datasetSourceDetail(dataset.id, sources, EXPLORATION.catalog); - return { ...control, ...(detail ? { detail } : {}) }; - }) }; - const directSourceIdsByDataset = Object.fromEntries(presentation.controls.map(dataset => [dataset.id, - sources.filter(source => source.datasetId === dataset.id).map(source => source.id)])); - const prefix = path.replace(/object\.json$/u, ''); - const resources = Object.fromEntries(Object.entries(CONTEXT_OBJECT_ASSET_URLS).filter(([path]) => path.startsWith(prefix)).map(([path, url]) => [path.slice(prefix.length), url])); - return { bank, presentation, directSourceIdsByDataset, descriptor, resources }; -})); -import Detached from './Detached.astro'; ---- -{banks.map(({ bank, presentation, directSourceIdsByDataset, descriptor, resources }) => factsOnly ? : )} diff --git a/site/components/SequencePlayer.astro b/site/components/SequencePlayer.astro index 85f8a2b43db..d2c1c903d90 100644 --- a/site/components/SequencePlayer.astro +++ b/site/components/SequencePlayer.astro @@ -6,9 +6,10 @@ interface Props { objectId: string; dataset: Control; members: Control[]; - focus: boolean; + /** The scene's own sequence can play; a drawn page's steps are links to its page. */ + playable: boolean; } -const { objectId, dataset, members, focus } = Astro.props; +const { objectId, dataset, members, playable } = Astro.props; const index = members.findIndex(member => member.id === dataset.id); --- @@ -21,14 +22,13 @@ const index = members.findIndex(member => member.id === dataset.id); name="dataset" value={member.id} data-dataset-step={member.id} - data-focus-dataset={focus ? true : undefined} aria-current={position === index ? 'step' : undefined} aria-label={member.step!.label} title={member.step!.label} >)}
- {!focus &&
' - + '
'; -const card = () => parseHTML('

' - + '
') - .document.querySelector('[data-prepared-focus-card]')!; - -test('the fragment is fetched from its one path, and a failed response names it', async () => { - const requested: string[] = []; - assert.equal(await fetchFocusFragment(async url => { requested.push(url); return new Response(FRAGMENT); }), FRAGMENT); - assert.deepEqual(requested, [FOCUS_FRAGMENT_URL]); - await assert.rejects(fetchFocusFragment(async () => new Response('', { status: 404 })), /Focus fragment \/focus-fragment\/ failed: HTTP 404/u); -}); - -test('splicing replaces each placeholder with its bank group and keeps the retained card', () => { - const root = card(); - const name = root.querySelector('[data-focus-name]'); - assert.equal(focusBanksPending(root), true); - spliceFocusBanks(root, parseHTML(FRAGMENT).document); - assert.equal(focusBanksPending(root), false); - assert.equal(root.querySelector('[data-focus-name]'), name); - assert.deepEqual([...root.querySelectorAll('[data-focus-dataset-bank]')].map(bank => bank.dataset.focusDatasetBank), ['m42', 'm1']); - // The facts group lands where its placeholder was, inside the factsheet wrapper. - assert.equal(root.querySelector('[data-focus-facts-bank="m42"]')?.parentElement?.parentElement, root); - assert.equal(root.querySelector('style'), null); - assert.throws(() => spliceFocusBanks(card(), parseHTML('
').document), /no "facts" bank group/u); -}); diff --git a/site/test/galactic-focus.test.mts b/site/test/galactic-focus.test.mts index ce5b08ccfa6..5901f9b2fd7 100644 --- a/site/test/galactic-focus.test.mts +++ b/site/test/galactic-focus.test.mts @@ -1,40 +1,31 @@ import assert from 'node:assert/strict'; -import { sourceTest } from '@cssearth/objects/node/source-test'; -const test = sourceTest(); -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { readPreparedFocusObjects } from '@cssearth/bake/navigation'; +import { test } from 'node:test'; +import { catalogueObject } from '@cssearth/objects'; +import { prepareSceneDistance } from '@cssearth/bake/navigation'; import { searchObjects } from '../search/object-search.mts'; -test('a source-owned globular cluster is discovered, classified and searched through the shared focus route', async () => { - const root = await mkdtemp(join(tmpdir(), 'galactic-focus-')); - try { - const source = join(root, 'test-cluster/source'); - await mkdir(source, { recursive: true }); - await writeFile(join(source, 'nebula.json'), JSON.stringify({ - schema: 'cssearth-nebula-catalog@1', frame: { referenceFrame: 'sun-icrf', epochJdTt: 2461286.5 }, - sources: [{ id: 'paper', url: 'https://example.org/paper', bytes: 10, citation: 'Test measurement' }], - objects: [{ id: 'test-cluster', kind: 'globular-cluster', name: 'Test cluster', aliases: ['Cluster alias'], - introduction: { text: 'A source-backed test globular cluster.', sourceRefs: ['paper'] }, - positionM: [3.085677581491367e18, 0, 0], skyPosition: { raDeg: 0, decDeg: 0, sourceRef: 'paper' }, - distance: { valuePc: 100, sourceRef: 'paper', method: 'Test distance' }, - classification: { name: 'Globular cluster', basis: 'Integrated stellar light.', sourceRef: 'paper' }, - status: 'confirmed', detailedObjectId: 'test-cluster' }], - })); - const [focus, ...others] = await readPreparedFocusObjects(root, 'sun'); - assert.equal(others.length, 0); - assert.ok(focus); - assert.equal(focus.classification, 'globular-cluster'); - assert.equal(focus.systemName, 'Milky Way'); - assert.equal(focus.route, '/test-cluster/'); - assert.equal(focus.distance.quantity, 'catalogue'); - const labels = [{ name: focus.name.toLowerCase(), names: focus.searchNames, classification: focus.classification, - classificationName: 'globular cluster', systemName: focus.systemName.toLowerCase() }]; - assert.equal(searchObjects(labels, 'globular clusters').matches.length, 1); - assert.equal(searchObjects(labels, 'Cluster alias').matches.length, 1); - assert.equal(searchObjects(labels, 'nebula').matches.length, 0); - } finally { - await rm(root, { recursive: true, force: true }); - } +test('a globular cluster package is an entry of the registry from its own descriptor, classified and searched like any other', () => { + const PC_M = 3.085677581491367e16; + const descriptor = { schema: 'cssearth-object@2', id: 'test-cluster', type: 'layered-body', properties: { + catalog: { name: 'Test cluster', systemName: 'Milky Way', classification: 'globular-cluster', color: '#9a9a9a', distanceAu: 20626480.6, + description: 'A source-backed test globular cluster.', aliases: ['Cluster alias'], order: 9, context: {} }, + worldFrame: { referenceFrame: 'sun-icrf', epochJdTt: 2461286.5, originM: [100 * PC_M, 0, 0], presentationToReference: [0, 1, 0, 1, 0, 0, 0, 0, 1], + metersPerUnit: PC_M, bodyRadiusM: 10 * PC_M } } }; + const distance = prepareSceneDistance(descriptor); + // A globular cluster's distance is its catalogued one: no epoch, read from the observer. + assert.partialDeepStrictEqual(distance, { meters: 100 * PC_M, unit: 'pc', quantity: 'catalogue', referencePoint: 'observer', epochJdTt: null }); + assert.ok(Math.abs(distance.value - 100) < 1e-9); + const focus = catalogueObject({ descriptor, distance, discovery: { featured: false, imagery: true, illustration: false } }, + () => async () => { throw new Error('unused'); }); + assert.equal(typeof focus.loadScene, 'function', 'a globular cluster is an object with a scene of its own, like any body'); + const names = (focus as { searchNames?: readonly string[] }).searchNames; + assert.ok(names); + assert.equal(focus.classification, 'globular-cluster'); + assert.equal(focus.systemName, 'Milky Way'); + assert.equal(focus.route, '/test-cluster/'); + const labels = [{ name: focus.name.toLowerCase(), names: names!, classification: focus.classification, + classificationName: 'globular cluster', systemName: focus.systemName.toLowerCase() }]; + assert.equal(searchObjects(labels, 'globular clusters').matches.length, 1); + assert.equal(searchObjects(labels, 'Cluster alias').matches.length, 1); + assert.equal(searchObjects(labels, 'nebula').matches.length, 0); }); diff --git a/site/test/navigation-content.test.mts b/site/test/navigation-content.test.mts index 0057061fe9b..c56889a6b36 100644 --- a/site/test/navigation-content.test.mts +++ b/site/test/navigation-content.test.mts @@ -3,17 +3,15 @@ import test from 'node:test'; import { objectLinkIsCurrent } from '../navigation/navigation-content.mts'; const origin = 'https://site.test'; -function link(href: string, preparedFocus = false) { +function link(href: string) { const url = new URL(href, origin); - return { origin: url.origin, pathname: url.pathname, search: url.search, - hasAttribute(name: string) { return preparedFocus && name === 'data-prepared-focus-id'; } }; + return { origin: url.origin, pathname: url.pathname, search: url.search }; } -test('object navigation leaves focus and overview links to the same scene unselected', () => { +test('object navigation leaves overview links to the same scene unselected', () => { assert.equal(objectLinkIsCurrent(link('/sun/'), origin, '/sun/'), true); assert.equal(objectLinkIsCurrent(link('/sun/?dataset=corona'), origin, '/sun/'), true); assert.equal(objectLinkIsCurrent(link('/sun/?overview=system'), origin, '/sun/'), false); assert.equal(objectLinkIsCurrent(link('/m42/'), origin, '/sun/'), false); - assert.equal(objectLinkIsCurrent(link('/sun/', true), origin, '/sun/'), false); assert.equal(objectLinkIsCurrent(link('https://elsewhere.test/sun/'), origin, '/sun/'), false); }); diff --git a/site/test/navigation-fragments.test.mts b/site/test/navigation-fragments.test.mts index 9ff891c8e8c..feb5ef1ef04 100644 --- a/site/test/navigation-fragments.test.mts +++ b/site/test/navigation-fragments.test.mts @@ -21,8 +21,6 @@ class IntentElement extends EventTarget { } class IntentAnchor extends IntentElement { origin: string; pathname: string; - preparedFocus = false; - hasAttribute(name: string) { return name === 'data-prepared-focus-id' && this.preparedFocus; } constructor(pathname: string, origin: string) { super(); this.anchor = this; this.pathname = pathname; this.origin = origin; } } function fixtureWindow() { @@ -141,9 +139,6 @@ test('hover, focus and world hover prefetch registry routes after a dwell; a pre fire('pointerover', target); flush(); } assert.deepEqual(requested, ['venus', 'ceres', 'venus'], 'The current body, foreign and unregistered routes are ignored'); - const nebula = new IntentAnchor('/ceres/', 'https://example.test'); nebula.preparedFocus = true; - fire('pointerdown', nebula); - assert.deepEqual(requested, ['venus', 'ceres', 'venus'], 'A prepared focus never fetches its fallback body card.'); const host = new IntentElement(); host.hovered = { dataset: { objectNavigate: 'ceres' } }; fire('objecthoverchange', host); flush(); diff --git a/site/test/navigation-ontology.test.mts b/site/test/navigation-ontology.test.mts index 626997472db..fad01c14d97 100644 --- a/site/test/navigation-ontology.test.mts +++ b/site/test/navigation-ontology.test.mts @@ -2,30 +2,50 @@ import assert from 'node:assert/strict'; import { sourceTest } from '@cssearth/objects/node/source-test'; const test = sourceTest(); import { readFile } from 'node:fs/promises'; -import { OBJECTS, SCENE_OBJECTS, requireObject, requireSceneObject } from '../objects.mts'; +import { OBJECTS, OVERVIEWS, SCENE_OBJECTS, requireObject, requirePage, requireSceneObject } from '../objects.mts'; import { objectAdapter } from '../object-adapter.mts'; import { SEARCH_OBJECTS } from '../search/search-objects.mts'; -import { readPreparedFocusObjects, prepareSceneDistance, prepareFocusObject } from '@cssearth/bake/navigation'; -import { readOverviews } from '@cssearth/objects/node'; -import { distanceDescription, normalizeDestinationQuery, parseNavigationDistance } from '@cssearth/objects'; +import { prepareSceneDistance } from '@cssearth/bake/navigation'; +import { readObjectDescriptors, readOverviews } from '@cssearth/objects/node'; +import { distanceDescription, isExtendedClassification, normalizeDestinationQuery, parseNavigationDistance } from '@cssearth/objects'; +import { isRecord } from '@cssearth/core'; import { parsePreparedGalaxyCatalog, resolveSpatialCitation } from '@cssearth/catalog'; import { resolve } from 'node:path'; -test('every prepared spatial subject and every scene has exactly one searchable destination', async () => { - const prepared = await readPreparedFocusObjects(resolve('src/objects'), 'sun'); - const overviews = await readOverviews(resolve('src/objects'), 'sun'); - assert.equal(OBJECTS.length, SCENE_OBJECTS.length + prepared.length + overviews.length); - assert.deepEqual(OBJECTS.filter(object => object.kind === 'prepared-focus'), prepared); - assert.deepEqual(OBJECTS.filter(object => object.kind === 'overview'), overviews); - // Overviews are searched through their own rows (CatalogueOverviewRows.astro); everything else through the catalogue. - assert.deepEqual(new Set(SEARCH_OBJECTS.map(object => object.id)), new Set(OBJECTS.filter(object => object.kind !== 'overview').map(object => object.id))); +test('every scene and every package the host draws has exactly one searchable destination, built from its own descriptor', async () => { + const descriptors = await readObjectDescriptors(resolve('src/objects')); + // A galaxy, a nebula or a cluster is an authored object like any body: its recipe declares no surface. + const hosted = [...descriptors].filter(([, descriptor]) => isRecord(descriptor) && isRecord(descriptor.properties) && isRecord(descriptor.properties.recipe) + && Array.isArray(descriptor.properties.recipe.surfaces) && !descriptor.properties.recipe.surfaces.length).map(([id]) => id); + assert.equal(hosted.length, 18); + // A bank is context the world draws, never an object: none carries a catalogue entry. + for (const [id, descriptor] of descriptors) if (isRecord(descriptor) && typeof descriptor.type === 'string' && /-bank$/u.test(descriptor.type)) assert.ok(isRecord(descriptor.properties) && descriptor.properties.catalog === undefined, id); + const overviews = await readOverviews(resolve('src/objects')); + // The registry holds objects only, each with its own scene. A level of the zoom ladder is a view, not an object. + assert.equal(OBJECTS, SCENE_OBJECTS); + for (const id of hosted) assert.equal(requireSceneObject(id).id, id); + assert.deepEqual(OVERVIEWS.map(level => level.id), overviews.map(overview => overview.id)); + for (const level of OVERVIEWS) assert.equal(OBJECTS.some(object => object.id === level.id), false, level.id); + // Every level still has its page: the layout and the sitemap find it, though the object registry does not hold it. + for (const level of OVERVIEWS) assert.equal(requirePage(level.id).route, `/${level.id}/`); + assert.equal(requirePage('mars').name, 'Mars'); + const sitemap = await (await import('../pages/sitemap.xml.ts')).GET().text(); + for (const level of OVERVIEWS) assert.ok(sitemap.includes(`/${level.id}/`), level.id); + // The Local Group is a level with a place of its own: the world draws and names it. + assert.ok(OVERVIEWS.find(level => level.id === 'local-group')?.worldFrame); + // Levels are searched through their own rows (CatalogueOverviewRows.astro); every object through the catalogue. + assert.deepEqual(new Set(SEARCH_OBJECTS.map(object => object.id)), new Set(OBJECTS.map(object => object.id))); assert.deepEqual(objectAdapter.routes(SCENE_OBJECTS), SCENE_OBJECTS.map(object => object.route)); - assert.equal(requireObject('m31').kind, 'prepared-focus'); - assert.throws(() => requireSceneObject('m31'), /not a scene owner/); + assert.equal(requireSceneObject('m31').id, 'm31'); + assert.throws(() => requireSceneObject('milky-way'), /no scene of its own/); for (const [query, id] of [['Andromeda', 'm31'], ['M31', 'm31'], ['LMC', 'lmc'], ['SMC', 'smc'], ['NGC 1976', 'm42'], ['Virgo', 'virgo-cluster']]) { const object = requireObject(id!); - assert.equal(object.kind, 'prepared-focus'); - assert.ok(object.kind === 'prepared-focus' && object.searchNames.some(name => name.includes(normalizeDestinationQuery(query!))), query); + assert.ok((object.searchNames ?? []).some(name => name.includes(normalizeDestinationQuery(query!))), query); + } + // Each is a body of the world, placed by its own world frame. + for (const id of hosted) { + const object = requireObject(id); + assert.ok(isExtendedClassification(object.classification) && Math.hypot(...object.worldFrame.originM) > 0, id); } }); @@ -34,8 +54,9 @@ test('distance display and order use the prepared position, never the legacy orb const descriptor = JSON.parse(await readFile(`src/objects/${object.id}/object.json`, 'utf8')); const distance = prepareSceneDistance(descriptor); assert.deepEqual(object.distance, distance, object.id); - assert.equal(distance.epochJdTt, object.worldFrame?.epochJdTt); - assert.equal(distance.referencePoint, 'heliocentre'); + // A body on an orbit has a distance at the frame's epoch; one placed by a catalogue distance has no epoch. + assert.equal(distance.epochJdTt, distance.quantity === 'catalogue' ? null : object.worldFrame?.epochJdTt, object.id); + assert.equal(distance.referencePoint, distance.quantity === 'catalogue' ? 'observer' : 'heliocentre', object.id); } const halley = requireSceneObject('comet-1p'); assert.ok(halley.distance.value > 30 && halley.distance.value < 40); @@ -43,7 +64,6 @@ test('distance display and order use the prepared position, never the legacy orb assert.ok(!('distanceAu' in halley)); assert.ok(SEARCH_OBJECTS.every((object, index) => index === 0 || object.distance.meters >= SEARCH_OBJECTS[index - 1]!.distance.meters)); const cluster = requireObject('virgo-cluster'); - assert.ok(cluster.kind !== 'overview'); // A cluster sits at its Cosmicflows-4 group's measured distance, a catalogue distance, not its redshift's comoving one. assert.equal(cluster.distance.quantity, 'catalogue'); assert.equal(cluster.distance.epochJdTt, null, 'navigation epoch must not become a measured distance epoch'); @@ -63,9 +83,6 @@ test('galaxy citations resolve across the full prepared catalogue, including the } assert.equal(resolveSpatialCitation('Graczyk2020ApJ...904...13G', catalogue.sources)?.url, 'https://arxiv.org/abs/2010.08754'); const galaxy = catalogue.objects[0]!; - const future = prepareFocusObject({ ...galaxy, id: 'future_galaxy', name: 'Future galaxy', aliases: ['New alias'] }, 'sun'); - assert.equal(future.route, '/future_galaxy/'); - assert.ok(future.searchNames.includes('new alias')); const broken = { ...catalogue, objects: [{ ...galaxy, distance: { ...galaxy.distance, sourceRef: 'missing-paper' } }] }; assert.throws(() => parsePreparedGalaxyCatalog(broken), /Unresolved distance reference/); }); @@ -75,12 +92,11 @@ test('physical hosts remain distinct from scene hosts and M45 retains its measur const raw: unknown = JSON.parse(await readFile('src/objects/local-group/prepared/catalogue.json', 'utf8')); const catalogue = parsePreparedGalaxyCatalog(raw), satellite = catalogue.objects.find(o => o.id === 'andromeda_01')!; assert.equal(satellite.hostId, 'm31', 'a host its object package details takes the package id'); - assert.equal(prepareFocusObject(satellite, 'sun').sceneHostId, 'sun'); + assert.equal(OBJECTS.some(o => o.id === satellite.id), false, 'a catalogue row without a package is data, never a destination'); // The Milky Way row is detailed by the milky-way package, so it carries that id; its LVDB key stays in its source reference. assert.equal(catalogue.unpositionedHosts?.find(o => o.id === 'milky-way')?.sourceRef, 'lvdb-v1.1.1:mw:name_discovery'); - assert.equal(OBJECTS.find(o => o.id === 'milky-way')?.kind, 'overview', 'a physical host does not fabricate a catalogue destination; its page is the overview'); + assert.ok(OVERVIEWS.some(level => level.id === 'milky-way') && !OBJECTS.some(o => o.id === 'milky-way'), 'a physical host does not fabricate a catalogue destination; its page is the level'); const m45 = OBJECTS.find(o => o.id === 'm45')!; - assert.ok(m45.kind !== 'overview'); assert.equal(m45.distance.subject?.id, 'm45-stellar-cluster'); assert.match(distanceDescription(m45.distance), /Pleiades stellar cluster/); assert.match(distanceDescription(m45.distance), /dust-filament distances are not measured/); diff --git a/site/test/navigation-request.test.mts b/site/test/navigation-request.test.mts index 275bee36354..9b148aa3343 100644 --- a/site/test/navigation-request.test.mts +++ b/site/test/navigation-request.test.mts @@ -43,3 +43,20 @@ test('a star hosting planets flies to frame its system on the first click and op const second = select('eps-eridani'); assert.equal(second.destination.camera.kind === 'frame' && second.destination.camera.framing, 'detail'); }); + +test('a galaxy picked from another galaxy\'s page is a destination like any body: it adds a history entry', () => { + const navigation = { + centerTarget() { return null; }, + systemTarget() { assert.fail('a galaxy hosts no system'); }, + overviewTarget() { assert.fail('object selection does not open an overview'); }, + } as unknown as Parameters[1]['navigation']; + // M 33, then LMC from search, then Back must return to M 33 (it left the site on 2026-10-01). + const { destination } = resolveNavigation({ kind: 'object' }, { + object: requireSceneObject('lmc'), objects: WORLD_OBJECTS, navigation, + current: { objectId: 'm33', href: 'https://css.earth/m33/', subject: { kind: 'object', objectId: 'm33' }, + centeredObjectId: null, hasPresented: true, reuseScene: false, mount: null, pending: null }, + }); + assert.equal(new URL(destination.url).pathname, '/lmc/'); + assert.deepEqual(destination.history, { history: 'push' }); + assert.deepEqual(destination.subject, { kind: 'object', objectId: 'lmc' }); +}); diff --git a/site/test/navigation-scope.test.mts b/site/test/navigation-scope.test.mts index 5a0918dd141..75f887d73f5 100644 --- a/site/test/navigation-scope.test.mts +++ b/site/test/navigation-scope.test.mts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { sourceTest } from '@cssearth/objects/node/source-test'; const test = sourceTest(); -import { drawnPageFromUrl, overviewPage, overviewScopeFromUrl, preparedFocusFromUrl, satelliteSystemFromUrl, withOverviewScope, - withPreparedFocus, WORLD_HOST_ID } from '../navigation/navigation-scope.mts'; +import { drawnPageFromUrl, overviewPage, overviewScopeFromUrl, satelliteSystemFromUrl, withOverviewScope, + WORLD_HOST_ID } from '../navigation/navigation-scope.mts'; import { withSceneDataset, readSceneDatasetUrl } from '../dataset-url.mts'; const at = (path: string) => new URL(path, 'https://css.earth'); @@ -11,13 +11,13 @@ const write = (path: string, scene: string, scope: Parameters/: an overview and a catalogue subject are both read from the path', () => { +test('every page is //: a level is read from the path, and an object with a scene is its own page', () => { assert.equal(WORLD_HOST_ID, 'sun'); assert.equal(overviewScopeFromUrl(at('/milky-way/'), 'sun'), 'milky-way'); assert.equal(drawnPageFromUrl(at('/milky-way/'), 'sun'), 'milky-way'); - assert.equal(preparedFocusFromUrl(at('/milky-way/'), 'sun'), null, 'an overview is not a catalogue focus'); - assert.equal(preparedFocusFromUrl(at('/m31/'), 'sun'), 'm31'); - assert.equal(overviewScopeFromUrl(at('/m31/'), 'sun'), null, 'a catalogue focus has no overview'); + assert.equal(drawnPageFromUrl(at('/m31/'), 'm31'), null, 'a galaxy is the scene of its own page'); + assert.equal(overviewScopeFromUrl(at('/m31/'), 'm31'), null); + assert.equal(overviewScopeFromUrl(at('/m31/?overview=system'), 'm31'), 'system'); // The page wins over its query; only a star's system overview is a query. assert.equal(overviewScopeFromUrl(at('/milky-way/?overview=system'), 'sun'), 'milky-way'); assert.equal(satelliteSystemFromUrl(at('/milky-way/?view=satellites')), false); @@ -40,10 +40,6 @@ test("an overview's page is the world host's scene; another star's overview stay test("a drawn subject's page carries none of the scene's own selections, so a cold open reads none", () => { assert.equal(write('/sun/?dataset=spectral-slope&feature=12&v=saved', 'sun', 'milky-way'), '/milky-way/?v=saved'); - const focus = withPreparedFocus(at('/sun/?dataset=spectral-slope&feature=12'), 'sun', 'm42', null); - assert.equal(focus.pathname + focus.search, '/m42/'); - const dataset = withPreparedFocus(at('/sun/?dataset=spectral-slope'), 'sun', 'm42', 'optical'); - assert.equal(dataset.pathname + dataset.search, '/m42/?dataset=optical', "a focus's dataset is its own dataset"); assert.deepEqual(readSceneDatasetUrl(at('/milky-way/?dataset=spectral-slope'), 'sun'), { requested: false, id: null }); assert.equal(withSceneDataset(at('/milky-way/'), 'sun', 'spectral-slope').search, '', 'the scene never writes its dataset there'); }); diff --git a/site/test/navigation-test-values.mts b/site/test/navigation-test-values.mts index 1df07f26344..ce4e26b7764 100644 --- a/site/test/navigation-test-values.mts +++ b/site/test/navigation-test-values.mts @@ -22,12 +22,10 @@ export function quaternion(values: readonly number[]): WorldCameraPose['pose'][' export function navigationFixture(frame: PreparedWorldCameraFrame, capture: () => WorldCameraPose, optics: ObjectWorldNavigation['optics']): ObjectWorldNavigation { return { motion: createCameraMotion(), frame, capture, optics, apply() { throw new Error('This fixture only samples the camera.'); }, - preparedFocus: () => null, setPreparedFocus() {}, - async flyToPreparedFocus() { throw new Error('This fixture does not fly the camera.'); }, subscribe() { throw new Error('This fixture does not subscribe to camera changes.'); } }; } export function objectFixture(id: string, worldFrame: PreparedWorldCameraFrame, overrides: Partial = {}): ObjectEntry { - return { kind: 'scene', id, name: id, systemName: id, classification: 'planet', color: '#000000', distance: testDistance(1), + return { id, name: id, systemName: id, classification: 'planet', color: '#000000', distance: testDistance(1), route: `/${id}/`, description: id, worldFrame, discovery: { featured: false, imagery: false, illustration: false }, async loadScene() { throw new Error('This fixture does not mount a scene.'); }, ...overrides }; } diff --git a/site/test/nebula-search.test.mts b/site/test/nebula-search.test.mts index df48bb779ab..1866c9cd318 100644 --- a/site/test/nebula-search.test.mts +++ b/site/test/nebula-search.test.mts @@ -2,16 +2,16 @@ import assert from 'node:assert/strict'; import { sourceTest } from '@cssearth/objects/node/source-test'; const test = sourceTest(); import { OBJECTS } from '../objects.mts'; -import { defineObjects, definePreparedFocus, normalizeDestinationQuery, type PreparedFocusObject } from '@cssearth/objects'; +import { catalogueObject, defineObjects, normalizeDestinationQuery } from '@cssearth/objects'; test('rendered nebulae contribute common names and catalogue aliases to search', async () => { - const rows = OBJECTS.filter((object): object is PreparedFocusObject => object.kind === 'prepared-focus' && object.classification === 'nebula'); + const rows = OBJECTS.filter(object => object.classification === 'nebula'); for (const [query,id] of [['orion','m42'],['M42','m42'],['NGC 1976','m42'],['helix','helix'],['NGC 7293','helix'],['m2-9','m2-9'],['M2–9','m2-9'],['Twin Jet','m2-9'], ['Pleiades','m45'],['M45','m45'],['Seven Sisters','m45'],['Crab','m1'],['M1','m1'],['NGC 1952','m1'],['Lagoon','m8'],['M8','m8'],['NGC 6523','m8']]) { - const match = rows.find(row => row.searchNames.some(name => name.includes(normalizeDestinationQuery(query!)))); - assert.equal(match?.focusId,id); + const match = rows.find(row => ((row as { searchNames?: readonly string[] }).searchNames ?? []).some(name => name.includes(normalizeDestinationQuery(query!)))); + assert.equal(match?.id,id); assert.equal(match?.route,`/${id}/`); } assert.throws(()=>defineObjects([rows[0]!,rows[0]!]),/Duplicate object/); - assert.throws(()=>definePreparedFocus({})); + assert.throws(()=>catalogueObject({}, () => async () => { throw new Error('unused'); }), /Invalid catalogue entry/); }); diff --git a/site/test/object-directory.test.mts b/site/test/object-directory.test.mts index 39411f732b9..e7fda55fc4f 100644 --- a/site/test/object-directory.test.mts +++ b/site/test/object-directory.test.mts @@ -23,7 +23,10 @@ test('every prepared entry rebuilds the object the registry holds', () => { test('preparation reads the registry the application holds, without its scene loader', async () => { const prepared = readPreparedObjects(resolve(import.meta.dirname, '../..')); assert.deepEqual(facts(prepared.objects), facts(OBJECTS)); + // Preparation reads the same scene objects, galaxies and nebulae among them; a level has no scene of its own. assert.deepEqual(prepared.sceneObjects.map(object => object.id), REGISTERED_SCENE_OBJECTS.map(object => object.id)); + assert.equal(prepared.requireSceneObject('m31').id, 'm31'); + assert.throws(() => prepared.requireSceneObject('milky-way'), /no scene of its own/); assert.equal(prepared.requireSceneObject('mars').name, REGISTERED_SCENE_OBJECTS.find(object => object.id === 'mars')?.name); await assert.rejects(prepared.requireSceneObject('mars').loadScene(), /cannot mount a scene/); }); diff --git a/site/test/object-navigation.test.mts b/site/test/object-navigation.test.mts index 080dee823b4..3a1e47d621c 100644 --- a/site/test/object-navigation.test.mts +++ b/site/test/object-navigation.test.mts @@ -12,8 +12,8 @@ import { markerStyle, resolveMarkerStyle, validateMarkerPresentation } from "@cs import { PREPARED_NAVIGATION_MARKERS } from "../prepared-navigation-markers.mjs"; test("search contains every object but the overviews, including the Sun; only planets enter the scale", () => { - // Overviews have no distance to order by; search lists them in their own rows (CatalogueOverviewRows.astro). - assert.deepEqual(new Set(SEARCH_OBJECTS), new Set(OBJECTS.filter(object => object.kind !== "overview"))); + // The levels of the zoom ladder are not objects; search lists them in their own rows (CatalogueOverviewRows.astro). + assert.deepEqual(new Set(SEARCH_OBJECTS), new Set(OBJECTS)); assert.ok(SEARCH_OBJECTS.some(({ id }) => id === "sun")); assert.ok(PLANET_NAVIGATION_OBJECTS.every(({ classification }) => classification === "planet")); const unknown = [ diff --git a/site/test/object-result.test.mts b/site/test/object-result.test.mts index 1003b970c93..1c464533070 100644 --- a/site/test/object-result.test.mts +++ b/site/test/object-result.test.mts @@ -40,10 +40,9 @@ test('the shared server row escapes authored names and attributes', () => { test('overview navigation shares the row without a fabricated distance or focus target', () => { const { document } = parseHTML(objectResultMarkup({ kind: 'overview', id: 'milky-way', name: 'Milky Way', route: '/milky-way/', classificationName: 'galaxy', source: earth.source, - marker: { kind: 'focus', thumbnail: '/navigation/focus-milky-way@2x.webp' } })); + marker: { kind: 'thumbnail', thumbnail: '/navigation/focus-milky-way@2x.webp' } })); const link = document.querySelector('a')!; assert.equal(link.getAttribute('href'), '/milky-way/'); - assert.equal(link.dataset.preparedFocusId, undefined); assert.equal(link.dataset.objectId, undefined); assert.equal(link.querySelector('img')?.getAttribute('width'), '40'); assert.equal(link.querySelector('.object-distance')?.textContent, 'Galaxy'); diff --git a/site/test/overview-context.test.mts b/site/test/overview-context.test.mts index bd5bf0446d9..2359347e552 100644 --- a/site/test/overview-context.test.mts +++ b/site/test/overview-context.test.mts @@ -88,10 +88,9 @@ test('zooming out from the Sun walks the registry overviews in their order, and }); test('the ladder is the overview entries: an authored fifth level is reached past its own threshold', async () => { - const { defineOverview } = await import('@cssearth/objects'); - const beyond = defineOverview({ kind: 'overview', id: 'beyond-the-horizon', name: 'Beyond', description: 'A test level.', order: 5, - zoom: { enter: { distancePc: 3e10 }, returnBelow: { distancePc: 2e10 }, frame: { distance: { distancePc: 5e10 } } }, - holds: [], packages: [], route: '/beyond-the-horizon/', sceneHostId: 'sun' }); + const { catalogueLevel } = await import('@cssearth/objects'); + const beyond = catalogueLevel({ descriptor: { schema: 'cssearth-object@2', id: 'beyond-the-horizon', type: 'map-sphere', properties: { overview: { name: 'Beyond', description: 'A test level.', order: 5, + zoom: { enter: { distancePc: 3e10 }, returnBelow: { distancePc: 2e10 }, frame: { distance: { distancePc: 5e10 } } }, holds: [] } } } }); const levels = [...OVERVIEWS, beyond], gpc = 1e9 * 3.085677581491367e16; assert.equal(overviewScopeAtCamera(camera(40 * gpc), 'observable-universe', context, undefined, levels), 'beyond-the-horizon'); assert.equal(overviewScopeAtCamera(camera(25 * gpc), 'observable-universe', context, undefined, levels), 'observable-universe'); diff --git a/site/test/page-datasets.test.mts b/site/test/page-datasets.test.mts index 1f7b8a55227..a39eabda931 100644 --- a/site/test/page-datasets.test.mts +++ b/site/test/page-datasets.test.mts @@ -11,7 +11,7 @@ const test = sourceTest(); const card = () => parseHTML(`
-
`).document; +
`).document; test("a page's dataset is its own dataset while the address is that page, and its default elsewhere", () => { const [datasets] = readPageDatasets(card()); @@ -27,7 +27,7 @@ test('the card marks the chosen dataset on its host scene and hides its datasets assert.equal(root.hidden, false); assert.deepEqual([...document.querySelectorAll('button')].map(button => button.getAttribute('aria-pressed')), ['false', 'true']); // Only the chosen dataset's details are mounted; the other waits in a template (detached-sections.ts). - assert.deepEqual([...document.querySelectorAll('[data-focus-dataset-details]')].map(detail => detail.dataset.focusDatasetDetails), ['full']); + assert.deepEqual([...document.querySelectorAll('[data-page-dataset-details]')].map(detail => detail.dataset.pageDatasetDetails), ['full']); // Another star zoomed out to the same level shows the level's card without them: choosing one would leave that star. presentPageDatasets(document, 'https://css.earth/trappist-1/?overview=system', 'trappist-1'); assert.equal(root.hidden, true); diff --git a/site/test/prepared-context-navigation.test.mts b/site/test/prepared-context-navigation.test.mts deleted file mode 100644 index 1477d60400e..00000000000 --- a/site/test/prepared-context-navigation.test.mts +++ /dev/null @@ -1,504 +0,0 @@ -import assert from 'node:assert/strict'; -import { sourceTest } from '@cssearth/objects/node/source-test'; -const test = sourceTest(); -import { createPreparedContextNavigation } from '../prepared-context-navigation.mts'; -import { createSceneSelection } from '../scene/scene-selection.mts'; -import { createNavigationLifecycle } from '../navigation/navigation-lifecycle.mts'; -import { withPreparedFocus } from '../navigation/navigation-scope.mts'; -import { createImageFocusBank } from '@cssearth/renderer/universe/prepared-focus-bank.ts'; -import { overviewScopeFromUrl, preparedFocusFromUrl } from '../navigation/navigation-scope.mts'; -import { isPreparedCluster } from '@cssearth/catalog'; -import type { PreparedFocusPresentation } from '../prepared-focus.mts'; -import type { PreparedCatalogObject, PreparedGalaxyRecord, PreparedClusterRecord, SpatialCatalogSource, SpatialCitation } from '@cssearth/catalog'; -import type { ObjectWorldNavigation } from '@cssearth/renderer/runtime/world-navigation-types.ts'; -import type { PreparedNavigationFocus } from '@cssearth/renderer/navigation/prepared-focus.ts'; -import type { PreparedVolumeDatasetState } from '@cssearth/renderer/volume/prepared-volume-datasets.ts'; -import type { DensityVolumeFrame } from '@cssearth/objects'; -type ContextLayer = Parameters[0]['layer']; -type Content = { record: PreparedCatalogObject | null; references: readonly SpatialCitation[]; presentation: PreparedFocusPresentation | null }; -type FixtureOptions = { object?: Partial & Partial>; - unavailableObjectIds?: readonly string[]; bankReady?: Promise; loadedFramingRadiusUnits?: number; - imageLayerFrames?: Readonly>; volumeDatasetFrames?: Readonly>; volumeBank?: PreparedVolumeDatasetState | null; - deferredVolumeBank?: PreparedVolumeDatasetState | null; - cameraPositionM?: readonly [number, number, number] }; -const baseFrame: DensityVolumeFrame = { referenceFrame: 'sun-icrf', epochJdTt: 1, originM: [0,0,0], localToReferenceXyzw: [0,0,0,1], - metersPerUnit: 1e18, boundsUnits: { min: [-500,-500,-500], max: [500,500,500] } }; -function required(value: T | null | undefined): T { assert.ok(value !== null && value !== undefined); return value; } -function last(values: T[]): T { return required(values.at(-1)); } - - -function fixture({ object = {}, imageLayerFrames = {}, volumeDatasetFrames = {}, volumeBank = null, deferredVolumeBank = null, - unavailableObjectIds = [], bankReady, loadedFramingRadiusUnits, cameraPositionM = [1e20, 0, 1e19] }: FixtureOptions = {}) { - let current: PreparedNavigationFocus | null = null, signal: AbortSignal | undefined; - const flights: {id:string; reducedMotion?:boolean}[] = [], flightFocuses: PreparedNavigationFocus[] = []; - const presentationFocuses: (PreparedNavigationFocus | null | undefined)[] = []; - const callbacks = new Set<() => void>(), errors: Error[] = [], selections: (string | null)[] = [], writes: (string | URL)[] = [], content: Content[] = []; - const windowTarget = { location: new URL('https://example.test/catalogue-a/?v=saved'), - history: { state: {}, replaceState(state: unknown, _: string, url?: string | URL | null) { assert.ok(url); windowTarget.location = new URL(url, windowTarget.location); writes.push(url); } } }; - const owner = { frame: { referenceFrame: 'sun-icrf', epochJdTt: 1, originM: [0,0,0], - presentationToReference: [1,0,0,0,-1,0,0,0,1], metersPerUnit: 1e18, bodyRadiusM: 1e18 }, - capture: () => ({ referenceFrame: 'sun-icrf', epochJdTt: 1, - pose: { positionM: cameraPositionM, orientationXyzw: [0,0,0,1] } }), - optics: () => ({ focalPixels: 1000, widthPixels: 1000, heightPixels: 1000, principalOffsetPixels: [0,0], - framingRadiusPixels: 400, visibleRect: {left:-500,right:500,top:-500,bottom:500}, detailHandoffDiameterPixels: 20 }), - preparedFocus: () => current, - setPreparedFocus(focus: PreparedNavigationFocus | null) { current = focus; for (const callback of callbacks) callback(); }, - subscribe(callback: () => void) { callbacks.add(callback); callback(); return () => callbacks.delete(callback); }, - flyToPreparedFocus(focus: PreparedNavigationFocus, options: { signal?: AbortSignal; reducedMotion?: boolean } = {}) { signal = required(options.signal); this.setPreparedFocus(focus); - flights.push({id:focus.id,reducedMotion:options.reducedMotion}); flightFocuses.push(focus); - if (options.reducedMotion) return Promise.resolve({completed:true}); - return new Promise<{ completed: boolean }>(resolve => required(signal).addEventListener('abort', () => resolve({ completed: false }), { once: true })); } }; - // The real `subscribeVolumeDataset` hands the listener the bank state, so a deferred bank can replay a - // notification once its payload arrives; a no-argument listener no longer satisfies that signature. - const datasetCallbacks = new Set<(state: PreparedVolumeDatasetState) => void>(), datasetWrites: string[] = []; - let bankState = volumeBank, currentVolumeDatasetFrames = volumeDatasetFrames; - // A deferred bank is released through the same load the runtime awaits, so a declared-but-absent - // bank stays a pending load rather than an unknown focus. - let releaseDeferredBank: (() => void) | null = null; - const readyBank = bankReady ?? (deferredVolumeBank - ? new Promise(resolve => { releaseDeferredBank = () => resolve(deferredVolumeBank); }) - : undefined); - const applyBank = (change: Partial) => { bankState = { ...required(bankState), ...change }; for (const callback of datasetCallbacks) callback(required(bankState)); }; - // Only the one declared bank may be addressed, whether its payload is resident, deferred, or still loading. - const declaredBankId = () => bankState?.objectId ?? deferredVolumeBank?.objectId ?? Object.keys(currentVolumeDatasetFrames)[0]; - const layer = { imageLayerFrames, get volumeDatasetFrames() { return currentVolumeDatasetFrames; }, - async ensureVolumeDataset(objectId: string) { - if (!readyBank) return; - bankState = await readyBank; - assert.equal(bankState.objectId, objectId); - if (loadedFramingRadiusUnits !== undefined) currentVolumeDatasetFrames = { ...currentVolumeDatasetFrames, [objectId]: { ...required(currentVolumeDatasetFrames[objectId]), framingRadiusUnits: loadedFramingRadiusUnits } }; - for (const callback of datasetCallbacks) callback(bankState); - }, - volumeDatasetState: (objectId: string) => objectId === bankState?.objectId ? bankState : null, - selectVolumeDataset(objectId: string, id: string) { - assert.equal(objectId, declaredBankId()); - datasetWrites.push(id); - if (!bankState) return; - assert.ok(bankState.datasets.some(dataset => dataset.id === id)); applyBank({ id, selectedDataset: id }); - }, - subscribeVolumeDataset(objectId: string, listener: (state: PreparedVolumeDatasetState) => void) { - assert.equal(objectId, declaredBankId()); - datasetCallbacks.add(listener); return () => { datasetCallbacks.delete(listener); }; - }, - selectGalaxy: (id: string | null, focus?: PreparedNavigationFocus | null) => { selections.push(id); presentationFocuses.push(focus); }, - resolveGalaxy: (id: string): PreparedCatalogObject | null => { - if (!['catalogue-a','catalogue-b'].includes(id)) return null; - const galaxy: PreparedGalaxyRecord = { id, name: id, aliases: [], status: 'confirmed', positionM: [1e20,0,0], - skyPosition: { raDeg: 0, decDeg: 0, sourceRef: 'positions:row' }, - distance: { valuePc: 1, method: 'Published distance', sourceRef: 'PublishedBibliographicKey' }, - membership: { group: 'local-group', subgroup: 'field', basis: 'Published membership', sourceRef: 'membership:row' }, ...object }; - if (object.kind !== 'galaxy-cluster') return galaxy; - return { ...galaxy, kind: 'galaxy-cluster', classification: required(object.classification), - redshift: { value: .01, type: 'spectroscopic', sourceRef: 'positions:row' }, - aperture: { definition: 'R500', properRadiusM: 1e22, comovingRadiusM: 1e22, sourceRef: 'positions:row' } }; - } }; - const focusLayer: ContextLayer = { ensureGalaxyCatalog: async () => {}, selectGalaxy: layer.selectGalaxy, resolveGalaxy: layer.resolveGalaxy, - focusBank(id) { - if (imageLayerFrames[id]) return createImageFocusBank(id, imageLayerFrames[id], () => Promise.resolve()); - if (!currentVolumeDatasetFrames[id]) return null; - return { objectId: id, - framingRadiusM: () => { const framing = required(currentVolumeDatasetFrames[id]); return framing.framingRadiusUnits * framing.frame.metersPerUnit; }, - state: () => layer.volumeDatasetState(id), load: () => layer.ensureVolumeDataset(id), - selectDataset: dataset => layer.selectVolumeDataset(id, dataset), subscribe: listener => layer.subscribeVolumeDataset(id, listener) }; - }, - }; - const sources: SpatialCatalogSource[] = [{ id: 'positions', url: 'https://example.test/positions', bytes: 1, citation: 'Published positions', references: [{ id: 'PublishedBibliographicKey', url: 'https://example.test/paper', citation: 'Distance paper' }] }, - { id: 'membership', url: 'https://example.test/membership', bytes: 1, citation: 'Published membership' }, - { id: 'unrelated', url: 'https://example.test/unrelated', bytes: 1, citation: 'Unused audit input' }]; - // Narrow test doubles intentionally expose only this controller's browser/runtime surface. - const executor = createPreparedContextNavigation({ layer: focusLayer, windowTarget: windowTarget as unknown as Window, onError: error => { assert.ok(error instanceof Error); errors.push(error); }, - sources, unavailableObjectIds, presentation: { metersPerParsec: 3e16, defaultFocusRadiusM: 1e18, minimumDistanceRadii: .01, maximumDistanceM: 1e23 } }); - // The application request now owns supersession and abort; the focus executor receives that lifetime. - const requests = createNavigationLifecycle({ onCancel() {}, onError: error => { assert.ok(error instanceof Error); errors.push(error); } }); - let available = false; - const selection = createSceneSelection({ objectId: 'mercury', initial: { kind: 'object', objectId: 'mercury' }, onChange() {} }); - const disconnect = executor.connect(owner as unknown as ObjectWorldNavigation, { canPublish: () => available, - readFocus: () => selection.current.kind === 'focus' ? selection.current.id : null, - onFocusChange: ({ record, sources: references, presentation, url: policy }) => { - available = true; content.push({ record, references, presentation }); - selection.focus(record, references, presentation); - if (policy === 'preserve') return; - const url = new URL(windowTarget.location.href); - if (policy === 'reframe') url.searchParams.delete('v'); - const selected = selection.url(url); - if (selected !== windowTarget.location.href) windowTarget.history.replaceState(windowTarget.history.state, '', selected); - } }); - function apply(url: string, frame: boolean) { - available = false; - const request = requests.begin({ id: 'mercury', url, subject: { kind: 'object', objectId: 'mercury' }, - camera: frame ? { kind: 'focus' } : { kind: 'restore', animate: false }, history: { history: 'replace' }, - scene: 'reuse', origin: 'link', feature: null, timing: { mark() {} } }); - const finish = () => { if (requests.finish(request, 'finished')) available = true; }; - const fail = (error: unknown) => { if (requests.owns(request)) { assert.ok(error instanceof Error); errors.push(error); } }; - try { - const result = executor.apply(url, { sceneId: 'mercury', signal: request.signal, isCurrent: () => requests.owns(request), frame, reducedMotion: !frame }); - if (result) return result.catch(fail).finally(finish); - } catch (error) { fail(error); } - finish(); - } - const controller = { - restore: (url: string) => apply(url, false), - select: ({ id }: { id: string }) => apply(withPreparedFocus(new URL(windowTarget.location.href), 'mercury', id, null).href, true), - suspend() { available = false; requests.cancel(); }, - destroy() { available = false; requests.cancel(); executor.destroy(); }, - }; - return { controller, disconnect, owner, layer, datasetCallbacks, datasetWrites, windowTarget, errors, selections, presentationFocuses, writes, callbacks, content, flights, flightFocuses, signal: () => signal, - resolveDeferredVolumeBank() { required(releaseDeferredBank)(); } }; -} - -test('a saved dataset link to an unavailable package still opens its actual catalogue record', () => { - const f = fixture({ object: { detailedObjectId: 'helix' }, unavailableObjectIds: ['helix'] }); - f.windowTarget.location.searchParams.set('dataset', 'eso-vista'); - f.controller.restore(f.windowTarget.location.href); - assert.deepEqual(f.errors, []); - assert.equal(last(f.content).record?.id, 'catalogue-a'); - assert.equal(last(f.content).presentation, null); - assert.deepEqual(f.datasetWrites, []); - f.controller.destroy(); -}); - -test('a disconnected or destroyed focus ignores its old camera owner', () => { - for (const release of ['disconnect', 'destroy'] as const) { - const f = fixture(); - f.windowTarget.location.searchParams.delete('v'); - if (release === 'disconnect') f.disconnect(); else f.controller.destroy(); - assert.equal(f.callbacks.size, 0, `${release} releases the owner's subscription`); - f.controller.restore(f.windowTarget.location.href); - assert.deepEqual(f.flights, [], `${release}: a later restore does not fly the old owner`); - assert.deepEqual(f.content, [], `${release}: nothing is published`); - f.controller.destroy(); - } -}); - -test('a direct focus link without a saved camera frames its target immediately', () => { - const f = fixture(); - f.windowTarget.location.searchParams.delete('v'); - f.controller.restore(f.windowTarget.location.href); - assert.deepEqual(f.flights,[{id:'catalogue-a',reducedMotion:true}]); - assert.deepEqual(f.errors,[]); - f.controller.destroy(); -}); - -test('a focus link with a visible saved camera restores selection without reframing', () => { - const f = fixture(); - f.controller.restore(f.windowTarget.location.href); - assert.deepEqual(f.flights,[]); - assert.equal(f.owner.preparedFocus()?.id,'catalogue-a'); - f.controller.destroy(); -}); - -test('a focus link whose saved camera looks away reframes the named target', () => { - const f = fixture({ cameraPositionM: [1e20, 0, -1e19] }); - f.controller.restore(f.windowTarget.location.href); - assert.deepEqual(f.flights,[{id:'catalogue-a',reducedMotion:true}]); - assert.equal(f.owner.preparedFocus()?.id,'catalogue-a'); - assert.equal(f.windowTarget.location.searchParams.has('v'), false); - assert.deepEqual(f.errors,[]); - f.controller.destroy(); -}); - -test('a focus link whose saved camera puts the named target off-screen reframes it', () => { - const f = fixture({ cameraPositionM: [2e20, 0, 1e19] }); - f.controller.restore(f.windowTarget.location.href); - assert.deepEqual(f.flights,[{id:'catalogue-a',reducedMotion:true}]); - assert.equal(f.windowTarget.location.searchParams.has('v'), false); - assert.deepEqual(f.errors,[]); - f.controller.destroy(); -}); - -test('suspension isolates camera restore publications from incoming focus history and cancels an older selection flight', async () => { - const f = fixture(); - f.controller.restore(f.windowTarget.location.href); - assert.equal(required(last(f.content).record).id, 'catalogue-a'); - assert.deepEqual(last(f.content).references.map(source => source.id), ['positions', 'PublishedBibliographicKey', 'membership']); - assert.equal(required(last(f.content).record).distance.sourceRef, 'PublishedBibliographicKey'); - const flight = f.controller.select({ id: 'catalogue-b' }); - assert.equal(required(f.owner.preparedFocus()).id, 'catalogue-b'); - assert.equal(required(last(f.content).record).id, 'catalogue-b'); - const incoming = 'https://example.test/catalogue-a/?v=restored'; - f.windowTarget.location = new URL(incoming); - f.controller.suspend(); - f.owner.setPreparedFocus(null); // Shared v restoration clears the runtime pivot. - assert.equal(f.windowTarget.location.href, incoming); - assert.equal(required(f.signal()).aborted, true); - await flight; - assert.equal(f.windowTarget.location.href, incoming); - f.controller.restore(incoming); - assert.equal(required(f.owner.preparedFocus()).id, 'catalogue-a'); - assert.equal(required(last(f.content).record).id, 'catalogue-a'); - assert.equal(f.windowTarget.location.href, incoming); - assert.deepEqual(f.errors, []); - f.controller.destroy(); - assert.equal(f.callbacks.size, 0); -}); - -test('an invalid incoming catalogue ID remains available for diagnosis after restoration publications', async () => { - const f = fixture(); - f.controller.restore(f.windowTarget.location.href); - f.controller.suspend(); - const incoming = 'https://example.test/unknown/?v=restored'; - f.windowTarget.location = new URL(incoming); - f.owner.setPreparedFocus(null); - await f.controller.restore(incoming); - f.owner.setPreparedFocus(null); - assert.equal(f.errors.length, 1); - assert.match(f.errors[0].message, /Unknown prepared galaxy/); - assert.equal(f.windowTarget.location.href, incoming); - assert.equal(f.selections.at(-1), null); - assert.equal(last(f.content).record, null); - f.controller.destroy(); -}); - -test('an authored framing radius takes precedence over an oversized transparent image frame', () => { - const imageLayerFrames = { detailed: baseFrame }; - const f = fixture({ imageLayerFrames, object: { detailedObjectId: 'detailed', presentation: { focusRadiusM: 2e18 } } }); - f.controller.restore(f.windowTarget.location.href); - assert.equal(required(f.owner.preparedFocus()).framingRadiusM, 2e18); - assert.equal(required(f.owner.preparedFocus()).limits.minimumDistanceM, 2e16); - assert.equal(required(f.owner.preparedFocus()).limits.maximumDistanceM, 1e23); - assert.deepEqual(f.errors, []); - f.controller.destroy(); - const fallback = fixture({ imageLayerFrames, object: { detailedObjectId: 'detailed' } }); - fallback.controller.restore(fallback.windowTarget.location.href); - assert.equal(required(fallback.owner.preparedFocus()).framingRadiusM, 5e20); - fallback.controller.destroy(); -}); - -test('a cluster focus uses its prepared aperture framing and source without pretending it has galaxy membership', () => { - const f = fixture({ object: { kind: 'galaxy-cluster', membership: undefined, - classification: { name: 'Cluster', basis: 'Published aperture', sourceRef: 'membership:cluster-row' }, presentation: { focusRadiusM: 9e22 } } }); - f.controller.restore(f.windowTarget.location.href); - assert.deepEqual(f.errors, []); - assert.equal(required(f.owner.preparedFocus()).framingRadiusM, 9e22); - assert.deepEqual(last(f.content).references.map(source => source.id), ['positions', 'PublishedBibliographicKey', 'membership']); - const cluster = required(last(f.content).record); - assert.ok(isPreparedCluster(cluster)); - assert.equal(cluster.kind, 'galaxy-cluster'); - f.controller.destroy(); -}); - -const volumeBank = (): PreparedVolumeDatasetState => ({ objectId: 'detailed', id: 'first', defaultDataset: 'first', selectedDataset: 'first', starsVisible: true, - datasets: ['first', 'second', 'third'].map(id => ({ id, label: id, title: `${id} dataset`, description: 'Prepared observation', sourceUrl: 'https://example.test/source' })) }); -const volumeDatasetFrames = { detailed: { framingRadiusUnits: 2, frame: baseFrame } }; - -test('volume focus uses its authored framing radius before transparent bounds and retains an explicit catalogue override', () => { - for (const focusRadiusM of [undefined, 3e18]) { - const f = fixture({ volumeDatasetFrames, volumeBank: volumeBank(), object: { detailedObjectId: 'detailed', - ...(focusRadiusM ? { presentation: { focusRadiusM } } : {}) } }); - f.controller.restore(f.windowTarget.location.href); - assert.equal(required(f.owner.preparedFocus()).framingRadiusM, focusRadiusM ?? 2e18); - assert.deepEqual(f.errors, []); - f.controller.destroy(); - } -}); - -test('focused dataset selection follows applied runtime state while URL restore keeps the same camera owner', () => { - const f = fixture({ volumeDatasetFrames, volumeBank: volumeBank(), object: { detailedObjectId: 'detailed' } }); - f.windowTarget.location.searchParams.set('dataset', 'second'); - const incoming = f.windowTarget.location.href; - f.controller.restore(incoming); - assert.equal(required(f.layer.volumeDatasetState('detailed')).selectedDataset, 'second'); - assert.equal(required(last(f.content).presentation).selectedDataset, 'second'); - assert.equal(f.windowTarget.location.href, incoming); - assert.equal(f.datasetCallbacks.size, 1); - const focus = f.owner.preparedFocus(), controls = required(last(f.content).presentation); - controls.selectDataset('third'); - assert.equal(f.owner.preparedFocus(), focus, 'Changing dataset does not replace or move the camera focus'); - assert.equal(required(f.layer.volumeDatasetState('detailed')).selectedDataset, 'third'); - assert.equal(required(last(f.content).presentation).selectedDataset, 'third'); - assert.equal(f.windowTarget.location.searchParams.get('dataset'), 'third'); - assert.equal(f.windowTarget.location.searchParams.get('v'), 'saved'); - f.controller.suspend(); - controls.selectDataset('first'); - assert.equal(required(f.layer.volumeDatasetState('detailed')).selectedDataset, 'third'); - f.windowTarget.location = new URL(incoming); - f.controller.restore(incoming); - assert.equal(required(f.layer.volumeDatasetState('detailed')).selectedDataset, 'second'); - f.windowTarget.location.searchParams.delete('dataset'); - f.controller.restore(f.windowTarget.location.href); - assert.equal(required(f.layer.volumeDatasetState('detailed')).selectedDataset, 'first', 'A plain focus restores its authored default'); - assert.equal(f.windowTarget.location.searchParams.get('dataset'), 'first'); - f.owner.setPreparedFocus(null); - assert.equal(last(f.content).presentation, null); - assert.equal(f.windowTarget.location.pathname, '/mercury/', 'Clearing the focus returns to the scene page'); - assert.equal(f.windowTarget.location.searchParams.has('dataset'), false); - assert.equal(f.datasetCallbacks.size, 0); - controls.selectDataset('third'); - assert.equal(required(f.layer.volumeDatasetState('detailed')).selectedDataset, 'first', 'Stale controls cannot mutate a departed focus'); - assert.deepEqual(f.errors, []); - f.controller.destroy(); -}); - -test('a saved dataset waits for its lazy bank instead of rejecting the focus as unknown', async () => { - const bank = volumeBank(); - const f = fixture({ volumeDatasetFrames, deferredVolumeBank: bank, object: { detailedObjectId: bank.objectId } }); - f.windowTarget.location.searchParams.set('dataset', 'second'); - const incoming = f.windowTarget.location.href; - const restoring = f.controller.restore(incoming); - assert.deepEqual(f.errors, [], 'A bank that is still loading is not an unknown focus dataset'); - assert.equal(f.owner.preparedFocus(), null, 'The focus waits for its declared bank rather than being rejected'); - assert.deepEqual(f.datasetWrites, []); - f.resolveDeferredVolumeBank(); await restoring; - assert.deepEqual(f.errors, []); - assert.equal(f.owner.preparedFocus()?.id, 'catalogue-a'); - assert.deepEqual(f.datasetWrites, ['second']); - assert.equal(last(f.content).record?.id, 'catalogue-a'); - assert.equal(required(last(f.content).presentation).selectedDataset, 'second'); - assert.equal(f.windowTarget.location.href, incoming, 'The saved dataset link is preserved exactly'); - f.controller.destroy(); -}); - -test('invalid focused datasets retain their diagnostic URL and never apply an arbitrary bank', () => { - for (const query of ['dataset=unknown', 'dataset=second&dataset=third']) { - const f = fixture({ volumeDatasetFrames, volumeBank: volumeBank(), object: { detailedObjectId: 'detailed' } }); - const incoming = `https://example.test/catalogue-a/?${query}`; - f.windowTarget.location = new URL(incoming); - f.controller.restore(incoming); - assert.equal(f.errors.length, 1); - assert.match(f.errors[0].message, /focus dataset/); - assert.equal(f.windowTarget.location.href, incoming); - assert.equal(f.owner.preparedFocus(), null); - assert.deepEqual(f.datasetWrites, []); - f.controller.destroy(); - } -}); - -test("a scene page's dataset is the scene's own, never a focus dataset, and focused bank subscriptions are released on destruction", () => { - const f = fixture({ volumeDatasetFrames, volumeBank: volumeBank(), object: { detailedObjectId: 'detailed' } }); - f.controller.restore(f.windowTarget.location.href); - assert.equal(f.datasetCallbacks.size, 1); - f.windowTarget.location = new URL('https://example.test/mercury/?dataset=third&v=saved'); - f.controller.restore(f.windowTarget.location.href); - assert.equal(f.windowTarget.location.searchParams.get('dataset'), 'third'); - assert.equal(f.windowTarget.location.searchParams.get('v'), 'saved'); - assert.equal(f.datasetCallbacks.size, 0); - f.windowTarget.location = new URL('https://example.test/catalogue-a/?v=saved'); - f.controller.restore(f.windowTarget.location.href); - assert.equal(f.datasetCallbacks.size, 1); - f.controller.destroy(); - assert.equal(f.datasetCallbacks.size, 0); -}); - - -test('an image-layer focus exposes its single optical dataset without volume-only actions', () => { - const f = fixture({ imageLayerFrames: {detailed:baseFrame}, object:{detailedObjectId:'detailed'} }); - f.controller.restore('https://example.test/catalogue-a/?dataset=optical'); - assert.deepEqual(f.errors, []); - const controls = required(last(f.content).presentation); - assert.equal(controls.selectedDataset, 'optical'); - controls.selectDataset('optical'); - assert.deepEqual(f.datasetWrites, []); - f.controller.destroy(); -}); - -function deferredBank() { - let accept!: (bank: PreparedVolumeDatasetState) => void, reject!: (error: Error) => void; - const promise = new Promise((resolve, fail) => { accept = resolve; reject = fail; }); - return { promise, accept, reject }; -} -function coldVolumeFixture() { - const load = deferredBank(); - const f = fixture({ object: { detailedObjectId: 'detailed' }, - volumeDatasetFrames: { detailed: { frame: baseFrame, framingRadiusUnits: 5268.814 } }, - bankReady: load.promise, loadedFramingRadiusUnits: 1650 }); - return { ...f, load }; -} - -test('cold native focus waits for the real bank framing before choosing a destination', async () => { - const f = coldVolumeFixture(); - const restoring = f.controller.restore('https://example.test/catalogue-a/'); - assert.deepEqual(f.flights, [], 'Loading may not fly using descriptor fallback bounds'); - assert.equal(f.owner.preparedFocus(), null); - f.load.accept(volumeBank()); await restoring; - assert.equal(last(f.flightFocuses).framingRadiusM, 1650 * baseFrame.metersPerUnit); - assert.deepEqual(f.flights, [{id:'catalogue-a',reducedMotion:true}]); - assert.deepEqual(f.errors, []); f.controller.destroy(); -}); - -test('ordinary cold selection waits for the same authored framing and cancellation prevents stale focus', async () => { - const f = coldVolumeFixture(); - const selecting = f.controller.select({id:'catalogue-a'}); - assert.deepEqual(f.flights, []); - f.load.accept(volumeBank()); await new Promise(resolve => setImmediate(resolve)); - assert.equal(last(f.flightFocuses).framingRadiusM, 1650 * baseFrame.metersPerUnit); - f.controller.suspend(); await selecting; f.controller.destroy(); - const cancelled = coldVolumeFixture(); - const stale = cancelled.controller.select({id:'catalogue-a'}); - cancelled.controller.suspend(); - cancelled.owner.setPreparedFocus({id:'newer-focus',positionM:[0,0,0],framingRadiusM:1,limits:{minimumDistanceM:1,maximumDistanceM:2}}); - cancelled.load.accept(volumeBank()); await new Promise(resolve => setImmediate(resolve)); - assert.deepEqual(cancelled.flights, []); await stale; - assert.equal(cancelled.owner.preparedFocus()?.id, 'newer-focus'); - assert.deepEqual(cancelled.errors, []); cancelled.controller.destroy(); -}); - -test('cold saved camera keeps its pose while waiting for an explicitly selected dataset', async () => { - const f = coldVolumeFixture(); - const restoring = f.controller.restore('https://example.test/catalogue-a/?dataset=second&v=exact-saved-pose'); - assert.deepEqual(f.errors, []); assert.deepEqual(f.flights, []); - f.load.accept(volumeBank()); await restoring; - assert.deepEqual(f.flights, []); assert.equal(f.owner.preparedFocus()?.framingRadiusM,1650 * baseFrame.metersPerUnit); - assert.deepEqual(f.datasetWrites, ['second']); assert.deepEqual(f.errors, []); f.controller.destroy(); -}); - -test('a failed bank load reports its actual error and never falls back to an inaccurate destination', async () => { - const f = coldVolumeFixture(), failed = Error('Failed to fetch prepared bank'); - const restoring = f.controller.restore('https://example.test/catalogue-a/'); - f.load.reject(failed); await restoring; - assert.deepEqual(f.flights, []); assert.equal(f.owner.preparedFocus(),null); - assert.deepEqual(f.errors,[failed]); f.controller.destroy(); -}); - -test('a later saved restore supersedes a pending native focus without a delayed flight', async () => { - const f = coldVolumeFixture(); - const old = f.controller.restore('https://example.test/catalogue-a/'); - const newer = f.controller.restore('https://example.test/catalogue-b/?v=saved-later-pose'); - f.load.accept(volumeBank()); await Promise.all([old,newer]); - assert.equal(f.owner.preparedFocus()?.id,'catalogue-b'); - assert.deepEqual(f.flights,[]); assert.deepEqual(f.errors,[]); f.controller.destroy(); -}); - - -test('selecting a catalogue focus replaces the overview it supersedes', async () => { - const f = fixture(); - f.windowTarget.location = new URL('https://example.test/sun/?overview=system'); - const flight = f.controller.select({ id: 'catalogue-b' }); - assert.equal(preparedFocusFromUrl(f.windowTarget.location, 'mercury'), 'catalogue-b'); - assert.equal(f.windowTarget.location.searchParams.has('overview'), false, - 'a focus and an overview are one camera, so the URL may name only one'); - f.controller.suspend(); await flight; - assert.deepEqual(f.errors, []); - f.controller.destroy(); -}); - -test('shared context receives the exact authoritative focus on restore, switch and clear', async () => { - const f = fixture({ object: { detailedObjectId: 'prepared-image' }, imageLayerFrames: { 'prepared-image': baseFrame } }); - f.controller.restore(f.windowTarget.location.href); - assert.equal(last(f.presentationFocuses), f.owner.preparedFocus()); - assert.equal(required(last(f.presentationFocuses)).framingRadiusM, 500 * baseFrame.metersPerUnit); - const selecting = f.controller.select({ id: 'catalogue-b' }); - assert.equal(last(f.presentationFocuses), f.owner.preparedFocus()); - assert.equal(required(last(f.presentationFocuses)).id, 'catalogue-b'); - f.owner.setPreparedFocus(null); - assert.equal(f.presentationFocuses.at(-1), null); - f.controller.suspend(); await selecting; - f.controller.destroy(); -}); - -test('a focus waiting for its lazy bank still owns the URL, and resolves to one state', async () => { - const bank = volumeBank(); - const f = fixture({ volumeDatasetFrames, deferredVolumeBank: bank, object: { detailedObjectId: bank.objectId } }); - f.windowTarget.location = new URL('https://example.test/catalogue-a/?overview=system'); - const restoring = f.controller.restore(f.windowTarget.location.href); - // The runtime cannot report this focus yet, but the URL names it, so nothing - // may treat the selection as absent and write an overview over it. - assert.equal(f.owner.preparedFocus(), null); - assert.equal(preparedFocusFromUrl(f.windowTarget.location.href, 'mercury'), 'catalogue-a'); - assert.equal(overviewScopeFromUrl(f.windowTarget.location.href, 'mercury'), null, 'a pending focus leaves no overview to resolve'); - f.resolveDeferredVolumeBank(); await restoring; - assert.equal(f.owner.preparedFocus()?.id, 'catalogue-a'); - assert.equal(preparedFocusFromUrl(f.windowTarget.location, 'mercury'), 'catalogue-a'); - assert.equal(f.windowTarget.location.searchParams.has('overview'), false, 'exactly one state survives'); - assert.deepEqual(f.errors, []); - f.controller.destroy(); -}); diff --git a/site/test/prepared-focus-card.test.mts b/site/test/prepared-focus-card.test.mts deleted file mode 100644 index 7213d9943b7..00000000000 --- a/site/test/prepared-focus-card.test.mts +++ /dev/null @@ -1,196 +0,0 @@ -import assert from 'node:assert/strict'; -import { sourceTest } from '@cssearth/objects/node/source-test'; -const test = sourceTest(); -import { createPreparedFocusCard } from '../prepared-focus-card.mts'; -import type { PreparedGalaxyRecord } from '@cssearth/catalog'; -import type { PreparedFocusPresentation } from '../prepared-focus.mts'; - -class Element extends EventTarget { - dataset: Record = {}; selectors = new Map(); attributes = new Map(); hidden = false; checked = false; disabled = false; textContent = ''; - querySelector(selector: string) { const value = this.selectors.get(selector); return value instanceof Element ? value : null; } - // A selector list answers each of its parts, as the card's detached-section lookups ask (detached-sections.ts). - querySelectorAll(selector: string): Element[] { - const own = selector.replace(', template[data-detached-section]', ''), whole = this.selectors.get(own); - if (whole !== undefined) return Array.isArray(whole) ? whole : [whole]; - return own.split(', ').flatMap(part => { const value = this.selectors.get(part); return Array.isArray(value) ? value : value instanceof Element ? [value] : []; }); - } - matches() { return true; } - setAttribute(name: string, value: string) { this.attributes.set(name, value); } - getAttribute(name: string) { return this.attributes.get(name) ?? null; } - removeAttribute(name: string) { this.attributes.delete(name); } -} -function fixture(unavailableObjects = '', { banksLater = false } = {}) { - const root = new Element(), bank = new Element(), datasetTab = new Element(); - const tabs: string[] = []; - root.selectors.set('[data-information-tab="dataset"]', datasetTab); - const unavailable = new Element(); unavailable.dataset.unavailableObjects = unavailableObjects; - root.selectors.set('[data-focus-unavailable]', unavailable); - bank.dataset.focusDatasetBank = 'prepared-galaxy'; - const ids = ['first', 'second', 'third']; - const buttons = ids.map(id => { const button = new Element(); button.setAttribute('value', id); return button; }); - const details = ids.map(id => Object.assign(new Element(), { dataset: { focusDatasetDetails: id } })); - bank.selectors.set('[data-focus-dataset]', buttons); - bank.selectors.set('[data-focus-dataset-details]', details); - const factsBank = new Element(); factsBank.dataset.focusFactsBank = 'prepared-galaxy'; - const facts = ids.map(id => Object.assign(new Element(), { dataset: { focusDatasetDetails: id }, textContent: 'Source pixels 2048 × 4096' })); - factsBank.selectors.set('[data-focus-dataset-details]', facts); - const spliceBanks = () => root.selectors.set('[data-focus-dataset-bank], [data-focus-facts-bank]', [bank, factsBank]); - if (!banksLater) spliceBanks(); - for (const name of ['name', 'aliases', 'introduction', 'status', 'distance', 'uncertainty', 'membership', 'association']) { - root.selectors.set(`[data-focus-${name}]`, new Element()); - } - root.selectors.set('[data-focus-aliases-row]', new Element()); - root.selectors.set('[data-focus-learn-more]', new Element()); - const record: PreparedGalaxyRecord = { id: 'catalogue:galaxy', detailedObjectId: 'prepared-galaxy', name: 'Prepared galaxy', aliases: [], status: 'confirmed', - positionM: [0, 0, 0], skyPosition: { raDeg: 0, decDeg: 0, sourceRef: 'observations' }, - distance: { valuePc: 50000, sourceRef: 'observations', method: 'Published distance' }, membership: { group: 'local-group', subgroup: 'milky-way', basis: 'Published membership' } }; - const presentation: PreparedFocusPresentation = { id: 'first', defaultDataset: 'first', objectId: 'prepared-galaxy', selectedDataset: 'first', starsVisible: true, - datasets: ids.map(id => ({ id, label: id, title: id, description: id, sourceUrl: 'https://example.test/source' })), selectDataset() {} }; - // This retained DOM stand-in implements only the card's queried fields and events. - return { root, bank, factsBank, spliceBanks, facts, buttons, details, record, presentation, datasetTab, tabs, unavailable, - card: createPreparedFocusCard(root as unknown as HTMLElement, id => tabs.push(id)) }; -} - -test('an unavailable volume keeps catalogue facts and a retained explanation, without stale dataset controls', () => { - const f = fixture('prepared-galaxy'); - f.card.set(f.record); - assert.equal(f.unavailable.hidden, false); - assert.match(f.unavailable.textContent, /3D view of Prepared galaxy is unavailable/); - assert.equal(f.datasetTab.hidden, true); - assert.equal(f.bank.hidden, true); - assert.equal(f.root.querySelector('[data-focus-distance]')?.textContent, '50 kpc'); - f.card.set({ ...f.record, detailedObjectId: undefined }); - assert.equal(f.unavailable.hidden, true); - f.card.set(f.record); f.card.set(null); - assert.equal(f.unavailable.hidden, true); - f.card.destroy(); -}); - -test('prepared focus datasets retain controls and reflect only the applied runtime selection', () => { - const f = fixture(), requested: string[] = []; - f.presentation.selectDataset = id => requested.push(id); - f.card.set(f.record, [], f.presentation); - assert.equal(f.bank.hidden, false); - assert.equal(f.buttons[0].getAttribute('aria-pressed'), 'true'); - assert.deepEqual(f.details.map(detail => detail.hidden), [false, true, true]); - assert.equal(f.factsBank.hidden, false); - assert.deepEqual(f.facts.map(detail => detail.hidden), [false, true, true]); - f.buttons[1].dispatchEvent(new Event('click')); - assert.deepEqual(requested, ['second']); - assert.equal(f.buttons[0].getAttribute('aria-pressed'), 'true', 'Do not publish a dataset before the runtime applies it'); - const retained = [...f.buttons, ...f.details]; - f.card.set(f.record, [], { ...f.presentation, selectedDataset: 'second' }); - assert.deepEqual(f.buttons.map(button => button.getAttribute('aria-pressed')), ['false', 'true', 'false']); - assert.deepEqual(f.details.map(detail => detail.hidden), [true, false, true]); - assert.deepEqual(f.facts.map(detail => detail.hidden), [true, false, true]); - assert.deepEqual([...f.bank.querySelectorAll('[data-focus-dataset]'), ...f.bank.querySelectorAll('[data-focus-dataset-details]')], retained); - f.card.set(f.record, [], { ...f.presentation, selectedDataset: 'third' }); - f.card.destroy(); - f.buttons[0].dispatchEvent(new Event('click')); - assert.deepEqual(requested, ['second']); -}); - -test('departed or unsupported galaxy focus hides its retained dataset bank and disables stale actions', () => { - const f = fixture(), requested: string[] = []; - f.presentation.selectDataset = id => requested.push(id); - f.card.set(f.record, [], f.presentation); - f.card.set({ ...f.record, detailedObjectId: 'image-galaxy' }); - assert.equal(f.bank.hidden, true); - assert.equal(f.factsBank.hidden, true); - f.buttons[1].dispatchEvent(new Event('click')); - assert.deepEqual(requested, []); - f.card.set(f.record, [], f.presentation); - assert.equal(f.bank.hidden, false); - f.card.set(null); - assert.equal(f.root.hidden, false, 'the selection presentation owns the card\'s visibility'); - assert.equal(f.bank.hidden, true); - f.buttons[2].dispatchEvent(new Event('click')); - assert.deepEqual(requested, []); - f.card.destroy(); -}); - -test('a nebula focus keeps classification out of the title and preserves the shared dataset controls', () => { - const f = fixture(); - const { membership: _membership, ...common } = f.record; - f.card.set({ ...common, kind: 'nebula', detailedObjectId: 'prepared-galaxy', - aliases: ['Example 1'], introduction: { text: 'A source-backed introduction to this nebula.', sourceRefs: ['observations'] }, - classification: { name: 'Emission nebula', basis: 'Conditional image reconstruction.', sourceRef: 'observations' } }, [], f.presentation); - assert.equal(f.root.querySelector('[data-focus-introduction]')?.textContent, 'A source-backed introduction to this nebula.'); - assert.equal(f.root.querySelector('[data-focus-aliases]')?.textContent, 'Example 1'); - assert.equal(f.root.querySelector('[data-focus-aliases-row]')?.hidden, false); - assert.equal(f.root.querySelector('[data-focus-status]')?.textContent, ''); - assert.equal(f.root.querySelector('[data-focus-status]')?.hidden, true); - assert.equal(f.root.querySelector('[data-focus-membership]')?.textContent, 'Milky Way'); - assert.equal(f.root.querySelector('[data-focus-association]')?.textContent, 'Galactic nebula'); - assert.equal(f.bank.hidden, false); - f.card.set(f.record, [], f.presentation); - assert.equal(f.root.querySelector('[data-focus-status]')?.hidden, false, 'A later galaxy restores its status tag'); - f.card.destroy(); -}); - -test('a globular-cluster focus keeps its stellar classification and the shared dataset controls', () => { - const f = fixture(); - const { membership: _membership, ...common } = f.record; - f.card.set({ ...common, kind: 'globular-cluster', detailedObjectId: 'prepared-galaxy', - introduction: { text: 'A source-backed introduction to this cluster.', sourceRefs: ['observations'] }, - classification: { name: 'Globular cluster', basis: 'Integrated stellar light.', sourceRef: 'observations' } }, [], f.presentation); - assert.equal(f.root.querySelector('[data-focus-introduction]')?.textContent, 'A source-backed introduction to this cluster.'); - assert.equal(f.root.querySelector('[data-focus-status]')?.textContent, ''); - assert.equal(f.root.querySelector('[data-focus-status]')?.hidden, true, 'Galactic volumes keep classification out of the title'); - assert.equal(f.root.querySelector('[data-focus-membership]')?.textContent, 'Milky Way'); - assert.equal(f.root.querySelector('[data-focus-association]')?.textContent, 'Galactic globular cluster'); - assert.equal(f.root.querySelector('[data-focus-basis]'), null, 'Model and measurement notes stay out of the standard Factsheet rows'); - assert.equal(f.bank.hidden, false); - assert.equal(f.datasetTab.hidden, false); - f.card.destroy(); -}); - -test('focus uses shared dataset tabs only when a prepared presentation is available', () => { - const f = fixture(); - f.card.set(f.record); - assert.equal(f.datasetTab.hidden, true); - assert.deepEqual(f.tabs, ['factsheet']); - f.card.set(f.record, [], f.presentation); - assert.equal(f.datasetTab.hidden, false); - assert.deepEqual(f.tabs, ['factsheet', 'dataset']); - f.card.set(f.record, [], { ...f.presentation, selectedDataset: 'second' }); - assert.deepEqual(f.tabs, ['factsheet', 'dataset'], 'Dataset updates preserve the user’s current information tab'); - f.card.set({ ...f.record, id: 'catalogue:other', detailedObjectId: undefined }); - assert.equal(f.datasetTab.hidden, true); - assert.deepEqual(f.tabs, ['factsheet', 'dataset', 'factsheet']); - f.card.destroy(); -}); - -test('banks spliced after a focus is presented are adopted and show the current selection', () => { - const f = fixture('', { banksLater: true }), requested: string[] = []; - f.presentation.selectDataset = id => requested.push(id); - f.card.set(f.record, [], { ...f.presentation, selectedDataset: 'second' }); - // Spliced banks arrive hidden, as the fragment renders them. - f.bank.hidden = true; f.factsBank.hidden = true; - f.spliceBanks(); - f.card.adoptBanks(); - assert.equal(f.bank.hidden, false); - assert.deepEqual(f.buttons.map(button => button.getAttribute('aria-pressed')), ['false', 'true', 'false']); - assert.deepEqual(f.facts.map(detail => detail.hidden), [true, false, true]); - f.card.adoptBanks(); - f.buttons[2].dispatchEvent(new Event('click')); - assert.deepEqual(requested, ['third'], 'Adopting twice binds each control once'); - f.card.destroy(); -}); - - -test('Wikipedia follows the selected focus without replacing the retained link', () => { - const f = fixture(); - const link = f.root.querySelector('[data-focus-learn-more]')!; - f.card.set({ ...f.record, aliases: ['Example'] }); - assert.equal(link.hidden, false); - assert.equal(new URL(link.getAttribute('href')!).searchParams.get('search'), 'Prepared galaxy'); - f.card.set({ ...f.record, name: 'Another galaxy', aliases: ['Other'] }); - assert.equal(f.root.querySelector('[data-focus-learn-more]'), link); - assert.equal(new URL(link.getAttribute('href')!).searchParams.get('search'), 'Another galaxy'); - f.card.set(f.record); - assert.equal(link.hidden, true, 'No introduction leaves no orphaned Learn more link'); - f.card.set(null); - assert.equal(link.hidden, true); - f.card.destroy(); -}); diff --git a/site/test/prepared-world-context.test.ts b/site/test/prepared-world-context.test.ts index d3dd92078a0..1c367ed7881 100644 --- a/site/test/prepared-world-context.test.ts +++ b/site/test/prepared-world-context.test.ts @@ -19,6 +19,7 @@ import { type PackedWorldContextView, unpackWorldBodies } from '../../packages/r import { createWorldContextPlanner } from '../../packages/renderer/src/universe/world-context/world-context-planner.js'; import { CONTEXT_LINE_WIDTH, INDICATOR_DOT_MAX_DIAMETER, indicatorDotDiameter } from '../../packages/renderer/src/universe/world-context/context-scale.js'; import { SCENE_OBJECTS } from '../objects.mts'; +import { isPlacedClassification } from '@cssearth/objects'; import { labelImportance } from '../../packages/renderer/src/labels/universe-label-policy.js'; import { SYSTEM_RANGES, SYSTEM_VIEWS, SYSTEM_VIEW_HOSTS, loadSystemView, systemFramingRect, systemViewTarget } from '../system-framing.mts'; import { stubGlobal, unstubAllGlobals } from '@cssearth/objects/node/contract'; @@ -761,17 +762,19 @@ test('accepts the generated Sun context and rejects detached or malformed prepar const source = JSON.parse(await readFile(fileURLToPath(new URL('../../src/objects/sun/prepared/world-context.json', import.meta.url)), 'utf8')) as Record; const [{ readCatalog }, { parseNavigationDistance }] = await Promise.all([import('@cssearth/objects/node'), import('@cssearth/objects')]); // Each entry's distance as `prepare:catalog` placed it in the registry. - const distance = (descriptor: unknown) => parseNavigationDistance(SCENE_OBJECTS.find(object => object.id === (descriptor as { id: string }).id)?.distance); + // Every body of the world: the scenes, the packages the host draws (galaxies, clusters, nebulae) and the levels that are bodies. + const worldObjects = (await import('@cssearth/objects/node')).readPreparedObjects(fileURLToPath(new URL('../..', import.meta.url))).worldObjects; + const distance = (descriptor: unknown) => parseNavigationDistance(worldObjects.find(object => object.id === (descriptor as { id: string }).id)?.distance); const contextEntries = (await readCatalog(fileURLToPath(new URL('../../src/objects', import.meta.url)), distance)).filter(body => body.context && body.id !== 'sun') .sort((a, b) => (a.context!.order ?? Number.MAX_SAFE_INTEGER) - (b.context!.order ?? Number.MAX_SAFE_INTEGER) || a.id.localeCompare(b.id, 'en')); // Bodies drawn from their astronomy records around a packaged host follow the catalogue's own entries. assert.deepEqual(parsePreparedWorldContext(source).bodies.filter(body => !body.unpackaged).map(body => body.id), contextEntries.map(body => body.id)); for (const body of parsePreparedWorldContext(source).bodies.filter(body => !body.unpackaged)) { - const frame = SCENE_OBJECTS.find(object => object.id === body.id)!.worldFrame!; + const frame = worldObjects.find(object => object.id === body.id)!.worldFrame!; assert.equal(body.radiusM, frame.bodyRadiusM, `${body.id} context must match the selectable detail radius`); expectAlignedContextOrigin(body.positionM, frame.originM, `${body.id} context must match the selectable detail origin`); - // A placed star or black hole has no orbit in the Sun's context; every orbiting body's orbit facts are prepared. - if (!body.orbit) { assert.ok((['star', 'black-hole']).includes(SCENE_OBJECTS.find(object => object.id === body.id)!.classification)); continue; } + // A placed body (a star, a black hole, a galaxy, a cluster, a nebula) has no orbit in the Sun's context; every orbiting body's orbit facts are prepared. + if (!body.orbit) { assert.ok(isPlacedClassification(worldObjects.find(object => object.id === body.id)!.classification)); continue; } assert.notEqual(body.orbit.bounds, undefined, `${body.id} orbit bounds are owned by preparation`); assert.deepEqual(([...body.orbit.activeChords!]), [...body.orbit.trail].flatMap((weight, index) => weight > 0 ? [index] : [])); assert.deepEqual([...(body.orbit.extentChords ?? [])].sort((a, b) => a - b), [...body.orbit.activeChords!]); diff --git a/site/test/prepared-world-navigation.test.mts b/site/test/prepared-world-navigation.test.mts index 03ad39e3312..3ad96b9fa85 100644 --- a/site/test/prepared-world-navigation.test.mts +++ b/site/test/prepared-world-navigation.test.mts @@ -11,7 +11,6 @@ import { createPreparedWorldNavigation } from '../prepared-world-navigation.mts' import { required, position, navigationFixture, unusedSharedView } from './navigation-test-values.mts'; import type { PreparedWorldCameraFrame, WorldCameraPose, WorldCameraPresentation } from '@cssearth/renderer/navigation/world-camera.ts'; import type { ObjectWorldNavigation } from '@cssearth/renderer/runtime/world-navigation-types.ts'; -import type { PreparedNavigationFocus } from '@cssearth/renderer/navigation/prepared-focus.ts'; import type { ObjectSceneLifecycle } from '@cssearth/renderer/runtime/object-scene.ts'; import type { ObjectPreparationView } from '@cssearth/renderer/runtime/prepared-object-navigation.ts'; import type { SceneFactory } from '../browser/browser-types.mts'; @@ -26,7 +25,7 @@ type MockLease = { resources: Resources; destroy(): void; projection(): undefine type MockPreparation = { frame: Mutable; framingRadius(): Promise; framingScale(): Promise; prepare(options: { getView(): ObjectPreparationView }): Promise }; type MockFactory = { navigation: MockPreparation }; type PrepareOptions = Omit['prepare']>[0]>, 'toFactory'> & { toFactory?: MockFactory | Promise }; -type MockNavigation = Omit & { frame: Mutable; activePreparedFocus: PreparedNavigationFocus | null }; +type MockNavigation = Omit & { frame: Mutable }; const lifecycle = { ready: Promise.resolve(), sharedView: unusedSharedView, pause() {}, resume() {}, destroy() {} } satisfies Omit; /** CSS presentation to a right-handed reference: a reflection. */ const identity = [1,0,0,0,-1,0,0,0,1] as const; @@ -44,9 +43,6 @@ function fixtureFactory(arrival?: PreparedArrivalView) { const windowTarget = { performance: { now: () => time }, matchMedia: () => ({ matches: true }), requestAnimationFrame(fn: FrameRequestCallback) { callbacks.set(++next, fn); return next; }, cancelAnimationFrame(id: number) { callbacks.delete(id); } }; const navigation: MockNavigation = { ...navigationFixture(frames[0], () => current, () => { throw new Error("optics overridden"); }), frame: frames[0], capture: () => current, - activePreparedFocus: null, - preparedFocus() { return this.activePreparedFocus; }, - setPreparedFocus(focus) { this.activePreparedFocus = focus; }, optics: () => ({ focalPixels: 1000, principalOffsetPixels: [0,0], widthPixels: 2000, heightPixels: 2000, framingRadiusPixels: 200, detailHandoffDiameterPixels: 14, visibleRect: null }), apply(value) { current = value; paints.push(value); } }; @@ -440,21 +436,6 @@ test('the same object can recenter at overview distance without resetting to its closePose(f.navigation.capture().pose, targetWorldCamera.pose); }); -test('ordinary planet selection clears a prepared catalogue pivot for both same-owner focus and detail handoff', async () => { - for (const sameOwner of [true, false]) { - const f = fixtureFactory(); - f.navigation.setPreparedFocus({ id: 'catalogue:7', positionM: [0,0,0], framingRadiusM: 1, limits: { minimumDistanceM: 1, maximumDistanceM: 1e20 } }); - const before = f.navigation.capture(); - const task = sameOwner - ? f.service.focus({ objectId: '0', mount: { sharedView: unusedSharedView, navigation: f.navigation }, signal: f.controller.signal, reducedMotion: true }) - : f.start({ preserveView: true }); - assert.equal(f.navigation.preparedFocus(), null); - assert.equal(f.navigation.capture(), before, 'Clearing the input pivot cannot move the existing observer'); - const result = await drainFrames(f, { task }); - if (!sameOwner) { assert.ok(result); await result.afterMount(f.mounted()); } - f.controller.abort(); - } -}); // The synthetic optics use a 2000px square viewport. A sphere beside the // eye plane can have a huge projected ellipse entirely outside that viewport. const proxyVisible = (view: WorldCameraPresentation) => view.silhouette !== null && view.centerPixels !== null && diff --git a/site/test/search-response.test.mts b/site/test/search-response.test.mts index 36ec1fdbd44..a3187eb27e3 100644 --- a/site/test/search-response.test.mts +++ b/site/test/search-response.test.mts @@ -17,15 +17,15 @@ const index = { schema: 'cssearth-prepared-feature-index@2', features: [{ objectId: 'moon', id: 'tycho', name: 'Tycho', type: 'Crater', diameterKm: 85, searchNames: ['tycho'], searchContext: 'crater' }], places: [] }; const pin = { url: '/features/index.json', count: 1 }; -const entry = (kind: 'scene' | 'prepared-focus', name: string, classification: string, distanceMeters: number, searchNames: string[] = []): CatalogueIndexEntry => { +const entry = (kind: 'scene' | 'bank', name: string, classification: string, distanceMeters: number, searchNames: string[] = []): CatalogueIndexEntry => { const id = name.toLowerCase(); - return { kind, id, name, searchNames, classification, classificationName: classification, systemName: 'solar system', + return { kind: 'scene', id, name, searchNames, classification, classificationName: classification, systemName: 'solar system', route: `/${id}/`, illustration: false, distanceMeters, detail: { text: `${distanceMeters} m`, title: 'Observer distance', ariaLabel: `${distanceMeters} m. Observer distance` }, - source: { subject: `${kind === 'scene' ? 'object' : 'focus'}:${id}`, document: `/sources/${id}/`, label: `Sources for ${name}` }, - marker: kind === 'scene' ? { kind: 'scene', id: 'saturn', color: '#fff' } : { kind: 'focus', thumbnail: null } }; + source: { subject: `object:${id}`, document: `/sources/${id}/`, label: `Sources for ${name}` }, + marker: kind === 'scene' ? { kind: 'scene', id: 'saturn', color: '#fff' } : { kind: 'thumbnail', thumbnail: null } }; }; -const entries = [entry('scene', 'Saturn', 'planet', 1), entry('scene', 'Titan', 'satellite', 2), entry('prepared-focus', 'M42', 'nebula', 3, ['orion nebula', 'm42'])]; +const entries = [entry('scene', 'Saturn', 'planet', 1), entry('scene', 'Titan', 'satellite', 2), entry('bank', 'M42', 'nebula', 3, ['orion nebula', 'm42'])]; const reads: string[] = []; const data = (catalogue = entries, read: SearchData['read'] = async path => { reads.push(path); if (path === pin.url) return index; throw new Error(`${path} is not prepared.`); }): SearchData => ({ pin, read, catalogue: async () => catalogue }); @@ -40,7 +40,6 @@ const html = `<